Corteksa

Relation picker permissions & unnamed records — frontend integration

Relation picker permissions & unnamed records — frontend integration

Two fixes to the relation picker → relation write round-trip. Both are value-level; no key was renamed, added, or removed.

  1. The picker no longer offers partner records the caller cannot attach, so the "pick, submit, get a 400" dead end is gone for the relation shape that hit it.
  2. A relation item for a record with no name now carries an empty name instead of that record's slug, so the client can render its own placeholder.

1. The picker filters by write permission where writing needs it

GET /object/data/select-options/:objectSlug/:fieldSlug used to scope its options by the caller's view level on the partner object. That is the right gate for most relations, but not for one shape.

A reverse direct-FK relation (many-side, no junction table) stores the link as a column on the partner row. Attaching a target therefore UPDATEs that partner row, and the write path bounds the UPDATE by the caller's edit level. A caller with view = A and edit = M was shown every record and rejected on all but their own:

400 Relation 'relation-ms3efde1': you do not have permission to modify
    1 of the 3 target record(s)

For that shape the picker now scopes by the narrower of view and edit, so an option that appears is an option the caller can actually attach.

Unchanged for the other shapes, deliberately — attaching does not touch the partner row there, so edit is not required and demanding it would hide records the caller may legitimately link:

Relation shapeAttaching writesPicker scoped by
Reverse direct FK (many-side, no junction)the partner rowmin(view, edit)
Junction table (many-to-many)a junction rowview
Forward FK (belongs-to)our own record's FK columnview

FE impact: none — no shape change. Expect fewer options in a reverse-FK picker for non-admin users. That is the fix, not a regression: those options were never attachable.

2. The rejection names the records

When the write is still rejected — the picker is a snapshot, and rights can change between opening it and submitting — the 400 now lists the offending record slugs, in the order they were submitted:

// before
"Relation 'relation-ms3efde1': you do not have permission to modify 1 of the 3 target record(s)"

// now
"Relation 'relation-ms3efde1': you do not have permission to modify 1 of the 3 target record(s): a556a7bc942b"

The slugs are the ones the client sent, so they can be matched straight back to the submitted array and highlighted.

3. An unnamed record's name is empty, never its slug

The server-side display-name projection used to fall back to the record's slug when name was blank. An opaque 12-hex slug is indistinguishable from a real display value, so a relation badge rendered 5c9696e92c84 and the client's own "(Untitled)" placeholder never fired.

The fallback is gone. What each surface emits for a record with no name:

// records read — relations[<relation_slug>].items[]
{ "id": 12, "name": null, "slug": "5c9696e92c84", "random_id": "R12",  }

// GET /select-options/:objectSlug/:fieldSlug — data.options[]
{ "type": "relation", "slug": "5c9696e92c84", "name": "" }

name was already typed nullable on relation items; the picker keeps its non-null string contract and sends "". Either way it is now falsy, so a name || "(Untitled)"-style guard works everywhere.

FE impact: if a client special-cased a slug-shaped name, that guard is now dead code — harmless to keep, safe to delete.

Ordering is unchanged: unnamed records still sort by slug in the position the old expression put them, because the list's ORDER BY deliberately keeps the slug fallback (it has to match the shipped idx_<table>_display_btree functional index verbatim, or the sort stops using it).

On this page