Entity System
The core data primitive of the Sprinter Platform. Every record in the system is an entity, typed by a DB-driven schema, stored in a universal table, and connected through a relationship graph.
Overview
The entity system is the foundational data layer of the Sprinter Platform. Every record -- whether it represents an opportunity, a person, a company, a research document, or a meeting -- is stored as an entity in a single entities table. Entity types are not hardcoded in application code; they are defined in the database as rows in the entity_types table, each carrying a JSON schema that describes its fields.
This design makes the platform genuinely reusable. When forking the codebase for a new product, the entity types change (via DB seeds or admin UI), but the platform code that renders, searches, and manages entities stays identical.
Key Concepts
Entity Types
An entity type is a schema definition stored in the entity_types table. Each type has:
- slug -- URL-safe identifier (e.g.,
opportunity,person,technology) - name -- Human-readable display name
- json_schema -- JSON Schema object describing the type's fields, their types, enums, and validation rules
- config --
EntityTypeConfigJSONB column holding scoring criteria, UI hints, field extraction configs, relation definitions, and container settings - visibility -- Access level (
private,shared,tenant,public) - tenant_id -- Nullable;
nullmeans the type is global (available to all tenants), otherwise tenant-scoped
Entity type IDs have no default in the database. When creating a new entity type, you must generate the UUID explicitly via crypto.randomUUID().
interface EntityTypeRecord {
id: string;
tenant_id: string | null;
slug: string;
name: string;
json_schema: Record<string, any> | null;
config: EntityTypeConfig | null;
visibility: string;
created_at: string;
updated_at: string;
}Entities
An entity is an instance of an entity type. The core fields are:
- title -- Display name
- slug -- URL-safe identifier (auto-generated from title + timestamp)
- description -- Free-form markdown body (the entity's primary prose field). Indexed in FTS at weight B. Rendered via the TipTap rich editor on the detail page.
- content -- JSONB column storing all schema-defined field values
- metadata -- JSONB column for system data (scores, extraction status, locked fields)
- tags --
text[]array with a GIN index for fast containment queries - owner_id -- User who created the entity
- parent_id -- Self-referencing FK for parent-child hierarchies
- visibility -- Per-entity access control (
private,shared,tenant,public) - share_token -- Unique token for link-based sharing
- entity_type_slug -- Denormalized slug kept in sync by a DB trigger
- external_id -- Identifier from an external system (e.g., an OpenClaw record ID or API response ID). Enables idempotent upserts from external agents.
- external_source -- Name of the source system that owns the external ID (e.g.,
"openclaw","webhook","api"). Together withexternal_id, forms a unique identity per tenant.
interface EntityRecord {
id: string;
tenant_id: string;
slug: string;
entity_type_id: string;
title: string | null;
content: Record<string, any> | null;
metadata: Record<string, any> | null;
owner_id: string | null;
parent_id: string | null;
tags: string[];
visibility: EntityVisibility;
share_token: string | null;
external_id: string | null;
external_source: string | null;
// ...timestamps, joined entity_type
}A partial unique index on (tenant_id, external_source, external_id) WHERE external_id IS NOT NULL enforces uniqueness for entities with external identity while leaving the index lean — the vast majority of platform-native entities have null external_id and are not indexed.
Entity Type Config
The config JSONB column on entity_types controls platform behavior beyond the raw schema:
interface EntityTypeConfig {
ui?: {
isParent?: boolean;
cardFields?: string[];
cardConfig?: CardConfig;
icon?: string;
color?: string;
description?: string;
hidden?: boolean;
};
container?: ContainerConfig;
fields?: Record<string, FieldConfig>;
relations?: EntityTypeRelationConfig[];
coreFields?: string[];
/** Custom empty state shown when no entities of this type exist */
emptyState?: EntityTypeEmptyState;
}
/**
* Config-driven empty state for entity list views.
* Rendered by EntityEmptyState when the list has no records.
*/
interface EntityTypeEmptyState {
icon?: string; // lucide icon slug from the platform icon map
title?: string; // Heading text (default: "No {typeName} yet")
description?: string; // Body text below the heading
ctaLabel?: string; // CTA button label
ctaAction?: "create" | "import" | "link";
}Field Configs
Per-field configuration stored in config.fields. Each entry maps a JSON schema property name to a FieldConfig that controls display, relations, promotion policy, and lifecycle:
interface FieldConfig {
label?: string;
displayType?: string;
/**
* Status chip mapping — when displayType is "status", maps raw field values
* to colored chips with optional icon. Keys are the raw field values.
*/
statusMap?: Record<string, StatusMapEntry>;
humanInput?: boolean;
/** First-class relation field config — canonical replacement for legacy `connection`. */
relation?: FieldConfigRelation;
/** ISO timestamp set when a field is archived (soft-deleted via delete_field_cascade). */
archivedAt?: string;
/**
* When to auto-promote a response value to entity.content.
* - "always" — promote immediately on submission
* - "if_confident" — promote only when confidence >= confidence_threshold
* - "never" — require explicit human promotion
*/
auto_promote_policy?: "always" | "if_confident" | "never";
/** Confidence threshold for "if_confident" auto-promote policy (0–1). */
confidence_threshold?: number;
}Deprecated keys removed (as of 2026-04-15): FieldConfig.extraction, FieldConfig.connection, and FieldConfig.actions are no longer accepted. Extraction is now authored as tasks with output_type: 'response'. Connection display config was absorbed into FieldConfig.relation. Field-action triggers were removed in Phase 7 in favour of task-based triggers. The assertValidEntityTypeWrite() gate rejects writes that contain these keys.
Deprecated EntityTypeConfig keys removed: config.dashboard and config.statusTriggers are no longer accepted.
Local Fields and Drift (per-entity overlay)
Every entity (including the untyped concept) carries an optional local field overlay with split storage:
metadata.localFields: Record<string, LocalFieldDefinition>— per-record field definitions. Low-churn, structural. Adding a key here attaches a field to one entity only; the global type schema is untouched.entity.content[key]— values for those local fields, stored alongside canonical values in the unified content namespace.EntityWriter.assertValidContent()accepts a content key when it's declared inmetadata.localFields— so writes are validated, but local-field-typed values pass through.
Why definitions on metadata and values on content:
- Definitions are structural overlay (alongside
field_sources,lockedFields); values are user content. Different write frequencies, different concurrency profiles. Splitting them keeps the model honest. - Unified value namespace means local fields ride existing infrastructure for free: FTS over
entity.content, bento rendering, criteria-set / response targeting (criteria_set.dimensions[].fieldNamealready resolves toentity.content[*]), task-driven extraction (output_type: 'field'), per-field provenance viaentity_responses.field_meta, CSV import/export, and external API key writes throughPATCH /api/entities/[id]. None of those paths need branching for local vs canonical fields.
Field types supported by the overlay are intentionally narrower than the canonical taxonomy: text | long_text | number | boolean | date | url. Reserved keys (entity-row columns) are rejected at the server-action boundary.
Field state model
features/entities/lib/field-set.ts exposes resolveEntityFieldSet(entity, entityType) — a pure helper that classifies every field on an entity:
canonical— active, non-archived field on the entity type schemalocal— defined inmetadata.localFieldsfor this entity only (value pulled fromcontent[key])orphaned— value inentity.contentwith no canonical or local definition (drift)archived— value inentity.contentfor a canonical field marked archived (drift)incompatible— content value type does not match the canonical jsonType (drift)shadowed— local field key collides with an active canonical key (drift; canonical wins for the value)
Returned effectiveFields is the ordered render list (canonical first, then local by createdAt). Both pull values from entity.content.
Read features/entities/components/entity-detail/local-fields-panel.tsx for the rendered surface (Local fields + Needs cleanup + Add field). Read features/entities/server/local-fields.ts for the four server actions:
addLocalField— writes a def intometadata.localFieldsvia the deep-mergeapply_entity_metadata_patchRPC.setLocalFieldValue— coerces viacoerceLocalFieldValuethen writes intoentity.content[key]via the standardmerge_entity_contentRPC.renameLocalField— two RPCs ordered metadata-first: move the def, then move the value (apply_entity_content_patchdoes delete-then-merge in one statement underFOR UPDATE).deleteLocalField(mode: "wipe" | "archive") — wipe drops both def and value (two RPCs, metadata-first); archive drops the def only and lets the value surface as orphaned drift on the next read.
concept — the untyped-by-default system entity type
concept is the generic system entity type that backs untyped capture. A concept is just an entity without a domain-specific schema — capture first, harden into a typed entity (or promote into a new type) when the shape proves itself. The type has an empty content schema and exists so users can hit "New concept" and land in an editable record without picking a type first.
- Slug:
concept. User-facing copy: "Concept", "Untitled concept". - All concept-level fields are local fields (the type itself contributes none).
- A concept can later be converted to a typed entity via type-change (deferred follow-up); unmapped local fields are preserved as local on the resulting entity.
The untyped-capture flow lives at app/(app)/new/page.tsx and is reached via the "New concept" command palette item.
Entity Relations
Directed edges between entities, stored in the entity_relations table:
- from_entity_id / to_entity_id -- The two endpoints
- relationship_type -- Freeform string (e.g.,
related_to,belongs_to,manages,depends_on) - tenant_id -- Scoped to tenant
- metadata -- JSONB. First-class relation fields stamp this with
{ field, rank, source }(see Relation Fields below). When a relation-scoped criteria set has responses, an additionalscores[criteriaSetId]key carries the per-edge aggregate (see Edge Scoring below).
Relations are queried bidirectionally. The getRelatedEntities() server action fetches both outgoing and incoming relations for a given entity.
Important: entity_relations.id has a gen_random_uuid() DB default, but all insert sites also pass an explicit crypto.randomUUID() as belt-and-suspenders protection against accidental migration regressions that drop the default. Always pass an explicit ID when inserting relations.
Relation Fields (First-Class)
A relation field declares that an entity type has a named attribute whose value is one or more references to other entities, stored in entity_relations rather than entity.content. Relation fields behave identically to regular content fields in forms, cards, filters, sort, responses, and AI tools.
Declared on FieldConfig.relation inside config.fields[fieldKey]:
interface FieldConfigRelation {
/** Required target entity type slug. */
targetTypeSlug: string;
/** Relationship type written to entity_relations.relationship_type. Defaults to fieldKey. */
relationshipType?: string;
/** Cardinality. */
multiple?: boolean;
/** Maximum selectable targets when multiple. */
max?: number;
/** Enable drag-drop ordering — writes metadata.rank. */
rankable?: boolean;
/** Allow creating a new target from the picker. */
createInline?: boolean;
/** Narrow the picker's search scope. */
filter?: {
tags?: string[];
typeSlugs?: string[];
contentEq?: Record<string, string | number | boolean>;
};
// UI display config (absorbed from legacy FieldConfig.connection in Phase 2):
/** How to render the relation list in the entity detail view. */
display?: "list" | "data-table" | "inline";
/** Compact or full data table variant when display is "data-table". */
dataTableVariant?: "compact" | "full";
/** Auto-create child entities and trigger their extraction. */
cascadeExtraction?: {
enabled: boolean;
titleField?: string;
autoExtract?: boolean;
};
/** Whether the relation is populated by agents (auto) or manually curated. */
mode?: "auto" | "curated";
}Migration note: The legacy FieldConfig.connection shape is no longer accepted by the schema or write gate. All callers must use FieldConfig.relation. The resolver getRelationFieldMap() in features/entities/lib/relation-fields.ts handles forward compatibility for any rows not yet migrated.
Example: a product_idea entity type with a ranked target-markets field.
{
"config": {
"fields": {
"target_markets": {
"label": "Target Markets",
"relation": {
"targetTypeSlug": "market",
"multiple": true,
"rankable": true,
"createInline": true
}
}
}
}
}Storage: Relation values never land in entity.content. They live as rows in entity_relations with metadata.field = "target_markets" and, when rankable, metadata.rank = 0, 1, 2, .... The resolver getRelationFieldMap() in features/entities/lib/relation-fields.ts merges new-style field configs with legacy config.relations[] and FieldConfig.connection entries so any shape works.
Filter and sort: The data table supports two new filter types that target relations directly:
{ type: "relation", fieldKey, targetIds }-- filters entities whose specific relation field links to one of the given targets. Rendered via an EntityPicker in the column header.{ type: "connected", targetIds }-- filters entities with any relation to the given targets, in any field. Rendered via the "Connected to" button in the toolbar.
URL serialization uses f.<field>_rel=id1,id2 and f._connected_conn=id1 so filter state survives refresh and sharing.
AI ergonomics: The createEntity tool accepts relation values directly in content by UUID, slug, or title. Agents can write:
{
"typeSlug": "product-idea",
"title": "New Platform",
"content": {
"priority": "high",
"target_markets": ["Enterprise SaaS", "Healthcare"],
"owner": "jane-doe"
}
}and the runtime resolves each string to an entity UUID via resolveRelationTargets(), creating inline targets when the field's createInline is true. getEntityType and listEntityTypes({ detailed: true }) include a relationFields array summarizing each relation field so agents know what values to pass.
Rankable responses: Criteria sets support a relation-rank dimension type. When a criteria set has a relation-rank dimension, the response form renders the RelationFieldInput component with drag-drop enabled. Users can submit a new priority order as a scored response; the ordered IDs land in response.values[fieldKey].
Hierarchy (parent_id, breadcrumbs, cascade delete)
entities.parent_id is a self-referencing FK. Each entity belongs to at most one parent; the tree lives alongside entity_relations (graph). A DB trigger rejects self-parent, cycles, and depth > 10, serialized per-tenant via pg_advisory_xact_lock(hashtext(tenant_id)). Four tenant-scoped RPCs — entity_ancestors, entity_descendants, entity_descendant_counts_by_type, entity_nearest_ancestor_of_type — power breadcrumbs, children panels, and agent tools with explicit p_tenant_id args and depth caps.
Server actions
reparentEntity(entityId, newParentId)— validates permission on both source and destination, enforces the destination type'scontainer.allowedChildTypes, walks ancestors for cycle detection (belt-and-braces), logs activity, firesentity.reparentedanalytics, and revalidates cache tags.deleteEntityWithMode(entityId, { mode, cascadeConfirmed })— the single cascade gate. When descendants exist,mode: "cascade"requirescascadeConfirmed: true; otherwise it rejectsCASCADE_NOT_CONFIRMED(HTTP 409).mode: "detach-children"is atomic: the SQL functiondelete_entity_with_modeholds the advisory lock,FOR UPDATEs the parent, NULLs children, then deletes — closing the detach-then-delete TOCTOU window.
UI
EntityBreadcrumbs— ancestor chain with an inline Move icon-button.EntityReparentDialog— search-based parent picker with a detach-to-root affordance.EntityChildrenPanel— renders children grouped per type with inline create; auto-shows for types withcontainer.allowedChildTypeseven when empty.DeleteEntityDialog— shows descendant counts and two one-call options: "Detach children, then delete" (autofocused) or "Delete everything (N items)".
Agent tools
getDescendants— list subtree with optional type filter.getEntityacceptsincludeAncestors: true— response includesancestors(nearest first).updateEntityacceptsparentId— routes throughreparentEntity(same gate as humans).deleteEntityacceptsmode+cascadeConfirmed— must be true only after the user approves destruction.
Ancestor context (prompt-cache safe)
buildAncestorContextMessage(entityId) returns a separate user-role message summarizing the ancestor chain. The agent's system prompt stays entity-independent so Anthropic prompt caching keeps a stable prefix across turns. Controlled per-type via container.inheritAncestorContext (default true), container.ancestorContextFields (whitelist), and container.excludeFromAncestorContext (PHI-safe denylist).
Type config
entity_types.config.container = {
enabled?: boolean,
allowedChildTypes?: string[], // restrict child picker + create
inheritAncestorContext?: boolean, // default true
ancestorContextFields?: string[], // stable fields to inject
excludeFromAncestorContext?: string[], // always-redacted fields
}Configure via /admin/data-types/[slug] → Overview → Children.
Edge Scoring
When a criteria set has scope.type === "relation", submitting a scored response promotes a per-edge aggregate onto each matching row in entity_relations. The blob lives at metadata.scores[criteriaSetId] with shape:
type EdgeScoreBlob = {
weighted: number | null;
normalized: number | null;
count: number;
updatedAt: string;
};Promotion runs inside insertEntityResponse() as a fire-and-forget call to promoteEdgeScores() after the response insert commits. The helper recomputes the aggregate from ALL non-draft responses (submitted + promoted + partially_promoted) for the (tenant, entity, criteriaSet) tuple — there is no incremental path, which keeps the blob stable under supersede/reject churn. The per-edge merge uses a set_edge_score(uuid, uuid, text, jsonb) SQL function that runs jsonb_set on the one sub-path so metadata.field, metadata.rank, and metadata.source written by the relation-sync path are preserved.
If the inline promotion throws, the response submit logs a structured warning and enqueues an edge-scores/repair Inngest event. The repairEdgeScores function retries the write with concurrency pinned to one per (entity, criteria set) tuple to avoid racing overlapping submits.
Reads: getRankedConnections({ tenantId, entityId, criteriaSetId, limit }) returns the scored edges sorted by normalized desc, nulls last. The same helper is exposed as a permission-gated AI tool (getRankedConnections, gated on entities.team.read) so agents can ask "rank X's connections by criteria set Y" in one call instead of re-aggregating responses.
Entity Type Access Control
Entity types support a visibility-based access model. The listAccessibleEntityTypes() and getAccessibleEntityTypeBySlug() helpers filter types by combining:
- Global types (tenant_id IS NULL) visible to all tenants
- Tenant-specific types (tenant_id = current tenant)
Entity-level visibility (private, shared, tenant, public) and an entity_shares table enable fine-grained per-record access.
How It Works
Entity Response as Universal Write Primitive
Every write to an entity field value flows through the same three-step pipeline:
Session → entity_response (staged value) → Promote → entities.contentThis applies to all write paths:
| Write path | Entry point |
|---|---|
| Inline field edit (entity detail bento) | saveInlineFieldEdit() server action |
| Inline field edit (data table) | saveInlineFieldEdit() via use-data-table-editing hook |
API write (POST/PATCH /api/entities) | bundleContentThroughResponses() helper |
Chat tool (createEntity / updateEntity) | bundleContentThroughResponses() via createEntityKeyed() |
| Cascade extraction | bundleContentThroughResponses() in cascade handler |
Each write creates a session_type='response' micro-session and one entity_response row per field. The target_type='field' row is then promoted via promote_field_value RPC which writes the value into entities.content[fieldName] and stamps entity.metadata.field_sources[fieldName].
Auto-promotion is controlled by FieldConfig.auto_promote_policy:
"always"(default) — promotes immediately on submission"if_confident"— promotes only whenentity_response.confidence >= FieldConfig.confidence_threshold"never"— value sits inentity_responsesuntil a human promotes it explicitly
The canonical promotion helper is promoteEntityResponseValue() from features/responses/server/promote-response.ts. It dispatches to promote_field_value RPC for target_type='field' and to promote_dimension_response RPC for target_type='dimension'. The function is idempotent — already-promoted responses return { promoted: false }.
Entity Type Write Gate
All writes to entity_types.config and entity_types.json_schema must flow through assertValidEntityTypeWrite() in features/entities/server/entity-type-write-gate.ts.
// Every API route, server action, and tool that modifies entity type config
const validatedConfig = await assertValidEntityTypeWrite(rawConfig);
// SchemaWriteError is thrown (caught as 400) if deprecated keys are present
// or if Zod strict validation failsThe gate runs in two phases:
- Pre-Zod deprecation check — inspects the raw input for
connection,extraction,actions(field-level), anddashboard,statusTriggers(top-level). Produces a clearSchemaWriteErrormessage before Zod strict mode would fire a generic "unrecognized keys" error. - Zod strict parse —
FieldConfigSchemaandEntityTypeConfigSchemause.strict(), so any unknown key causes a parse error.
The allowDeprecated: true option exists only for migration scripts that must read-and-rewrite legacy rows without failing. It uses .strip() instead of .strict() to silently drop unknown keys.
The ESLint rule no-direct-entity-type-write (in eslint-rules/no-direct-entity-type-write.mjs) is the second enforcement layer. It fires a lint error on any call to .from("entity_types").update() or .from("entity_types").insert() that includes a config or json_schema key in the payload literal. Exceptions are allowlisted for: entity-type-write-gate.ts, scripts/, supabase/migrations/, and test files.
Unified Fields Admin Tab
The Admin > Data Types > Fields tab (features/admin/components/admin/fields-tab/) provides a split-pane editor for all field configuration:
- Left pane —
FieldList: scrollable list of all fields for the entity type. Each row shows the field slug, type badge, and whether it has a dedicated extraction subtask. Selecting a row loads the field into the right pane. - Right pane —
FieldEditor: 8 collapsible sections, each owning a specific slice ofFieldConfig+json_schema:
| Section | What it controls |
|---|---|
| Schema | Slug (read-only), JSON schema type (read-only), description (editable), required toggle |
| Display | displayType picker, StatusMapEditor for status chip color/label/icon mapping |
| Relation | RelationSection — target type slug, relationship type, display variant, mode, max, multiple, rankable; CascadeExtractionEditor for child-entity auto-create |
| Extraction | ExtractionSection — shows linked extraction subtask (agent + edit link) or offers a one-click "Create dedicated subtask" CTA |
| Promotion | PromotionSection — auto_promote_policy select, confidence_threshold slider |
| ScoringUsage | Shows which criteria set dimensions reference this field key |
| Layout | Controls the field's section assignment and ordering within config.fieldLayout |
| Lifecycle | LifecycleSection — Rename and Delete dialogs with pre-flight impact preview |
All sections share a single draft state object. Changes accumulate in memory and are written to the DB via assertValidEntityTypeWrite() on Save.
Field Lifecycle — Rename and Delete
Field rename and delete are destructive operations that must update every reference to the field key across the entire platform. The platform provides two SQL RPCs for this:
rename_field_cascade(p_entity_type_id, p_old_key, p_new_key, p_tenant_id)
Atomically rewrites all references to the old key:
entity_types.config.fields— renames the keyentity_types.json_schema.properties— renames the propertyentities.content— renames the key in every entity of this typetasks.output_config.fields[]— rewrites any extraction task that targets this fieldcriteria_sets.dimensions[].fieldName— rewrites scoring dimension references- Writes a
schema_repair_logaudit entry
delete_field_cascade(p_entity_type_id, p_field_key, p_mode, p_tenant_id)
Two modes:
'archive'— setsFieldConfig.archivedAtto the current timestamp. The field is hidden from the UI and excluded from drift checks but its data is preserved inentities.content.'wipe'— archives the config AND deletes the key from everyentities.contentJSONB object.
Both modes write a schema_repair_log audit entry.
Impact preview: Before the rename or delete RPC runs, previewFieldImpact() in features/entities/server/field-lifecycle.ts returns counts of affected entities, tasks, criteria sets, and views. The Rename and Delete dialogs in FieldsTab display these counts so administrators can assess blast radius before committing.
// Returns counts of affected objects
const impact = await previewFieldImpact({ entityTypeId, fieldKey });
// {
// entityCount: 145,
// taskCount: 2,
// criteriaSetCount: 1,
// viewCount: 0
// }Schema-Health Drift Detection
features/schemas/completeness-checks.ts runs drift scans over entity type definitions. Six new categories were added:
| Category | Description |
|---|---|
task_unknown_field | An extraction task references a fields[] key that does not exist in json_schema.properties |
entity_type_no_extraction_task | A type has extractable fields but no parent extraction task |
entity_type_no_fields | A type has a config.fields map with entries but an empty json_schema.properties (schema/config mismatch) |
field_deprecated_key | A FieldConfig in production data still contains a deprecated key (connection, extraction, actions) |
status_map_shorthand | A statusMap entry is still stored as a bare color string instead of the canonical {color, label?, icon?} object |
orphan_response | An entity_response row references a target_key (field name) that no longer exists in json_schema.properties |
Archived fields (those with archivedAt set) are excluded from all drift checks.
CRUD Operations
All entity operations go through server actions in features/entities/server/actions.ts:
- Create -- Resolves entity type by slug, generates a slug from the title, inserts with tenant scoping, logs an activity record.
- Read --
getEntityById()(cached per request via Reactcache()),getEntityByIdentifier()(accepts UUID or slug),getEntitiesByType(),searchEntities()(full-text search with field filters, tag filters, sorting, pagination). - Update -- Fetches existing content/metadata first, performs a shallow merge so partial updates do not erase sibling fields, logs activity.
- Delete -- Removes the entity, logs activity with the entity's former title.
entity_type_slug Denormalization
The entity_type_slug column on the entities table is kept in sync with the entity type's slug via a database trigger. This avoids the need to join entity_types just to filter entities by type slug -- a significant performance optimization for list pages and search queries.
All entity queries that filter by type use entity_type_slug directly:
.eq("entity_type_slug", typeSlug)Tags and GIN Indexing
Tags are stored as a text[] column with a GIN index. Filtering by tag uses PostgreSQL's @> (contains) operator:
.contains("tags", [tag])Tags are replaced (not merged) on update. The searchEntities action supports an optional tag parameter for filtering.
Value Locking
Fields can be locked to prevent extraction from overwriting human-curated values. Locked field names are stored in entity.metadata.lockedFields as a string array. During extraction, locked fields are skipped entirely.
Parent-Child Hierarchies
Entities support a parent_id self-referencing foreign key. Entity types can be configured as containers:
interface ContainerConfig {
enabled: boolean;
allowedChildTypes?: string[];
defaultChildCollections?: ChildCollectionConfig[];
}When an entity type has container.enabled = true, its detail page renders child entity collections. The getChildEntities() server action queries by parent_id, optionally filtered by child type slug.
Search and Filtering
The searchEntities() function supports:
- Full-text search via PostgreSQL's
websearchtext search on theftscolumn - Field filters -- text (ILIKE), exact/not-exact, enum (IN), range/comparison (
gt,gte,lt,lteon JSONB), boolean, array containment, date-relative - Tag filtering --
contains("tags", [tag]) - Sorting -- by title, score (metadata->weighted_score), created_at, or any schema field (JSONB path)
- Pagination -- offset-based with parallel count query
Field name validation prevents injection: only identifier-safe names matching /^[a-zA-Z_][a-zA-Z0-9_]*$/ are allowed in dynamic query paths.
Entity list URL parsing and serialization is centralized in features/entities/lib/entity-query-codec.ts. It owns the q, sort, order, tag, page, pageSize, and f.* URL shape, and it converts compatible table filters to/from entity-native DataSourceConfig.filters. URL range suffixes continue to support legacy _min / _max and now also accept data-source-native _gt, _gte, _lt, and _lte. Dynamic relative-date and relation-backed table filters are reported as unsupported when a caller tries to persist them as static FilterRule[].
Date-Relative Filters
Two date-relative filter types are handled server-side in searchEntities():
| Filter type | Shape | Behavior |
|---|---|---|
before_today | { type: "before_today" } | Matches records where the field value (ISO date string) is less than today's date. |
within_days | { type: "within_days", days: number } | Matches records where the field value falls between today and N days in the future (inclusive). |
Both types compute ISO date strings at query time and apply .lt() / .gte().lte() predicates to the JSONB field path. They operate on fields that store dates as ISO strings (e.g., "2026-05-15").
Scoring
Entity types can define scoring criteria in config.scoring. Each criterion has a name, label, weight, and scale. Scores are stored in entity.metadata as individual criterion scores plus a computed weighted_score. The scoring radar chart (rendered as a radar block) provides interactive sliders for score entry.
Import/Export
CSV and xlsx import, and CSV export, are supported on entity list pages. Export serializes all schema fields for the current filtered result set.
Import Dialog (features/entities/components/import-dialog.tsx): A guided import flow that accepts both .csv and .xlsx files and maps their columns to entity fields before importing. Features:
- File formats: Accepts
.csvand.xlsx. xlsx files are parsed client-side before the column-mapping step. - Auto-mapping: Column headers are automatically matched to schema field names using exact, case-insensitive, and humanized matching. Unmatched columns default to "skip".
- Preview table: Shows the first 10 rows with mapped column headers highlighted, and validation issues (errors in red, warnings in amber) per cell.
- Validation: Checks that a title column is mapped, title values are non-empty, number fields contain valid numbers, and enum fields contain valid options.
- Column remapping: Users can manually change any column mapping via dropdown selects before importing.
- Upsert mode: An optional
UpsertOptionsPanellets users choose a match key (any schema field) and an overwrite toggle. When enabled, the import payload setsbody.upsert = { matchKey, overwrite }and the server performs an upsert rather than a pure insert. Useful for refreshing datasets without duplication. - While the import request is in flight, an indeterminate progress bar appears and the Import button switches to a spinner + "Importing…" label to prevent double-submission.
- The mapped data is sent to
POST /api/entities/importfor server-side processing. - The route enforces
entities.team.createpermission before any write path runs. - Server-side import reuses the shared keyed entity creation service so imports follow the same tenant-scoped entity-type resolution and activity/event side effects as normal record creation.
The older CsvImportDialog (features/entities/components/csv-import-dialog.tsx) is deprecated — it remains functional for backward compatibility but new callers should use ImportDialog.
Body Field and Rich Editor
Every entity has a description TEXT column that serves as its primary prose / notes field. It is:
- FTS-indexed at weight B — body text is included in full-text search alongside the title (weight A) and structured field values (weight C).
- Rendered above structured fields on the entity detail page using a TipTap-based editor.
- Always available in custom views as a collapsible section — it is not part of the JSON schema, so it does not need to be declared in
json_schema.properties.
TipTap Editor and Wikilinks
EntityBodyEditor (features/entities/components/entity-body-editor.tsx) renders a rich text editor with the following capabilities:
[[wikilink]]autocomplete — Typing[[opens an inline picker that searches entities via/api/search/global. Selecting an entity inserts a styled wikilink node. In view mode, wikilinks render as clickable links to the referenced entity's detail page.- Mention relation sync — On each body save,
syncWikilinkRelations()(features/entities/server/wikilink-relations.ts) reconciles the full set ofmentionentity relations with the current wikilinks in the document. Stale mentions are deleted; new mentions are inserted. This is an unconditional full-reconcile on save, not an incremental diff. - Slash commands — Typing
/opens a formatting command palette:
| Command | Output |
|---|---|
| Heading 1–3 | #, ##, ### headings |
| Bullet List | Unordered list |
| Numbered List | Ordered list |
| Quote | Blockquote |
| Code Block | Fenced code block |
| Divider | Horizontal rule |
| Link Entity | Opens the wikilink picker |
The editor is implemented as a pair of TipTap extensions in features/entities/components/editor/:
wikilink-extension.ts— Custom node type, input rule for[[, and inline suggest pluginslash-command-extension.ts— Keyboard-triggered command palette with fuzzy matching
Bulk Operations
The bulk actions system enables operating on multiple entities simultaneously from the data table. Select rows using the checkbox column, then use the bulk actions bar.
API: POST /api/entities/bulk accepts a discriminated union payload validated with Zod:
action: "delete"— Delete selected entities (up to 500)action: "add_tags"— Merge tags into selected entities (deduplicates)action: "remove_tags"— Remove specific tags from selected entitiesaction: "update_field"— Set a content field value across selected entities
Server actions (features/entities/server/bulk-actions.ts): All operations are tenant-scoped via getActiveTenantId(), use the authenticated Supabase client for RLS enforcement, and log activity. Tag and field updates are batched in groups of 50 concurrent requests to balance throughput with connection limits.
UI (features/entities/components/data-table/bulk-actions-bar.tsx): Shows when rows are selected. Offers the following actions:
| Button | Behaviour |
|---|---|
| Extract | Triggers AI enrichment for all selected records (batched in groups of 10). |
| Tag | Popover with a free-text input to add a tag to all selected records. |
| Untag | Visible only when selected records have at least one tag. Popover shows every unique tag across the selection as one-click removal buttons, plus a free-text input for removing by name. |
| Set Field | Popover with a schema-driven field/value selector (BulkFieldEditor). |
| Export | Dropdown offering CSV and JSON export of the selected rows (client-side, no server round-trip). |
| Delete | Confirmation dialog before permanent deletion. |
Export helpers (exported from bulk-actions-bar.tsx for testability):
| Function | Description |
|---|---|
buildSelectedCsv(entities, entityType) | Serializes selected entities to RFC-4180 CSV. Columns: title, all schema fields, tags (semicolon-joined), score, created_at. Arrays are semicolon-joined; values containing " are doubled-quoted. |
buildSelectedJson(entities) | Returns a plain object array with id, title, slug, content, tags, score, and created_at. |
collectTags(entities) | Collects all unique tags across a list of entities, sorted alphabetically. |
The component receives an entities prop (the current page of loaded records) so selected-row data is resolved in the browser without an extra network request.
Filter Presets
Filter presets (features/entities/components/data-table/filter-presets.tsx) let users save and restore named filter configurations. Presets are persisted in localStorage per entity type slug.
- Save: When active filters exist, a "Save filter" button opens a name input popover. The current
FilterStateis serialized viaserializeFilters()and stored. - Load: Saved presets appear in a "Presets" dropdown. Selecting one calls
parseFilters()to restore theFilterStateand applies it to the table. - Delete: Each preset has a trash button for removal.
Presets remain browser-local. Reusable named data sources use the shared entity query codec and DataSourceConfig.filters; save-from-table UX is tracked as follow-up work.
Data Table Density Modes
The data table supports three density modes that control row height, font size, and cell padding:
| Density | Row height | Use case |
|---|---|---|
compact | 32px (h-8) | High-density monitoring dashboards, many-row tables |
default | 40px (h-10) | Standard usage |
spacious | 48px (h-12) | Presentation mode, accessibility |
A DataTableDensityToggle component (three-icon ToggleGroup) appears in the table toolbar. The selected density is persisted in localStorage under the key density-{typeSlug} and validated on load (unknown values fall back to "default").
// features/entities/components/data-table/types.ts
type Density = "compact" | "default" | "spacious";
const DENSITY_HEAD_CLASSES: Record<Density, string>;
const DENSITY_CELL_CLASSES: Record<Density, string>;
const DENSITY_TABLE_CLASSES: Record<Density, string>;Multi-Column Sort
The data table supports sorting by up to three columns simultaneously.
- Primary sort: click a column header as normal (cycles through asc → desc → unsorted).
- Secondary / tertiary sort: hold Shift and click a column header to add it to the existing sort, or toggle it within the multi-sort. A small numeric priority badge (2, 3) appears next to the sort arrow on non-primary sorted columns.
- Sorting more than three columns is not supported — additional shift+clicks are ignored.
Sort state is serialized to the URL as comma-separated sort and order params so deep-linking and browser navigation preserve multi-sort state:
/opportunities?sort=pipeline_status,next_contact_date&order=asc,descThe page route extracts the first sort key for the SSR fetch; the full comma-separated string is passed as initialSort.field and initialSort.order to DataTable, which parses them into a SortingState array on mount.
Aggregate Summary Footer
A Sigma (∑) toggle button in the data table toolbar shows or hides a sticky footer row below the table body.
| Column | Footer content |
|---|---|
| Title | Filtered row count ("42 records") |
Numeric (number / integer schemaType) | Locale-formatted SUM with count in parentheses (e.g., $4,200,000 (12)) |
| All other columns | Empty cell |
The footer operates on TanStack Table's getFilteredRowModel(), so totals always reflect the current client-side filtered set (not the total page). The toggle state is persisted in localStorage under the key dt-summary-{typeSlug}.
Data Table Meta Interface
Column definitions that need access to shared table state (filters, setFilter, clearFilter) use the DataTableMeta interface rather than as any casts:
// features/entities/components/data-table/types.ts
export interface DataTableMeta {
filters: FilterState;
setFilter: (field: string, value: FilterValue | null) => void;
clearFilter: (field: string) => void;
}Access it via table.options.meta as DataTableMeta in column cell and header renderers.
Column Visibility and localStorage
Column visibility state is persisted in localStorage under the key column-visibility-{typeSlug}. On mount, stale keys (columns that existed in a previous schema version but no longer exist) are filtered out before initializing TanStack Table state. This prevents "unknown column ID" warnings when an entity type's schema changes between sessions.
Field Display Formatters
formatDisplayValue(value, displayType?) in features/tools/lib/format.ts formats raw field values for human display. All individual formatters are also exported for direct use.
displayType | Formatter | Example output |
|---|---|---|
currency | formatCurrency | $1,234,567 |
percentage | formatPercentage | 67.5% |
date | formatRelativeDate | 2 days ago, Mar 15, 2026 |
url | formatUrl | example.com/page (strips protocol/www) |
email | — | user@example.com (as-is) |
phone | formatPhone | (555) 123-4567, +1 (555) 123-4567 |
number / metric | formatNumber | 1,234,567 |
bytes | formatBytes | 1.2 GB, 456 KB |
duration | formatDuration | 3h 45m, 2d 4h |
React component: FormattedFieldValue (features/tools/components/formatted-field-value.tsx) is the canonical component for rendering field values in the UI. It renders linkable types (url, email, phone) as clickable <a> elements with appropriate icons and href schemes (https, mailto, tel). All other types delegate to formatDisplayValue. Renders a muted em-dash for null/empty values.
Status Chips
When a field has displayType: "status" and a statusMap in its FieldConfig, the StatusChip component (features/entities/components/status-chip.tsx) renders a compact colored badge.
// types
type StatusColor = "success" | "error" | "warning" | "info" | "muted";
interface StatusMapEntry {
label?: string; // Override display label (defaults to raw value)
color: StatusColor;
icon?: string; // Optional icon slug from the platform icon map
}
// usage in FieldConfig
{
displayType: "status",
statusMap: {
"active": { color: "success", label: "Active", icon: "check-circle" },
"archived": { color: "muted", label: "Archived" },
"rejected": { color: "error", label: "Rejected", icon: "x-circle" },
}
}Status chips use the platform CSS variable system exclusively (--status-success, --status-error, etc.). The resolveStatusChip() helper returns null for non-status display types, making it safe to call unconditionally in cell renderers.
Config-Driven Empty States
When an entity type list has no records, EntityEmptyState (features/entities/components/entity-empty-state.tsx) renders a centered card with icon, heading, body text, and a CTA button. The content is driven by EntityTypeConfig.emptyState:
// EntityTypeConfig
emptyState?: {
icon?: string; // lucide icon slug
title?: string; // "No {typeName} yet" by default
description?: string; // "Create your first {typeName}…" by default
ctaLabel?: string; // "Create {EntityType.name}" by default
ctaAction?: "create" | "import" | "link";
}The searchQuery prop triggers a separate "no results" variant with a search icon and prompt to try different terms. If no emptyState config exists, sensible defaults are derived from the entity type name.
Owner Display
The OwnerBadge component (features/entities/components/owner-badge.tsx) renders the entity's owner in the detail hero as a small avatar with display name or email. When no owner is set it shows "Unassigned". Data is fetched from GET /api/entities/[id]/owner and cached for 60 seconds by React Query. The fetch is skipped entirely when initialOwnerId is null.
export function ownerQueryKey(entityId: string) {
return ["entity-owner", entityId];
}
// Usage in entity-detail-hero.tsx
<OwnerBadge entityId={entity.id} initialOwnerId={entity.owner_id} />The owner can be updated via PATCH /api/entities/[id]/owner with body { ownerId: string | null }. Pass null to unassign.
Entity History Timeline
The EntityHistoryTab component (features/entities/components/entity-history-tab.tsx) renders a collapsible vertical timeline of the last 10 activity entries for an entity. It is placed below Responses on the entity detail page.
The tab is lazy-loaded: the React Query fetch only fires when the user first expands the collapsible (enabled: sectionOpen). Data is fetched from GET /api/entities/[id]/activities, which returns activities with resolved actor display names. Stale time is 30 seconds.
Each timeline entry shows:
- Action icon (creates, updates, extractions each have distinct icons)
- Entry title and optional description
- Actor name with a human or bot indicator
- Relative timestamp with a full date/time tooltip
"New" Field Badges
Field cards in the entity bento grid show a "New" badge and a subtle primary-colored ring when a field was populated by extraction since the current user's last view. This draws attention to data that changed while the user was away without requiring them to compare snapshots.
The badge is determined during block assembly in EntityBento:
getLastViewedAt(entityId)is called server-side in the page'sPromise.alland passed down aslastViewedAt.- For each field,
field_sources[name].promoted_atis compared againstlastViewedAt. - A field is "new" only when it is non-empty,
viewedThresholdis known, andpromoted_at > viewedThreshold.
// EntityBento computes isNew per field during block assembly
const isNewField =
!isEmptyFieldValue(value) &&
viewedThreshold != null &&
source?.promoted_at != null &&
new Date(source.promoted_at) > viewedThreshold;The FieldCardData.isNew boolean is passed into field-card blocks and rendered by FieldCardView as a <Badge> next to the field label plus a ring-1 ring-primary/30 bg-primary/[0.02] class on the card container.
Auto-Field Grouping
The entity detail page auto-organizes fields into named sections without requiring a persisted view configuration. groupFieldsBySection() in features/entities/lib/field-grouping.ts inspects each field's displayType and FieldConfig hints to assign it to one of three buckets:
| Section | Fields assigned |
|---|---|
| Key Metrics | currency, percentage, number, metric display types |
| Status | status display type; fields named status, stage, priority, phase |
| Details | All remaining fields |
The resulting groups are rendered as collapsible sections in EntityBento. Fields in config.ui.cardFields are always promoted to the summary card and excluded from the grouped grid.
Agents can override grouping by setting config.fields[name].section to a custom section name. Custom sections appear after the three built-in sections.
EntitySummaryCard
EntitySummaryCard (features/entities/components/entity-summary-card.tsx) renders a compact header card at the top of the entity detail page showing the entity's most important fields.
Fields shown are determined by config.ui.cardFields on the entity type. When cardFields is not configured, the card shows the first three non-empty fields from the schema. The card is always rendered above the grouped field sections and is not editable inline — it links to the relevant field card below.
Click-to-Edit Field Affordance
Field cards in the entity bento grid show an edit icon on hover. Clicking the icon opens an inline popover editor for the field without navigating away from the detail page. The popover uses the same field input components as the create/edit form and submits via updateEntity(). This replaces the previous pattern of opening a full edit dialog to change a single field.
Cross-Entity Activity Timeline
The entity detail sidebar now shows a Related Activity section displaying activity entries from entities connected to the current record (via entity_relations). This gives a "last contacted" view for people and companies without navigating away.
The related activity feed is fetched from GET /api/entities/[id]/related-activities, which:
- Loads all related entity IDs via
getRelatedEntities() - Fetches the most recent 5 activity entries per related entity
- Returns a unified timeline sorted by
created_atdescending, annotated with the related entity's title and type
Each entry in the timeline shows a context badge (the related entity's name + type chip) alongside the standard activity fields. The badge links to the related entity's detail page.
"Last contacted" is derived from the most recent activity entry across all related entities of person or company type. The timestamp is shown as a relative date badge on the entity summary card.
First-Login Onboarding Wizard
A "What's your role?" dialog appears on a user's first login after signup. The wizard:
- Asks the user to select their role (e.g., Analyst, Executive, Sales, Ops)
- Applies a role-based nav preset (
saveNavConfig(preset, "user")) - Pins a role-appropriate default view on the dashboard
- Marks the onboarding complete in the user's profile metadata (
onboarding_completed_at)
The wizard is re-runnable from Settings > Account. Completing it again re-applies the selected preset, overwriting prior user-level nav customizations.
The wizard is implemented in features/entities/components/onboarding-wizard.tsx and triggered by OnboardingGate in the app shell layout, which checks profile.metadata.onboarding_completed_at.
Schema-Driven Form Sections
Entity create/edit forms now group fields into the same sections as the detail page (Key Metrics, Status, Details). Section headings appear as dividers in the form, and fields within each section are presented in config.fieldLayout order.
A required field progress indicator appears in the form footer: "3 of 5 required fields filled". The Submit button is disabled until all required fields (marked with "required": true in json_schema) have non-empty values.
Smart defaults:
- Fields with
extraction.agentSlugshow a muted placeholder ("Will be populated by AI") select/multi_selectfields withextraction.agentSlugdon't default to the first option (they wait for extraction)- Without
extraction.agentSlug,selectfields default to their first defined option
Quick-create mode: A compact variant of the form showing only required fields, toggled by ?quick=true in the dialog URL or passed as the quickCreate prop. Optional fields are accessible via a "Show all fields" link at the bottom. Quick-create is used by the capture widget and keyboard shortcut C on list pages.
View Tracking
getLastViewedAt(entityId) (features/entities/server/view-tracking.ts) is a server action that queries user_recent_views for the current user and entity combination, returning the ISO timestamp or null:
export async function getLastViewedAt(entityId: string): Promise<string | null>;It uses .maybeSingle() so a missing record returns null without an error. The function is called in the entity detail page's Promise.all alongside the other page-level data fetches.
Entity Form Field Ordering and Labels
The entity form (EntityFormDialogContent) now respects config.fieldLayout when rendering fields, matching the order shown in the bento detail view. Previously fields were rendered in JSON schema insertion order.
Field labels also now prefer schema.title over the raw key name:
// Before: name.replace(/_/g, " ")
// After: schema?.title ?? name.replace(/_/g, " ")This applies to all input types: enum selects, booleans, arrays, numbers, long text, and plain text fields.
Form Help Text, Smart Placeholders, and Keyboard Submit
FormFieldRenderer consumes the standard JSON Schema description and examples keys per field:
- Help text —
schema.descriptionrenders as muted small text under the field label, and is wired to the input viaaria-describedbyso screen readers announce it. - Smart placeholder — the first stringifiable entry from
schema.examplesis used as the inputplaceholder(text, number, comma-separated array, and Markdown long-text inputs). Falls back to existing copy whenexamplesis absent.
In every entity type's JSON schema, declare description and examples for every field that benefits from a hint:
{
"properties": {
"insurance_id": {
"title": "Insurance ID",
"type": "string",
"description": "Member ID printed on the front of your insurance card",
"examples": ["XJK123456789"]
}
}
}EntityFormDialog adds two keyboard ergonomics:
- Autofocus on create — opening the dialog in create mode (no
entityprop) focuses the Title input. Edit mode preserves whatever focus the user already had. - Cmd/Ctrl+Enter to submit — pressing Cmd+Enter (Mac) or Ctrl+Enter (Win/Linux) anywhere in the form triggers
requestSubmit(). The shortcut is suppressed for<textarea>,contenteditableeditors, active IME composition, andAltGr+Enteron European keyboards so multi-line editors and normal typing are unaffected.
The shortcut detection lives in the pure helper isFormSubmitShortcut() in features/entities/lib/form-field-helpers.ts so it can be unit-tested without rendering React.
Entity Hover Cards and Preview API
EntityHoverCard (features/entities/components/entity-hover-card.tsx) wraps any child element with a shadcn HoverCard that lazily fetches and displays a lightweight entity preview after a 400ms hover delay.
// Usage — wrap any trigger element
<EntityHoverCard entityId={entity.id}>
<button>{entity.title}</button>
</EntityHoverCard>The preview is fetched from GET /api/entities/[id]/preview, which returns:
interface EntityPreview {
id: string;
title: string | null;
entity_type_slug: string;
typeName: string | null;
imageUrl: string | null;
fields: { label: string; value: string }[]; // Up to 3 non-empty fields
tags: string[]; // Up to 4 tags shown
}The API selects the entity avatar, type name, and up to three non-empty scalar fields from content, using json_schema.properties[key].title for labels (falls back to humanize(key)). Values longer than 80 characters are truncated. The response has Cache-Control: private, max-age=30.
Link to a record detail page with a Next.js <Link> to /${entity.entity_type_slug}/${entity.id} and wrap the trigger in EntityHoverCard to attach the preview:
<EntityHoverCard entityId={entity.id}>
<Link href={`/${entity.entity_type_slug}/${entity.id}`} className="font-medium hover:underline">
{entity.title}
</Link>
</EntityHoverCard>Performance: React Query caches preview data for 60 seconds (staleTime: 60_000). The fetch is deferred until the card opens (enabled: open) so no network traffic occurs on render. If the fetch fails, EntityHoverCard falls back to rendering its children directly with no card.
Reset-to-Public (Entity Type Reset)
When a tenant has forked a global entity type (creating a tenant-local override), resetEntityType() in features/entities/server/entity-type-settings.ts resets it back to inheriting the global version:
- Verifies the target is a global type (tenant_id IS NULL).
- Finds the tenant-local fork by matching slug + tenant_id.
- Reassigns all tenant entities from the local type ID to the global type ID (data is preserved).
- Deletes the tenant-local entity type row.
- Upserts
tenant_entity_type_settingsto clearforked_from_versionand mark as enabled.
// Returns count of entities that were reassigned
const { reassigned } = await resetEntityType(globalEntityTypeId);The API route POST /api/entity-types/settings exposes this via action: "reset" with body { entityTypeId: string }.
In the Admin > Data Types UI, the Reset button appears only for entity types with status: "customized". A confirmation dialog shows the action is irreversible (the local customizations will be lost).
API Reference
Write Seam (features/entities/server/entity-writer.ts)
New server-side entity mutations should call EntityWriter directly:
| Function | Signature | Description |
|---|---|---|
EntityWriter.create(input, ctx) | (EntityWriterCreateInput, WriteContext) => Promise<{ record }> | Creates an entity for request or background callers with schema validation, relation-field splitting, response provenance, activity, analytics, lifecycle events, and cache invalidation. |
EntityWriter.update(id, input, ctx) | (string, EntityWriterUpdateInput, WriteContext) => Promise<{ record }> | Merges content/metadata, syncs relations, preserves previous-content event context, and emits the unified write tail. |
EntityWriter.delete(id, ctx, opts?) | (string, WriteContext, DeleteEntityOptions?) => Promise<EntityDeleteResult> | Runs the cascade-safe delete RPC and records delete activity/analytics. |
EntityWriter.reparent(id, parentId, ctx) | (string, string | null, WriteContext) => Promise<{ record }> | Runs the locked reparent RPC, preserves cycle/depth/allowed-child-type violations, and invalidates parent-child caches. |
EntityWriter.bulkUpsert(rows, ctx) | (EntityUpsertRow[], WriteContext) => Promise<{ created, updated }> | Routes each row through create or update so bulk writes share the same behavior as single-row writes. |
WriteContext lives in features/tenant/types.ts and is intentionally a discriminated union: { kind: "request" } for session/RLS callers, or { kind: "background", tenantId, actor } for API-key, tool, import, and Inngest callers. The writer does not expose an admin: boolean or silent flag.
The canonical write paths now include the compatibility server actions, keyed API helpers, bulk entity actions, API route update/delete flows, AI tool writes, document-processing entity status updates, and the legacy todo-to-Task-entity migration. Specialized maintenance writes that mutate fields outside the CRUD writer contract, such as embeddings, share tokens, owner assignment, media image fields, entity-type reassignment, and response-promotion RPC companions, remain in their owning domain seams.
Server Actions (features/entities/server/actions.ts)
These exports are compatibility wrappers around EntityWriter plus read helpers. Prefer EntityWriter for new server-side write paths.
| Function | Signature | Description |
|---|---|---|
getEntityTypes() | () => Promise<EntityTypeRecord[]> | Cached per-request. Returns all accessible entity types for the current tenant. |
getEntityTypeBySlug(slug) | (slug: string) => Promise<EntityTypeRecord | null> | Look up a single entity type by slug. |
getEntitiesByType(typeSlug, limit?) | (typeSlug: string, limit?: number) => Promise<EntityRecord[]> | Fetch entities of a given type, ordered by creation date. |
getEntityById(id) | (id: string) => Promise<EntityRecord | null> | Cached per-request. Fetch a single entity by UUID. |
getEntityByIdentifier(identifier) | (identifier: string) => Promise<EntityRecord | null> | Fetch by UUID or slug. |
createEntity(input) | (input: CreateEntityInput) => Promise<EntityRecord> | Compatibility wrapper for EntityWriter.create(input, { kind: "request" }). |
updateEntity(id, input) | (id: string, input: UpdateEntityInput) => Promise<EntityRecord> | Compatibility wrapper for EntityWriter.update(id, input, { kind: "request" }). |
deleteEntity(id) | (id: string) => Promise<void> | Compatibility wrapper for EntityWriter.delete(id, { kind: "request" }). |
getRelatedEntities(entityId) | (entityId: string) => Promise<{relation, entity}[]> | Bidirectional relation query. |
getChildEntities(parentId, typeSlug?, limit?) | (parentId: string, typeSlug?: string, limit?: number) => Promise<EntityRecord[]> | Children of a container entity. |
searchEntities(query, typeSlug?, sortBy?, sortOrder?, limit?, offset?, tag?, filters?) | Full search with filtering, sorting, pagination | Returns { entities, total }. |
getEntityCounts() | () => Promise<{slug, name, count, icon, color}[]> | Aggregated counts per entity type using the get_entity_counts_by_type RPC function. |
Bulk Actions (features/entities/server/bulk-actions.ts)
| Function | Signature | Description |
|---|---|---|
bulkDeleteEntities(ids) | (ids: string[]) => Promise<BulkActionResult> | Delete multiple entities. RLS-enforced. |
bulkAddTags(ids, tags) | (ids: string[], tags: string[]) => Promise<BulkActionResult> | Add tags to entities (deduplicates with existing). |
bulkRemoveTags(ids, tags) | (ids: string[], tags: string[]) => Promise<BulkActionResult> | Remove specific tags from entities. |
bulkUpdateField(ids, fieldName, value) | (ids: string[], fieldName: string, value: unknown) => Promise<BulkActionResult> | Update a content field on multiple entities. |
All functions return BulkActionResult: { succeeded: number; failed: number; errors: string[] }.
Entity Type Write Gate (features/entities/server/entity-type-write-gate.ts)
| Function | Signature | Description |
|---|---|---|
assertValidEntityTypeWrite(config, options?) | (config: unknown, options?: { allowDeprecated?: boolean }) => Promise<EntityTypeConfig> | Validates config against strict EntityTypeConfigSchema. Throws SchemaWriteError on deprecated keys or unknown keys. Pass { allowDeprecated: true } only in migration scripts. |
Field Lifecycle (features/entities/server/field-lifecycle.ts)
| Function | Signature | Description |
|---|---|---|
previewFieldImpact({ entityTypeId, fieldKey }) | Returns FieldImpactCounts | Returns { entityCount, taskCount, criteriaSetCount, viewCount } — how many objects reference this field key. Used to show impact preview before rename/delete. |
executeFieldRename({ entityTypeId, fieldKey, newKey, tenantId }) | Returns void | Calls rename_field_cascade RPC to atomically rewrite all references from fieldKey to newKey. |
executeFieldDelete({ entityTypeId, fieldKey, mode, tenantId }) | mode: "archive" | "wipe" | Calls delete_field_cascade RPC. archive sets archivedAt, wipe also clears entity content values. |
Inline Edit / Response Routing (features/responses/server/)
| Function | Location | Description |
|---|---|---|
saveInlineFieldEdit(entityId, fieldName, value) | inline-edit-action.ts | Server action called by entity bento and data table inline edits. Creates a micro-session + entity_response and promotes per the field's auto_promote_policy. |
bundleContentThroughResponses(entityId, content, options) | inline-edit.ts | Creates one shared micro-session for a batch of field values, then fans out one entity_response per field. Used by API writes and chat tool writes. |
promoteEntityResponseValue(input) | promote-response.ts | Canonical promotion dispatcher. Routes target_type='field' to promote_field_value RPC and target_type='dimension' to promote_dimension_response RPC. Idempotent. |
Entity Type Settings (features/entities/server/entity-type-settings.ts)
| Function | Signature | Description |
|---|---|---|
listPublicTypesWithStatus() | () => Promise<PublicTypeWithStatus[]> | All global entity types with their enablement status for the current tenant. |
toggleEntityTypeEnabled(entityTypeId, enabled) | Returns void | Enable or disable a global type for the tenant. |
forkEntityType(entityTypeId) | Returns EntityTypeRecord | Create a tenant-local copy of a global type for customization. |
resetEntityType(entityTypeId) | Returns { reassigned: number } | Reset a forked entity type back to inheriting the global version. Reassigns all tenant entities. |
provisionEntityTypes(entityTypeIds) | Returns void | Provision global types for a tenant (create settings rows, copy views from default tenant). |
Upsert API (External Identity)
| Endpoint | Auth | Description |
|---|---|---|
POST /api/entities/upsert | API key (entities:write scope) | Create or update an entity keyed on (externalSource, externalId). Returns { entity, created } — HTTP 201 on create, HTTP 200 on update. |
Request body:
{
externalId: string; // External system's record ID
externalSource: string; // Source system name (e.g., "openclaw")
typeSlug: string; // Entity type slug (required on create, used for lookup)
title: string;
content?: Record<string, unknown>; // Merged into existing content on update
tags?: string[]; // Merged (union) with existing tags on update
metadata?: Record<string, unknown>; // Merged into existing metadata on update
}On update, content and tags are merged with the existing values rather than replaced. This allows external agents to write partial updates without clobbering fields they do not own. The underlying function is upsertEntityKeyed() in features/entities/server/api-actions-keyed.ts; successful creates and updates route through EntityWriter.
Vault Import/Export API
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/entities/export/vault | POST | Session (entities.team.read) | Export entities as a zip of Obsidian-compatible markdown files. |
/api/entities/import/vault | POST | Session (entities.team.create) | Import a zip of markdown files as entities with wikilink relation resolution. |
POST /api/entities/export/vault request body:
{
typeSlug?: string; // Filter by entity type (omit to export all types)
ids?: string[]; // Specific entity IDs to export (omit to export all matching type)
}Returns a binary ZIP download (Content-Type: application/zip) where each file is named {entity-slug}.md and follows the entity markdown format.
POST /api/entities/import/vault request body:
Accepts multipart/form-data with a single vault file field containing a .zip archive of markdown files.
Import is two-pass:
- First pass creates all entities from frontmatter and body content, collecting title-to-ID mappings.
- Second pass resolves
[[wikilinks]]against the title map and upsertsmentionentity relations.
Returns { imported: number, failed: number, errors: string[] }.
Wikilink Relations (features/entities/server/wikilink-relations.ts)
| Function | Signature | Description |
|---|---|---|
syncWikilinkRelations(entityId, wikilinkTitles) | (entityId: string, wikilinkTitles: string[]) => Promise<void> | Full-reconcile mention relations for an entity. Deletes stale mention relations not in the title list; creates new ones. Called on every body save. |
resolveWikilinkTitles(titles, tenantId) | (titles: string[], tenantId: string) => Promise<Record<string, string>> | Look up entity IDs by title. Returns a { title → id } map for titles that matched. Used during vault import. |
Preview API
| Endpoint | Description |
|---|---|
GET /api/entities/[id]/preview | Lightweight entity preview for hover cards. Returns id, title, type info, image URL, up to 3 non-empty content fields, and up to 4 tags. Requires auth. Response cached 30s. |
Owner API
| Endpoint | Method | Description |
|---|---|---|
/api/entities/[id]/owner | GET | Returns { ownerId: string | null, profile: OwnerProfile | null } where OwnerProfile has id, displayName, email, avatarUrl. |
/api/entities/[id]/owner | PATCH | Updates owner_id. Body: { ownerId: string | null }. Returns the updated entity row. |
Activities API
| Endpoint | Method | Description |
|---|---|---|
/api/entities/[id]/activities | GET | Returns the 10 most recent activity entries for the entity, with actor_name resolved from profiles. Each entry includes id, action, title, description, actor_id, actor_name, metadata, and created_at. |
View Tracking (features/entities/server/view-tracking.ts)
| Function | Signature | Description |
|---|---|---|
getLastViewedAt(entityId) | (entityId: string) => Promise<string | null> | Returns the ISO timestamp of when the current user last viewed this entity, or null if no record exists. |
Type Helpers (features/entities/types.ts)
| Function | Description |
|---|---|
getEntityTypeIcon(type) | Reads icon from config.ui.icon, falls back to top-level icon field. |
getEntityTypeColor(type) | Reads color from config.ui.color, falls back to top-level color field. |
getEntityTypeDescription(type) | Reads description from config.ui.description, falls back to top-level field. |
isContainerType(config) | Returns true if the entity type has container.enabled = true. |
getAllowedChildTypes(config) | Returns allowed child type slugs for a container. |
For Agents
AI agents interact with entities through eight entity tools (defined in features/tools/entity-tools.ts):
| Tool | Permission | Description |
|---|---|---|
searchEntities | entities.team.read | Search by title, filter by type slug and/or tag. Results include the body (description) field. |
getEntity | entities.team.read | Full entity details by UUID. Response includes body (description) field. |
createEntity | entities.team.create | Create with content fields, tags, optional relation map, and optional body (markdown text). |
updateEntity | entities.team.update | Modify title, content fields, tags, or body (content is merged; body replaces). |
deleteEntity | entities.team.delete | Permanent deletion |
createRelation | entities.team.create | Link two entities with a typed relationship |
listEntityTypes | entity_types.team.read | List all types with field schemas |
getEntityStats | entities.team.read | Counts per entity type |
Agent prompt context includes entity body text so agents have access to prose notes and narrative alongside structured fields.
TypeSpec Tools for Agents
Agents can create and evolve entity type definitions using the TypeSpec runtime API:
GET /api/entity-types/[slug]/typespec— Export an existing type as markdown to understand its current schema before modifying it.POST /api/entity-types/from-typespec— Submit a modified or new TypeSpec markdown document to create or update an entity type definition.
This enables agents with admin permissions to design new data types, add fields, and define extraction instructions entirely via markdown — without needing direct DB access.
Agents also interact with entities through the extraction system, which uses specialized tools (searchLinkedDocuments, searchConnectedEntities, getEntityField, submitValue) to populate individual fields.
Type-Spec Markdown Format
features/entities/type-spec/ provides a bidirectional Markdown representation for entity type definitions. Specs are .md files that define a type's schema, field extraction instructions, connections, and scoring criteria in a human-readable format. They compile to the { slug, name, json_schema, config } shape used by seed scripts and the entity_types table.
Spec File Structure
---
type: opportunity
icon: lightbulb
color: amber
cardFields: [status, estimated_annual_value]
---
# Opportunity
An AI automation opportunity or use case.
---
## Fields
- status: select [identified, researching, scoped, deployed] (required)
- estimated_annual_value: currency — Estimate the annual dollar value. Return a number.
sources: connected-entities
dependsOn: business_case
- is_archived: boolean
## Connections
- solves → pain_point (many) — Pain Points Solved
- belongs_to → company (one) — Company
## Scoring
- Revenue Impact: weight 0.30, scale 1-10
- Ease of Implementation: weight 0.25, scale 1-10Frontmatter keys:
| Key | Type | Description |
|---|---|---|
type | string (required) | Entity type slug |
icon | string | Lucide icon name |
color | string | Tailwind color name |
cardFields | string[] | Fields shown on entity cards |
isParent | boolean | Whether the type acts as a container |
hideFromDataNav | boolean | Hide from the sidebar navigation |
Field line syntax:
- field_name: type [opt1, opt2] (constraint, constraint) — Extraction instructions
sources: connected-entities, linked-documents
dependsOn: other_field, another_fieldtypeis one of:text,long_text,number,currency,select,multi_select,boolean,date,url,email,array[options]— comma-separated enum values forselectandmulti_select(constraints)—required,min: N,max: N,integer: true,label: Display Name— description— doubles as the field's extraction instruction (populatesconfig.fields[name].extraction.instructions)- Indented
sources:anddependsOn:lines map toconfig.fields[name].extraction.sourcesand.dependsOn
Connection line syntax:
- relationship_type → target_slug (one|many) — Label TextUse * as the target slug for untyped connections.
Scoring line syntax:
- Label Text: weight 0.30, scale 1-10The criterion name is auto-derived from the label ("Revenue Impact" → "revenue_impact").
TypeSpec Types
interface TypeSpec {
slug: string;
name: string;
prose: string; // Body text between H1 and first ## section
icon?: string;
color?: string;
fields: TypeSpecField[];
connections: TypeSpecConnection[];
scoring: TypeSpecScoringCriterion[];
extractionOrder: string[]; // Fields with descriptions, in spec order
cardFields?: string[];
isParent?: boolean;
hideFromDataNav?: boolean;
}
interface TypeSpecField {
name: string;
type: TypeSpecFieldType;
required?: boolean;
options?: string[];
constraints?: {
min?: number;
max?: number;
integer?: boolean;
label?: string;
};
description?: string; // Extraction instructions
sources?: string[];
dependsOn?: string[];
}API Reference
| Function | Location | Description |
|---|---|---|
parseTypeSpec(markdown) | parse-type-spec.ts | Parse a spec string into a TypeSpec object. Throws on syntax errors. |
compileTypeSpec(spec) | compile-type-spec.ts | Convert a TypeSpec into { slug, name, json_schema, config } ready for DB insert. |
generateTypeSpec(record) | generate-type-spec.ts | Convert a DB entity type record back into a spec Markdown string. |
typeSpecToMarkdown(spec) | generate-type-spec.ts | Serialize a TypeSpec to Markdown. |
entityTypeToTypeSpec(record) | generate-type-spec.ts | Convert a { slug, name, json_schema, config } record into a TypeSpec. |
loadTypeSpecs(dir) | load-type-specs.ts | Read all .md files from a directory and return CompiledEntityType[]. Used in seed scripts. |
All functions are exported from features/entities/type-spec/index.ts.
TypeSpec Runtime API
The TypeSpec parser and compiler are also exposed at runtime via two HTTP routes, allowing agents and external tools to create or export entity type definitions as markdown without direct database access.
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/entity-types/from-typespec | POST | Session (admin) | Create or update an entity type from a TypeSpec markdown string. If a type with the same slug already exists, it is updated in place. Returns the created/updated entity type record. |
/api/entity-types/[slug]/typespec | GET | Session | Export an entity type as a TypeSpec markdown string. Returns { markdown: string }. |
POST /api/entity-types/from-typespec request body:
{
markdown: string; // Full TypeSpec markdown document
tenantScoped?: boolean; // If true, creates a tenant-specific type (default: global)
}Entity Instance Markdown Format
features/entities/type-spec/entity-markdown.ts provides an Obsidian-compatible markdown representation for entity instances.
// Generate markdown for an entity
generateEntityMarkdown(entity: EntityRecord, entityType: EntityTypeRecord): string
// Parse a markdown file back into create/update input
parseEntityMarkdown(markdown: string): ParsedEntityMarkdown
interface ParsedEntityMarkdown {
title: string;
description?: string; // Body text (between frontmatter and structured fields)
content: Record<string, unknown>; // Structured fields from frontmatter
tags?: string[];
typeSlug?: string; // From frontmatter `type` key
wikilinks: string[]; // All [[wikilink]] titles found in body text
}File format:
---
type: opportunity
tags: [ai, automation]
status: researching
estimated_annual_value: 250000
---
# Opportunity Title
This is the body text. It can reference [[Related Company]] or [[Pain Point Name]]
using wikilinks.
Additional prose here...- Frontmatter holds structured
contentfields (by their JSON schema key names) plustypeandtags. - The H1 line is the entity title.
- All text after the H1 (and optional frontmatter section divider) becomes the
descriptionbody field. [[wikilinks]]in the body are preserved in generated output and parsed back towikilinks[]on import.
Example Specs
content/type-specs/ contains 14 ready-to-use specs covering common entity types (company, person, meeting, opportunity, goal, research, pain point, process, technology, source, agent-task, agent-goal). These serve as both working examples and the seed data for the Amble MortgageQ product.
Roundtrip Guarantee
generateTypeSpec(compileTypeSpec(parseTypeSpec(markdown))) produces semantically equivalent output to the original spec. The test suite in roundtrip.test.ts verifies this property across all 14 example specs.
Extraction (Task-Based)
Field extraction runs through the unified tasks → sessions → session-executor primitive.
How it works:
- Saving an entity type config calls
syncSystemTasks(), which idempotently generates one parentextractiontask plus one child task per extractable field (and oneinput-{field}perhumanInputfield). Child tasks useoutput_type: 'response'(the canonical value as of 2026-04-15). Stale children for removed fields are deleted in the same pass. - When an entity is created,
entity/created→task-dispatch→triggerTask()fires a session for each matching active task. - The session-executor claims each child session, resolves the agent via
SprinterAgentadapter, and executes — the agent's output flows throughbundleContentThroughResponses()→entity_response→promote_field_valueRPC →entities.content. - Every step is recorded in
session_events(append-only audit log).
The ExtractionSection of the unified FieldsTab shows whether a field has a dedicated extraction subtask. If not, a one-click "Create dedicated subtask" CTA creates the child task linked to the entity type's default_extraction_task_id.
output_type migration: Legacy tasks with output_type: 'field' or output_type: 'fields' were backfilled to output_type: 'response' in migration 20260415000010_migrate_tasks_output_type.sql. New code must use 'response'.
Design Decisions
Every field write flows through entity_response, never directly to entities.content. Prior to this change, inline edits, API writes, and agent writes each took different code paths and bypassed the response system entirely. The response table is now the universal write primitive: it provides versioned audit history, confidence tracking, and configurable auto-promotion in one place. The performance cost of the extra row is negligible; the audit and promotion benefits are permanent.
FieldConfigSchema and EntityTypeConfigSchema use .strict(), not .passthrough(). The previous .passthrough() allowed arbitrary unknown keys to silently persist in the JSONB columns — deprecated keys accumulated over time without any warning. Switching to .strict() means any write with an unknown key fails immediately at the schema boundary. The allowDeprecated escape hatch exists only for migration scripts that legitimately need to strip-and-rewrite legacy rows.
Deprecation checks run before Zod parsing in the write gate. assertValidEntityTypeWrite() inspects the raw input for deprecated keys before calling EntityTypeConfigSchema.parse(). If this order were reversed, Zod strict mode would fire a generic "unrecognized keys" error that gives no guidance on what to do instead. The pre-parse check produces messages like "field status: use 'relation' not 'connection'" that tell callers exactly what to change.
FieldConfig.relation absorbs the full FieldConfig.connection shape. The legacy connection field was a parallel concept to relation that added UI-only properties (display, dataTableVariant, mode). Keeping two shapes for the same concept caused divergence between the TypeSpec runtime, the admin UI, and the resolver. Absorbing the UI properties into RelationConfig gives a single shape that works everywhere. The resolver remains backward-compatible for rows not yet migrated.
Field rename and delete use server-side RPCs, not application-layer loops. rename_field_cascade and delete_field_cascade execute atomically in a single database transaction. An application-layer loop (update entities one by one) would be non-atomic, subject to partial failure, and slow for large tenants. The RPC approach also means the cascade logic lives in a single audited place rather than scattered across multiple server actions.
Impact preview runs before the cascade, not after. previewFieldImpact() queries counts of affected objects and returns them to the UI before the administrator confirms the action. This is a SELECT-only operation — it has no side effects. The rename/delete RPCs are not called until the administrator explicitly confirms. This pattern follows the principle of showing blast radius before committing destructive operations, matching the approach used by manageTasks and deleteEntityType.
The ESLint rule is the second enforcement layer, not the first. assertValidEntityTypeWrite() is the primary gate — it runs at runtime and rejects bad writes with clear errors. The ESLint rule no-direct-entity-type-write is a second layer that catches bypass attempts at lint time. The allowlist (the gate file itself, scripts, migrations, tests) is intentionally narrow: new platform code that writes entity type config must go through the gate. The lint rule makes it impossible to accidentally add a new bypass in a PR.
auto_promote_policy defaults to "always" for backward compatibility. Existing fields without an explicit auto_promote_policy behave as they always have: value is promoted immediately. Administrators can change this per-field via the Promotion section of FieldsTab. The "if_confident" policy is useful for AI-extracted fields where confidence varies; "never" is useful for human-review workflows where an agent populates a draft and a human promotes.
Single table for all entities. Rather than creating a separate table per entity type, all entities share one table with a JSONB content column. This keeps the schema flexible and makes the platform code truly type-agnostic. The tradeoff is that field-level queries require JSONB path operators, but PostgreSQL's JSONB indexing mitigates this.
Denormalized entity_type_slug. Joining entity_types just to filter by slug was a measurable performance cost on list pages. The denormalized column, kept in sync by a trigger, eliminates this join for the most common query pattern.
Content merge on update. Partial updates merge with existing content using shallow object spread. This prevents agent-driven field extraction from accidentally clearing other fields. Tags, however, are replaced entirely on update since partial tag modification is ambiguous.
Per-request caching. getEntityTypes() and getEntityById() use React's cache() to deduplicate within a single server render. Multiple components on the same page can call these functions without additional DB hits.
Activity logging on every write. Every create, update, and delete operation logs to the activities table. This is fire-and-forget (errors are swallowed) to avoid blocking the primary operation.
Hover card fetches on open, not on render. EntityHoverCard uses enabled: open in its React Query config so no network request fires until the user actually hovers. This keeps list and grid pages fast regardless of how many entity links are rendered.
Reset requires the global type ID, not the local fork ID. resetEntityType() takes the global entity type's UUID. This makes the caller's intent explicit (you are resetting to this specific global version) and prevents accidental deletion of a non-fork type by passing the wrong ID.
Body field uses the existing description TEXT column, not a new column. Entities already had a description column that was sparsely used. Promoting it to a first-class body field with FTS indexing (weight B) and a rich editor avoids a migration that renames or moves data. The column name is intentionally generic so it works across all entity types without schema changes.
Wikilink mentions use full-reconcile on save, not incremental diff. On each body save, syncWikilinkRelations() deletes all existing mention relations for the entity and re-inserts the current set. This is simpler than tracking an "old wikilinks" state and computing a diff, and it self-heals from any out-of-sync state. The cost is a few extra DB writes per save — acceptable given body edits are infrequent relative to structured field reads.
Vault import uses a two-pass strategy. The first pass creates all entities without resolving wikilinks, so forward references work without requiring a topological sort of the import files. The second pass resolves [[wikilinks]] using the title-to-ID map built during the first pass. This handles cycles and mutual references cleanly.
TypeSpec runtime API requires admin session, not API key. Creating or modifying entity type definitions changes the platform schema — an operation that is too significant for API-key-scoped automation. Restricting it to admin sessions adds a human checkpoint and preserves the TypeSpec CLI workflow for automated seeding.
Owner badge skips fetch when owner_id is null. OwnerBadge receives initialOwnerId as a prop so it can disable the React Query fetch entirely (enabled: !!initialOwnerId) for the common case of an unowned record. This avoids unnecessary round-trips on every entity detail page load.
History tab is lazy-loaded on first expand. EntityHistoryTab sets enabled: sectionOpen in its React Query config so no network request fires until the user explicitly opens the section. Most entity views do not need the history timeline; the deferred fetch keeps initial page load fast.
isNew is computed once in the bento, not per field card. The "new field" determination is performed during block assembly in EntityBento, where the viewedThreshold Date object is created once and reused across all fields. Doing this in each FieldCardView would force each block to independently parse the timestamp.
getLastViewedAt is fetched in the page's Promise.all. The last-viewed timestamp is a page-level concern — it should be available before any blocks render so there is no content flash. Fetching it server-side in Promise.all with the other page data keeps it on the critical path without adding a waterfall.
Belt-and-suspenders UUIDs for entity relations. All insert sites for entity_relations pass an explicit crypto.randomUUID() even though the DB column has a gen_random_uuid() default. A prior migration accidentally dropped this default and caused widespread relation insert failures. The explicit ID ensures the operation succeeds even if the default is lost again.
UUID suppression in bento Default tab. The EntityBento component skips fields whose values are raw UUIDs when the field matches entity-reference naming conventions: schema.format === "uuid", name ending in _id, or name starting with in_ or has_. Entity reference fields are already rendered as connection blocks above the field grid; displaying their raw UUID value below adds no information. The heuristic is intentionally conservative — it only suppresses fields that look like foreign keys by naming convention. A future improvement would mark relation reference fields explicitly in the JSON schema (x-is-relation-ref: true) so the bento can suppress them without pattern matching.
External identity for idempotent external writes. The external_id + external_source columns allow external agents (OpenClaw, webhooks, import pipelines) to reference their own IDs without tracking internal UUIDs. upsertEntityKeyed() looks up by (tenant_id, external_source, external_id) — if a match exists it merges; if not it creates. The partial unique index (WHERE external_id IS NOT NULL) keeps the index small since only externally-sourced records have this pair set.
Upsert merges content and tags, never replaces. External agents often write partial records (they know some fields but not all). Merging rather than replacing ensures that data written by one agent (or by a human) is not silently overwritten by a second agent that only knows a subset of fields. This matches the behavior of updateEntity() for session-based writes.
Related Modules
- Block System -- Entities are rendered through blocks;
entityToBlocks()converts entity fields tofield-cardblocks - Field Rendering -- The unified substrate at
features/schemas/that renders every field input and display; the entity edit form delegates to<FieldInput />viafieldDefinitionFromProperty() - View System -- Views compose blocks into layouts for entity list and detail pages
- Tool System -- Entity tools expose CRUD operations to AI agents
- Agent System -- Agents use entity tools and the extraction system to populate entity fields
- Obsidian Interop -- Bidirectional Obsidian vault sync, wikilink graph, and TypeSpec markdown format
- Responses --
entity_responsestable is the universal write primitive for all field values; criteria sets, scored responses, promotion, and aggregation live infeatures/responses/ - Sessions -- Micro-sessions created by every field write are tracked in the unified
sessions+session_eventstables infeatures/sessions/
Taylor the Guest Viewer
External consultant invited to view a workspace. No onboarding, no training, cautious by nature.
Command Center
The four-tab operator surface for AI transformation — Operations, My Role, Delegate, Transformation — backed by typed system entities (Task, Workstream, Role) and a unified data pipeline.