Documentation source
Entity Type Admin Hardening + Response-as-Universal-Primitive
Unified Fields editor, schema hardening (constraints + strict Zod), and one canonical write primitive (entity_responses) for every human/agent contribution to entity data
## Problem
The entity type admin can edit name/description/icon/color and the surface of field configs (label, humanInput, basic relation), but there is **no UI for many things the schema already supports**:
- `statusMap` (per-value color/label/icon for status fields)
- `displayType` (currency, percentage, status, url, …)
- `connection.display` (list / data-table / inline) — fixed in PR #698 but not editable
- `connection.dataTableVariant`, `cascadeExtraction`
- `ui.cardConfig`, `ui.cardFields`, `ui.hidden`, `ui.isParent`
- `emptyState`, `coreFields`, most of `fieldLayout`
- Field-level extraction tasks beyond a deep-link to `/tasks/by-slug/extract-{field}`
Concurrently the data layer is **drift-prone**:
- Two JSONB columns (`json_schema`, `config`) governed by Zod with `.passthrough()` — unknown keys silently survive
- No `UNIQUE(tenant_id, slug)` on `entity_types`; duplicates have happened (cleaned via SQL)
- No FK enforcing `entities.entity_type_id` matches the entity's `tenant_id` (cross-tenant entities have happened)
- `statusMap` accepts both bare strings and `{color}` objects (legacy shorthand)
- `connection` (deprecated) and `relation` (canonical) coexist indefinitely
- Many code paths write `entities.content` directly — bypassing validation, audit, version, and review
- `output_type ∈ {'field', 'fields'}` tasks write fields directly; `output_type='response'` submits to `entity_responses` — two parallel pipelines
The product result: human edits, agent extraction, polls, CSV imports, and API submissions all use different code paths. Audit, review, change tracking, consensus, and confidence are inconsistent across them.
## Solution
One unified primitive — **`entity_response`** — for every change to entity data, regardless of source. Every field value becomes the latest promoted response. Every poll submission, inline edit, agent extraction, API write, and human review flows through the same `Session → Response → Promote` pipeline.
Concurrently:
- The entity type admin Fields tab is rebuilt as a **split-pane editor** that exposes every UI-supported config (statusMap, displayType, relation/connection, layout, lifecycle, extraction subtask binding, scoring usage).
- Schema is hardened: strict Zod (no `.passthrough()`), DB constraints (`UNIQUE`, tenant-match FK guard), one canonical write helper (`assertValidEntityTypeWrite`), and a lint rule banning direct `entity_types` config writes outside it.
- The block renderer's existing `mode='edit'` capability + the existing `ResponseModeProvider` are wired into the entity detail page so inline edits queue draft responses and submit as one session — the same primitive that backs forms/polls.
This spec **supersedes `field-dimension-unification.mdx`**. After evaluation, we chose the opposite direction: criteria sets and field dimensions stay orthogonal — fields are *schema*, criteria sets are *analyses*.
## Design
### The locked-in primitives
```
Session ──────────────────── produces ──────────────────► session_events + responses
session_type ∈ { agent | response | tool | mixed }
status ∈ { draft | pending | running | completed | failed | waiting_human | … }
source ∈ { manual | extraction | embed | sequence | workshop | api | heartbeat | cron | webhook }
is_anonymous, share_token (anon + share-link flows preserved from Phase 7)
session_events append-only granular log
├─ response.draft (field value proposed)
├─ response.submitted (response row written)
├─ response.promoted (auto-promote satisfied)
└─ response.rejected (reviewer rejected)
Response (entity_response)
├─ entity_id, session_id
├─ target_type NEW ∈ { 'field' | 'dimension' }
├─ target_key NEW = fieldName | dimensionId
├─ value (jsonb — single value, not the legacy values map)
├─ confidence, reasoning
├─ status ∈ { draft | submitted | promoted | partially_promoted | superseded | rejected }
├─ source ∈ { manual | extraction | api | aggregation | workshop | optimization | feedback }
├─ criteria_set_id (nullable — only set when target_type = 'dimension')
├─ is_anonymous, share_token NEW (preserved from sessions)
├─ submitted_by_user | submitted_by_agent
└─ version, parent_response_id
Task (output_type = 'response')
├─ output_config.fields[] — schema field slugs
├─ output_config.criteria_set_ids[] — scoring sets
├─ depends_on[] — DAG edges
└─ metadata.consensus / metadata.refinement (existing — preserved)
Entity type
├─ default_extraction_task_id NEW
├─ schema_version int NEW
├─ UNIQUE(tenant_id, slug) NEW
├─ config.fields[k].auto_promote_policy NEW ∈ { always | if_confident | never }
├─ config.fields[k].confidence_threshold NEW (numeric, when policy = if_confident)
└─ criteria_sets — orthogonal scoring overlays (unchanged shape)
Entity field value = latest promoted response with target_type='field' AND target_key=fieldName
└─ entity.content[fieldName] is a denormalized mirror written ONLY by the promote handler
```
### Default extraction pattern (DAG-first)
Every entity type ships with a seeded structure:
```
parent "extraction" (output_type='none', orchestrator)
├─ subtask "extract-basics" fields=[name, stage, owner]
├─ subtask "extract-financials" fields=[size, arr] depends_on=[extract-basics]
└─ subtask "extract-assessment" fields=[fit, risk] depends_on=[extract-financials]
```
Each subtask carries: field scope, field-specific prompt, agent assignment, dependencies, optional consensus/refinement metadata. Auto-promote policy is per-field (inherited unless overridden on subtask).
From the Fields tab → Extraction section, the admin can:
- Bind the field to a subtask (creates one inline if missing)
- Edit the subtask's prompt + agent + depends_on inline
- Add the field to an existing subtask's fields[]
- Or leave it covered by the parent extraction task
### Consensus + iteration (preserved, not built)
Existing `tasks.metadata.consensus` and `metadata.refinement` shapes already support multi-replica + judge-based aggregation (per `features/tasks/types.ts:135-148`). This spec preserves them and ensures aggregation responses are written as `source='aggregation'` with a `parent_response_id` chain. The aggregator implementation is **out of scope**; the primitives must support it.
### Uniform application — every write becomes response-native
| Write path | Before | After |
|---|---|---|
| Inline edit on entity detail page | Direct `merge_entity_content` RPC | Micro-session (`type='response'`), 1 response, auto-promote |
| Quick capture | Direct insert + content set | Session, batch responses, auto-promote on create |
| CSV import | Direct bulk insert | Bulk session, N responses, auto-promote (or review queue if configured) |
| API `POST /api/entities` | `createEntity()` direct | Same shell-create + initial responses bundle |
| API `PATCH /api/entities/[id]` | `updateEntity()` direct | Field changes routed to `submitResponse` + auto-promote |
| Chat tool `createEntity` / `updateEntity` | Direct writes | Agent session → responses |
| Form / poll submission | n/a today | View opened in response mode = same primitive |
| Agent extraction (output_type field/fields) | Direct field writes | `output_type='response'` always; field/fields enum removed |
| Webhook ingestion | Direct writes | Session + responses with `source='api'` |
| AI entity creation (`/api/entities/ai-create`) | Direct generation + insert | Generates session; LLM emits responses |
| Cascade child entity creation | Direct insert in `cascade.ts:172` | Session for cascade run; child entities created via initial-responses bundle |
**Invariant after migration:** the only code that mutates `entities.content[fieldName]` is `promoteEntityResponseValue()` (single canonical helper). Everything else writes to `entity_responses`. A lint rule enforces this.
### Form / poll = view in response mode
Forms and polls are not a separate UI subsystem. A view (any view) opened in **response mode** lets blocks render as editable inputs. Edits queue as draft responses on a session. Submit writes them as `submitted` responses; auto-promote applies per-field. Sharing a "form" = sharing a link to the view in response mode (anonymous via `is_anonymous=true` + `share_token`, both already on the sessions table from Phase 7).
The infrastructure already exists:
- `BlockRenderer` switches on `block.mode === 'edit'` and dispatches to `editComponent` (already wired)
- `ResponseModeProvider` + `useResponseSession` hooks (already implemented in `features/views/components/workspace-editor/response-mode.tsx`)
- `useSaveDraft` debounced autosave at 500ms (already implemented)
- 21 of 40+ block types are already classified `role: 'input' | 'both'`
What's missing: (a) UI toggle on the entity detail page to switch view→response mode; (b) `BlockConfig.fieldName` so a block knows which field it binds to; (c) `editComponent` implementations for the field-bound blocks that don't have one; (d) per-block validation badges.
### Schema changes (DB)
```sql
-- entity_types: hardening + extraction pointer
ALTER TABLE entity_types
ADD COLUMN default_extraction_task_id uuid NULL REFERENCES tasks(id) ON DELETE SET NULL,
ADD COLUMN schema_version int NOT NULL DEFAULT 1;
ALTER TABLE entity_types
ADD CONSTRAINT entity_types_tenant_slug_unique UNIQUE (tenant_id, slug);
-- entities: tenant-scoped FK guard (composite)
-- Strategy: trigger-based check (composite FKs require composite UNIQUE on entity_types(tenant_id, id)
-- which would be an extra index — trigger is cheaper and clearer)
CREATE OR REPLACE FUNCTION enforce_entity_type_tenant_match() RETURNS trigger AS $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM entity_types
WHERE id = NEW.entity_type_id AND tenant_id = NEW.tenant_id
) THEN
RAISE EXCEPTION 'entity.entity_type_id must belong to entity.tenant_id';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER entities_tenant_match BEFORE INSERT OR UPDATE OF entity_type_id, tenant_id
ON entities FOR EACH ROW EXECUTE FUNCTION enforce_entity_type_tenant_match();
-- entity_responses: target discriminator + anon/share + nullable criteria_set
ALTER TABLE entity_responses
ADD COLUMN target_type text CHECK (target_type IN ('field', 'dimension')),
ADD COLUMN target_key text,
ADD COLUMN is_anonymous boolean NOT NULL DEFAULT false,
ADD COLUMN share_token text UNIQUE;
ALTER TABLE entity_responses
ALTER COLUMN criteria_set_id DROP NOT NULL;
-- New canonical rows: one row per target_key. Legacy rows (multi-key values map) keep
-- target_key NULL during transition; reads use values jsonb. Writes via the new path
-- always set target_key. CHECK accommodates both during transition window.
ALTER TABLE entity_responses
ADD CONSTRAINT entity_responses_target_consistency CHECK (
(target_type = 'dimension' AND criteria_set_id IS NOT NULL) OR
(target_type = 'field' AND target_key IS NOT NULL AND criteria_set_id IS NULL) OR
(target_type IS NULL) -- pre-migration rows only; new writes must set target_type
);
CREATE INDEX idx_entity_responses_field_target
ON entity_responses(entity_id, target_type, target_key, status DESC)
WHERE target_type = 'field';
CREATE INDEX idx_entity_responses_field_history
ON entity_responses(entity_id, target_type, target_key, version DESC)
WHERE target_type = 'field';
CREATE INDEX idx_entity_responses_share_token
ON entity_responses(share_token) WHERE share_token IS NOT NULL;
-- views: add share_token for view-in-response-mode public sharing
ALTER TABLE views ADD COLUMN share_token text UNIQUE;
CREATE INDEX idx_views_share_token ON views(share_token) WHERE share_token IS NOT NULL;
CREATE INDEX idx_entity_responses_draft_resume
ON entity_responses(tenant_id, submitted_by_user, status, created_at DESC)
WHERE target_type = 'field' AND status = 'draft';
-- RLS additions on entity_responses
CREATE POLICY entity_responses_field_member_insert ON entity_responses
FOR INSERT TO authenticated WITH CHECK (
target_type = 'field'
AND tenant_id IN (SELECT tenant_id FROM user_tenants WHERE user_id = (SELECT auth.uid()))
);
CREATE POLICY entity_responses_field_anon_insert ON entity_responses
FOR INSERT TO anon WITH CHECK (
target_type = 'field' AND is_anonymous = true AND share_token IS NOT NULL
);
CREATE POLICY entity_responses_draft_self_update ON entity_responses
FOR UPDATE TO authenticated USING (
status = 'draft' AND submitted_by_user = (SELECT auth.uid())
) WITH CHECK (status IN ('draft', 'submitted'));
CREATE POLICY entity_responses_share_token_select ON entity_responses
FOR SELECT TO anon USING (share_token IS NOT NULL);
-- tasks: enum trim (after data migration) + canonical output_config shape
-- Existing CHECK constraint replaced:
ALTER TABLE tasks DROP CONSTRAINT tasks_output_type_check;
ALTER TABLE tasks ADD CONSTRAINT tasks_output_type_check CHECK (
output_type IS NULL OR output_type IN
('response', 'entity', 'entities', 'relation-entity', 'document', 'status', 'none')
);
-- 'field' and 'fields' removed
```
### Schema changes (Zod / TS types)
```ts
// features/entities/types.ts — FieldConfig absorbs connection's UI/behavior fields into relation
export interface FieldConfigRelation {
targetTypeSlug: string;
relationshipType?: string;
multiple?: boolean;
max?: number;
rankable?: boolean;
createInline?: boolean;
filter?: RelationFieldFilter;
// NEW — absorbed from connection
display?: 'list' | 'data-table' | 'inline';
dataTableVariant?: 'compact' | 'full';
cascadeExtraction?: { enabled: boolean; titleField?: string; autoExtract?: boolean };
mode?: 'auto' | 'curated';
}
// FieldConfig — strict, no .passthrough()
export interface FieldConfig {
label?: string;
description?: string;
displayType?: string;
width?: number | { px?: number; pct?: number; span?: number }; // canonicalized
statusMap?: Record<string, StatusMapEntry>;
humanInput?: boolean;
relation?: FieldConfigRelation;
// NEW — promotion control
auto_promote_policy?: 'always' | 'if_confident' | 'never';
confidence_threshold?: number;
// archived flag for soft-delete
archivedAt?: string;
// NOTE: connection, extraction, actions REMOVED from type after migration
}
// features/schemas/json-column-schemas.ts — drop .passthrough() on:
// FieldConfigSchema, EntityTypeConfigSchema
// statusMap union (string|object) kept for one release as READ-time tolerance only;
// writes must produce object form (enforced by assertValidEntityTypeWrite).
// features/responses/types.ts — Response type with target_type discriminator
export const RESPONSE_TARGET_TYPES = ['field', 'dimension'] as const;
export type ResponseTargetType = typeof RESPONSE_TARGET_TYPES[number];
export interface EntityResponseRecord {
id: string;
tenant_id: string;
entity_id: string;
session_id: string | null;
target_type: ResponseTargetType;
target_key: string;
value: unknown;
confidence: number | null;
reasoning: string | null;
status: 'draft' | 'submitted' | 'promoted' | 'partially_promoted' | 'superseded' | 'rejected';
source: 'manual' | 'extraction' | 'api' | 'aggregation' | 'workshop' | 'optimization' | 'feedback';
criteria_set_id: string | null;
is_anonymous: boolean;
share_token: string | null;
// ...existing columns preserved
}
// features/tasks/types.ts — narrow output type enum
export const TASK_OUTPUT_TYPES = [
'response', 'entity', 'entities', 'relation-entity', 'document', 'status', 'none',
] as const;
// 'field', 'fields' removed
export interface TaskOutputConfig {
fields?: string[]; // schema fields this task populates
criteria_set_ids?: string[]; // scoring sets this task evaluates
entityTypeSlug?: string; // for 'entity'/'entities'/'relation-entity'
metadataKey?: string; // for 'status'
metadataValue?: unknown; // for 'status'
sources?: ExtractionSource[];
// fieldNames removed (renamed to `fields` for clarity; one-line shim during transition)
}
```
### Schema changes (Block + Block field binding)
```ts
// features/blocks/types.ts — BlockConfig adds optional fieldName
export interface BlockConfig {
// ...existing (incl. dataSourceId for query-derived data)
fieldName?: string; // NEW — block bound to a single entity field (response target_type='field')
dimensionId?: string; // NEW — block bound to a criteria dimension (response target_type='dimension')
// fieldName and dimensionId are mutually exclusive; dataSourceId is independent
// (used for query/list data, not single-value bind).
}
```
When in response mode, blocks with `fieldName` set will render their `editComponent` and call `setFieldValue(fieldName, newValue)` on the `ResponseModeProvider`.
### Field rename / archive / delete (lifecycle, with impact preview)
Rename and delete open a **preview dialog** that runs an impact query and shows counts:
```
Rename "size" → "deal_size":
Impact:
• Entities carrying value: 127
• Extraction subtasks referring: 2
• Criteria dimensions referring: 1
• Views referencing in blocks: 3
• Block configs referring: 5
[Cancel] [Rename + Rewrite References]
Delete "legacy_field":
• 8 entities carry a value.
[Archive (data retained, hidden from UI)] [Wipe + Delete] [Cancel]
```
On confirm, a single transaction:
1. Rewrites `entity_types.config.fields[old]` → `[new]`
2. Updates `tasks.output_config.fields[]` array entries
3. Updates `criteria_sets.dimensions[*].fieldName` references
4. Updates `views.blocks[*].fieldName` references
5. For rename: writes a `field_rename_audit` log row + a backfill script for `entities.content` (rekey JSONB)
6. For wipe: drops `entities.content[fieldName]` from each entity (via RPC); creates final `response.rejected` event for audit
Per Tyler's 80/20 guidance: counts and confirm dialog (not a granular per-row chooser). Audit log entry shows what was rewritten.
### Unified Fields tab — split-pane editor
```
/admin/data-types/[slug] → Fields tab
┌────────────────────────────────────────┬──────────────────────────────────────────┐
│ Field list (sortable, filterable) │ Selected field editor (right panel) │
│ │ │
│ ◎ name Text ✓ │ Schema │
│ ◎ stage Status ✓ ● │ slug: stage type: string required ☐ │
│ ◎ size Number ● │ default: "" description: … │
│ ◎ owner Relation ▷ │ │
│ ◎ arr Currency │ Display │
│ [+ Add field] │ displayType: status │
│ │ label override: "" │
│ Legend: ✓ required ● has subtask │ width: span 2 │
│ ▷ relation ☆ in scoring │ statusMap: { active: {color: success}, │
│ │ paused: {color: warning}, │
│ │ churned: {color: error} } │
│ │ │
│ │ Relation (relation fields only) │
│ │ targetTypeSlug | display | cascade … │
│ │ │
│ │ Extraction │
│ │ ☑ Has dedicated subtask │
│ │ subtask: extract-stage │
│ │ prompt: "…field-specific guidance…" │
│ │ agent: claude-sonnet │
│ │ depends_on: [extract-basics] │
│ │ │
│ │ Promotion │
│ │ policy: if_confident │
│ │ confidence_threshold: 0.8 │
│ │ │
│ │ Used in scoring (READ-ONLY) │
│ │ • Commercial Fit (w=0.3) │
│ │ │
│ │ Layout │
│ │ section: Status order: 3 │
│ │ │
│ │ Lifecycle │
│ │ [Rename…] [Archive] [Delete…] │
└────────────────────────────────────────┴──────────────────────────────────────────┘
```
Components (each ≤ 350 lines per project rule):
- `features/entities/components/admin/fields-tab/fields-tab.tsx` (entry point, layout)
- `features/entities/components/admin/fields-tab/field-list.tsx` (left pane)
- `features/entities/components/admin/fields-tab/field-editor.tsx` (right pane container)
- `features/entities/components/admin/fields-tab/sections/schema-section.tsx`
- `features/entities/components/admin/fields-tab/sections/display-section.tsx`
- `features/entities/components/admin/fields-tab/sections/relation-section.tsx`
- `features/entities/components/admin/fields-tab/sections/extraction-section.tsx`
- `features/entities/components/admin/fields-tab/sections/promotion-section.tsx`
- `features/entities/components/admin/fields-tab/sections/scoring-usage.tsx`
- `features/entities/components/admin/fields-tab/sections/layout-section.tsx`
- `features/entities/components/admin/fields-tab/sections/lifecycle-section.tsx`
- `features/entities/components/admin/fields-tab/dialogs/rename-dialog.tsx`
- `features/entities/components/admin/fields-tab/dialogs/delete-dialog.tsx`
- `features/entities/components/admin/fields-tab/editors/status-map-editor.tsx`
- `features/entities/components/admin/fields-tab/editors/display-type-editor.tsx`
- `features/entities/components/admin/fields-tab/editors/cascade-extraction-editor.tsx`
The existing `field-config-editor.tsx` and `fields-grid-editor.tsx` are deleted (their features absorbed).
### Overview tab additions (entity-type level)
```
Overview
├─ Identity (name, slug, description, icon, color) [existing]
├─ Empty state (icon, title, description, CTA) [NEW UI]
├─ Card config (cardFields, cardConfig) [NEW UI]
├─ Extraction pipeline (NEW)
│ Default extraction task: [extraction] — prompt, agent, schedule
│ Subtask DAG preview (read-only)
│ [Run extraction now] [Open task editor]
│ Auto-promote summary (count by policy: always / if_confident / never)
└─ Default agent (workflow.defaultAgentSlug) [existing]
```
### Single canonical write helper
```ts
// features/entities/server/entity-type-write-gate.ts (NEW)
export async function assertValidEntityTypeWrite(
config: unknown,
options?: { allowDeprecated?: boolean }
): Promise<EntityTypeConfig> {
// 1. Strict Zod parse — throws on unknown keys
const parsed = EntityTypeConfigSchema.parse(config);
// 2. Reject deprecated fields unless allowDeprecated
if (!options?.allowDeprecated) {
if ((parsed as any).dashboard) throw new SchemaWriteError('config.dashboard is deprecated');
if ((parsed as any).statusTriggers) throw new SchemaWriteError('config.statusTriggers is deprecated');
}
// 3. Per-field assertions: no `connection` (use `relation`), no `actions`, no `extraction`
for (const [key, field] of Object.entries(parsed.fields ?? {})) {
if ((field as any).connection) throw new SchemaWriteError(`field ${key}: use 'relation' not 'connection'`);
if ((field as any).actions) throw new SchemaWriteError(`field ${key}: 'actions' deprecated, use task triggers`);
if ((field as any).extraction) throw new SchemaWriteError(`field ${key}: 'extraction' moved to tasks`);
if (field.statusMap) {
for (const [k, v] of Object.entries(field.statusMap)) {
if (typeof v === 'string') throw new SchemaWriteError(`field ${key}.statusMap.${k}: use object form not shorthand`);
}
}
}
return parsed;
}
```
Called from every entity-type write entry point. A lint rule (`eslint-plugin-amble/no-direct-entity-type-write`) bans `.update({json_schema: …})` and `.update({config: …})` on `entity_types` outside this helper.
### Single canonical promote helper
```ts
// features/responses/server/promote-response.ts (NEW or refactored)
export async function promoteEntityResponseValue(input: {
response_id: string;
tenant_id: string;
reviewer_id?: string | null;
}): Promise<{ promoted: boolean; target_type: ResponseTargetType; target_key: string }> {
const response = await fetchResponse(input.response_id);
if (response.status === 'promoted') return { promoted: false, ... }; // idempotent
if (response.target_type === 'field') {
// Atomic write to entity.content[response.target_key] via promote_field_value RPC
await rpc('promote_field_value', { ... });
} else {
// Score promotion: keep existing path; updates response_summary in entity.metadata
await rpc('promote_dimension_response', { ... });
}
await appendSessionEvent({
session_id: response.session_id,
type: 'response.promoted',
payload: { response_id: response.id, target_key: response.target_key },
});
return { promoted: true, target_type: response.target_type, target_key: response.target_key };
}
```
Called from:
- Auto-promote in `finalizeEntityResponseAdmin()` (per field's `auto_promote_policy`)
- Manual promote button in entity detail UI
- Reviewer approval action
- API `POST /api/responses/[id]/promote`
### Data migrations
In one migration file `2026041500000X_entity_type_admin_and_response_unification.sql` (idempotent, transactional where possible):
1. **Add columns + constraints + indexes + RLS** as listed above (no behavior change yet)
2. **Backfill `entity_responses.target_type`** for existing rows: all current rows get `target_type='dimension'`, `target_key=` first key in `values` map
3. **Backfill `entity_responses.value`** from `values[target_key]` for new query convenience (legacy `values` jsonb retained for back-compat reads)
4. **Backfill `entity_types.default_extraction_task_id`** by linking each entity type to its `slug='extraction'` task (creates one with `output_type='none'` orchestrator if missing)
5. **Rewrite tasks**: every `output_type IN ('field', 'fields')` → `'response'`; `output_config.fieldNames` → `output_config.fields`. Existing `extract-{fieldName}` tasks become subtasks of the parent extraction (set `parent_task_id` if the column exists, otherwise add `depends_on=[parent_extraction_task_id]` — verify against current task table shape during implementation)
6. **Rewrite seeds**: `scripts/seed-nathan-excavating-agent.mjs`, `scripts/seed-ims-influencer-entity-type.mjs`, `scripts/repair-tenant-content-system.mjs` updated
7. **Normalize `statusMap` shorthand**: any bare-string entries → `{color: <string>}` (writes already use object form per audit)
8. **Migrate `connection` → `relation`** in `entity_types.config.fields[*]`: copy entityTypeSlug→targetTypeSlug, mode/max/display/dataTableVariant/cascadeExtraction → relation
9. **Strip deprecated keys**: `actions[]`, `extraction` (already gone), `dashboard`, `statusTriggers` — log each to `schema_repair_log` table for audit
10. **Add `UNIQUE(tenant_id, slug)` on entity_types** — preceded by a FAIL-FAST audit query that aborts the migration if duplicates exist (operator must dedupe via SQL first; we do NOT auto-dedupe)
11. **Drop `output_type IN ('field', 'fields')` from CHECK constraint** — only after step 5 verifies zero remaining rows
12. **Add `entities` tenant-match trigger** AFTER a pre-trigger audit query confirms no cross-tenant violators (operator remediates if any)
13. **Run `pnpm db:types`** post-migration to regenerate `lib/supabase/database.types.ts`; verify build passes
A `schema_repair_log` table records every legacy-shape rewrite with `(tenant_id, table_name, row_id, repair_kind, before, after, repaired_at)` for audit.
**Inheritance — auto_promote_policy:** Each field carries its own `auto_promote_policy` and optional `confidence_threshold`. A subtask MAY override per-field at execution time via `output_config.auto_promote_policy_overrides[fieldName]`; if absent the field's own policy applies. Per-field policy is the default; overrides are explicit and visible in the subtask editor.
### Schema health checker extension
`/api/admin/schema-health` already scans for invalid configs. Extended to flag:
- Tasks with `output_config.fields[]` containing names not present in entity type schema (orphaned references)
- Entity types missing `default_extraction_task_id`
- Entity types with no fields
- Field configs with deprecated keys (`connection`, `extraction`, `actions`, `statusTriggers`)
- statusMap entries in shorthand form
- entity_responses with `target_type='field'` but `target_key` not in entity type schema (post-rename rot)
### Realtime + activity + cache hooks
Existing realtime subscriptions on `entity_responses` filtered by `entity_id` continue working. Add a server-side validation pass before publishing: if `target_type='field'` and `target_key` no longer exists on entity type schema, log warning + skip publish (no subscriber crash).
Existing activity logger on `promote_field_value` RPC continues to fire (verified). Inline edits creating responses don't bypass — promote handler is the single writer of `entities.content`, and it appends activity.
Cache invalidation: every `assertValidEntityTypeWrite()` call wraps `revalidateTag('entity-types-{tenantId}')` so the existing `'use cache'` layer in `features/entities/server/cached-queries.ts` stays consistent.
### Views RLS for share_token-based response mode
Sharing a view in response mode requires the view itself to be selectable by the anon role when a valid `share_token` is presented. Existing `sessions_shared_tool_select` policy (Phase 7) covers tool sessions; we add a parallel `views_shared_response_select` policy: `FOR SELECT TO anon USING (share_token IS NOT NULL)` on a `views.share_token` column (added by this spec). The shared link URL pattern is `/v/{share_token}` which renders the view in response mode without authentication.
### Lint rule
New ESLint rule `amble/no-direct-entity-content-write` flags any `.from('entities').update(`/`.insert(` that includes `content` or `metadata.field_sources` in the payload outside an allowlist of canonical helpers (`promote_field_value`, `seed-*` scripts, migrations).
## Trade-offs
| Choice | Rejected alternative | Why |
|---|---|---|
| One response primitive (`entity_response` with `target_type` discriminator) | Two tables (`field_responses`, `dimension_responses`) | Single audit/review/promote pipeline; same code handles human + agent |
| Criteria sets stay orthogonal to schema | Default criteria set 1:1 with fields (dimensions = fields) | Avoids forced dimension-per-field bloat for plain fields like `name`. Fields are *what is*, criteria sets are *what is scored* |
| Fields-direct responses (`target_type='field'`) | Always go through criteria set indirection | Inline edit doesn't need a scoring rubric; clean separation of concerns |
| Subtask DAG default for extraction | One monolithic extraction task | Field-specific prompts + agent assignment + dependencies (basics → financials → assessment) |
| Hard cutover (`output_type` enum drops 'field'/'fields') | Soft sunset (warn, keep accepting) | Cleaner state; one-time migration cost vs forever-tolerant validator |
| Trigger-based entity-type tenant guard | Composite FK on `entity_types(tenant_id, id)` | Trigger is cheaper than maintaining composite UNIQUE |
| Strict Zod (no .passthrough()) on FieldConfig + EntityTypeConfig | Keep tolerant validation forever | Eliminates drift permanently; backed by data migration normalizing existing rows |
| Form/poll = view in response mode | Separate forms subsystem | Reuses block renderer + ResponseModeProvider (already built); no UI duplication |
| Field rename with cascade preview (80/20) | Archive-only / require deprecate-then-add | Rename is common; preview gives awareness; no granular per-row chooser keeps UX simple |
## Acceptance Criteria
### UI
- [ ] Fields tab opens to a split-pane editor with sortable left list and detail right panel
- [ ] Right panel exposes editors for: schema, displayType, label override, statusMap (per-value color/label/icon picker), width, relation (incl. display/dataTableVariant/cascadeExtraction), extraction subtask binding (with inline subtask edit), promotion policy, scoring usage (read-only), layout, lifecycle
- [ ] Overview tab gains: Empty state editor, Card config editor, Extraction pipeline (default task, DAG preview, run-now button)
- [ ] Field rename opens preview dialog showing impact counts; confirm rewrites all references in one transaction
- [ ] Field delete offers Archive vs Wipe; both leave audit log entry
- [ ] Entity detail page has "Switch to response mode" toggle that activates the existing ResponseModeProvider
- [ ] Inline edits in response mode queue draft responses; submit writes them as one session
- [ ] Sharing a view (any view) generates a public URL with `share_token`; opening it in another browser allows anonymous response submission
### Schema + data
- [ ] `entity_types` has `UNIQUE(tenant_id, slug)`, `default_extraction_task_id`, `schema_version`
- [ ] `entities.entity_type_id` enforces tenant match via trigger
- [ ] `entity_responses` has `target_type`, `target_key`, `is_anonymous`, `share_token`; `criteria_set_id` is nullable
- [ ] `tasks.output_type` no longer accepts 'field' or 'fields'; check constraint enforced
- [ ] Every existing task row migrated to `output_type='response'`
- [ ] Every entity type linked to a parent extraction task
- [ ] All `connection` field configs migrated to `relation`
- [ ] All `statusMap` shorthand strings normalized to objects
- [ ] All deprecated keys (`actions`, `extraction`, `dashboard`, `statusTriggers`) stripped or migrated
- [ ] `schema_repair_log` table contains an audit row per legacy-shape rewrite
### Code
- [ ] `assertValidEntityTypeWrite()` is the only entry point that validates+writes entity type config; lint rule bans direct writes
- [ ] `promoteEntityResponseValue()` is the only function that mutates `entities.content`; lint rule bans direct writes
- [ ] All write paths in the "uniform application" table are rerouted through Session+Response
- [ ] FieldConfig + EntityTypeConfig Zod schemas have no `.passthrough()`
- [ ] FieldConfig type no longer has `connection`, `actions`, `extraction` keys (after migration)
- [ ] `TASK_OUTPUT_TYPES` no longer includes 'field' or 'fields'
### Tests
- [ ] Unit: `assertValidEntityTypeWrite` rejects every deprecated key
- [ ] Unit: `promoteEntityResponseValue` is idempotent + handles target_type='field' and 'dimension'
- [ ] Unit: legacy data migration normalizes statusMap shorthand + connection→relation correctly
- [ ] Integration: inline edit on entity detail produces one session + one response + one promote event
- [ ] Integration: agent extraction task with `output_type='response'` populates fields via the same path
- [ ] Integration: anonymous response via share_token persists with `is_anonymous=true`
- [ ] Integration: rename cascade rewrites references in tasks, criteria_sets, views, blocks, and entities atomically
- [ ] E2E (Playwright): admin can edit statusMap + displayType + cascadeExtraction on a status field, save, and see chip render correctly on entity detail
- [ ] E2E: admin opens entity detail in response mode, edits 3 fields, submits, sees all promoted
### Out of scope (explicit)
- ❌ Consensus aggregator implementation (primitives support; no UI yet)
- ❌ Iteration/refinement loop orchestrator (existing `metadata.refinement` shape preserved)
- ❌ "My contributions" / "my sessions" UI (data model supports; new UI is its own spec)
- ❌ Field type system overhaul (we work within current types)
- ❌ View configs hardening (separate spec; Zod tightening here is for entity_types only)
## Migration risks + mitigations
| Risk | Mitigation |
|---|---|
| Pre-existing duplicate `entity_types(tenant_id, slug)` blocks UNIQUE constraint | Migration aborts with audit query output; operator runs supplied dedupe SQL; constraint then succeeds. We do NOT auto-merge. |
| Cross-tenant `entities` rows blocking the trigger | Pre-trigger audit query lists violators; operator remaps; trigger then enforces |
| Tasks with `output_type='field'` referencing fields no longer in schema | Schema health scan flags them post-migration; `manageTasks` admin tool can edit/delete |
| Anonymous response RLS exposes responses across tenants | RLS scoped to `share_token IS NOT NULL` (UUID, unguessable); insert requires `is_anonymous=true`; covered by integration tests |
| Inline-edit response mode conflicts with concurrent agent extraction | Per-field `promote_field_value` already row-locks; last promote wins; UI realtime invalidates so user sees latest |
| Removed `.passthrough()` rejects rows that survived migration | Migration logs every rewrite to `schema_repair_log`; if any unknown key remains, migration FAILS (we don't silently strip in production) |
| Field rename rewrites `entities.content` JSONB across many rows (large entity types) | Background job for tenants with > 10k entities; default synchronous for smaller |
| Block rendering breaks where `block.fieldName` not yet set | Optional field on BlockConfig; missing → falls back to existing data resolution |
## Files to touch (anti-conflict for parallel agents)
See `files-touched` frontmatter. The largest contention zones:
- `features/responses/server/actions.ts` — submit + finalize + promote flows
- `features/entities/types.ts` — FieldConfig type
- `features/schemas/json-column-schemas.ts` — Zod schemas
- `features/tasks/types.ts` — output type enum
- `app/(app)/admin/data-types/[slug]/entity-type-admin-client.tsx` — Fields tab integration
If any other in-progress spec touches these files, this work must serialize behind it.