You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
bug: inserting an edge whose type shares a node type's name fails #921
A schema can declare a node type and an edge type with the same name. This is a supported design: the node and edge namespaces are separate. With node Link {} and edge Link: Link -> Link, the statement insert Link { from: "a", to: "a" } fails during type checking. The type checker always selects the node type when both namespaces contain the name, so you cannot insert an edge of that type with GQ. delete has the same defect: delete Link where @src = "a" also selects the node type and fails. Loading and reading these edges works, and so do inserts of the node type.
Verdict: reproduced
Item
Value
Main SHA
0b5e17fa23d36483178240c28f39c5d3448394a1 (latest main at the time of this test; clean detached worktree)
Version
omnigraph 0.14.0, built from source (cargo build -p omnigraph-gqt --locked)
Toolchain
rustc 1.97.1
Environment
Ubuntu 24.04.5, Linux 6.12 x86_64, omnigraph-engine target, local-filesystem storage
I ran the GQT case from the issue body without changes:
$ omnigraph-gqt repro.gqt --artifacts out
GQT report: /tmp/repro921/out/invocation-FoBG6w.json
GQT replay: omnigraph-gqt --replay '/tmp/repro921/out/invocation-FoBG6w.json'
FAIL repro 0.56s
step 1 (mutate): mutation failed: type error: T11: type `Link` has no property `from`; environment={"target":"omnigraph-engine","storage":"local-filesystem"} seed=None replay=0
The error is the same as in the report. These are the differences from the report: Linux x86_64 instead of macOS arm64, and 0b5e17fa (0.14.0) instead of dfc73f46 (0.13.0). The behavior did not change.
I ran three more cases on the same build to find the scope of the defect.
Additional cases and outputs
Delete of the same-named edge (node Link { name: String @key }, edge Link: Link -> Link, one edge seeded through the loader):
--- mutate
query drop_edge() {
delete Link where @src = "a"
}
--- expect affected: nodes=0 edges=1
FAIL shared_name_edge_delete 0.56s
step 1 (mutate): mutation failed: type error: T11: type `Link` has no meta-field `@src`; the meta-fields of this type are `@id`; ...
Same schema. A loader-seeded edge ({"edge": "Link", ...}) is read back by the traversal $x $e:link $y, and then insert Link { name: "b" } inserts one node:
ok shared_name_load_read_node_insert 0.58s
Control: the issue's case with the edge renamed to Linked (insert Linked { from: "a", to: "a" }):
ok control_distinct_edge_name 0.59s
The existing compiler test that pins the node-first choice passes on this SHA:
$ cargo test -p omnigraph-compiler --locked --lib mutation_target_retains_node_namespace
test query::typecheck::tests::mutation_target_retains_node_namespace_when_an_edge_shares_its_name ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 482 filtered out
Root cause
The defect is in the compiler's resolution of a mutation's target type. It is not in storage or execution.
Node and edge types are in separate catalog maps. The catalog refuses duplicates only inside one kind (catalog/mod.rs L380, L478).
The GQ grammar has one type_name for all three mutation statements and no kind marker (query.pest L129-L131). Reads do not have this problem: a binding ($x: Link) is always a node, and a traversal names the edge in lowercase ($a $e:link $b).
typecheck_mutation looks up insert in catalog.node_types first and returns from that branch (L1045). The edge branch (L1114) cannot run for a shared name. from is not a node property, so the node branch fails with T11. delete uses the same node-first order (L1264-L1276). update also selects the node, but edge update is not supported (T16), so update has no edge path to lose.
Storage and execution keep the kinds apart: node:<name> and edge:<name> table keys (exec/mutation.rs L1480, L1569) and identity-based dataset paths. The JSONL loader uses type and edge. GQ load writes the kind explicitly (into node:Person / into edge:Knows, query.pest L42-L44). Only insert and delete have no way to name the kind.
Thus the contract is "two namespaces", but the GQ mutation syntax can address only one of them when a name is in both.
Recommended fix (correct by design)
Let a mutation name its namespace. Use the qualified spelling that GQ load and the engine's table keys already use. Refuse an unqualified name that is ambiguous. Do not select one kind silently.
Grammar and AST: accept insert edge:Link { ... }, delete edge:Link where ... and node: for any of the three statements. Reuse load_kind ~ ":" ~ type_name. The AST target becomes a name with an optional kind.
One resolver shared by insert, update and delete. This replaces the three copies of the node-then-edge lookup:
A qualified name resolves only in its namespace. If the namespace does not have the name, the result is T10 "unknown".
An unqualified name that is in one namespace resolves as it does today. All schemas without shared names keep their current behavior.
An unqualified name that is in both namespaces is a typed error. The error names both spellings, for example: "type Link is both a node type and an edge type; write node:Link or edge:Link".
Tests: rename the reporter's case to crates/omnigraph-gqt/cases/issue_921_*.gqt with # issue: 921. Add steps for the qualified edge insert and delete, a qualified node insert, and the ambiguity error. Update mutation_target_retains_node_namespace_when_an_edge_shares_its_name and the lint test to use a qualified target. A .gqt case is the correct tier because the defect is visible in errors and affected counts.
Docs and changelog: update docs/user/mutations/index.md (qualified targets) and docs/user/schema/index.md (shared names), and add a changelog.d note.
Why refuse the unqualified ambiguous name instead of keeping "node wins":
A node type can have properties named from and to. The schema reserves these names only on edge types (schema/parser.rs L903, schema docs L76-L77). With "node wins", a schema apply that adds node Link { from: String, to: String } next to an existing edge Link silently changes a stored insert Link { from, to } from an edge insert to a node insert. With the refusal, the stored query fails at check time. This agrees with invariant 8 (integrity failures are loud) and invariant 9 (the target is selected once, by a typed structure).
It uses the same rule that the open namespaces RFC (rfc: schema namespaces and multi-file schemas #1015) gives for references inside a schema: an unqualified reference that resolves in two places "is an error naming both".
Compatibility cost: a graph that already has a shared name and uses unqualified insert/delete on the node gets a compile error with the corrected spelling. It does not get a silent change. The edge side of such a graph never worked through GQ. If maintainers know of affected users, a softer option is one release with a lint warning and "node wins", then the refusal.
Alternatives considered
Option
Result
Tradeoff
Refuse same-named node and edge types at schema admission
Removes the ambiguity for new schemas
Goes against the documented separate-namespace design, the existing tests, invariant 9 and RFC #1015 ("Node and edge names stay separate per kind, as today"). Accepted graphs that already have a shared name must keep opening, so their edges stay unwritable through GQ unless a qualifier also exists. It is the smallest change, but it removes capability and does not close the defect for existing graphs.
Infer the kind from the assignments (from/to present means edge)
No syntax change
Ambiguous when a node has from/to properties. Does not help delete ... where @id = .... It gives meaning to spelling (invariant 9) and gives confusing errors. Not recommended.
Lowercase edge name in mutations (insert link { ... }), as in traversals
Reuses the read-side convention
type_name requires an uppercase first letter. Lowercase collides with ident. It is less explicit than edge:. Not recommended.
Patch: try the edge branch when the node branch fails
Smallest diff
Gives an order-dependent result and keeps the silent-retarget risk above. Not recommended.
Liability: the recommended change adds one grammar alternative (reused from load_target), one optional AST field, and one resolver that replaces three copies. It composes with planned work: a namespaced name from #1015 becomes edge:crm::Link, and interface targets from #884 can use the same kind slot. Stored-query descriptors and lint facts already carry the kind (QueryGraphFact), so SDK work in #468 and #654 is not affected. No storage format, protocol or manifest change is necessary, so the change is reversible.
Lance upstream assessment
No Lance change can fix or affect this issue. The error comes from the compiler's type checker before the engine opens a Lance dataset. Lance has no concept of graph type names: each OmniGraph table is a separate dataset at an identity-based path, and OmniGraph keys it as node:<name> or edge:<name> (Lance table layout). The pinned version is Lance 11.0.0. The latest stable release is v13.0.0 (2026-10-07), after v12.0.0. v14.0.0 betas are in progress. I read the v12.0.0 and v13.0.0 release notes. They contain storage, schema-evolution and merge-insert changes, and namespace items that are about Lance's table catalog client (for example lance#8915 and lance#9285), not graph type names. None of them is related to this issue. A Lance bump does not need to change the fix.
rfc: schema namespaces and multi-file schemas #1015 (open RFC, schema namespaces): keeps node and edge names separate per kind. Inside a schema, it refuses an unqualified reference that resolves in two places. The recommended fix applies the same rule to mutation targets.
rfc: bulk load as a query plan #1005 (bulk load RFC) and the current load csv ... into node:X | edge:X syntax: the precedent for the qualified spelling.
None. I found no other issue that reports this defect.
Limitations
I tested only the omnigraph-engine target on local-filesystem storage, on Linux x86_64, at 0b5e17fa. I did not run the DST target, the CLI or HTTP. All of them use the same compiler path, so I expect the same result, but I did not test it.
I did not write or test a fix. The recommendation comes from reading the code and the design documents.
The delete finding comes from my own case, not from the report.
I have taken this one — a pull request is open: #1101
The fix follows the design from the triage note: a mutation may name the namespace it targets, using the spelling load csv already uses — insert node:Person { … }, insert edge:Knows { … }. The namespace travels through the grammar, the parser (MutationKind::Node / MutationKind::Edge) and the type checker, and a bare name that both namespaces declare is refused with a new code (T60) instead of silently selecting the node type. It ships as a breaking change, with changelog.d/mutation-target-namespace.breaking.md, and the user docs for mutations and schema are updated.
The regression coverage is a compiler unit test for each resolution, the updated lint-level test, and a GQT case in the repository's own format (crates/omnigraph-gqt/cases/issue_921_shared_node_and_edge_mutation_target.gqt) carrying the schema, seed and mutation from the issue with expect affected: nodes=0 edges=1.
Activity
ragnorc commentedon Oct 17, 2026
Reproduction and fix assessment
Summary
A schema can declare a node type and an edge type with the same name. This is a supported design: the node and edge namespaces are separate. With
node Link {}andedge Link: Link -> Link, the statementinsert Link { from: "a", to: "a" }fails during type checking. The type checker always selects the node type when both namespaces contain the name, so you cannot insert an edge of that type with GQ.deletehas the same defect:delete Link where @src = "a"also selects the node type and fails. Loading and reading these edges works, and so do inserts of the node type.Verdict: reproduced
0b5e17fa23d36483178240c28f39c5d3448394a1(latestmainat the time of this test; clean detached worktree)cargo build -p omnigraph-gqt --locked)omnigraph-enginetarget,local-filesystemstorageI ran the GQT case from the issue body without changes:
The error is the same as in the report. These are the differences from the report: Linux x86_64 instead of macOS arm64, and
0b5e17fa(0.14.0) instead ofdfc73f46(0.13.0). The behavior did not change.I ran three more cases on the same build to find the scope of the defect.
Additional cases and outputs
Delete of the same-named edge (
node Link { name: String @key },edge Link: Link -> Link, one edge seeded through the loader):Same schema. A loader-seeded edge (
{"edge": "Link", ...}) is read back by the traversal$x $e:link $y, and theninsert Link { name: "b" }inserts one node:Control: the issue's case with the edge renamed to
Linked(insert Linked { from: "a", to: "a" }):The existing compiler test that pins the node-first choice passes on this SHA:
Root cause
The defect is in the compiler's resolution of a mutation's target type. It is not in storage or execution.
catalog/mod.rsL380, L478).typecheck.rsL20-L25).MutationTargetexists so that consumers carry the resolved kind (L651-L659). Invariant 9 says that execution does not choose again "when both namespaces contain the same name" (invariants.mdL100-L101, added in feat(query): type the IR and plan every cast and aggregate #901).type_namefor all three mutation statements and no kind marker (query.pestL129-L131). Reads do not have this problem: a binding ($x: Link) is always a node, and a traversal names the edge in lowercase ($a $e:link $b).typecheck_mutationlooks upinsertincatalog.node_typesfirst and returns from that branch (L1045). The edge branch (L1114) cannot run for a shared name.fromis not a node property, so the node branch fails with T11.deleteuses the same node-first order (L1264-L1276).updatealso selects the node, but edge update is not supported (T16), soupdatehas no edge path to lose.typecheck_tests.rsL135-L190) and a lint test (lint_tests.rsL473) pin the node choice. No test covers the edge side.node:<name>andedge:<name>table keys (exec/mutation.rsL1480, L1569) and identity-based dataset paths. The JSONL loader usestypeandedge. GQloadwrites the kind explicitly (into node:Person/into edge:Knows,query.pestL42-L44). Onlyinsertanddeletehave no way to name the kind.Thus the contract is "two namespaces", but the GQ mutation syntax can address only one of them when a name is in both.
Recommended fix (correct by design)
Let a mutation name its namespace. Use the qualified spelling that GQ
loadand the engine's table keys already use. Refuse an unqualified name that is ambiguous. Do not select one kind silently.insert edge:Link { ... },delete edge:Link where ...andnode:for any of the three statements. Reuseload_kind ~ ":" ~ type_name. The AST target becomes a name with an optional kind.Linkis both a node type and an edge type; writenode:Linkoredge:Link".update edge:Xkeeps the current T16 refusal.MutationTargetalready carries the kind (feat(query): type the IR and plan every cast and aggregate #901).crates/omnigraph-gqt/cases/issue_921_*.gqtwith# issue: 921. Add steps for the qualified edge insert and delete, a qualified node insert, and the ambiguity error. Updatemutation_target_retains_node_namespace_when_an_edge_shares_its_nameand the lint test to use a qualified target. A.gqtcase is the correct tier because the defect is visible in errors and affected counts.docs/user/mutations/index.md(qualified targets) anddocs/user/schema/index.md(shared names), and add achangelog.dnote.Why refuse the unqualified ambiguous name instead of keeping "node wins":
fromandto. The schema reserves these names only on edge types (schema/parser.rsL903, schema docs L76-L77). With "node wins", a schema apply that addsnode Link { from: String, to: String }next to an existingedge Linksilently changes a storedinsert Link { from, to }from an edge insert to a node insert. With the refusal, the stored query fails at check time. This agrees with invariant 8 (integrity failures are loud) and invariant 9 (the target is selected once, by a typed structure).insert/deleteon the node gets a compile error with the corrected spelling. It does not get a silent change. The edge side of such a graph never worked through GQ. If maintainers know of affected users, a softer option is one release with a lint warning and "node wins", then the refusal.Alternatives considered
from/topresent means edge)from/toproperties. Does not helpdelete ... where @id = .... It gives meaning to spelling (invariant 9) and gives confusing errors. Not recommended.insert link { ... }), as in traversalstype_namerequires an uppercase first letter. Lowercase collides withident. It is less explicit thanedge:. Not recommended.Liability: the recommended change adds one grammar alternative (reused from
load_target), one optional AST field, and one resolver that replaces three copies. It composes with planned work: a namespaced name from #1015 becomesedge:crm::Link, and interface targets from #884 can use the same kind slot. Stored-query descriptors and lint facts already carry the kind (QueryGraphFact), so SDK work in #468 and #654 is not affected. No storage format, protocol or manifest change is necessary, so the change is reversible.Lance upstream assessment
No Lance change can fix or affect this issue. The error comes from the compiler's type checker before the engine opens a Lance dataset. Lance has no concept of graph type names: each OmniGraph table is a separate dataset at an identity-based path, and OmniGraph keys it as
node:<name>oredge:<name>(Lance table layout). The pinned version is Lance 11.0.0. The latest stable release is v13.0.0 (2026-10-07), after v12.0.0. v14.0.0 betas are in progress. I read the v12.0.0 and v13.0.0 release notes. They contain storage, schema-evolution and merge-insert changes, andnamespaceitems that are about Lance's table catalog client (for example lance#8915 and lance#9285), not graph type names. None of them is related to this issue. A Lance bump does not need to change the fix.Related work
MutationTargetand added the invariant text. It did not add a way to select the edge namespace.load csv ... into node:X | edge:Xsyntax: the precedent for the qualified spelling.Supersession
None. I found no other issue that reports this defect.
Limitations
omnigraph-enginetarget on local-filesystem storage, on Linux x86_64, at0b5e17fa. I did not run the DST target, the CLI or HTTP. All of them use the same compiler path, so I expect the same result, but I did not test it.lab1207 commentedon Oct 20, 2026
I have taken this one — a pull request is open: #1101
The fix follows the design from the triage note: a mutation may name the namespace it targets, using the spelling
load csvalready uses —insert node:Person { … },insert edge:Knows { … }. The namespace travels through the grammar, the parser (MutationKind::Node/MutationKind::Edge) and the type checker, and a bare name that both namespaces declare is refused with a new code (T60) instead of silently selecting the node type. It ships as a breaking change, withchangelog.d/mutation-target-namespace.breaking.md, and the user docs for mutations and schema are updated.The regression coverage is a compiler unit test for each resolution, the updated lint-level test, and a GQT case in the repository's own format (
crates/omnigraph-gqt/cases/issue_921_shared_node_and_edge_mutation_target.gqt) carrying the schema, seed and mutation from the issue withexpect affected: nodes=0 edges=1.