Skip to content

bug: inserting an edge whose type shares a node type's name fails #921

Open

Description

@azimafroozeh

Actual result

With a node type and an edge type both named Link, insert Link { from: "a", to: "a" } fails with "type error: T11: type Link has no property from".

Expected result

The insert writes one Link edge from a to a.

GQT reproduction or limitation

# issue: none
# red_on: dfc73f46

--- runner
timeout_ms: 10000
environments:
  - target: omnigraph-engine
    storage: local-filesystem

--- schema
node Link {}

edge Link: Link -> Link

--- seed
{"type": "Link", "id": "a", "data": {}}

--- mutate
query link() {
    insert Link { from: "a", to: "a" }
}

--- expect affected: nodes=0 edges=1

Version

omnigraph 0.13.0, main @ dfc73f4, built from source

Execution path

Embedded Rust API

Environment

macOS on arm64, local filesystem storage

Reproduction command and output

./omnigraph-gqt repro.gqt --artifacts out
GQT report: <dir>/out/invocation-Rc2ENv.json
GQT replay: omnigraph-gqt --replay '<dir>/out/invocation-Rc2ENv.json'
FAIL repro 1.19s
step 1 (mutate): mutation failed: type error: T11: type `Link` has no property `from`; environment={"target":"omnigraph-engine"%

Additional logs

Activity

  1. self-assigned this
    on Oct 9, 2026
  2. added
    acceptedTriaged and validated; open for a PR
    P-highHigh priority
    and removed
    needs-triageAwaiting maintainer triage
    on Oct 9, 2026
  3. ragnorc commented on Oct 17, 2026

    @ragnorc
    Contributor

    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 {} 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).
    • This is intentional. The type checker says that "a node type and an edge type may share a name" (typecheck.rs L20-L25). MutationTarget exists 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.md L100-L101, added in feat(query): type the IR and plan every cast and aggregate #901).
    • 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.
    • A compiler unit test (typecheck_tests.rs L135-L190) and a lint test (lint_tests.rs L473) pin the node choice. No test covers the edge side.
    • 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.

    1. 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.
    2. 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".
      • update edge:X keeps the current T16 refusal.
    3. Execution does not change. MutationTarget already carries the kind (feat(query): type the IR and plan every cast and aggregate #901).
    4. 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.
    5. 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.

    Related work

    Supersession

    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.
  4. lab1207 commented on Oct 20, 2026

    @lab1207
    Contributor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P-highHigh priorityacceptedTriaged and validated; open for a PRbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions