Repository navigation
Pasting an already-linked node's ID offers an Add that silently does nothing #2434
Description
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:
- Paste the UUID of an already-linked node into the target picker
- The picker offers "Use ID …"
- If the relationship declares
edge_fields, fill them in and press Add - 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_relationshipbehavior 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
malibio commentedon Sep 8, 2026
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:
existingguard this issue asked for, and this issue is still live, just with "restore" added to "fix".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:
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:<target-type>nodes are already linked" only when targets are genuinely exhausted — needs a count of available targets, which the panel does not currently haveSo 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
existingset the search path usesCode references in the original description above (
relationship-viewer-modal.svelte:1157-1166, and therunSearchfilter) are now stale — the file was rewritten in #2446.Compare linked-target ids case-insensitively
mstomar125 commentedon Sep 8, 2026
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/filterUnlinkedTargetsinrelationship-grouping.tsas the single source of truth for "already linked into this group" (case-insensitive comparison — caught in review of the first attempt).runSearchnow filters throughfilterUnlinkedTargetsinstead of an inline, unsharedSet.Daemon's idempotent
create_relationshipis unchanged, as specified.