Skip to content

fix(engine): historical queries bind properties by stable identity - #959

Merged
ragnorc merged 1 commit into
ModernRelay:mainfrom
ragnorc:claude/cranky-rubin-38b1e1
Oct 12, 2026
Merged

ragnorc merged 1 commit into
ModernRelay:mainfrom
ragnorc:claude/cranky-rubin-38b1e1

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

What & why

A historical query (run_query_at, or a query at ReadTarget::Snapshot) could read a dropped property's values through a property later re-added with the same name. It plans against the current schema, and its tables were already rebound by table identity, but its properties were matched to stored columns by name only. Invariant 6 says identity survives renames, not drop and re-add, and is never inferred from a name. This PR makes historical reads resolve each property by the stable property id its column persists (omnigraph.stable_property_id), the mechanism Blob reads already use.

Reproduction, before this change: schema Person { name @key, note: String? }, write Alice with note = "secret" (commit A), drop note, re-add note: String?. Then at A, return { $p.note } returned "secret", and the filter $p.note = "secret" matched Alice.

Backing issue / RFC

  • Fixes an accepted issue: no issue is filed for this. The defect is against invariant 6 and the property-lifetime rule RFC 0033 set for Blob reads. Happy to file an issue first if a maintainer wants one.
  • Is an RFC PR, or implements an accepted RFC
  • Trivial fast-lane

Changes

  • stored_field in crates/omnigraph/src/engine/typed_value.rs resolves a declared property by its stable id. check_stored_fields and check_scan_leaves use it, so every scan, pushed filter, hydration and edge read is covered. System columns carry no id and still resolve by name.
  • Snapshot::bind_catalog_aliases(catalog, live) (catalog crate) now takes the live view the catalog was accepted against. It records each table the historical view pins at a version other than the live one (contract_covers). Only there can the column of a property's name belong to another lifetime. Coverage is decided by DatasetEntry::same_physical_version, the same comparison Blob uses.
  • On an uncovered table, a property is refused with a BadRequest, matching the existing "node type … does not exist at this snapshot":
    • same-name re-add: property 'note' of node type 'Person' does not exist at this snapshot
    • renamed since: property 'remark' of node type 'Person' is named 'memo' at this snapshot; a historical query cannot read a property across a rename
    • added since: the "does not exist" message (previously an Internal error)
    • a column with no id, written before 0.10: … has no persisted property-lifetime witness at this snapshot (the RFC 0033 Blob rule)
  • On a covered table (every live read, and a historical read at the live version), behaviour is unchanged. A column whose id differs from the contract is now an integrity failure (Internal) rather than being read.
  • User docs: historical-read rules in docs/user/branching/index.md, a re-add note in docs/user/schema/index.md. Developer guide: docs/dev/execution.md. Release note: changelog.d/historical-query-property-identity.fixed.md.

Checklist

  • Change is focused (one logical change)
  • Tests added/updated for behavior changes
  • Public docs updated
  • Reviewed against docs/dev/invariants.md: this restores invariant 6 for properties; no deny-list item. No extra I/O: the live view was already captured to accept the catalog.

Local verification

All runs on macOS, debug build, with RUST_MIN_STACK=16777216 as CI sets. Without it composite_flow overflows the 2 MiB default stack.

  • cargo test -p omnigraph-engine --test integration point_in_time::historical_query_binds_properties_by_identity_not_name: fails with name-only matching (returns "secret"), passes with the fix
  • cargo test -p omnigraph-engine --locked: lib 469, integration 608 (8 ignored), schema_apply 32, search 28, writes 57, lance_surface_guards 65, policy_engine_chassis 24, forbidden_apis 35; all pass
  • cargo test -p omnigraph-catalog --locked: 198 passed
  • cargo test --workspace --exclude omnigraph-gqt --exclude omnigraph-gqt-served --exclude omnigraph-dst --locked --features omnigraph-engine/failpoints,omnigraph-cluster/failpoints: 4316 passed, 0 failed, 32 ignored
  • cargo clippy --workspace --all-targets --locked -- -D warnings -W clippy::dbg_macro: clean
  • cargo fmt --all --check, typos, bash scripts/check-agents-md.sh, python3 scripts/check-docs.py, python3 scripts/release_notes.py preview --working-tree: pass
  • cargo test -p omnigraph-gqt --locked --lib --test runner_dispatch: 47 + 26 passed with --test-threads=1. In parallel, concurrent_block_refusals (starved_wall, 4 s budget) timed out at load average 15–45 from other builds on the machine.
  • cargo test -p omnigraph-gqt --locked --test gq_logic_tests: 329–331 of 332 passed under the same load. The few failures were cases hitting the 10 s wall budget at 10.01 s, and each passed alone in about 8.5 s. None reads an old snapshot.

Notes for reviewers

  • Tests. The regression lives in point_in_time.rs, beside the type rename and re-add test. GQT has no schema-apply step, so a .gqt case cannot express it. It covers a node projection, a pushed filter, rename, a later addition, a whole node and an edge property, through both historical entry points. The decision logic, including the column-without-id path, is unit-tested in typed_value.rs. Coverage is unit-tested in the catalog. No current writer produces a column without an id, so that path has no integration test.
  • Semantics choice. Absent identities refuse rather than read as null, and renames are refused rather than aliased. The cost: a whole-node return { $p } is refused at commits made before a property was added (it was an Internal error before). Binding by identity (reading the old column under the current name, nulling absent nullable properties) would touch roughly ten Lance read sites and is left as a possible follow-up.
  • Compatibility. For table data written before 0.10, which records no property id, historical queries at older versions now refuse the properties they read. This matches the existing Blob rule.

A historical query plans against the current accepted schema, and its
tables were rebound by table identity, but its properties were matched
to stored columns by name only. After a property was dropped and
re-added with the same name, a query at an earlier commit read the
dropped property's values under the new one, in projections and in
pushed filters, against invariant 6.

Every stored property column already records its stable property id
(omnigraph.stable_property_id). The stored-field checks behind every
scan, hydration and edge read now resolve a property by that id.
bind_catalog_aliases records which tables a historical view pins at a
version other than the live one; only there can the column of a
property's name belong to another lifetime. On such a table a property
is refused with a request error when the column of its name carries
another id (a same-name re-add), when the id sits under an earlier name
(a rename since) or nowhere (an addition since), and, as Blob reads do,
when the column carries no id because it was written before 0.10.
Renamed and later-added properties used to fail there with an internal
error; crossing a rename stays unsupported.

@ragnorc ragnorc left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Recommendation: approve at d142e17eddb1cdd8667767c93ea0cd280560b223. No blocking defects or optional changes to request. This is a COMMENT review because the authenticated account is the PR author.

This PR prevents an old value from appearing as a new property. If Person.note is dropped and then added again, the two properties have different identities. A historical query previously matched their column names and could return the dropped value. The fix checks the persisted property ID before it reads a column. It returns a typed error when the selected version does not contain the current property under its current name.

The underlying rule is invariant 6: names do not establish a property's lifetime. The fix belongs at the boundary between the accepted schema and the stored field. The shared stored_field check covers projections and pushed predicates. Its callers also cover search fields, deferred return columns, traversal destinations, and edge properties. I traced key narrowing: it keeps the original property predicate beside the derived ID predicate. I also checked the metadata-count shortcut: it accepts entity counts, not property counts. Neither route removes a required property check.

The accepted catalog and its live snapshot are captured together. bind_catalog_aliases derives coverage from stable table identity, path, published version, native branch, and the persisted read witness. It does not infer coverage from a name or version number alone. The extra snapshot set is derived, local to the read, and rebuilt on binding. It adds no durable authority and no extra storage read to catalog capture.

The tradeoff is deliberate. An unchanged property still reads at an older commit. A renamed property, a later addition, or a same-name replacement refuses. Whole-node results therefore refuse if any current property is absent. Older unmarked columns refuse unless the selected table is still the exact current physical version. This limits historical convenience, but it avoids both a value leak and invented nulls. The user guide and release note state this limit. It follows the existing Blob rule.

This reduces long-term liability: one field check replaces name-only trust across the scalar read paths. It reuses accepted property IDs and existing version witnesses; it adds no format, writer, migration, or alternate query language. The new obligation is to carry coverage through each stored-field reader. Five more operators should call the same check, rather than add five policies. The remaining Blob-specific validation still owns Blob encoding and integrity checks. Current reads incur schema/metadata comparisons; historical reads also derive table coverage. I did not measure latency or throughput.

I checked the relevant Lance schema, evolution, and versioning documentation against the locked, unmodified Lance 11.0.0, source commit ab6b5bbe46009ed78746b444df8db59a8bc5d842. The inspected source files match their Cargo.lock-verified crate archives. Arrow/Lance field conversion preserves metadata, and renaming preserves the field's identity metadata. OmniGraph's schema evolution separately stamps and checks its graph property IDs. Lance field IDs remain a physical mechanism, not graph identity. I also inspected these surfaces in Lance 13.0.0: metadata preservation and rename behavior remain compatible with this approach. This is source inspection, not qualification of an OmniGraph dependency upgrade.

I surveyed the other open PRs. This complements #839/#815: plan acceptance and diagnostics do not replace validation against the selected stored version. It also fits the immutable-version caches in #818/#825. The per-concrete-table boundary composes with the direction in #884; actor views in #951 will need to preserve it across restricted reads. Search work in #792/#793/#952 must keep the same field check. #677 may later change historical schema semantics, but it is a draft and does not change today's contract. I found no reason to defer this fix for those changes.

The regression has a real before/after proof. GQT cannot perform schema apply at this head: its step/control types cannot express the required drop/re-add sequence. The existing Rust point_in_time owner is appropriate.

  • Before: 680fdfef96a61db898c75468e6bb42858bc4d3af, with only the PR's regression-test diff applied. One test ran and failed because the historical query returned p.note = "secret" instead of refusing. This was a behavioral failure, not a build or setup failure.
  • After: exact head d142e17eddb1cdd8667767c93ea0cd280560b223. The same test passed. It covers both historical entry points, projection, a pushed filter, rename, addition, whole-node projection, edge properties, and a valid unchanged property.
  • Additional local checks at head: all 18 point_in_time:: tests passed; the property-ID/legacy decision test passed; the catalog alias/coverage test passed. Temporary test edits were restored. The isolated checkout is clean at the reviewed head.

The exact regression command was run at both commits:

RUSTUP_TOOLCHAIN=1.97.1 \
CARGO_TARGET_DIR=/tmp/review-928/target \
RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable' \
RUST_MIN_STACK=16777216 RUSTC_WRAPPER= \
cargo test -p omnigraph-engine --locked --test integration \
  point_in_time::historical_query_binds_properties_by_identity_not_name \
  -- --exact --nocapture

The additional commands used the same environment:

cargo test -p omnigraph-engine --locked --test integration point_in_time:: -- --nocapture
cargo test -p omnigraph-engine --locked --lib historical_schema_binds_properties_by_stable_property_id -- --nocapture
cargo test -p omnigraph-catalog --locked --lib tests::historical_alias_binding_keeps_same_name_node_and_edge_identities_distinct -- --exact --nocapture

Separately, workspace/storage CI and GQT/served/DST-mode CI completed successfully; both workflow runs name this exact head SHA. I did not rerun those full matrices locally. The legacy unmarked-column branch has a local unit proof, not a new end-to-end old-store fixture. Search-specific schema-evolution paths were traced in code, not each reproduced separately.

@ragnorc
ragnorc merged commit e5c14da into ModernRelay:main Oct 12, 2026
33 checks passed
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