Skip to content

Require reverseName and reverseCardinality so a relationship edge is named from both ends #2433

Closed

Description

@malibio

Problem

A schema relationship is declared once and read from both ends — the storage model already does this correctly. The declaration is itself an edge between two schema nodes: relationship row with in_node = declaring schema, out_node = target schema, and the full SchemaRelationship JSON in properties (packages/core/src/db/schema.sql:44-56). Declaring invoice → customer therefore updates customer's view automatically; there is no second declaration to keep in sync.

But the reverse half of that declaration is optional, and nothing requires or derives it:

pub reverse_name: Option<String>,
pub reverse_cardinality: Option<RelationshipCardinality>,

packages/nodespace-types/src/schema.rs:249-251

Neither create_schema nor update_schema validates them. The result is that one stored edge is only half-named: it reads well from the end that declared it, and poorly or not at all from the other.

What this costs, concretely

The inverse gets a synthesized label instead of a chosen one. With reverse_name unset, the target side falls back to "{SourceType} ({Relationship Name})" — packages/desktop-app/src/lib/services/relationship-grouping.ts:152. So an invoice declaring customer shows up on the customer as "Invoice (Customer)" rather than "Invoices".

Compare the one core schema that does set it — project --tasks--> task with reverse_name: "project" (core_schemas.rs:405) — where the task side reads "Project". That is the difference between a modeled inverse and a generated one.

The inbound side has no cardinality at all. reverse_cardinality governs the inbound view (rel_ops.rs:610, "Reading from the inbound side, the reverse cardinality governs"). Unset, the inbound group's cardinality is None, so nothing downstream can reason about how many sources may point at a node.

The guidance already treats these as best practice. packages/skill/references/cli.md:329 says "Set reverseName when the edge has a natural name read from the target's end", and every worked example in the reference includes both reverse fields. So the recommended shape and the enforced shape have drifted apart — the docs teach one thing and the API accepts another.

Proposal

Require reverseName and reverseCardinality on every declared relationship. create_schema and update_schema reject a relationship missing either, with an error naming the field and showing a corrected example.

Naming the inverse is a modeling decision only the author can make — invoices, not Invoice (Customer). Requiring it means every stored edge carries a real name from both ends, which is what makes one declaration genuinely readable from either side.

This is a breaking change to the schema API. Per CLAUDE.md this is greenfield with zero users: fix the affected declarations and reset the database rather than writing any migration or compatibility path.

Scope

  • create_schema / update_schema validation — reject relationships missing either reverse field
  • The two fields stay Option in the Rust struct only if something genuinely needs that; prefer making them non-optional so the type carries the invariant rather than a validator
  • Audit built-in schemas in core_schemas.rs — project.tasks already sets both; confirm nothing else declares a relationship without them
  • packages/skill/references/cli.md — reword from optional/"set when natural" to required, in both the field description (:329) and the self-referential section (:331). Per repo convention, CLI-guidance changes ship in the same issue as the CLI behavior change
  • groupDisplayLabel's "{SourceType} ({Relationship Name})" fallback (relationship-grouping.ts:152) becomes unreachable for newly-created schemas. Decide deliberately: delete it, or keep it as a defensive branch with a comment saying why. Its unit test needs the same decision

Out of scope

There is no schema-authoring UI — the frontend only reads reverseName (relationship-grouping.ts). Schemas are authored via the CLI/API, so no form changes are needed.

Acceptance criteria

  • create_schema rejects a relationship without reverseName, with an error naming the field
  • create_schema rejects a relationship without reverseCardinality, likewise
  • update_schema applies the same validation when adding a relationship
  • The error message shows a corrected example, so the fix is obvious from the message alone
  • Built-in schemas in core_schemas.rs all satisfy the rule
  • A relationship declared with both fields is readable from the target's end by its declared reverseName, with the declared reverseCardinality reported as the inbound group's cardinality
  • cli.md documents both fields as required, and every example in the reference includes them
  • The synthesized-label fallback is either deleted or retained with a comment stating why, and its test matches that decision
  • No migration or backfill is added — existing schemas are fixed or the database is reset

Context

Surfaced while designing #2432 (three-pane relationships panel). The panel shows an inbound relationship's name and, where known, its cardinality — and both are frequently absent today, so the panel would be rendering "Invoice (Customer)" and omitting a constraint the schema could have stated.

Worth noting the storage model needs no change: the declaration is already a single edge between schema nodes, reachable from either endpoint (idx_rel_out indexes exactly that traversal). This issue only makes the authoring side fill in the half it currently allows to be left blank.

Activity

  1. malibio commented on Sep 7, 2026

    @malibio
    CollaboratorAuthor

    Blocks #2432 (three-pane relationships panel) — this should land first.

    That panel displays an inbound relationship's name and cardinality in its rail, and both come from the reverse half this issue makes required. Landing this first means the panel is built against complete declarations rather than against the "{SourceType} ({Relationship Name})" fallback.

    It also settles a question #2432 would otherwise inherit: once both reverse fields are required, that fallback becomes unreachable for newly-created schemas, so the decision to delete or defensively keep it belongs here rather than being carried into the new rail rendering and reversed later.

  2. malibio commented on Sep 7, 2026

    @malibio
    CollaboratorAuthor

    Baseline (worktree issue-2433-required-reverse-fields): Frontend: 4900 passed (233 test files)

  3. self-assigned this
    on Sep 7, 2026
  4. added 3 commits that reference this issue on Sep 7, 2026
    661af74
    01f3275
    48fd5de
  5. added a commit that references this issue on Sep 8, 2026
    9ef3240
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions