Skip to content

rfc: polymorphic types with interfaces, unions and polymorphic edges - #884

Open
ragnorc wants to merge 14 commits into
ModernRelay:mainfrom
ragnorc:rfc/polymorphic-types
Open

ragnorc wants to merge 14 commits into
ModernRelay:mainfrom
ragnorc:rfc/polymorphic-types

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

What & why

Adds a draft RFC for first-class polymorphism: interfaces and inline unions become abstract node types that queries can bind, traverse to and mutate through, and edge endpoints may be interfaces or unions. Today interface/implements parse and persist but nothing after the schema compiler reads them; typed edge alternation left heterogeneous endpoint unions out of scope.

The proposal keeps one concrete type and one table per node. Abstract types are type sets resolved once per invocation and lowered to per-type pieces the engine already runs. Polymorphic edges gain __src_type/__dst_type columns holding the endpoint's StableTypeId, added by a staged Lance Operation::Merge (no data rewrite; add_columns stays forbidden).

Backing issue / RFC

Checklist

  • Change is focused (one logical change)
  • Tests added/updated for behavior changes (or N/A) — N/A, docs only; the RFC lists the test owners to extend
  • Public docs updated if user-facing surface changed (or N/A) — N/A, draft RFC
  • Reviewed against docs/dev/invariants.md — the RFC's Invariants section maps each affected invariant and the reviewed deny-list items

Local verification

  • python3 scripts/check-docs.py — Documentation OK (204 Markdown files checked)
  • bash scripts/check-agents-md.sh — passed
  • typos docs/rfcs/2026-10-07-polymorphic-types.md — clean

Notes for reviewers

  • Evidence: before drafting, 134 claims behind the design were checked against this commit and the pinned Lance 11.0.0, DataFusion 54.0.0 and arrow-json 58.3.0 sources. Probes confirmed that a detached Operation::Merge adds a nullable column without touching data files or indexes, that BTREE and Bitmap indexes serve a UInt64 tag, and that BM25 scores differ across datasets for the same document.
  • Unresolved questions for review: keyed-edge id spelling for polymorphic endpoints, bare-id endpoint resolution, wide whole-node projection, historical abstract bindings, interface-named policy rules.
  • Found during the investigation and tracked separately: a named cross-type edge with a multi-hop bound (e.g. worksAt{1,2}) is accepted and silently runs one hop.

@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: keep this RFC in draft and resolve the two contract conflicts below before acceptance. This review covers ee55aa75948c7d911f765685ad2182f9a4205898. The PR changes documentation only. These findings concern the proposed contract, not new runtime failures in this PR.

What this PR means

Today, each graph node belongs to one concrete type. Applications need separate edge families or query variants when the same relationship can involve several types. This RFC lets an interface or union name that group. A query could then traverse one Subject -> Subject relationship across people and organizations.

Each node still lives in its concrete table. The compiler resolves an abstract type into a set of concrete types for one accepted snapshot. Queries read those tables and combine their results. Polymorphic edges store a stable type ID beside each endpoint ID. One graph publication still makes the complete change visible.

This could simplify application schemas and queries. It also makes some formerly single-table work depend on the number of concrete types. The RFC does not implement that behavior yet.

Changes needed before acceptance

  1. Define how endpoint generalization preserves keyed-edge identity. Adding a tag changes the canonical key tuple. The proposed metadata-only migration leaves the old edge ID unchanged. The unresolved choice of tag spelling does not resolve this migration conflict.
  2. Define direction for equal endpoint sets. The rule that rejects a source which fits both ends also rejects the promised Subject -> Subject recursion. The current compiler chooses outgoing traversal for equal concrete endpoint types.

The inline comments give the examples, source references, and required checks.

Tradeoffs and long-term liability

  • Keeping concrete tables and one graph publication limits liability. It reuses the existing storage and visibility model. It avoids a second registry that maps each node to its type.
  • Type sets can remove repeated application edge definitions and query branches. The engine must then support type sets across scans, traversal, mutation, constraints, serialization, and historical reads. That is a substantial maintenance cost, even though this PR only adds prose.
  • A stored type tag makes endpoint identity explicit and avoids searching every possible node table. Nullable tags avoid rewriting existing edge data. In exchange, every consumer must interpret an absent or null tag through the same implicit-type rule. Five similar features should reuse one logical endpoint identity and one type-set resolver. Separate rules for cascade, keys, export, and traversal would multiply liability.
  • For graph workloads with many small concrete tables, opening N tables can dominate an abstract query. Larger scans also need bounded memory and I/O across those tables. The proposed budgets and one edge scan per window fit this workload. They do not establish a latency improvement. Interface-wide uniqueness adds reads across member tables and graph-level conflict validation.
  • Refusing cross-table full-text scores is a sound initial boundary. Lance computes BM25 statistics within each dataset. A later shared search projection would add another derived artifact and its maintenance cost.

The design can reduce application liability, but it increases engine liability. Acceptance should depend on closing the identity and direction rules and keeping the added mechanisms shared. The net addition of 705 documentation lines does not measure the eventual software cost.

Evidence and limits

I checked the repository invariants, testing guide, first-principles guide, and Lance alignment material. I traced the current compiler, keyed mutation and load paths, keyed write matching, test owners, and relevant storage code.

The pinned substrate is Lance 11.0.0, source commit ab6b5bbe46009ed78746b444df8db59a8bc5d842. Its AllNulls path retains existing fragments. Its Merge validation preserves existing field bindings and requires new field IDs above the prior maximum. This supports the tag-column mechanism. It does not change existing logical edge IDs.

I also checked DataFusion 54.0.0 UnionExec and OmniGraph's partition-zero execution. The RFC's schema normalization and requirement to stream all arms have a concrete basis.

Local checks at the reviewed head:

  • All 434 compiler library tests passed. These include the existing outgoing-direction test for a same-type edge.
  • Documentation validation passed for 204 Markdown files. Agent-guide links and diff whitespace checks passed.
  • An additional key-codec unit run did not reach test execution during its dependency rebuild. I stopped that review-owned build. The identity finding rests on code inspection, not a reproduced polymorphic migration. No temporary source edits remain.

GitHub's GQT run passed and records this exact head SHA. These checks validate existing behavior and documentation. They do not validate the proposed polymorphic implementation. I did not reproduce the RFC author's compression, throughput, or detached-merge measurements. No cross-type performance claim is independently established by this review.

| Rename interface (`@rename_from`) | supported | none |
| Add `implements` when the node already declares every inherited property compatibly | supported | none; satisfaction links change |
| Add `implements` with missing nullable properties | supported | today's add-property path for those properties |
| Generalize an endpoint from `C` to an interface or union containing `C` | supported | staged `Operation::Merge` adds the tag column; `implicit_type = C` |

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.

[P2] Define keyed-edge identity before promising metadata-only generalization

This row marks generalization as supported with only a new nullable tag column. But the keyed-edge rule above also adds that tag to the canonical ID tuple. Consider an existing @key(@src, @dst) edge whose ID is ["alice","acme"]. Adding a type component produces a different ID, regardless of whether the component uses a name or a stable type ID.

The current mutation path derives this ID with canonical_key_id. Keyed writes match on that ID. A metadata-only Merge retains the old ID. A later typed upsert therefore cannot match the old row by its new canonical ID. The loader also rejects an explicit ID that differs from its canonical key, so exported old rows need a defined round-trip rule too.

Choose an identity-preserving encoding, define an explicit identity migration, or exclude keyed edges from this supported migration. The unresolved spelling question does not cover existing IDs. Require an existing keyed edge to survive generalization, typed upsert, export/load, and restart with one logical row and the intended stable identity.

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.

Addressed in 5ca8cd0 and b132a84. v1 refuses generalizing a keyed or @unique edge; rewriting ids versus keeping legacy ids is now an unresolved question. The prototype reproduced the conflict: after generalization the old row keeps [src, dst] while a later keyed write derives the id with the type, so a Merge load would insert a duplicate. For edges created polymorphic, the RFC now states the export/load round trip and requires the typed upsert, export, load and restart test you listed.

Comment on lines +197 to +199
alternation. Direction is still inferred. When a source's set fits both ends of
an edge (for example `Mentions: Named -> Note` with `Note implements Named`),
the traversal is refused unless the other endpoint's declared type decides it.

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.

[P2] Exempt equal endpoint sets from the ambiguous-direction refusal

For the next paragraph's RelatedTo: Subject -> Subject, every valid source fits both ends. Any valid destination also fits both ends, so its declared type cannot decide the direction. The stated rule therefore rejects the very recursive traversal that the RFC promises. It would also change ordinary same-type traversal if applied to singleton type sets.

The current resolve_member chooses Direction::Out when the source matches the edge's source type, including equal endpoint types. The existing test_traversal_direction_out asserts that behavior and passed locally.

Define a direction rule for equal endpoint sets, such as retaining the outgoing default, while rejecting genuinely ambiguous overlaps between unequal sets. Include one-hop and recursive tests for equal sets, plus a refusal test for the unequal overlapping case.

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.

Agreed, fixed in b132a84. Equal endpoint sets keep the outgoing reading, today's rule for same-type edges (test_traversal_direction_out unchanged and passing); only unequal overlapping sets are refused unless the other endpoint decides. The recursion was also blocked by #885's multi-hop check, which compares binding names in typecheck, ExpandStep validation and plan admission; all three now use one catalog predicate. Prototype tests cover one-hop and {1,3} over RelatedTo: Named -> Named across Person, Organization and Note with colliding ids, and refusal over Mentions: Named -> Note unless a Person destination decides.

@ragnorc
ragnorc marked this pull request as ready for review October 8, 2026 13:56

@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 this follow-up as a draft RFC. Both earlier P2 contract findings are resolved. I found no new blocking design defect. One optional clarification is inline. Before merge, correct the existing PR-title failure: use rfc: instead of docs(rfcs):.

Reviewed b132a84f993fc018d8312699c7395a83dafd90c3, including the changes since ee55aa75948c7d911f765685ad2182f9a4205898. This is a COMMENT review because the authenticated account is the author. Approval of this documentation update does not establish that the proposed runtime behavior works.

The RFC would let one relationship connect several concrete node types. Applications could replace repeated edge families and query variants with interfaces or unions. Nodes keep their concrete tables. Abstract queries combine those tables, and polymorphic edges store each endpoint's stable type ID beside its node ID. The graph still publishes a complete change once.

The two corrections address their causes:

  • Equal endpoint sets now default to outgoing traversal. This preserves today's same-type rule. Unequal overlapping sets need a declared endpoint that decides the reading. A shared catalog predicate would check whether each destination type can start another hop. That covers recursion from a concrete subtype without special-casing RelatedTo.
  • Generalizing keyed or unique edges is refused in v1. This avoids keeping old IDs while new writes derive different IDs. The current key encoder and loader's explicit-ID check support that concern. The refusal removes the unsafe migration from the supported contract. It adds no legacy-ID branch. The spelling for newly polymorphic keys remains an explicit decision before implementation, including rename and export/load behavior.

The revised catalog resolver also reduces liability. Validation, cascade, keys, export, change feeds, and traversal would share one endpoint interpretation. Filling live tags removes repeated null-tag rules. Historical images still need the single generalized_from rule. Excluding polymorphic edges from the existing graph index addresses the broader identity problem: its builder interns bare IDs under declared endpoint names.

The tradeoffs remain substantial. Type sets, tags, grammar, wire fields, cursor membership, and historical interpretation add permanent obligations. Many small member tables mean more opens and probes. Constant tags reduce encoded data, but adding a column still creates files per fragment. Five related features should reuse the resolver and type-set operations. Separate mappings in each consumer would multiply maintenance work. The facet clarification below helps keep that choice explicit.

I checked the revised storage proposal against the complete relevant upstream guidance and pinned Lance 11.0.0, source ab6b5bbe46009ed78746b444df8db59a8bc5d842. Its fragment-level add-columns API returns fragment metadata and schema without committing. The dataset-level API commits inline. Its Merge validator checks preserved fragments, row counts, field bindings, and fresh field IDs. These source checks support the proposed composition. They do not prove the complete constant-column detached transaction. The RFC correctly leaves that probe open.

Local validation on the exact head:

RUSTUP_TOOLCHAIN=1.97.1 CARGO_TARGET_DIR=/tmp/review-928/target \
RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable' \
cargo test -p omnigraph-compiler --locked --lib -j 3

All 434 compiler tests passed, with none ignored or filtered out. This includes test_traversal_direction_out. Documentation validation passed for 204 Markdown files. AGENTS/doc indexes, formatting, the RFC spelling check, and diff whitespace also passed. The isolated checkout is clean. No source or test changes were retained.

This PR changes documentation only. There is no runtime fix here for which GQT can demonstrate a behavioral failure before and success after. The prototype's reported tests are author evidence, not locally reproduced results. Its implementation should include minimal GQT cases for equal-set recursion, unequal-set ambiguity, and colliding IDs in cycle closing and negation. Each bug regression must fail for its predicted behavior before the fix and pass afterward. Detached publication, file preservation, and restart mechanics belong in the existing Rust owners. A parser refusal before a new feature exists would not prove those runtime fixes.

CI is separate evidence. The GQT job is green but explicitly skipped its tests because no GQT input changed. It checked out merge 369ca082db9231289ecc305c78f7f8386dab74cc, whose tree differs from this head. Workspace and the pinned DST suite were skipped too. The title check failed with the prefix error above. I did not reproduce the prototype, constant-column probe, cloud behavior, or compression and throughput measurements. Those remain limits on design acceptance and implementation evidence.

Comment on lines +386 to +387
`EdgeMember` gains concrete `src_type` and `dst_type`, so a polymorphic edge
expands into one member per concrete endpoint pair (a facet).

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.

[P3] Align the IR description with the tag-based prototype

This paragraph still requires one EdgeMember per concrete endpoint pair. The revised expansion and prototype sections say that one tagged scan works without per-pair facets. Please state whether the final plan keeps those facets, and what they own if it does. With N source types and M destination types, this choice adds N × M members before any rows are read. A shared endpoint declaration and tag resolver could avoid that extra representation. This is an optional clarification for the draft, not an observed runtime defect.

@ragnorc ragnorc changed the title docs(rfcs): draft polymorphic types RFC rfc: polymorphic types with interfaces, unions and polymorphic edges Oct 11, 2026

@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 this documentation update while the RFC remains draft and implementation remains not-started. I found no new blocking contract defect. One new optional correction is inline. The earlier optional question about per-pair facets still applies; I have not posted it again. This is a COMMENT review because the authenticated account is the author.

Reviewed f6ffa61d7588fa299376e3cf5352d0c73eb7dda5, including changes since b132a84f993fc018d8312699c7395a83dafd90c3. The direct RFC edit adds the prototype link and revision. The other commit merges main. The PR still changes only the RFC and its index relative to its current base.

The proposal lets one relationship connect several concrete node types. Interfaces or unions would replace repeated edge families and separate query variants. Nodes keep their concrete tables. Each operation resolves abstract types against one accepted catalog. Polymorphic edges store the endpoint's stable type ID beside its node ID. A shared resolver makes traversal, validation, cascade, keys and export interpret that pair consistently. Multi-table writes still publish once.

The earlier identity and direction corrections remain sound. Refusing generalization of keyed or unique edges avoids mixing old IDs with a new canonical key format. Equal endpoint sets retain outgoing traversal. Unequal overlapping sets require enough type information to choose a direction. These rules address the whole class of affected endpoints. They avoid a legacy-ID exception and an edge-name-specific traversal patch. The current compiler's same-type direction and cross-type hop-bound tests passed locally.

I resolved the new link to prototype commit 74cb7a8f172b59e6861850080335de5e095e0859. I read its findings and GQT case. The case uses colliding Person and Organization IDs and checks typed traversal, cycle closing, negation and cascade. The findings explicitly exclude constant-column staging, unions, abstract search, whole-node projection and interface-wide mutations from the prototype. Its reported passing suite counts remain author evidence. I did not execute the prototype.

The main merge changes the migration baseline. Current schema evolution already stages metadata-only Project and Merge operations. Schema apply commits that chain detached and publishes its final pin. The RFC should build on this owner and separate existing all-null additions from the proposed constant-filled tag addition. The latter still needs the stated storage probe. The inline correction concerns this changed baseline, not a new runtime failure.

The liability tradeoff remains explicit. Concrete tables and one endpoint resolver can reduce repeated application schemas and avoid a second identity registry. Type sets, stored tags, historical tag synthesis, wire fields and cursor membership still add permanent engine obligations. Many small member tables increase opens and integrity probes. Constant encoding may reduce bytes, but one added file per fragment still has storage and collection costs. Five similar features should reuse the same resolver, typed identity and schema-evolution path. Separate facet mappings or migration owners would add avoidable liability. No compression, latency or throughput claim was measured in this review.

I checked the repository guidance and relevant upstream material, then rechecked the storage assumptions in pinned Lance 11.0.0 (ab6b5bbe46009ed78746b444df8db59a8bc5d842). Fragment-level add_columns returns fragment metadata and schema without committing. AllNulls retains fragments and requires nullable added columns. Merge validation checks original fragments, row counts and field bindings. These facts support the proposed composition. They do not establish the full constant-column detached transaction or its recovery behavior.

Validation and limits:

  • Local, exact head: all 466 compiler library tests passed, with none ignored or filtered out. Documentation checks passed for 242 Markdown files. AGENTS/doc indexes, formatting, RFC spelling and diff checks passed. The isolated checkout is clean. No source or test edits remain.
  • GQT: this PR ships no runtime fix, so no behavioral before/after regression proof applies to this documentation change. The eventual implementation needs minimal GQT regressions for observable bugs such as colliding-ID cycle closing and negation. Each must fail for the predicted behavioral reason before its fix and pass after. Missing feature support or a parser failure does not establish that proof. Storage file preservation and crash behavior belong in the existing Rust owners.
  • CI: the latest title check accepts the corrected rfc: title. The earlier title failure is superseded. The ordinary GQT job explicitly skipped tests because no GQT input changed. Workspace and pinned DST were also skipped. Its checkout, f028fce17a0048e12a9f85c93d05a46fc9a189ad, has the same tree as this head. That does not turn skipped suites into runtime evidence.

Local compiler command:

RUSTUP_TOOLCHAIN=1.97.1 CARGO_TARGET_DIR=/tmp/review-928/target RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable' cargo test -p omnigraph-compiler --locked --lib -j 3

This review does not qualify a live cloud deployment, the prototype's performance, or the unimplemented durable format. Existing main-only schema apply and single-writer-process control boundaries remain. Immediately before posting, I checked reviews and inline comments again and confirmed the open PR's exact head.

Comment on lines +364 to +366
row ids and indexes on existing columns are kept. Today's add-property path
replaces the table with an Overwrite instead, which drops index coverage and
assigns fresh row ids. The forbidden-API registry pins the new primitive's

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.

[P3] Update the migration baseline after merging main

The merged code now uses TableStore::plan_schema_evolution and stage_schema_evolution (table_store.rs:4225,4386) for metadata-only Project/Merge changes. schema_apply.rs:791 stages and commits that chain without rewriting rows. This paragraph and the fallback description at lines 353–356 still describe the removed add-property rewrite path. Please update the baseline and identify how the constant-column addition extends the existing schema-evolution owner. Keep that unproven backfill distinct from the all-null Merge that already exists. This will avoid planning a parallel migration path from stale assumptions. This is an optional correction for the draft, not a runtime defect in this PR.

@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 this update while the RFC remains draft and implementation remains not-started. I found no new blocking design defect. One optional clarification is inline. This is a COMMENT review because the authenticated account is the author.

Reviewed 724af7a0acede28ef6298f04ab01e9c1b78f9601, including the changes since the last reviewed commit, f6ffa61d7588fa299376e3cf5352d0c73eb7dda5. The PR changes documentation only.

The proposal lets applications bind a query or UI view to an interface, and use one relationship across several concrete node types. Each node keeps its concrete table. A query resolves the member set against one accepted catalog, reads the relevant tables, and combines their results. Polymorphic edges identify an endpoint by its stable type ID and node ID. The new text makes the declared result shape independent of the current members. Adding an implementor can add rows without changing a view's columns.

Correctness and previous comments

Both previous optional comments are resolved. The IR section now specifies one EdgeMember, with row routing by tag. The migration section now starts from the existing metadata-only schema evolution path. Neither needs another inline comment.

The revised design addresses causes at their owning layers:

  • A declared interface determines the output schema. The current member closure determines rows. This covers empty, singleton and larger sets without changing the public shape.
  • Abstract bindings reach a union plan with per-member table pins, filters, access paths and late materialization. This addresses the class of single-table assumptions. The current HydrateColumns constructor confirms why intercepting one scan function is insufficient.
  • One endpoint resolver interprets explicit, null and absent tags. Cascade includes null tags only for the implicit concrete type. Thus deleting Person “alice” must preserve edges to Organization “alice”. Keyed or unique endpoint generalization remains refused in v1; equal endpoint sets retain the outgoing rule. The earlier identity and direction fixes remain intact.
  • Change-feed membership changes require a typed refusal and rebaseline. Stored-query binding identity and result descriptors prevent silent changes after names are reused. These are explicit new contracts, not behavior already supplied by today's concrete-name feed filter or query typecheck.

The optional clarification concerns the two public type values: @type changes on rename, while @type_id must remain stable. The lowering description currently maps both to the same column without defining their separate expressions.

Tradeoffs and liability

The update removes a proposed constant-column backfill and a per-pair facet model. It reuses concrete tables, schema staging, and one graph publication. This lowers implementation liability. The null-tag choice avoids writing files during generalization, but makes correct catalog-based interpretation a permanent obligation, including historical rows. A shared resolver is essential.

The feature still adds type-set operations, typed identity through joins and traversal, catalog and wire fields, registry descriptors, and feed membership rules. These costs are justified by stable interface-bound queries and mixed-type relationships. They should remain derived from the accepted catalog. Five similar features should reuse those owners rather than add separate membership or identity maps.

For many small member tables, opens and index probes can dominate latency. For larger scans, concurrency and memory bounds matter. Per-arm pruning and access selection address both shapes. Holding more handles trades retained metadata for fewer opens; the present cache caps handles at 64. The 75-member S3 latency target is an acceptance target, not a measured result of this review.

Validation and limits

I read the current repository instructions, invariants, testing, systems and Lance alignment guides. I reused the earlier complete upstream review and checked the relevant data-evolution, table-schema, DataFusion and performance guidance against the pinned source.

Lance is 11.0.0, source ab6b5bbe46009ed78746b444df8db59a8bc5d842. OmniGraph's schema evolution planner and staging compose with Lance's Merge validation and missing-field null reader. This supports unchanged fragments with a new nullable tag. I also checked DataFusion 54.0.0 UnionExec: its partitions span the inputs, whereas OmniGraph executes root partition zero. The RFC correctly requires an operator that streams every arm.

Local command on the exact reviewed head:

RUSTUP_TOOLCHAIN=1.97.1 CARGO_TARGET_DIR=/tmp/review-928/target \
RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable' \
cargo test -p omnigraph-compiler --locked --lib -j3

466 passed; none failed, ignored or filtered out. python3 scripts/check-docs.py passed for 242 Markdown files. bash scripts/check-agents-md.sh, typos docs/rfcs/2026-10-07-polymorphic-types.md, cargo fmt --all --check, and git diff --check b63a930debdd5c737edf0def1aa52fe786326435...HEAD also passed. The isolated checkout is clean; no temporary source or test edits remain.

There is no runtime bug fix in this documentation diff, so a GQT failure-before/pass-after proof is not applicable. I inspected the tests and findings at prototype commit 8f9f231d, including null-tag migration and colliding-ID cascade. I did not run that prototype. Its suite counts and compression, throughput and cloud measurements remain author evidence. The implementation must include minimal GQT cases for observable contracts and behavioral before/after proof for its bug fixes; file preservation and detached publication belong in the existing Rust owners.

CI is separate evidence. The ordinary GQT job is green but explicitly skipped the tests. Workspace tests and pinned DST were skipped too. Storage Upgrade Compatibility ran and passed. These jobs used merge ad78c24b3eacde74f80ed8a91ca4cad0b45e9103, whose tree differs from the reviewed head. They do not prove the proposed polymorphic behavior. The title check now passes.

A polymorphic edge stays one `EdgeMember`; its rows are routed by tag at
execution. The prototype needed no per-pair facets.
- `$x.@type` and `$x.@type_id` lower to literals for a singleton set and to the
query-only `~node_type` column otherwise, as `$e.@type` does with

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.

[P3] Define separate name and stable-ID expressions

Optional clarification for the draft: the public contract gives @type the canonical name and @type_id a decimal stable ID, but this rule lowers both to the same ~node_type column. For a multi-member binding, projecting both or filtering by one requires distinct expressions; otherwise one value is lost, and a rename can change the identity used by clients or tie-breaks. Please specify the column's value and how the other public value is derived from the same accepted catalog, including per-arm constant folding and expansion output. This can keep one identity source without a second registry. The implementation should include a small GQT case that projects and filters both values, then renames a member: the name changes, the stable ID does not, and the behavior also holds when the interface grows from one member to two.

@azimafroozeh
azimafroozeh force-pushed the rfc/polymorphic-types branch from 724af7a to 12f70b0 Compare October 12, 2026 15:56
Contracts from a review against UI components bound to interfaces, which
packed references do not cover:

- Result schemas come from declared types and never depend on membership;
  a redeclaration may not drop an inherited @key or @unique.
- `@type_id` is an expression separate from `@type`: the identity source is
  `~node_type_id`, the arm's StableTypeId or the packed destination's type
  half; `~node_type` is derived from it through the captured catalog.
- The union scan resolves filter, index access, statistics and late
  materialization per arm; on main every single-table shortcut broke.
- Pruning runs after parameter binding, for reads and interface-wide
  mutations; a non-member type is refused.
- The catalog route's field list and schema hash, per-commit feed expansion
  with a membership refusal, refusal of policy-denied members,
  interface-property migration rows, one namespace with stored-query
  pinning, and concurrent arms with a handle cache sized to the catalog.

@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 this documentation update while all three RFCs remain draft / not-started. I found no blocker to retaining them as proposals. Two nonblocking draft follow-ups are inline: the paging claim and the interaction with group commit. These need resolution before accepting the combined runtime contracts. This is a COMMENT review because the authenticated account is the author.

Reviewed 498ac7ce212678793c880274912a45784a446618, including the changes since 724af7a0acede28ef6298f04ab01e9c1b78f9601. The diff from base 47dfe7b4a00c1d6405bc076dc6f4f549ae6fc06c contains three RFCs and their index; it changes no runtime code.

The PR would let a query or UI view target an interface across several concrete node tables. One relationship could replace a family of nearly identical edge types. This revision adds a much larger prerequisite: every row receives an engine-issued integer ID, and every endpoint packs its type and row ID into one value. A separate per-table sequence reserves blocks of IDs. Applications keep natural-key addressing for keyed nodes, but unkeyed node handles and all edge handles change. Operators must convert existing graphs before ordinary writes resume.

The earlier name-versus-ID comment is resolved. The new lowering rule gives @type_id one stable identity source and derives @type through the captured catalog. The result-shape rules also cover empty, singleton and larger member sets without changing an interface's declared columns.

The important correctness choices are coherent at the proposal level:

  • One checked NodeRef owns endpoint identity across traversal, joins, dedup, cascade and export. This addresses collisions between equal keys in different types. Packed endpoints also remove the old keyed-edge generalization exception, after the integer upgrade.
  • Sequence reservations grant numbers only on confirmed conditional-write success. Lost acknowledgements grant nothing, missing state is not reset, and local callers share a root/table guard. These rules match the actual storage adapter: remote updates use backend ETags; local compare-and-replace requires caller exclusion. They do not create local cross-process fencing. Block caching has established precedent in PostgreSQL and Vitess, including gaps and allocation order that differs from commit order.
  • Merge correspondence is needed once independent inserts can have equal natural keys but different IDs, and keys can later change. Current three-way classification aligns string IDs directly. The proposed relation preserves that logical alignment across repeated merges. Publishing it with the row tables, carrying deleted-row equivalences, and normalizing endpoint images address the general problem; a one-time endpoint rewrite would not.
  • The upgrade extends the existing UpgradeIntent owner with a frozen conversion plan and confirmed grants. Its fence, resumable staging and single activation are necessary obligations. A successful string-to-integer conversion alone would not prove crash recovery or retained-history safety.

Tradeoffs and liability. This removes repeated endpoint interpretation and keeps one concrete table per type. It adds permanent sequence state, a correspondence dataset, old-format readers, conversion recovery, new public-handle rules, and finite 16/48-bit capacities. The shared schema allocator also spends IDs on properties and incarnations. This is a larger lifetime commitment than the previous tag-column proposal; net simplification and speed are not yet proved. Five more operators should reuse the same NodeRef and member resolver. Five more features must not add their own counters, identity maps or migration protocols. The sequence and correspondence state have distinct jobs: allocation grants versus accepted merge equivalence.

Bulk ingestion can amortize reservations. Short-lived writers consume unused grants, and interface reads still open several datasets. The proposed million-allocation bound is arithmetic, not a benchmark. The local prototype used the earlier identity representation. It does not establish integer conversion, remote allocation throughput, bounded merge-closure cost, or the 75-member S3 latency target.

Alignment. I checked the open-PR inventory. #785's group-commit admission currently relies on disjoint inserted IDs; natural-key conflicts must survive the new surrogate-ID model (inline). #951 explicitly proposes replacing this RFC's policy-denied-member behavior with actor-view semantics, so that remains a joint design decision. #963's live-branch schema work must not silently relax this upgrade's main-only fence. #964 and #677 need the correspondence pin and format boundary preserved in history, adoption and restore. #839's plan acceptance must cover union arms and their per-table predicates; #792/#793 remain the owners of lexical and ranking semantics.

I checked the pinned, unmodified Lance 11.0.0 source (ab6b5bbe46009ed78746b444df8db59a8bc5d842), including scalar-index support and transaction boundaries, against the upstream storage, index and versioning documentation. The Lance 13 release includes manifest-discovery, scalar-index and restore-conflict changes. Those are future requalification work, not evidence that this graph-wide identity protocol is supplied by Lance.

Validation and limits. On the exact head, in a clean isolated checkout:

  • /tmp/review-916-docs/bin/python scripts/check-docs.py: passed, 258 Markdown files. The initial attempts found missing Python dependencies and then a missing local v0.13.0 tag; using the docs environment and fetching the required tag resolved both setup failures.
  • bash scripts/check-agents-md.sh, typos on all three RFCs, and git diff --check 47dfe7b4a00c1d6405bc076dc6f4f549ae6fc06c HEAD: passed.
  • With CARGO_TARGET_DIR=/tmp/review-928/target, RUSTFLAGS='--cfg tokio_unstable --cfg tokio_unstable', RUST_MIN_STACK=16777216, and RUSTC_WRAPPER=: cargo test --locked -p omnigraph-storage --lib remote_conditional_updates_require_backend_etags -- --exact tests::remote_conditional_updates_require_backend_etags --nocapture: 1 passed. cargo test --locked -p omnigraph-storage --lib contract_suite -- --nocapture: local and in-memory contracts passed. The Azure wrapper reported success but explicitly skipped its body because no test container was configured; it is not Azure evidence.
  • The CI run associated with this head is green. Workspace tests were skipped. The GQT run says no GQT input changed and skipped the actual suites; green wrappers are not regression proof.

No runtime bug fix exists between the stated base and head, so a behavioral before/after GQT proof is not applicable to this PR. The new features still need the RFCs' minimal GQT cases and Rust storage/merge/failpoint owners when implemented. I added no temporary source or test edits. No new identity, sequence, migration or performance behavior was executed in this review.

Comment on lines +213 to +215
Ordering ties break on the packed reference, so an abstract binding's total
order is its `order` keys, then `@type_id`, then the row id. That makes keyset
paging by filters exact across members, because each arm evaluates the filter

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.

[P3] Make the resume predicate match an observable ordering tuple

Optional correction for the draft: a total order on the packed reference does not by itself make paging by public filters exact. The identity RFC keeps a keyed node's @id equal to its natural key and hides the numeric row ID. For two rows tied on the explicit order keys, IDs 1 and 2 can have public keys zeta and alpha. After returning zeta, a lexical @id > "zeta" filter misses alpha. Please state how the client expresses the hidden tie-break, or narrow this claim to queries with an explicit unique public ordering tuple whose comparisons the resume filter repeats. The deferred opaque cursor can carry the internal tuple. This avoids adding another public identity API just to support paging. Once paging ships, a minimal two-row GQT should use key order opposite to allocation order and verify both pages without loss or duplication. There is no new paging implementation to test in this PR.

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.

Narrowed. Paging by filters is exact only with a unique public set of ordering fields that the resume filter repeats. For keyed members that is the order keys, then @type_id, then the interface key. The deferred paging cursor carries the internal tuple. The two-row GQT, with key order opposite to allocation order, is recorded for when paging ships.

Comment on lines +205 to +208
New rows take their `__id` from the sequence cache. A moved graph head returns
`ReadSetChanged` under `ExactGraphHead`; retries capture a fresh snapshot,
resolve keys and validate again, and restage where required. A retained
confirmed allocation can supply the same ids, but cannot validate stale reads.

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.

[P3] Record the changed admission premise for group commit

Optional draft alignment, but required before combining this design with #785: the group-commit RFC admits append-only writes when their inserted ID sets are disjoint. Its proof equates ID absence with natural-key absence. Here two writers can both find Person.slug = "acme" absent and receive different sequence IDs. Their ID sets are disjoint, but their natural keys conflict. The current ExactGraphHead retry rule remains safe; the pending batching relaxation would no longer be justified. Please add an explicit dependency/compatibility rule: group-commit admission must compare the natural-key tuples that staging tested, including normalized endpoint/key tuples for keyed edges, or classify those writes as exclusive until that proof exists. Keep this in the existing admission owner. Require a focused concurrent-write test with one duplicate key and distinct grants, plus a distinct-key control. A sequential GQT cannot establish this interleaving.

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.

Added to the identity RFC's Writes section. Group commit must compare the natural keys that staging tested; for keyed edges that is the packed endpoints plus the key properties. Otherwise it treats keyed inserts and upserts as exclusive. Unkeyed inserts are unaffected, and a concurrent key change is still refused, because endpoint tables already count as validation reads. Test: two entries inserting one duplicate key under different id grants, plus a distinct-key control.

@ragnorc

ragnorc commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor Author

@azimafroozeh I read your rework (12f70b09) together with the integer node identity and distributed sequence RFCs, and checked the claims below against origin/main, the prototype (ragnorc/omnigraph proto/polymorphic-types @ 83caa5f6) and pinned Lance 11.0.0.

What 498ac7ce adds on top of your version (a fast-forward; your commits are untouched). These are contracts from a review against interface-bound UI components, which packed references don't cover:

  • declared-type result schemas;
  • @type_id as an expression separate from @type, over the packed type half (this answers the open P3 thread);
  • the union scan resolved per arm. On main, every single-table shortcut broke: the pipelined read, HydrateColumns, scan access and the metadata count;
  • pruning after parameter binding, for reads and interface-wide mutations;
  • the catalog route's field list and a schema hash;
  • per-commit feed expansion, with a membership refusal;
  • refusal of policy-denied members;
  • interface-property migration rows;
  • the inherited-@key refusal;
  • one namespace, with stored-query pinning;
  • handle-cache sizing.

Happy to adjust any of it to fit your direction.

Assessment

Packed references are the right root-cause fix:

  • They remove the three bare-id bugs the prototype hit (P2 cycle closing, P4 graph index, P6 @card) and the key-derived-id problem (P5). P1 and P7 disappear because there is no tag column. One small correction to the identity RFC's motivation: P1 isn't a bare-id comparison; it was tag placement in the loader.
  • Editable keys are a real gain. Main refuses update of a @key property today (exec/mutation.rs:1627-1636).
  • The type-range predicate on one indexed column works: Lance 11's BTREE supports SargableQuery::Range.
  • Your type-test semantics (prune only when the whole filter proves an arm can't contribute) are tighter than what I had, and so is the gated, admission-validated interface @unique.

Three points I'd push on:

  1. Phase 1 shouldn't wait for the storage upgrade. Interface bindings over node tables need no storage change; the prototype runs them on today's string ids. What they need is one identity carrier in dedup, tie-breaks, cycle closing and anti-joins. I propose introducing NodeRef in the engine first, carrying (StableTypeId, today's id), and switching its id half to u64 when integer identity lands. Phase 1 (abstract bindings, the catalog route, interface-wide update) then ships before the format upgrade, with no dual identity rule.
  2. Merge correspondence is the riskiest new surface. Today, identical keyed inserts on two branches converge because their ids derive equal, and differing payloads give DivergentInsert (merge truth table). Rebuilding that with natural-key matching, surrogate remapping and an authoritative correspondence dataset is well specified but large. I'd review and land it as its own unit, with the DST oracle in place before activation.
  3. A write-cost budget test. Measured today (500-row table, warm handles): a keyed node insert makes 1 data read; an edge insert makes 8 reads across 3 tables, because the endpoint RI probes already read both node tables; a keyed update makes 3 reads. Key resolution can reuse the RI probe, so these should stay roughly flat. A test that pins them would make "absent coverage changes cost only" checkable.

Edited: removed a wire-migration note and an optional type-id counter ask; measured headroom and further findings are in the follow-up below.

Evidence: the probes are in crates/omnigraph/tests/ui_consumer_probes.rs on the prototype branch, and the findings in UI_BINDING_FINDINGS.md and PROTOTYPE_FINDINGS.md.

@ragnorc

ragnorc commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@azimafroozeh Follow-up on the points above, investigated against origin/main. The new probes are on the prototype branch at 0f5e48c3.

1. Phase 1 before the storage upgrade (NodeRef first) looks feasible, and the work isn't thrown away.

  • Sites: about a dozen, roughly 700 to 900 lines:
    • sort tie-breaks (scope_tiebreaks, which also feeds rrf's row tie-break);
    • rrf fusion, which keys its rank and row maps by bare String ids;
    • per-arm HydrateColumns;
    • cycle closing;
    • the traversal interner;
    • typed hash join and id lookup.
  • Carrier: two columns, {b}.~node_type_id (UInt64) beside the existing {b}.__id. That pair sorts exactly like a packed value, and every single-table reader of __id stays untouched. In Rust, NodeRef<I> { type_id: StableTypeId, id: I }, with I a string id now and u64 later. For a singleton set, the type is a plan constant and folds away.
  • Not throwaway: your RFC keeps retained history readable in the old representation, and the binary reads not-yet-upgraded graphs. So a carrier generic over the id half is needed anyway.
  • Phase 1 must fix tie-breaks and rrf (the prototype missed both), and avoid:
    • the prototype's type\u{1f}id string keys;
    • type names as the identity half.
  • Costs:
    • Within-type order changes at the upgrade, which you already accept.
    • Unkeyed @id handles held by phase-1 clients change at the upgrade.
    • Saved plans are refused once more, because BOUND_PLAN_VERSION must match exactly; they are never run wrongly.
  • Limit: phase 2 still needs packed references.

2. Merge correspondence is correct, but heavier than the common case needs. Today, merge already behaves as a natural-key join, because the cursors join on __id and a keyed __id is its key. Three gaps hold on main:

  • ordered_table_keys.sort() processes edge:* before node:* (exec/merge.rs around 5262). The RFC's remap needs every node table classified first.
  • same_graph_state compares only the schema contract and table manifests (merge.rs:2984-2992). A merge whose only effect is a correspondence update would look unchanged, and a later fast-forward could adopt the source pin and drop the target's relation. The correspondence pin has to join that comparison, and such a merge has to publish.
  • @key uniqueness skips the committed lookup because it is "id-backed" (validate.rs:170-176). That skip, and proven_fast_forward_needs_no_validation's reliance on it, must go.

From analysis, not yet tested:

  • "Complete correspondence snapshot" is never checked, so a pin that resolves to a version with missing rows looks like singleton classes. A row count or checksum would catch that.
  • The relation only grows, including deleted-row members, and has no collection rule.
  • The DST oracle ("one row per key per type") still passes when an update lands on the wrong logical row. It needs a hidden logical id per insert.

Alternatives considered:

  • Shared key→id allocation means a contended conditional write per new key, plus a key registry that can never be collected.
  • A 48-bit key hash collides with probability about 0.36 when merging 1M new rows into 100M.
  • Both are worse than your design.

A simpler first step: align rows by surrogate first, then by (type, key) in all three images, with no stored state. With immutable keys, this reproduces today's semantics exactly. With mutable keys, it is still correct for every row that was never coalesced. The one degraded case is a coalesced row whose key later changes; I'd make that a typed conflict at first. If cross-branch key changes matter, carry correspondence as an alias list on the surviving row (__aliases: List<UInt64>) instead of a separately pinned dataset. That travels with the row through forks, fast-forwards, adopts and compaction, with no new pin or collector rule.

3. Write cost should stay flat if key resolution reuses the existing probes. Measured today: an append of 1,000 edges between existing keyed nodes makes 4 data reads across 3 tables. Endpoints are already resolved in one batched RI probe per node table, and the same per-type key probe can also serve the @key uniqueness lookup that comes back. A budget test pinning these counts, together with the single-write counts above (1, 8 and 3 reads), would hold that line. The bulk keyed-node merge count isn't reliable in our harness, which misses reads in spawned tasks, so I've left it out.

4. Type-id headroom is fine; no separate counter is needed. The personal graph's dataset paths (from /snapshot) peak at incarnation id 2,080, so the shared allocator has used at least 2,080 of 65,535 ids (3.2%), counting retired ones.

Abstract type sets resolve against the actor's schema view, and
view.restricted replaces the policy_denied_member refusal and the
policy_filtered marker: the refusal disclosed that a hidden member exists,
and its only escape required naming a type the caller may not know. An
inherited placement cannot be dropped or added per member, and the catalog
route, schema hash, descriptors and feed refusals describe the schema view.
Resolves the interface-named policy rules question.
…d scorer

Rechecked against the pinned Lance 11.0.0 source and a probe on the
prototype branch:

- Packed UInt64 endpoint filters (type range, =, IN, values above 2^63) are
  served by scalar indexes. Endpoint columns use BTREE, since a bitmap loads
  every distinct key, and packed filters are built from u64 literals.
- Row addresses and stable row ids are per dataset, so neither keys rows
  across member tables.
- A handle reopen costs a HEAD because the manifest size is not passed.
- bm25 across members becomes a phase 3 feature: one corpus scorer from all
  members' inverted indexes (build_global_bm25_scorer) injected into each
  arm (with_base_scorer), replacing the derived projection for cross-type
  full-text ranking. Verified in source, not yet run.
@ragnorc

ragnorc commented Oct 13, 2026

Copy link
Copy Markdown
Contributor Author

@azimafroozeh I rechecked the Lance-dependent assumptions in both RFCs against the pinned Lance 11.0.0 source, and with a direct probe on the prototype branch (tests/lance_identity_probes.rs @ e9147d30). Most hold. Four points touch the identity RFC.

Holds (probe plus source):

  • On a packed UInt64 column, BTREE and bitmap indexes both serve a type range (>=/<= and BETWEEN), = and IN, with correct rows. That includes packed values above 2^63 (type id 40000). maybe_range in lance-index scalar/expression.rs turns the bounded pair into SargableQuery::Range, and SQL literals above i64::MAX parse as u64.
  • Row addresses are (fragment << 32) | offset per dataset; two tables gave the same first addresses. Stable row ids also start at 0 in every dataset (next_row_id in the manifest).
  • Each dataset plans index use from its own index metadata. Index pages are cached per session by dataset URI and index UUID, so they survive a handle reopen.
  • merge_insert probes a key-column index with IsIn when one exists.
  • The staged Operation::Merge validation and the null reader for missing fields behave as the metadata-only evolution path assumes.

For the identity RFC:

  1. The stable-row-id alternative is rejected for a reason that doesn't hold. The table says move-stable row ids are "reassigned on update and merge". In Lance 11 they are preserved: update carries the old ids into the rewritten fragments (dataset/write/update.rs:331-334, 455-477, test test_row_ids_stable_after_update), and so does merge_insert (merge_insert.rs:2768-2789, asserted at 4052-4056). Compaction preserves them too. The rejection still stands on allocation: every dataset counts from 0, and I'd expect two branches of one table to allocate independently from the forked next_row_id and collide at merge. Delete plus re-insert also changes them. Suggest restating the reason as per-lineage allocation.
  2. Index the packed endpoints with BTREE only. A bitmap index answers the same predicates, but it loads every distinct key into memory when opened (scalar/bitmap.rs:412), and packed endpoints are nearly unique. The RFC currently cites both index kinds.
  3. Build packed filters from u64 literals. If a packed value is built through i64, it fails loudly in a SQL string. In filter_expr it silently loses the index instead: the comparison runs as Decimal128 through a cast rather than as a probe.
  4. A warm handle reopen still costs a HEAD. OmniGraph opens pinned versions with with_version and no manifest size, so resolve_version_location issues a HEAD before Lance's manifest cache is consulted (dataset/builder.rs:880-919, dataset.rs:836-848). Passing the size recorded in __manifest would make warm reopens free. That matters for a 75-member interface, which today evicts its own handles from the 64-entry cache.

A design opportunity, now in the RFC (d9caeb69). Lance 11 accepts injected corpus statistics. build_global_bm25_scorer (lance-index scalar/inverted.rs) sums document counts, token totals and per-term document frequencies over any list of inverted-index handles, and FTS exec nodes take the result through with_base_scorer (io/exec/fts.rs). One scorer built from every member's index makes bm25 scores comparable across an interface's member tables, without a derived projection. I've moved cross-member bm25 to phase 3 on that basis, and dropped the derived projection from phase 4. Requirements:

  • one analyzer per member, by declaring the full-text index on the interface property;
  • the scorer must cover every query token, including fuzzy expansions;
  • the engine builds those plans itself, since the scanner doesn't expose the hook.

This is verified in source only; I haven't run it.

Comment on lines +521 to +523
- **Search across members (phase 3).** `bm25` over an abstract binding builds
one corpus scorer from every member's inverted index with Lance's
`build_global_bm25_scorer`, which sums document counts, token totals and

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Build the shared scorer from the actor's statistics source

Blocking. This paragraph sums index-wide statistics "from every member's inverted index". The same revision adopts #951 for visibility. #951's non-interference contract covers search ranks. Its "Full-text statistics" section requires view-independent statistics (public or uniform) for restricted actors on placed types. Bypass actors keep index-wide statistics. Summed index-wide statistics carry the term frequencies of rows the actor cannot see. If "every member" means the declared closure, they also carry hidden member types. The Policy section says those types are absent from the set. So the two contracts conflict in this document.

The affected class is every score computed across tables: bm25 across members now, and later rrf or compound full-text queries over N arms.

Please state the rule here, once:

  • The scorer covers the type set after resolution against the actor's schema view. It never covers the declared closure.
  • Each member's statistics come from the source rfc: granular permissioning #951 selects for that actor.
  • When the source differs between arms (one arm restricted, one not), all arms use one source, so the scores stay comparable. View-independent statistics for all arms is the safe choice.
  • Bypass actors keep the summed index-wide scorer.

Test, with the other actor-view server tests: a restricted actor's bm25 scores over an interface do not change when a hidden row, or a hidden member type with matching rows, is added.

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.

Fixed in 014f921. The scorer now covers only the member types the actor can see, never the full declared set. Statistics come from the source #951 picks for that actor. One query uses one source for every type: if any type needs view-independent statistics, all of them use it. Bypass actors keep the summed index-wide statistics. The hidden-row and hidden-type test is under Tests to extend → Server.

Comment on lines +524 to +526
per-term document frequencies over any list of index handles. Each arm's
full-text plan takes that scorer through `with_base_scorer`, so every arm
scores against the same statistics and scores merge like distances. The

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] On Lance 11 the arms do not score against the same statistics

Blocking, because merging the arms' scores depends on this claim. Two cases break it.

Unindexed rows. In Lance 11.0.0 the scanner adds a FlatMatchQueryExec for rows that no segment covers. That branch takes the injected scorer and adds its own dataset's unindexed rows to it (lance-index scalar/inverted/index/flat_search.rs: initialize_scorer, called from flat_bm25_search_stream_with_options_and_scorer). So each arm scores with "shared statistics + its own unindexed rows". In OmniGraph this is the normal state after a write: commit_staged_detached commits an untrained segment, and "postings still come from explicit reconciliation" (crates/omnigraph/src/table_store.rs, doc comment near line 3888).

I ran a temporary probe of that public function, with one shared scorer (100 docs, document frequency 5) for two arms. The same text, graph database, got these scores:

  • 2.91 through the shared scorer (the indexed path);
  • 2.75 in an arm with 1 unindexed row;
  • 1.00 in an arm with 50 unindexed rows.

So a member with a write backlog ranks its own matches lower, and the merged order depends on index coverage. Invariant 6 says coverage can change cost, not results.

Pruning. Line 492 says pruned members are never opened. If pruned arms leave the scorer, an optimizer decision changes the scores: @type_id = $t, and an equivalent filter that does not prove pruning, give different scores for the same row. If pruned arms stay in the scorer, they must be opened.

Please define the corpus as a contract, not as a mechanism:

  • The corpus is the resolved type set after rebinding. Filters, including type tests, never change it. This matches one table today, where a filter does not change the statistics.
  • Unindexed rows are in the corpus for every arm, or the text says that scores are approximate while coverage is partial and gives the bound. For the first option: one pass collects each arm's flat statistics, then every arm scores with the total. Lance's SharedFtsScorer does this inside one dataset.
  • A pruned member contributes its index statistics without a data scan.

When this is implemented, extend bm25_distinct_scores_rank_descending.gqt to an interface fixture. Check that the scores do not change when a type-test filter is added. If GQT cannot keep one member's postings unreconciled, use the search.rs owner for that case.

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.

Agreed, and confirmed in the Lance source. One addition: for row-level documents, Lance 11 shares no scorer between one table's indexed and flat branches either. It does so only for list elements, and that is unchanged in 13.0.0. So single-table bm25 already depends on index coverage. The corpus is now a contract: the resolved type set after rebinding. Filters and type tests never change it, and skipping a type removes its rows but not its statistics. Unindexed rows count for every type: one pass collects statistics, then everything scores against one total. Tests: the GQT on an interface with a type-test filter, and the indexing-backlog case in tests/search.rs.

Comment on lines +529 to +531
fuzzy expansions. Lance's scanner does not expose the hook, so the engine
builds those plans itself. This replaces a derived projection for
cross-type full-text ranking; the API is verified in source, not yet run.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P3] Give scorer injection one owner, preferably in Lance

Optional, but it changes the long-term liability. Scanner::full_text_search does more than score. In Lance 11.0.0 it also:

  • selects the segments;
  • adds the flat branch for unindexed fragments;
  • blocks overlay-stale rows (fts_overlay_plan, then MatchQueryExec::with_overlay_block);
  • falls back to a full scan for legacy segments.

All of these are in lance/src/dataset/scanner.rs. MatchQueryExec::with_overlay_block is pub(crate) in 11.0.0, 13.0.0 and 14.0.0-beta.19. So a plan that the engine builds cannot do that step through the public API. A hand-built plan copies these rules, and each Lance upgrade must recheck them. That conflicts with this RFC's invariant text, "Nothing duplicates a Lance primitive".

#951 plans the same engine-built full-text plan for restricted actors. Two hand-built planners will drift. Please name one owner:

  • Best: an upstream scanner option that accepts a base scorer. The engine then supplies the statistics and Lance keeps the planning. Record it as a phase-3 dependency or in blocked_on.
  • Otherwise: one engine module, shared with rfc: granular permissioning #951. Its input is the resolved type set and the actor's statistics source. Its output is one plan per arm. A differential test compares its rows with Scanner::full_text_search on a dataset that has unindexed fragments.

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.

Took the upstream route, and plans the engine builds itself are now a rejected alternative. Phase 3 depends on a scanner option that takes a corpus scorer and uses it unchanged in both branches. It also needs a call that returns one dataset's statistics for the query's tokens. This is recorded under Rollout phase 3, not blocked_on, because phases 1 and 2 don't need it. One engine module, shared with #951, is the only code that chooses or sums statistics. with_overlay_block is also pub(crate) in 13.0.0. 14.0.0-beta.19 isn't on crates.io, so I cited 11 and 13.

Comment on lines +293 to +294
of opening every member. A bound type that is not a member of `I` is a typed
refusal, not zero rows. A member whose predicate matches nothing stages

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P3] Give a hidden member's @type_id the same refusal as an unknown ID

Optional and small. The Policy section makes a hidden type name fail like a misspelling. That covers literals. Here $t is a value, and it is checked after parameter binding. Please state that a $t that names a hidden member gets the same refusal, byte for byte, as an ID that is not in the actor's catalog. #951 requires this for hidden names. Without it, a component can probe type IDs and learn which hidden members exist.

A server test with the other actor-view cases can pin it: bind $t to a hidden member's ID and to an unused ID, then compare the two error bodies.

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.

Added. A bound $t or @type value that names a hidden type gets the refusal of a value missing from the actor's catalog, byte for byte. A server test compares the two error bodies.

… cost as a change

Bypass actors, and reads no view restricts, sum index statistics and
unindexed rows over the resolved type set. Restricted actors use Actor
views' view statistics for every arm, which replaced public and uniform
statistics there. The warm-reopen paragraph now states today's HEAD and GET
for a detached pin and proposes the commit-handler change. The public paging
tuple names every key property and the unkeyed Type:number sort. A hidden
type id's refusal matches an unknown id apart from the echoed value.
A non-negative i64 still coerces to UInt64; a literal Lance cannot coerce,
such as a wrapped negative i64 or a Decimal128 above i64::MAX, loses the
index. Packed values above i64::MAX come from every type id from 32768 up.
@ragnorc

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Optional 3: phase 3 names both Lance additions, and combined_fields is noted as internal precedent. Optional 5: the hidden-id refusal requires the same code and wording apart from the echoed id, and neither error says the type is hidden. The literal precision is applied in both RFCs. #951 now builds a flat-only plan for single-type restricted search. It reads no postings, so the overlay concern doesn't apply, and both RFCs share one statistics module.

of its rows. It sums the members' counts, and every arm scores against
that total. The flat operator adds its own input to a supplied base
(`lance-index` `flat_search.rs` `initialize_scorer`), so each arm's base
is the other arms' sum. Hidden rows, hidden member types and index

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] The flat base and the scanner corpus are different values

Blocking. This sentence gives each arm the other arms' sum, because initialize_scorer adds its own input. The module sentence below says the output is one scorer. The scanner option uses that corpus unchanged.

initialize_scorer (lance-index flat_search.rs) copies the base's document count, token total and document frequency, then adds every input row. A flat call whose base is already the full population counts this arm twice. Arms of different sizes then disagree. That is the split the earlier probe measured (2.91, 2.75, 1.00).

The unchanged scanner must receive the full population, including this arm. The flat call must receive the other arms only. Record both in the module: the per-arm counts, and their sum. Pass the sum of the other arms to the flat call. Pass the sum of every arm to the scanner.

The bypass bullet is short one field. It adds "document and token counts" for unindexed rows. initialize_scorer also adds document frequency: one per input row whose count for that query token is greater than zero. Index statistics already carry document frequency for indexed rows. The unindexed addition has to carry it too. Without it, an unindexed match is scored as if the term were rarer than it is, and two members with different backlogs disagree. Name document frequency in that bullet, as the restricted bullet already does.

`e_tag`, on an ordinary open of a detached pin. `get_manifest` then uses
the session cache, and a later open of that pin sends no request. A pin's
`manifest_size` and `e_tag` are optional (`TableVersionMetadata`), so a pin
without them keeps the HEAD and the GET.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P3] A missing e_tag does not keep the HEAD

Optional. Dataset::get_manifest skips the session cache only when size is absent (lance dataset.rs). The cache key stores e_tag, and a null e_tag still caches. open_with_size skips the HEAD when the size is known (lance-io object_store.rs). A pin with a size and no e_tag does not send the HEAD. Say that only a missing size keeps the HEAD and the GET.

The handler cannot return the pin yet. resolve_version_location receives the version (lance_clone.rs). open_pinned_dataset receives the version and not TableVersionMetadata (instrumentation.rs). Phase 1 needs a carrier from the caller that holds the pin into the handler, as CLONE_CONTEXT carries a clone location. The handler has no pin to return until that carrier exists.

with no query filter, and sums them. Every arm scores against that
total.
- **A restricted actor, when the view restricts any member the query
reads:** view statistics for every arm, as Actor views defines them for

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P3] Choose the procedure from the resolved type set

Optional. "Any member the query reads" can mean the arms that return rows. Take an interface {Public, Secret} where the view restricts Secret, and a query that prunes Secret with @type = Public. If the procedure then sees no restricted arm, it takes index statistics. The next sentence adds the pruned member's population. Secret's index statistics include hidden rows.

The corpus above is the resolved type set, and pruning removes rows, not statistics. Write the condition as "any member of the resolved type set, including a member pruning removed." The same view-statistics procedure then covers every arm.

traversal probes) is built from `u64` literals. Lance coerces a literal to
the column type only when the value fits (`lance-datafusion`
`safe_coerce_scalar`): a non-negative `i64` still coerces, but a literal it
cannot coerce, such as a wrapped negative `i64` or a `Decimal128` above

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P3] Every Decimal128 loses the packed index

Optional. safe_coerce_scalar returns None for every Decimal128 when the target is UInt64, including a value that fits in i64 (lance-datafusion expr.rs). maybe_scalar then builds no indexed expression (lance-index scalar/expression.rs), and the filter runs as a scan. The rows stay correct. The index is what is lost.

A SQL integer above i64::MAX is one way to get a Decimal128. A smaller Decimal128 takes the same path. Say that the type fails coercion, and that SQL uses that type for an integer above i64::MAX. The non-negative i64 rule and the type-id 32768 bound are right: 32768 << 48 is 2^63.

@cursor

cursor Bot commented Oct 18, 2026

Copy link
Copy Markdown

Recommendation: request changes before this revision is accepted. The three RFCs can stay drafts. This note is a comment. The tool that posts it cannot set a review state.

Reviewed commit 6dd108ecfd9287be515415acc487912842b9c72e. The comparison is fca9e3503bd7a5d54ccd1483f6126f758e37fc6a to that commit. The content commits are 98a4ed8369f3826ca02e3186d2e9e15100827cae and 6dd108ecfd9287be515415acc487912842b9c72e. They change the polymorphic-types RFC and the integer-identity RFC. They change no runtime code. Immediately before this comment, the PR was open and the head was still 6dd108ecfd9287be515415acc487912842b9c72e. No other review covers that commit. The empty reviews on that commit are the author's thread replies.

What changed

The revision answers the 2026-10-18 review. Each actor class has one corpus procedure. Bypass actors, and reads that no view restricts, sum index statistics and unindexed rows. Restricted actors use view statistics for every arm, which is the population #951 now specifies. The warm-reopen paragraph states today's HEAD and GET, and moves the commit-handler change to phase 1. The public paging tuple names every key property, and an unkeyed Type:number id sorts as a string. A hidden type id uses the same error code and the same wording apart from the echoed value. Packed-filter text names which literals still hit a BTREE and which literals lose it.

Earlier findings

  1. Partly fixed. Corpus procedure and the actor view (inline). The two procedures match rfc: granular permissioning #951 at 7f3d601e: view statistics for a restricted actor, index statistics for a bypass actor. Hidden rows stay out of the restricted population when the flat operator counts view-filtered rows. The value each arm receives is still two numbers. See blocking finding 1.
  2. Fixed. Arms that add their own unindexed rows. The Lance 11 and Lance 13 scanner still shares a scorer only for list elements. The text still requires one total before scoring, and it still refuses the feature until the scanner uses the supplied scorer unchanged.
  3. Fixed. Both Lance additions are named in phase 3. combined_fields is named as crate-private precedent. rfc: granular permissioning #951 and this RFC name one statistics module.
  4. Fixed. The literal rule is in both RFCs. A non-negative i64 still coerces. Optional finding 2 tightens the Decimal128 example.
  5. Fixed. The reopen paragraph states today's HEAD and GET, and it proposes the handler change as phase 1 work. Optional finding 1 covers the cache key and the missing carrier.
  6. Fixed. A hidden type id and an unknown id share the error code and the wording apart from the echoed value. Neither body says the type is hidden.
  7. Fixed. The public tuple is the order keys, then @type_id, then every key property. Note:10 sorts before Note:2. The composite-key GQT is named.
  8. Fixed. Group-commit admission is unchanged in this delta and still compares natural keys, or treats keyed inserts as exclusive.

Blocking

  1. One module output cannot be the base of both routes (inline).

Optional

  1. Only a missing size keeps the HEAD. The handler does not see the pin (inline).
  2. Every Decimal128 loses a packed index, including a value that fits in i64 (inline).
  3. Select the actor procedure from the resolved type set, with pruned members included (inline).

Tradeoffs and liability

The revision removes the false claim that a warm reopen is already free. It removes the single collection step that put hidden term frequencies into a restricted score. It adds one procedure per actor class, aligned with #951's withdrawal of public and uniform statistics.

The new liability is the scorer value. The flat operator adds its input. The future scanner uses a corpus unchanged. Those callers need different bases. "One scorer" names one of them. An implementer who passes that scorer to with_base_scorer counts each arm twice. Five later features (bm25, restricted bm25, rrf, compound search, and a Lance upgrade) then copy the wrong base. The module should record one population: per-arm counts and their sum. The flat call receives the other arms. The unchanged scanner receives every arm.

This RFC withholds multi-member bm25 from every actor until the scanner option exists. #951's flat plan could collect view statistics on Lance 11 today. One admission rule is the right trade. Shipping the restricted route first would give one actor a rank the other actor cannot get, and the two plans would drift. One gate comes before an earlier restricted-only path.

No performance number is claimed for the pruned-member pass. The text says that pass is not measured. A selective query can still read every member's visible text. I make no cost claim.

Lance alignment

Pinned Lance is 11.0.0 (Cargo.lock checksum be781f40c7a75f9eae2188a2f71174acb7a360dca97163db40b041d0828dea48). I read the FTS page on lance.org. Corpus statistics there are per index file (docs.lance token totals, posting _length as document frequency). There is no cross-table corpus. I read the pinned sources:

  • initialize_scorer adds the base's document count, token total and per-token document frequency, then adds every input row (lance-index scalar/inverted/index/flat_search.rs).
  • flat_bm25_search_stream_with_options_and_scorer is public and returns that scorer. An empty token list returns the base and does not count the input.
  • plan_match_query shares a scorer only when a flat path exists and the documents are list elements. The same condition is in Lance 13.0.0. with_overlay_block and with_shared_scorer are pub(crate) in 11.0.0 and 13.0.0. plan_combined_fields_query in 13.0.0 builds a SharedFtsScorer inside the crate.
  • safe_coerce_scalar converts a non-negative i64 to UInt64 and returns None for a negative i64 and for every Decimal128 (lance-datafusion expr.rs). maybe_scalar then drops the indexed form (lance-index scalar/expression.rs).
  • default_resolve_version returns a detached location with size: None and sends no HEAD (lance-table io/commit.rs). Dataset::get_manifest skips the session cache when size is absent (lance dataset.rs). The cache key includes e_tag, and a null e_tag still caches. CloudObjectReader::size sends the HEAD only when the reader has no size (lance-io object_reader.rs). open_with_size documents that a known size skips the HEAD.
  • Packed layout (StableTypeId << 48) | id. Type id 32768 shifts to 2^63, which is above i64::MAX. Type id 32767 fits.

OmniGraph at this head: open_pinned_dataset opens the detached pin by version only (instrumentation.rs). IndexOriginCommitHandler::resolve_version_location returns a location only from CloneContext, and main_source_location refuses a detached version (lance_clone.rs). TableVersionMetadata.manifest_size and e_tag are optional (metadata.rs). The handle cache cap is 64 (handle_cache.rs). max_exact_search_rows is not in this tree. It is the budget #951 names.

#951 at 7f3d601e withdraws public and uniform statistics, builds a flat plan with a zero base for one member, and asks for the other arms' view statistics as the base for several members. Its phase line unblocks restricted multi-member bm25 when arms can take those statistics. This RFC says Lance 11 can return them today, and it also refuses every actor until the scanner option. The phase line in #951 should name that same scanner-option gate, or the two drafts admit the feature on different days.

Validation

Local, in a detached worktree at 6dd108ecfd9287be515415acc487912842b9c72e:

  • python3 scripts/check-docs.py: Documentation OK (335 Markdown files checked).
  • bash scripts/check-agents-md.sh: OK.
  • typos 1.50.1 on the three RFCs and docs/rfcs/README.md: no findings.
  • git diff --check fca9e3503bd7a5d54ccd1483f6126f758e37fc6a HEAD: clean.

CI at this head is green: CI, GQT, PR Title. Test Workspace, rustfmt and clippy were skipped. Each GQT job took "Skip when no GQT input changed" and skipped the test steps. Green CI is not evidence for this design.

Regression proof

This delta fixes no runtime behavior. A GQT failure-before and pass-after does not apply. The concrete claims about Lance 11, Lance 13's scorer visibility, and current OmniGraph were checked by reading the pinned sources and the code at this head. I did not add a temporary test, and I did not push one.

Limits

I did not re-run the numeric probe (scores 2.91, 2.75 and 1.00). I did not open a dataset on S3. I did not run the prototype. I did not execute #951. Lance 13 was read from the v13.0.0 tag, not executed.

…spaces' type references

Stored-query pinning records the kind and id of every type and edge name a
query resolves, not only bindings. The migration table refuses only
cross-kind reuse in one apply, so a same-kind rename and re-add, including a
move under Schema namespaces, is caught here. Type references share Schema
namespaces' qualified spelling, and item 14 no longer says namespace.
Qualified names from Schema namespaces fit Type:number handles when the
number follows the last colon. A namespace move is a rename, so it respells
every unkeyed node and edge handle of the type.
`ownedBy`: the migration table refuses only cross-kind reuse in one apply, so
pinning is what catches same-kind reuse. A move under
[Schema namespaces](https://github.com/ModernRelay/omnigraph/pull/1015) is a
rename and relies on the same guard.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Posting test for review delivery. This comment will be replaced by the full review if the API accepts it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Pinning does not see a same-kind re-add in one apply

Blocking. This paragraph says one apply can rename edge OwnedBy away, add a new edge OwnedBy, and give the stored name a different id. Pinning then refuses the stored query. The migration table refuses only a cross-kind reuse, so pinning is the guard. The decision log puts a namespace move in that class and says pinning catches it.

resolve in crates/omnigraph-compiler/src/catalog/schema_ir.rs matches a desired declaration to an accepted type by kind and name first. A desired edge OwnedBy with no hint takes the accepted OwnedBy id. edge HeldBy @rename_from("OwnedBy") takes that same id. The function then returns accepted edge 'OwnedBy' is consumed by more than one desired declaration. The apply stops in the resolver. Pinning does not run.

Temporary tests in catalog::schema_ir::tests, run at this head and then removed:

  • same_kind_rename_and_readd_in_one_apply_consumes_the_id_twice returns that error.
  • same_kind_readd_across_two_applies_mints_a_new_id: the rename apply keeps the id. A later apply that adds OwnedBy mints a new id. Pinning is the guard for that second apply.
  • cross_kind_name_reuse_in_one_apply_is_admitted: node Event renamed to Gathering, plus a new interface Event, resolves. The interface receives a new id. The table's cross-kind refusal is a new rule. The resolver admits this case today.

#1015 at 2e672c0c says a move keeps StableTypeId. A stored query that still resolves the name, for example through use crm;, keeps that id. Pinning accepts it. A query that still says the old unqualified name fails typecheck. Pinning refuses the case where the old name resolves to a different id. #1015 also says ad-hoc query text is resolved again and can change meaning. This paragraph omits that path.

Write one rule:

  • A pure rename, including a namespace move, keeps the id. A stored query that still resolves to that id is accepted. A stored query whose name no longer resolves fails typecheck.
  • A later apply that adds a declaration with the released name gives that declaration a new id. Pinning refuses a stored query whose recorded name now has a different kind or id.
  • State what one apply does when the desired schema contains both the rename and a new declaration with the old name. Today the resolver rejects it. If the design wants a new id on the new declaration, say that @rename_from wins over the exact name match, and name resolve as the function that changes.
  • Item 14 says "same-apply reuse refused" with no kind. The table and phase 0 refuse cross-kind reuse. Use those words in item 14.
  • The cluster test should accept a pure rename and refuse the second-apply re-add. It should follow the one-apply rule you choose. Cover bindings, rebindings, alternation members and load targets. The paragraph records those names. The test lists a subset.
  • The decision log sentence that a move is caught by pinning should match this rule.

#1015's pinning list omits rebindings and alternation members. Keep one list in both drafts.

This is invariant 6. Identity is the stable id. The resolver owns the one-apply match. The registry owns the later check. A sentence that gives the later check the resolver's job can refuse a legal move, or miss the two-apply retarget.

| 11 | Live views | Expanding at cursor creation freezes membership. | Per-commit expansion and membership refusal. |
| 12 | Remove-`implements` dependency | "Depends" was undefined. | Typecheck-based rule and registry pinning. |
| 13 | Per-type policy | A denied member was silently filtered. | Member sets from the actor's schema view, with `view.restricted` as the completeness signal. |
| 14 | Shared core name collision | `interface Event` and `node Event` coexist on main; reusing a renamed name changes stored queries silently. | No interface shares a name with a node or edge type; same-apply reuse refused; registry pinning. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P3] Compare canonical names

Optional. This cell says an interface shares no name with a node or edge type. #1015 applies that rule to the canonical name, so crm::Event as an interface and core::Event as a node can coexist (2e672c0c, "Kinds"). Write "canonical name" in this cell and in the schema bullet ("A new schema may not declare an interface and a node or edge type with the same name"). The final segment can repeat. "same-apply reuse refused" also needs the kind limit from the migration table: cross-kind reuse in one apply.

@cursor

cursor Bot commented Oct 18, 2026

Copy link
Copy Markdown

Recommendation: request changes before this revision is accepted. The three RFCs can stay drafts. This note is a comment. The reviews API returned 403, so this account cannot set a review state.

Reviewed commit 161336e63370c0f3575bf5fd1523d0efd2b0fac4. The comparison is 6dd108ecfd9287be515415acc487912842b9c72e to that commit. The content commits are ef2bcc803ea3c8545ab7667d55c9886c95c2b61f and 161336e63370c0f3575bf5fd1523d0efd2b0fac4. They change the polymorphic-types RFC and the integer-identity RFC. They change no runtime code. Immediately before this review, the PR was open and the head was still 161336e63370c0f3575bf5fd1523d0efd2b0fac4. No other review covers that commit. The inline threads from the review of 6dd108ec remain on the unchanged lines. Devin Review skipped this head.

What this change does

A stored query names types and edges. A later schema change can make a name point at a different declaration. This revision records the kind and the stable id of every name the query resolves, and it refuses the apply when a name resolves to a different kind or id. It also splits a public handle at the last colon, so crm::Note:1048576 keeps the namespace inside the name.

Earlier findings

  1. Partly fixed, and the remaining part is still open. The corpus procedure and the actor view (inline). The two procedures still match rfc: granular permissioning #951 at 7f3d601e. The value each arm receives is still two numbers. See blocking finding 1.
  2. Fixed. Arms that add their own unindexed rows. The text still requires one total before scoring, and it still refuses the feature until the scanner uses the supplied scorer unchanged.
  3. Fixed. Phase 3 names both Lance additions. combined_fields is named as crate-private precedent. rfc: granular permissioning #951 and this RFC name one statistics module.
  4. Fixed. The literal rule is in both RFCs. A non-negative i64 still coerces. Optional finding 2 remains.
  5. Fixed. The reopen paragraph states today's HEAD and GET, and it proposes the handler change as phase 1 work. Optional finding 1 remains.
  6. Fixed. A hidden type id and an unknown id share the error code and the wording apart from the echoed value.
  7. Fixed. The public tuple is the order keys, then @type_id, then every key property. Note:10 sorts before Note:2. The composite-key GQT is named.
  8. Fixed. Group-commit admission is unchanged in this delta and still compares natural keys, or treats keyed inserts as exclusive.

Blocking

  1. Still open. One module output cannot be the base of both routes (inline). This delta does not change that paragraph. initialize_scorer in Lance 11.0.0 lance-index scalar/inverted/index/flat_search.rs copies the base document count, token total and document frequency, then adds every input row, including one document-frequency count per row whose token count is greater than zero. The flat base is the other arms. The scanner option uses the full population unchanged. The module still says its output is one scorer. The bypass bullet still omits document frequency for unindexed rows.

  2. New. Pinning does not see a same-kind re-add in one apply, and a namespace move is not that case (reply). The reply has the resolver evidence and the required rule. resolve matches kind and name first, so a new edge OwnedBy and edge HeldBy @rename_from("OwnedBy") both take the accepted id and the apply errors with consumed by more than one desired declaration. A second apply, after the rename, mints a new id. That second apply is the pinning case. A pure namespace move keeps the id. rfc: schema namespaces and multi-file schemas #1015 accepts a stored query that still resolves to it.

Optional

  1. Still open. Only a missing size keeps the HEAD. The handler does not see the pin (inline). Dataset::get_manifest skips the session cache only when size is absent (lance dataset.rs). The cache key stores e_tag, and a null e_tag still caches. open_with_size skips the HEAD when the size is known (lance-io object_store.rs). open_pinned_dataset still receives a version and not TableVersionMetadata (instrumentation.rs). main_source_location still refuses a detached version (lance_clone.rs).

  2. Still open. Every Decimal128 loses a packed index, including a value that fits in i64 (inline). safe_coerce_scalar returns None for every Decimal128 when the target is not Decimal128 (lance-datafusion expr.rs). Both RFCs still say "a Decimal128 above i64::MAX".

  3. Still open. Choose the procedure from the resolved type set, with pruned members included (inline). The restricted bullet still says "any member the query reads".

  4. New. Item 14 and the schema bullet should say "canonical name" (inline).

What holds in this delta

Tradeoffs and liability

The revision aims at one guard for a renamed name: record the stable id, and refuse when the name later resolves to a different id. That is the right layer for a stored query. Invariant 6 says identity is the stable id. A name is not a second identity.

The new text gives that guard a case the resolver already rejects, and it puts a namespace move in the same class. #1015 says a pure move keeps the id and keeps a stored query that still resolves. An implementer who follows this decision log refuses that move. An implementer who follows the resolver never reaches pinning for a one-apply re-add, and can miss the two-apply re-add, which is the case pinning exists for. Five later name features (namespaces, interfaces, unions, alternations, actor views) then each grow a private guard. One change to resolve, plus one pin check, covers the class.

The handle rule is one parser. It removes a second split that would cut crm::Note at the first colon. It adds no storage and no second identity.

The open scorer finding is the larger engine liability, and this delta leaves it in place. One population record (per-arm counts and their sum) serves the flat call and the scanner. "One scorer" names one of the two values.

No performance number is claimed in this delta. I make no cost claim.

Validation

Local, detached at 161336e63370c0f3575bf5fd1523d0efd2b0fac4:

  • python3 scripts/check-docs.py: Documentation OK (335 Markdown files checked).
  • bash scripts/check-agents-md.sh: OK.
  • typos 1.50.1 on the three RFCs and docs/rfcs/README.md: no findings.
  • git diff --check 6dd108ec HEAD: clean.

Temporary compiler tests, added in crates/omnigraph-compiler/src/catalog/schema_ir.rs, run at this head, then removed. The file was restored. Nothing was pushed.

  • cargo test -p omnigraph-compiler --lib catalog::schema_ir::tests::same_kind_: 2 passed.
  • cargo test -p omnigraph-compiler --lib catalog::schema_ir::tests::cross_kind_name_reuse_in_one_apply_is_admitted: 1 passed.

Pinned Lance is 11.0.0 (Cargo.lock checksum be781f40c7a75f9eae2188a2f71174acb7a360dca97163db40b041d0828dea48). I re-read initialize_scorer, safe_coerce_scalar, Dataset::get_manifest and open_with_size in that source.

CI at this head is green: CI, GQT, PR Title. The GQT ordinary job took "Skip when no GQT input changed" and skipped the test steps. Green CI is not evidence for this design.

Regression proof

This delta fixes no runtime behavior. A GQT failure-before and pass-after does not apply. GQT cannot express schema-identity assignment. That contract is the compiler resolver, and the owner is the schema_ir tests in omnigraph-compiler. The three temporary tests show the current resolver. They are not a regression case for this PR, and they are not in the tree.

Limits

I did not re-run the numeric probe (scores 2.91, 2.75 and 1.00). I did not open a dataset on S3. I did not run the prototype. I did not execute #951, #963, #996 or #1015. I read those drafts at 7f3d601e, 4fb15738, be7bbd25 and 2e672c0c. Lance 13 was not executed in this pass.

It ships with or directly after the identity RFC's `NodeRef`, so no phase
runs with type names in plans and type ids in rows. Phases 1 and 2 build
on it. Its serialized-plan and IR contract changes are decided in that
issue's RFC, not here.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P1] Add the id on the typed IR and drop it in erase

Blocking. This sentence says the IR contract is decided in issue #1079. That issue says adding type_id beside type_name keeps the frozen engine compiling, because the engine matches IR nodes with ...

The frozen engine reads ir/untyped.rs. QueryIR::erase builds that IR from the typed IR in ir/mod.rs. tests/frozen.rs pins ir/untyped.rs and every file under crates/omnigraph-reference-engine/src/ by hash.

These matches list every field and omit ..:

  • collect_pipeline_columns in query.rs. Its comment says a new field must be decided there.
  • the execute match in query.rs
  • collect_node_bindings in projection.rs

Add the id on the typed IR only. erase drops it, as it already drops distinct. Leave ir/untyped.rs and the reference-engine sources unchanged. Correct the same sentence in #1079. #1079 is an issue, and it says an RFC is still likely needed.

- the planner and engine key table lookups, plan pins and caches by id,
keeping the name for explain output and errors.

It ships with or directly after the identity RFC's `NodeRef`, so no phase

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P3] Put this item in the same release as NodeRef

Optional. "Directly after" allows a release that has NodeRef in rows and type names in plans. The next sentence forbids that release. Put this item in the same release as NodeRef, before phases 1 and 2. The other phase-0 bullets can still ship earlier. #1015 at 9c360b5a says namespaces do not depend on #1079. That part matches.

2026-10-18: phase 0 also resolves type references to `StableTypeId` below
the compiler (issue #1079), with or directly after the identity RFC's
`NodeRef`. The prototype's first draft chose between two identity rules per
site and got four of them wrong (P1, P2, P4, P6), so plans and rows should not

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[P3] P1, P2, P4 and P6 belong to the tag-column draft

Optional. The finding table in this RFC says those four belong to the tag column, and the identity RFC removes them. They do not show that a plan name and a row id are the same defect. The reasons for one vocabulary are the name-to-id translation at each engine boundary, and derived state that stamps type names (graph_index/persist.rs TableStamp, the change-feed filter_digest). Cite those.

@cursor

cursor Bot commented Oct 18, 2026

Copy link
Copy Markdown

Recommendation: request changes before this revision is accepted. The three RFCs can stay drafts. This note is a comment. This account posts a comment and does not set a review state.

Reviewed commit a4df45c93c2810e36ebd2970117b5ecf31c3b94e. The comparison is 161336e63370c0f3575bf5fd1523d0efd2b0fac4 to that commit. The content commit adds 16 lines to docs/rfcs/2026-10-07-polymorphic-types.md. It changes no runtime code. Immediately before this review, the PR was open and the head was still a4df45c93c2810e36ebd2970117b5ecf31c3b94e. No other review covers that commit. Devin Review skipped this head because the trial has no credits. The inline threads from the review of 161336e6 have no author replies.

What this change does

Phase 0 gains one item. Type references below the compiler become TypeRefIR { type_id, type_name }. The planner and engine key lookups, pins and caches by id. The name stays for explain output and errors. The item ships with or after NodeRef. Phases 1 and 2 depend on it. The IR contract is left to issue #1079. The decision log cites prototype findings P1, P2, P4 and P6 as the reason.

Findings from the review of 161336e6

  1. Still open. One module output cannot be the base of both routes (inline). This delta does not change that paragraph. initialize_scorer in Lance 11.0.0 lance-index scalar/inverted/index/flat_search.rs copies the base document count, token total and document frequency, then adds every input row. The flat base is the other arms. The scanner option uses the full population unchanged. The module still says its output is one scorer. No author reply.
  2. Still open. Pinning does not see a same-kind re-add in one apply (inline). This delta does not change that decision-log paragraph. No author reply.
  3. Still open. Only a missing size keeps the HEAD (inline). Dataset::get_manifest skips the session cache only when size is absent. A null e_tag still caches. open_pinned_dataset still receives a version. main_source_location still refuses a detached version. No author reply.
  4. Still open. Every Decimal128 loses a packed index (inline). safe_coerce_scalar returns None for every Decimal128 whose target is not Decimal128. Both RFCs still say "a Decimal128 above i64::MAX". No author reply.
  5. Still open. Choose the procedure from the resolved type set (inline). The restricted bullet still says "any member the query reads". No author reply.
  6. Still open. Item 14 and the schema bullet should say "canonical name" (inline). No author reply.

The earlier items that the 161336e6 review marked fixed stay fixed. This delta does not reopen them.

Blocking

  1. Still open. Finding 1 above.
  2. Still open. Finding 2 above.
  3. New. Add the id on the typed IR and drop it in erase (inline). rfc: resolve type references to StableTypeId below the compiler #1079 says .. patterns keep the frozen engine compiling. The frozen engine matches ir/untyped.rs. collect_pipeline_columns, the execute match, and collect_node_bindings list every field and omit ... frozen.rs pins that file and the reference-engine sources by hash. Add the id on the typed IR in ir/mod.rs. QueryIR::erase drops it, as it already drops distinct. Leave the pinned files unchanged. rfc: resolve type references to StableTypeId below the compiler #1079 is an issue. It says an RFC is still likely needed. This RFC should not say that contract is already decided.

Optional

  1. Still open. Finding 3 above.
  2. Still open. Finding 4 above.
  3. Still open. Finding 5 above.
  4. Still open. Finding 6 above.
  5. New. Put this item in the same release as NodeRef (inline). "Directly after" allows a release that has NodeRef in rows and type names in plans. The next sentence forbids that release. The other phase-0 bullets can still ship earlier.
  6. New. P1, P2, P4 and P6 belong to the tag-column draft (inline). The finding table says the identity RFC removes them. Cite the translation sites and the name stamps instead.

What holds in this delta

These claims in #1079 match the code at this head. The same symbols on main 0a62bb7b are unchanged, except unrelated edits in catalog/mod.rs, schema_ir.rs and planner/src/source.rs.

Tradeoffs and liability

One id below the compiler is the right source of truth once NodeRef exists. Invariant 6 says identity is the stable id. Invariant 9 says execution must not choose again between a node and an edge that share a name. Names stay labels for explain output and errors.

The codebase already has the compatibility door. erase builds the frozen IR and drops fields the frozen format does not carry. The new id uses that door. Five later type features then share the id. They do not each add a name-to-id map.

The new text records a false compile rule. An implementer who follows it edits pinned files, or expects a compile that fails. That adds liability. Stating the erase door removes it. The serialized-plan version bump is a real cost. #1079 already names it. The RFC that decides the envelope should carry it.

This accepts a larger phase 0 so phases 1 and 2 do not run with two vocabularies. Name-keyed plans are correct inside one snapshot. They become a second vocabulary after NodeRef, and namespaces make renames routine. That is the reason to pay for the IR change. The prototype findings are a different defect, and the identity RFC already closes them.

No performance number is claimed in this delta. I make no cost claim.

Validation

Local, detached at a4df45c93c2810e36ebd2970117b5ecf31c3b94e:

  • python3 scripts/check-docs.py: Documentation OK (335 Markdown files checked).
  • bash scripts/check-agents-md.sh: OK.
  • typos 1.50.1 on the three RFCs and docs/rfcs/README.md: no findings.
  • git diff --check 161336e63370c0f3575bf5fd1523d0efd2b0fac4 HEAD: clean.

Pinned Lance is 11.0.0 (Cargo.lock checksum be781f40c7a75f9eae2188a2f71174acb7a360dca97163db40b041d0828dea48). I re-read initialize_scorer, safe_coerce_scalar and Dataset::get_manifest in that source. The still-open sentences match it.

CI at this head: CI Gate passed. Classify skipped the workspace tests, Clippy and rustfmt. GQT ordinary printed "No GQT input changed; skipping the GQ logic tests." PR Title passed. Green CI is not evidence for this design.

Regression proof

This delta fixes no runtime behavior. A GQT failure-before and pass-after does not apply. GQT cannot express a frozen-source hash or an IR field addition. That contract belongs to frozen.rs and QueryIR::erase. I did not add a temporary test. I did not push.

Limits

I did not re-run the numeric probe. I did not open a dataset on S3. I did not run the prototype. I did not execute #951, #963, #996 or #1015. I read those drafts at the commits named above. Lance 13 was not executed in this pass.

…very write is checked

Name attempt validation and the commit revalidation as the point where an interface-wide mutation is checked against the actor's view, as Actor views checks every write. A check before staging could not see staged placement values.

ragnorc commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

Recommendation: request changes before this revision is accepted. The RFC stays a draft.

Reviewed commit 24b60eba9e37e50c7f6af6d2d416a4d072b5b320. The comparison is a4df45c93c2810e36ebd2970117b5ecf31c3b94e to that commit. The commit adds 10 lines and removes 3 in docs/rfcs/2026-10-07-polymorphic-types.md. It changes no runtime code. Immediately before this review, the PR was open and the head was still 24b60eba9e37e50c7f6af6d2d416a4d072b5b320. No other review covers that commit. Devin Review skipped this head because the trial has no credits.

What this change does

The Policy bullet moves the actor-view check for an interface-wide mutation. The check is now in attempt validation, over the staged rows, and again in the commit revalidation. The stated reason is that a check before staging cannot see staged placement values. A refusal leaves every staged arm detached. The 2026-10-20 decision-log entry records the same rule.

Earlier findings

The review of a4df45c9 left these open. This commit does not edit those paragraphs.

Blocking, still open:

  1. One module output cannot be the base of both routes (inline).
  2. Pinning does not see a same-kind re-add in one apply (inline).
  3. Add the id on the typed IR and drop it in erase (inline).

Optional, still open:

  1. Only a missing size keeps the HEAD (inline).
  2. Every Decimal128 loses a packed index (inline).
  3. Choose the procedure from the resolved type set. The restricted bullet still says "any member the query reads" (inline).
  4. Item 14 and the schema bullet should say "canonical name" (inline).
  5. Put the StableTypeId item in the same release as NodeRef. The text still says "with or directly after" (inline).
  6. P1, P2, P4 and P6 belong to the tag-column draft. The decision log still cites them as the reason for one vocabulary (inline).

Blocking

  1. Still open. Finding 1 above.
  2. Still open. Finding 2 above.
  3. Still open. Finding 3 above.
  4. New. Put the view check in validate_changeset, before stage_all (inline on the Policy bullet, below).

commit_staged_mutation in crates/omnigraph/src/exec/mutation.rs calls validate_staged_mutation first. That calls validate_changeset. It then calls stage_all_with_concurrency. commit_all then calls revalidate_write_txn. The load path calls validate_changeset_with_access and then stage_all. Same order.

validate_changeset in crates/omnigraph/src/validate.rs is the one evaluation of @key, @unique, referential integrity and cardinality. It reads the in-memory change set and the committed state. Insert placement values are columns on those rows, so this call sees them. A delete or a cascade has no after-row. The deleted ids are on the change set, and the placement is on the committed row. Actor views at 5f14b1ba already says the committed state supplies that before image.

revalidate_write_txn in crates/omnigraph/src/db/omnigraph.rs compares branch identity, graph head, schema hash, schema identity domain and version, the unattached base, and the catalog against the snapshot. It holds no change set. It evaluates no view, no placement and no constraint. commit_all in crates/omnigraph/src/exec/staging.rs does that comparison before its detached-commit loop. An authority mismatch returns while the staged files are still unreferenced. The function creates no detached commit on that path.

A view refusal inside validate_staged_mutation is before stage_all. No staged file exists. The sentence "a refusal leaves every staged arm detached" describes a later point than the view check.

lower_named_mutation typechecks the target before any row exists, so that check cannot see a placement value. That is the check to keep in the compiler. This mutation path then runs the compiler IR in execute_named_mutation. The change feed reads published commits under crates/omnigraph/src/changes/. It does not call validate_changeset.

Actor views' Writes section names those validation functions, then says commit_all revalidates the transaction and the publication compare-and-swap expects the captured access pins. Its "Write enforcement point" repeats this commit's "again in the commit revalidation" sentence. The two drafts now agree with each other. Both disagree with the call order on main 7fd9cc90. Correct both. A moved authority token refuses the attempt before a detached commit. A fresh attempt runs validate_changeset again. The access-pin compare-and-swap is the publication precondition Actor views already names. A second arm scan inside revalidate_write_txn closes no hole those two leave open.

Name one row check: validate_changeset over the change set and the committed before-image, before stage_all, for the mutation, the load and the merge. Keep revalidate_write_txn as the authority token. Say a view refusal writes no file and publishes nothing. Update the 2026-10-20 decision-log entry to the same sentence.

Optional

The six items above stay open. This commit adds none.

What holds

The view check does not belong at typecheck time. Inherited @key and @unique stay declaration checks. Interface-wide @unique stays in the same validate_changeset call, in phase 3, together with the schema-apply check of the prospective member universe. One publication still covers every arm. #1015 is still 9c360b5a. #1079 is unchanged since the previous review. #963 at 9a3f6213 still keeps schema apply on main. This commit does not edit that sentence.

Tradeoffs and liability

One integrity evaluation, before any staged file, is the contract a mutation already has. Invariant 4 publishes once. Invariant 5 keeps a pre-publication effect unreachable, and a view refusal has no effect to reach. The view rule joins @key and @unique in validate_changeset. Five later write rules then share that function.

The new sentence puts the row check on the authority door and says a refusal leaves detached arms. An implementer who follows it writes files before the view answer, or threads the change set into revalidate_write_txn. That adds a second source of the write decision. The fresh attempt and the access-pin compare-and-swap already cover a concurrent publication.

This commit removes no liability until the sentence names validate_changeset.

No performance number is claimed. I make no cost claim.

Validation

Local, detached at 24b60eba9e37e50c7f6af6d2d416a4d072b5b320:

  • python3 scripts/check-docs.py: Documentation OK (335 Markdown files checked).
  • bash scripts/check-agents-md.sh: OK.
  • typos 1.50.1 on the three RFCs and docs/rfcs/README.md: no findings.
  • git diff --check a4df45c93c2810e36ebd2970117b5ecf31c3b94e HEAD: clean.

Code inspection on main 7fd9cc9037c634e2f3284f1ede55a85a6f7b28e8: the call order in commit_staged_mutation, commit_all, revalidate_write_txn, validate_changeset, and the load path. I read Actor views at 5f14b1ba and schema apply with live branches at 9a3f6213. Schema namespaces remains 9c360b5a.

CI at this head: CI Gate passed. Classify skipped the workspace tests, Clippy and rustfmt. GQT ordinary printed "No GQT input changed; skipping the GQ logic tests." PR Title passed. Devin Review skipped this head. Green CI is not evidence for this design.

Regression proof

This commit fixes no runtime behavior. A GQT failure-before and pass-after does not apply. GQT cannot see the order of validate_changeset and stage_all. That order belongs to commit_staged_mutation and the load path. I did not add a temporary test. I did not push.

Limits

I did not run a mutation. I did not execute the actor-views, schema-apply, or namespace drafts. I did not re-run the Lance probes from earlier reviews. Pinned Lance on this main is 13.0.0. This commit makes no new Lance claim.

Inline finding on the Policy bullet (docs/rfcs/2026-10-07-polymorphic-types.md, the sentence that starts "An interface-wide mutation checks every arm"):

[P1] Put the view check in validate_changeset, before stage_all

Blocking. This sentence checks every arm again in the commit revalidation, and says a refusal leaves every staged arm detached.

On main 7fd9cc90 the order in commit_staged_mutation is validate_staged_mutation, then stage_all_with_concurrency, then commit_all. validate_staged_mutation calls validate_changeset. That is the one evaluation of @key, @unique, referential integrity and cardinality. It reads the in-memory change set. Insert placement values are columns on those rows. A delete or a cascade has no after-row: the deleted ids are on the change set, and the placement is on the committed row. The load path uses the same order (validate_changeset_with_access, then stage_all).

revalidate_write_txn compares branch identity, graph head, schema hash, schema identity, the unattached base, and the catalog against the snapshot. It holds no change set. commit_all does that comparison before its detached-commit loop. An authority mismatch returns with the staged files still unreferenced. It creates no detached commit.

A view refusal in validate_staged_mutation is before stage_all. No file exists. lower_named_mutation typechecks the target before any row exists, so that is the check that cannot see a placement value. The change feed does not call validate_changeset.

Actor views' Writes section names the validation functions, then says commit_all revalidates the transaction and the publication compare-and-swap expects the access pins. Its "Write enforcement point" repeats this sentence. Correct both.

Name one row check: validate_changeset over the change set and the committed before-image, before stage_all, for the mutation, the load and the merge. Keep revalidate_write_txn as the authority token. Say a view refusal writes no file and publishes nothing. Update the 2026-10-20 decision-log entry to the same sentence.

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.

3 participants