Skip to content

Pasting an already-linked node's ID offers an Add that silently does nothing #2434

Closed

Description

@malibio

Problem

An edge is uniquely identified by (in_node, out_node, relationship_type):

CREATE UNIQUE INDEX idx_rel_unique ON relationship (in_node, out_node, relationship_type);

packages/core/src/db/schema.sql:65

A node can therefore have at most one edge of a given type to a given target. Creating one that already exists is an idempotent success — create_relationship returns Ok(()) without writing (packages/core/src/services/node_service/relationship.rs:656-659).

Correct for an API. Wrong for the relationships panel, where it surfaces as a silent no-op:

  1. Paste the UUID of an already-linked node into the target picker
  2. The picker offers "Use ID …"
  3. If the relationship declares edge_fields, fill them in and press Add
  4. The daemon returns success without writing; the panel reloads and shows the existing edge with its old property values

No error. The user's input is discarded with no indication it was ignored.

Why the existing guard doesn't cover it

runSearch already filters linked targets out of search results:

const existing = new Set(current.rows.map((r) => r.id));
addResults = results.filter((n) => !existing.has(n.id));

relationship-viewer-modal.svelte

But the paste-a-UUID branch bypasses that filter entirely — it calls pickTarget directly on the typed string with no existing check:

{#if UUID_RE.test(addQuery.trim())}
  <button onclick={() => pickTarget(group, addQuery.trim(), addQuery.trim())}>
    <PlusIcon /> Use ID <code>{addQuery.trim()}</code>
  </button>

relationship-viewer-modal.svelte:1157-1166

Second, related gap

When every valid target is already linked, the picker shows "No matches." — indistinguishable from a search that genuinely found nothing. The user cannot tell whether their query was wrong or whether there is simply nothing left to add.

Proposal

Filter already-linked targets on every path into the picker, not just the search path. The paste-a-UUID branch checks the same existing set the search results are filtered against. An already-linked id is either not offered at all, or offered disabled with a note saying it is already linked — the latter is friendlier, since it explains why pasting a valid-looking id produced nothing.

Distinguish "nothing left to add" from "no matches." When the relationship's valid targets are exhausted, say so — a greyed-out line along the lines of "All <target-type> nodes are already linked" rather than "No matches."

Both are UI-side. The daemon's idempotent-success behavior is fine and should not change: it is the right contract for an API, and it stays the backstop if the UI guard is ever bypassed.

Acceptance criteria

  • Pasting the UUID of an already-linked node does not offer a live "Use ID" add
  • That case is explained — the entry is disabled or replaced with text saying the node is already linked — rather than silently absent
  • When every valid target is already linked, the picker says so in language distinct from "No matches."
  • A search that genuinely returns nothing still says "No matches."
  • The daemon's idempotent create_relationship behavior is unchanged
  • Filtering covers both entry paths (search results and pasted id) from one source of truth, not two copies of the rule

Notes

Found while designing #2432. Filed separately because it is a defect in code merged by #2426, fixable independently of that redesign — but if #2432 is implemented first, its picker must carry the same guarantees, and the acceptance criteria above apply to whatever picker exists at that point.

Worth stating for whoever picks this up: the UI never needs an edge id. Every mutation addresses edges by the (source, target, relationship_name) triple, which is what the unique index enforces. That is why "already linked" is knowable client-side from the group's own rows.

Activity

  1. malibio commented on Sep 8, 2026

    @malibio
    CollaboratorAuthor

    Status update after #2446 (two-pane panel)

    This issue's premise has shifted, and one part of it was resolved by accident rather than design. Recording that here so whoever picks it up isn't working from a stale reading.

    Part 1 (the paste-a-UUID no-op) is unreachable — because I deleted the feature

    The UUID_RE / "Use ID …" branch this issue is built on no longer exists. It was present in the pre-rewrite modal (1b041562, lines 171 / 1157 / 1165) and my rewrite in #2446 dropped it.

    That was not deliberate. I catalogued what should survive the rewrite and what should be replaced, and this simply wasn't on either list — it went out with the markup it lived in. So the bug is gone, but so is the capability: you can no longer paste a node's UUID into the target picker to link something the title search won't surface.

    That leaves a decision this issue should now carry:

    • If pasting an ID was worth having — it is the only escape hatch when a node's title search doesn't find it (an untitled node, or a title the search tokenizer misses) — then it needs restoring with the existing guard this issue asked for, and this issue is still live, just with "restore" added to "fix".
    • If it wasn't worth having, then closing this part is legitimate, but the removal should be recorded as intentional rather than left as an accident nobody noticed.

    I don't think I should make that call unilaterally, having removed it by accident in the first place.

    Part 2 (the "no matches" wording) is partly addressed, and not exactly as specified

    The new panel shows:

    No matches — every result is already linked.

    That is closer than the old bare "No matches.", but it is not what this issue asked for, and I want to be precise rather than claim it done. The condition is group.rows.length > 0 — i.e. "this relationship has at least one edge" — not "the valid targets are exhausted". So:

    • Search for a genuinely non-existent name on a relationship that happens to have one edge → still says "every result is already linked", which is false and misleading
    • The requested behaviour — "All <target-type> nodes are already linked" only when targets are genuinely exhausted — needs a count of available targets, which the panel does not currently have

    So part 2 is a partial improvement over the old wording with a new inaccuracy of its own, not a fix.

    Unchanged

    The daemon's idempotent-success contract still stands and still shouldn't change — it remains the correct API behaviour and the backstop if any UI guard is bypassed. Edge identity is still (in_node, out_node, relationship_type).

    Suggested reshaping

    1. Decide whether paste-by-ID is a capability worth having (see above)
    2. If yes: restore it, filtered against the same existing set the search path uses
    3. Replace the current conditional message with one keyed on actual target availability, not on whether the group has any edges — or, if that count is not cheaply available, revert to a neutral "No matches." rather than asserting something that can be false

    Code references in the original description above (relationship-viewer-modal.svelte:1157-1166, and the runSearch filter) are now stale — the file was rewritten in #2446.

  2. mstomar125 commented on Sep 8, 2026

    @mstomar125
    Contributor

    Resolved in #2461 (merged). Note the fix landed against the two-pane picker from #2432, which merged while an earlier attempt (#2456, closed unmerged) was in review and removed the paste-a-UUID picker path this issue's repro used — so the specific "paste UUID → live Use ID → silent no-op" symptom can no longer occur (there's no paste-UUID affordance left).

    What shipped instead, carrying forward the spirit of the fix:

    • linkedTargetIds / isTargetLinked / filterUnlinkedTargets in relationship-grouping.ts as the single source of truth for "already linked into this group" (case-insensitive comparison — caught in review of the first attempt).
    • The two-pane picker's runSearch now filters through filterUnlinkedTargets instead of an inline, unshared Set.
    • Fixed a real imprecision already present in the merged two-pane picker: "No matches — every result is already linked." was keyed off whether the relationship had ANY linked row at all, not whether this search's own raw results were non-empty before filtering — so a genuinely-empty search could misreport itself as "everything's linked." Now correctly distinguishes the two.

    Daemon's idempotent create_relationship is unchanged, as specified.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions