Documentation source
Relations as First-Class Fields
Make entity relationships behave identically to regular fields — picker, rank, filter, sort, response, AI-native creation
# Relations as First-Class Fields
## Problem
Entity relationships are second-class citizens in Amble today. A product idea's "target markets" is conceptually a field on the entity, but it is handled by a completely different code path than regular fields. This results in:
1. **Two parallel schemas.** `entity_types.config.relations[]` (type-level relation keys) and `FieldConfig.connection` (field-level rendering hint) overlap confusingly. Agents and humans see inconsistent models.
2. **Three parallel picker components.** `RelationPickerField`, `EntityPicker` (in features/tools), and `AddRelationDialog` — each built for a slightly different case, duplicating logic.
3. **No filter or sort by connection.** Users cannot filter "product ideas that target Enterprise SaaS" or sort by connection rank. The data table filter system only understands JSON-path content fields.
4. **No ranked connections.** `entity_relations.metadata` is unused. There is no way to express "Enterprise SaaS is our #1 target, Healthcare #2" — essential for prioritization and response workflows.
5. **No response submission for relations.** Users cannot send an entity page to a team and collect back a drag-and-drop ranked opinion. The response system only knows scalar dimensions.
6. **AI ergonomics are uneven.** `createEntity` accepts inline `relations` by ID, but agents cannot pass names or slugs, so they must make multiple round-trips (search → create → relate). Extraction cannot populate relation fields at all.
7. **Select/multi-select fields are hard-coded.** Pickers that conceptually reference existing records (statuses, personas, markets) still require hard-coded enum arrays in field config.
## Solution
Promote relations to first-class fields. A field of type `relation` behaves exactly like any other field: it appears in forms, cards, detail views, filters, sort, responses, and AI tools. Its value is one or more references to existing records, optionally ranked, with optional inline creation.
The key architectural move: **`config.fields[fieldName].relation` becomes the canonical place to declare a relation field**, living alongside `extraction`, `displayType`, etc. The legacy `config.relations[]` array and `FieldConfig.connection` object collapse into this single concept. Storage stays in `entity_relations` (single source of truth for the graph), with `metadata.field` stamping which field owns the row and `metadata.rank` carrying the optional rank position.
## Design
### Data Model
**`entity_relations.metadata` schema (finalized):**
```json
{
"field": "target_markets", // field key this relation belongs to
"rank": 0, // 0-indexed position (optional)
"source": "manual" | "ai" | "extraction" | "response"
}
```
An index on `(tenant_id, from_entity_id, (metadata->>'field'))` allows fast "relations for this entity/field" queries. A second index on `(tenant_id, to_entity_id, (metadata->>'field'))` covers reverse traversal.
Legacy relations without `metadata.field` remain visible in a "general connections" fallback, so nothing breaks.
### `FieldConfig.relation` (new)
```ts
interface FieldConfig {
// ... existing fields ...
relation?: {
/** Target entity type slug (required). */
targetTypeSlug: string;
/** Relationship label stored on the row (defaults to field key). */
relationshipType?: string;
/** Cardinality. */
multiple?: boolean;
/** Allow user-controlled ordering (drag-drop). */
rankable?: boolean;
/** Allow creating a new target entity from the picker. */
createInline?: boolean;
/** Optional filter applied to the picker's search. */
filter?: {
tags?: string[];
typeSlugs?: string[]; // widen picker scope
contentEq?: Record<string, unknown>; // field=value filters
};
/** Max selection count (1 for single-reference). */
max?: number;
};
}
```
`FieldConfig.connection` stays as a deprecated alias: helpers resolve it to `relation` at read time.
JSON schema representation in `entity_types.json_schema.properties[field]`:
```json
{
"type": "array",
"items": { "type": "string", "format": "uuid" },
"title": "Target Markets"
}
```
(Single-reference relations use `{ "type": "string", "format": "uuid" }`.)
### Value Hydration
Relations do **not** live in `entity.content[fieldName]`. `getEntityWithRelations(id)` returns a hydrated entity where relation fields are expanded from `entity_relations`. The writes are atomic:
- `createEntity({ content: { target_markets: [...] } })` — detected as a relation field, extracted from content, inserted into `entity_relations` with `metadata.field = "target_markets"`.
- `updateEntity({ content: { target_markets: [...] } })` — sync semantics via `syncEntityRelationsFromConfig`.
### Unified `EntityPicker` Component
One component, one props contract, three rendering modes (button, inline badges, list). Lives in `features/entities/components/entity-picker/`. Props:
```ts
interface EntityPickerProps {
value: string[]; // always an array — max:1 = single
onChange: (ids: string[]) => void;
relation: FieldConfigRelation; // full relation config
mode?: "popover" | "inline";
placeholder?: string;
disabled?: boolean;
}
```
Existing `RelationPickerField` becomes a thin wrapper that adapts legacy `EntityTypeRelationConfig` to the new config shape. Tool and block code imports the canonical picker from `features/interactivity/primitives/entity-picker`.
### `RankableEntityList`
A drag-and-drop list that wraps `EntityPicker`'s selected items. Used when `relation.rankable` is true. Built on `@dnd-kit/core` (already in repo via some other feature — or added). The component emits an ordered array of IDs; the server stores order as `metadata.rank`.
### Filter/Sort by Connection
New filter types:
```ts
type FilterValue =
| ...existing types...
| { type: "relation"; targetId: string; fieldKey?: string } // specific or any field
| { type: "connected"; targetId: string }; // any relation to target
```
URL serialization:
- `f.target_markets_rel=<uuid>` — relation filter scoped to a specific field
- `f._connected=<uuid>` — general connected filter
Server-side query:
```ts
// For per-field relation filter:
.in("id", supabase.from("entity_relations")
.select("from_entity_id")
.eq("tenant_id", tenantId)
.eq("to_entity_id", targetId)
.eq("metadata->>field", fieldKey))
```
The data table filter UI adds a new "Connection" section: for each relation field on the type, a picker; plus a single "Any connection" picker at the top.
Sort by relation: defer to Phase 2 unless trivially free from the `rank` column added to `metadata`.
### Response Submission — Ranked Entities
The response form gains a new dimension type `"relation-rank"`. When a criteria set has such a dimension, the response form renders `RankableEntityList` with the current relation values pre-loaded. On submit, `response.values[fieldName]` is an ordered array of IDs. On promotion, `finalizeEntityResponseAdmin` writes the new rank order back to `entity_relations.metadata.rank`.
For this pass we support one meta-shortcut: any relation field on the entity type with `rankable: true` auto-generates a "Rank {label}" dimension in the default response form when no custom criteria set is configured — allowing instant "send this to a colleague and ask them to rank the markets" flows.
### AI Tool Improvements
1. **`getEntityType` / `listEntityTypes({ detailed })`** — surface relation fields as part of `properties` with their target type slug. Agents see `{ target_markets: { type: "relation", targetTypeSlug: "market", multiple: true } }` and know exactly what to populate.
2. **`createEntity` accepts inline relation values by name/slug/id**. The tool resolves each string via best-effort match (exact slug → exact title → fuzzy title). If no match and `createInline: true`, it creates the target on the fly. The existing `relations` parameter stays as a compatibility alias.
3. **Extraction output contract `relation-entity`** actually gets implemented: extraction can return `{ relations: { target_markets: ["Enterprise SaaS", "Healthcare"] } }` and the runtime resolves + writes via the same pathway.
### Component Consolidation
| Before | After |
| ------------------------------------------------ | ----------------------------------------------------------- |
| `features/interactivity/primitives/entity-picker.tsx` | Canonical platform picker used by forms, tools, and blocks |
| `features/entities/components/relation-picker-field.tsx` | Thin wrapper over `EntityPicker` |
| `features/entities/components/add-relation-dialog.tsx` | Uses `EntityPicker` |
| ad-hoc pickers in custom tools | Use canonical `EntityPicker` |
## Trade-offs
- **Legacy `config.relations[]` deprecation.** We do NOT remove it — existing entity types keep working. Helpers read either shape. A future cleanup migration can rewrite old configs into the new form, but we don't want to block on it.
- **`content[fieldName]` never holds relation values.** Consumers of raw content need to be updated to read from the hydrated entity. This is a quiet but important change.
- **No schema-validated content fields for relations.** We keep the JSON schema shape loose (array of strings) rather than codifying "relation" as a JSON schema type, because Zod/JSON Schema don't natively support cross-table references.
- **Drag-drop ranking requires a new dep.** If `@dnd-kit/core` isn't already present, we add it. It's ~20 KB gzipped and widely used.
## Acceptance Criteria
A product idea's "target markets" field:
- [x] Is declared as a single field in entity type config, alongside other fields.
- [x] Renders a search-and-multi-select picker in the create/edit form.
- [x] Supports drag-and-drop ranking when `rankable: true`.
- [x] Displays the picked markets as a card block on the entity detail page with the same visual treatment as other connection blocks.
- [x] Can be filtered in the product ideas list: "target market = Enterprise SaaS".
- [x] Can be filtered in the product ideas list: "connected to Enterprise SaaS in any field".
- [x] AI agents creating product ideas can pass `{ target_markets: ["Enterprise SaaS", "Healthcare"] }` in a single `createEntity` call, by name.
- [x] A user can submit a response that re-ranks the markets via drag-drop.
- [x] Sort and filter work for all existing connection configs without any migration (backwards compat via helper).
- [x] No hardcoded entity type slugs in platform code.
- [x] All tests pass, typecheck passes, build passes, lint passes.
## Files Touched
See `files-touched` in frontmatter.