Documentation source
Unified Response System
One typed submission layer for people and agents — criteria sets define templates, entity_responses preserve versioned evidence, and promotion writes approved values into canonical record fields.
## Architecture: Three Layers
The platform has three semantic layers. No exceptions, no mixing.
### 1. Canonical Graph (schemas, core properties, entities, relationships)
Entity schemas define the durable structure of the graph. Core properties live in `entity.content` and represent accepted truth. Schemas are not replaced by criteria sets — they remain canonical.
### 2. Collaboration Layer (criteria sets, submissions/responses, current values, rollups)
Criteria sets are reusable evaluation overlays. They define _how_ humans and agents propose data. Submissions (entity responses) log the work — append-only, versioned, with provenance. Promotion writes accepted values into entity.content (the "current value"). Aggregation computes rollups (group meaning across many responses).
Scoring is not a separate subsystem. A score is a response with numeric/rating criteria items and rollup rules — computed at submission time and stored on the response row.
### 3. Presentation Layer (views, blocks)
Views and blocks render all of the above. They are strictly presentational — no storage semantics, no truth definitions.
## Overview
The unified response system gives every submission — regardless of source — a single home, a review step, and an explicit promotion action that writes the value into the canonical record.
The four moving parts are:
1. **Entity schema / `entity.content`** — the canonical typed record (current values).
2. **Criteria sets** — define _how_ a user or agent proposes data (`record`, `assessment`, or `temporal` templates).
3. **Entity responses** — record _who_ submitted _what values_ _when_ for a given entity and criteria set (append-only history).
4. **Promotion** — an atomic RPC that cherry-picks a value from a response and writes it to `entity.content` (acceptance).
## Key Concepts
### Criteria Set
A criteria set is a reusable evaluation framework. Each set has a list of **dimensions** — the columns to collect — and optional metadata controlling display and aggregation.
```ts
interface CriteriaSetDimension {
key: string; // field identifier, e.g. "revenue"
label: string; // display label
type: "number" | "text" | "rating" | "select" | "richtext";
scale?: [number, number]; // [min, max] for number/rating
step?: number;
options?: string[]; // for select type
weight?: number; // 0–1, used in weighted score
description?: string;
fieldName?: string; // maps to an entity content field for promotion
required?: boolean;
}
```
A criteria set also has a `kind`:
- `record` — schema-backed field capture for canonical record values
- `assessment` — scoring/evaluation rubrics
- `temporal` — repeated snapshots keyed by `temporal_key`
A criteria set can be scoped to one or more entity type slugs (`entity_type_slugs`) or left unscoped (available to all types). When `is_default` is true, the set cannot be deleted and is treated as the canonical evaluation framework for its entity type.
**Auto-generation:** `syncDefaultCriteriaSet(tenantId, entityTypeSlug, schema, fieldConfigs)` inspects the entity type's JSON schema and field configs, maps each field to a dimension type, and upserts a default criteria set with slug `default-{entityTypeSlug}` and `kind: "record"`. This runs after entity type creation and update, and can also be called from background jobs.
### Entity Response
An entity response is one submission event — a snapshot of values for all (or some) dimensions at a point in time.
```ts
interface EntityResponseRecord {
id: string;
entity_id: string;
criteria_set_id: string;
criteria_snapshot: CriteriaSetDimension[];
submitted_by_user: string | null;
submitted_by_agent: string | null;
source: "extraction" | "manual" | "workshop" | "optimization" | "feedback";
values: Record<string, unknown>; // dimension key → submitted value
field_meta: Record<string, FieldMeta>; // per-dimension confidence + sources
weighted_score: number | null;
normalized_score: number | null;
status: ResponseStatus;
promoted_fields: string[]; // which dimensions have been promoted
reviewed_by: string | null;
review_notes: string | null;
parent_response_id: string | null; // for linked follow-up responses
temporal_value: string | null; // for time-series snapshots
notes: string | null;
}
```
`criteria_snapshot` preserves the exact submitted template on the row, so historical responses remain stable even if the source criteria set changes later.
**Statuses:** `submitted` → `promoted` / `partially_promoted` / `superseded` / `rejected`. A response is `partially_promoted` when some but not all of its dimensions have been promoted.
**Scoring:** `computeResponseScore(values, dimensions)` computes a weighted score and a 0–1 normalized score from numeric and rating dimensions that have both a `weight` and a `scale`. Scores are stored on the response row at submission time.
### Promotion
Promotion is the controlled step that writes a response value into the canonical record. The `promote_field_value` SQL RPC is atomic: it updates `entity.content` and marks the field in `promoted_fields` in a single transaction. This prevents partial state if the caller crashes mid-way.
The promotion route requires `entities.team.update`, validates that the response belongs to the target record, and passes reviewer metadata into the RPC.
The server action `promoteFieldValue(entityId, responseId, fieldName)` wraps this RPC.
### FieldMeta
Each response dimension can carry extraction provenance:
```ts
interface FieldMeta {
confidence?: "high" | "medium" | "low";
sources?: FieldMetaSource[]; // document, entity, web, or field references
tokens_used?: number;
duration_ms?: number;
}
```
### Feedback Quality
Rejected responses can trigger feedback reruns. The rerun path scores the
reviewer feedback before dispatching the next extraction task, then writes that
score into both the retry prompt and `feedback.context`.
The score rewards concrete signals that help the next agent act without reading
chat history: a specific rejection reason, the rejected value, field name,
response lineage, source evidence, criteria snapshot, and field metadata.
`features/responses/lib/feedback-quality.ts` exposes:
| Function | Description |
|---|---|
| `scoreFeedbackQuality(input)` | Returns a 0-100 score, grade, present signals, and missing signals. |
| `formatFeedbackQualitySummary(result)` | Formats the agent-visible `Feedback quality: N/100 (...)` summary used in rerun instructions. |
The benchmark fixtures in
`features/responses/lib/response-quality-loop-benchmark.ts` use this score as
one metric in the broader response quality loop.
`submitResponse` auto-promotion respects criteria dimensions that declare
`extraction.requiresApproval: true`. When a requested auto-promoted field maps
to a requires-approval dimension in the submitted response's
`criteria_snapshot`, `finalizeEntityResponseAdmin()` leaves that field submitted
for review instead of promoting it into `entity.content`.
## How It Works
**Submission flow:**
1. A user fills the `ResponseForm` block in the workspace, or an agent calls a submission server action.
2. `submitEntityResponse()` validates the values against the criteria set dimensions, runs `computeResponseScore()`, snapshots the criteria definition, and inserts the row with `status: "submitted"`.
3. `ResponsesTab` on the entity detail page lists all responses for the entity, grouped by criteria set, with per-row promotion controls.
4. A reviewer clicks Promote on a dimension value. The client calls `promoteField()` → `promote_field_value` RPC → `entity.content` is updated atomically. The response row's `promoted_fields` array and `status` are updated in the same transaction.
**Field-population flow:**
1. Field-population work starts from an action/task and executes in a `sessions` row with ordered `session_events` for auditability.
2. The produced value is submitted as an `entity_responses` row with `source: "extraction"` or the more specific agent/API source.
3. Reviewers promote accepted response values into `entity.content`; rejection feedback triggers a rerun session against the same response context.
4. Direct entity writes are reserved for explicit editor/API paths. Agent-produced values should move through responses so review, provenance, realtime, and promotion semantics stay consistent.
**Aggregation flow:**
`aggregateEntityResponses(entityId, criteriaSetId)` computes per-dimension statistics (count, mean, median, min, max, value frequency for selects) and a score progression time series. The result is a `ResponseAggregation` object consumed by `useResponseAggregation` and displayed in `ScoreProgressionChart`.
## API Reference
### Server actions (`features/responses/server/actions.ts`)
| Function | Description |
|---|---|
| `listCriteriaSets(opts?)` | List all criteria sets for the tenant. Pass `entityTypeSlug` to filter. |
| `getCriteriaSet(id)` | Fetch a single criteria set by ID. |
| `getCriteriaSetBySlug(slug)` | Fetch a criteria set by slug. |
| `createCriteriaSet(input)` | Create a new criteria set. |
| `updateCriteriaSet(id, input)` | Update name, dimensions, or display settings. |
| `deleteCriteriaSet(id)` | Delete (blocked if `is_default`). |
| `submitEntityResponse(input)` | Submit a new response; validates values, snapshots criteria, and computes/stores scores. |
| `submitEntityResponseAdmin(input)` | Admin-client variant used by extraction/background jobs. |
| `listEntityResponses(entityId, opts?)` | List responses for an entity, optionally filtered by criteria set. |
| `getEntityResponse(id)` | Fetch a single response. |
| `updateResponseStatus(responseId, status, reviewNotes?)` | Update review status and review metadata on a response. When `status === "rejected"` and `reviewNotes` is provided, dispatches one `extraction/result-rejected` Inngest event per field in the response's `values`, which `feedbackRerun` consumes to schedule an auto-rerun with the rejected value + reviewer notes as agent instructions. The event dispatch is best-effort (failures are logged, not thrown) so a reject click can never 500 on an Inngest outage. |
| `promoteFieldValue(entityId, responseId, fieldName)` | Atomically promote one dimension value into `entity.content`. |
| `promoteFieldValueAdmin(input)` | Admin-client variant used by extraction/background jobs. |
| `aggregateResponses(entityId, criteriaSetId)` | Compute dimension statistics and score progression. |
### Auto-generation (`features/responses/server/sync-default-criteria-set.ts`)
| Function | Description |
|---|---|
| `syncDefaultCriteriaSet(tenantId, entityTypeSlug, schema, fieldConfigs)` | Upsert a default record criteria set from the entity type schema. Uses admin client — safe to call from background jobs. |
### Score utility (`features/responses/types.ts`)
| Function | Description |
|---|---|
| `computeResponseScore(values, dimensions)` | Returns `{ weighted, normalized }` from numeric/rating dimensions with weight and scale. Returns `{ weighted: null, normalized: null }` if no qualifying dimensions. |
| `defaultCriteriaSetSlug(entityTypeSlug)` | Returns `"default-{entityTypeSlug}"`. |
### API routes
| Route | Methods | Description |
|---|---|---|
| `/api/criteria-sets/` | GET, POST | List or create criteria sets |
| `/api/criteria-sets/[id]` | GET, PATCH, DELETE | Fetch, update, or delete a criteria set |
| `/api/entities/[id]/responses/` | GET, POST | List or submit responses for an entity |
| `/api/entities/[id]/responses/[rid]` | GET, PATCH, DELETE | Fetch, update, or delete a response |
| `/api/entities/[id]/responses/aggregate` | GET | Fetch aggregated statistics |
### Client hooks (`features/responses/hooks/use-entity-responses.ts`)
| Hook | Description |
|---|---|
| `useEntityResponses(entityId, opts?)` | React Query hook — list responses, auto-refetch on focus |
| `useCriteriaSets(opts?)` | React Query hook — list criteria sets |
| `useResponseAggregation(entityId, criteriaSetId)` | React Query hook — aggregated statistics |
## Components
| Component | Location | Purpose |
|---|---|---|
| `CriteriaSetRenderer` | `components/criteria-set-renderer.tsx` | Dispatcher — chooses the renderer based on `criteriaSet.display_type` (default vertical-list vs. `likert-matrix`). Drop-in replacement for `<FormSpecRenderer>`. |
| `LikertMatrixRenderer` | `components/likert-matrix-renderer.tsx` | Controlled Likert grid — rows = numeric dimensions, columns = scale values. Required dimensions get inline error messaging via `aria-required` + `role="alert"`. |
| `ResponseForm` | `components/response-form.tsx` | Inline workspace block — renders a submission form driven by a criteria set. Uses `<CriteriaSetRenderer>` so any criteria set with `display_type='likert-matrix'` automatically gets the matrix UI. |
| `ResponsesTab` | `components/responses-tab.tsx` | Entity detail tab — lists all responses with promotion controls |
| `ResponseRow` | `components/response-row.tsx` | Single response row with per-dimension promote buttons |
| `FieldResponseIndicator` | `components/field-response-indicator.tsx` | Inline provenance control showing response coverage and promotion actions for a specific field |
| `CriteriaSetManager` | `components/criteria-set-manager.tsx` | Admin list of all criteria sets |
| `CriteriaSetEditor` | `components/criteria-set-editor.tsx` | Admin create/edit form for a criteria set |
| `ScoreProgressionChart` | `components/score-progression-chart.tsx` | Recharts line chart — normalized score over time for an entity |
| `ResponseScoreBadge` | `components/response-score-badge.tsx` | Compact score badge retained for response-centric surfaces. |
### Response Form Block
The `response-form` block type is registered in the block system. It renders:
- **View mode:** Summary showing criteria set name, response count, and latest score.
- **Edit mode:** Inline `ResponseForm` driven by the active criteria set. On submission, the block switches back to view mode and invalidates response queries so other blocks refresh.
The block resolver populates `criteriaSet`, `responses`, and `latestScore` from the entity's responses.
### Display types
Criteria sets carry an optional `display_type text` + `display_config jsonb` pair. NULL = the default vertical-list renderer (one input per dimension). Set `display_type='likert-matrix'` for clinical instruments / diligence-scoring forms — e.g. DOC's Theralight Symptom Inventory renders as a severity matrix instead of stacked numeric fields.
Known kinds:
| `display_type` | `display_config` shape | Use case |
|---|---|---|
| `null` (default) | `null` | Vertical list — every dimension as its own labelled input. Existing rendering behaviour. |
| `'likert-matrix'` | `{ scale?: [min, max], step?, anchorLabels?: { min, mid?, max } }` | Symptom-inventory / Likert grid. Numeric dimensions only. Per-dimension `dim.scale` overrides `display_config.scale`. Capped at 11 columns. |
Per-tenant assignment lives in tenant-scoped seed scripts (`scripts/seed-*.ts`), not platform migrations. Add new kinds at `<CriteriaSetRenderer>` in `features/responses/components/criteria-set-renderer.tsx` — the dispatcher does Zod-validated config parsing + graceful fallback on parse failure (`console.warn`, falls back to default path so a misconfigured row doesn't dead-end the submit flow).
### Inline Provenance
Generic field cards now render `FieldResponseIndicator` beneath canonical values when response history exists for that field. This exposes the record's current promoted source, lets reviewers inspect alternate submissions in context, and keeps provenance next to the field the user is actually reasoning about.
### Score Surfaces
All score displays — entity detail hero, cards, lists, and data table columns — read from `metadata.response_summary.latest_score` via the `getEntityScore()` helper on a unified 0–100 scale. `getEntityScore()` also handles a legacy fallback: if `response_summary` is absent but a `weighted_score` key exists in `entity.metadata` (from old seed scripts that used a 1–10 scale), it normalizes the value to 0–100 before returning it. This fallback is retained until all entity types have been confirmed to use the `entity_responses` / `response_summary` pipeline.
`scoreBadgeVariant(score)` maps a 0–100 score to a badge variant: `default` (≥ 70), `secondary` (≥ 40), `outline` (< 40). All progress bar `value` props in cards, lists, and table columns receive the score directly — no division or multiplier is applied at the call site.
## For Agents
### `submitResponse` Tool
The `submitResponse` tool is registered in the response tool bundle and requires `responses.team.create`. It allows agents to submit scored responses against criteria sets without also granting direct canonical-record edit rights.
```ts
submitResponse({
entityId: "uuid", // required — entity to score
criteriaSetId: "uuid", // optional — specific criteria set
criteriaSetSlug: "slug", // optional — alternative to ID
values: { quality: 8 }, // required — dimension key → value
notes: "Assessment notes", // optional
source: "manual", // optional — default "manual"
})
```
If the caller also has `entities.team.update`, workflow extraction can auto-promote the submitted fields into `entity.content`. If not, the response is still accepted and the tool returns `promotionDeferred: true` plus `pendingPromotionFields`, leaving canonical promotion for a later reviewer or a higher-privilege workflow agent.
**Criteria set resolution:** If neither `criteriaSetId` nor `criteriaSetSlug` is provided, the tool automatically resolves the default criteria set for the entity's type (the one with `is_default: true`).
**Return value:** `{ submitted: true, responseId, weightedScore, normalizedScore, status }` on success, or `{ error: "message" }` on failure.
The tool is defined in `features/tools/response-tools.ts` and wired into agent tool resolution via `getResponseTools()` in `features/agents/resolve-tools.ts`.
**Note:** The `promoteField` tool is not yet exposed. Agents can submit responses but cannot promote values into `entity.content` via tool — promotion requires human review through the ResponsesTab UI.
## Metadata Sync
When a scored response is submitted, `submitEntityResponse()` automatically syncs a lightweight score summary into `entity.metadata.response_summary`. All consumers read scores via `getEntityScore(metadata)` which reads `response_summary.latest_score`.
The response row remains canonical; the metadata summary is a denormalized cache for list/card displays that need quick access to the latest score without joining to `entity_responses`.
### `response_summary` shape
```ts
interface EntityResponseSummary {
latest_score: number; // 0-100 normalized
latest_response_id: string;
latest_criteria_set_id: string;
latest_source: string;
score_scale_max: 100; // always 100 — enforced as a literal type
updated_at: string;
}
```
`score_scale_max` is a constant sentinel value that lets future readers confirm the stored score is on the 0–100 scale without additional context.
## Default Criteria Set Auto-Generation
`syncDefaultCriteriaSet(tenantId, entityTypeSlug, schema, fieldConfigs)` inspects the entity type's JSON schema and field configs, maps each field to a dimension type, and upserts a default criteria set with slug `default-{entityTypeSlug}` and `kind: "record"`. The sync runs on entity-type create and update, and extraction can trigger it lazily if the default set is missing.
- `number` / `integer` → `number`
- `boolean` → `select` with `["true", "false"]`
- other `string` values → `text`
Default criteria sets have `is_default: true` and are deletion-protected. The function runs after entity type schema updates to keep the criteria set in sync.
## Design Decisions
**Promotion is an RPC, not a server action write.** Writing to `entity.content` and updating `promoted_fields` must be atomic. A server action doing two sequential DB calls would leave the response in a bad state if the process crashed between them. The `promote_field_value` RPC handles both in one transaction.
**Scores are computed at submission time, not on read.** `weighted_score` and `normalized_score` are stored on the response row when it is created. This makes aggregation queries cheap (no recomputation) and keeps the historical score stable even if the criteria set dimensions or weights are later changed.
**Default criteria sets are schema-managed.** `syncDefaultCriteriaSet()` upserts the default record set from the entity type schema. The UI treats these sets as read-only so tenants do not make edits that will be overwritten on the next sync.
**`entity_type_slugs` is nullable.** A null value means the criteria set is available to all entity types. This supports cross-type evaluation frameworks (e.g., a common "data quality" set) without requiring an explicit list of every type.
**Response history must outlive criteria-set churn.** `entity_responses.criteria_set_id` is delete-restricted and each response stores `criteria_snapshot`, so historical submissions remain intact even if a criteria set is archived or later edited.
**Metadata sync is a denormalized cache.** Response scores are synced to `entity.metadata.response_summary` on submission. All readers use `getEntityScore()` which reads from `response_summary.latest_score`. Scores are always stored on the 0–100 scale; `score_scale_max: 100` on the summary shape is a sentinel to make this explicit.
**Legacy `weighted_score` retained as a normalizing fallback in `getEntityScore()`.** Old seed data and records from before the unified response system may carry a `weighted_score` on a 1–10 scale in `entity.metadata`. `getEntityScore()` detects this (absence of `response_summary`), normalizes via `((raw - 1) / 9) * 100`, and returns a 0–100 value. Migration `20260402000012` converts existing opportunity records to proper `entity_responses` rows; the fallback stays until all types are confirmed migrated.
**`featuredScore.criteriaSetId` must not be NULL.** Entity type config drives which criteria set is used as the featured score. If `criteriaSetId` is NULL, `syncEntityResponseSummary()` accepts any submitted response as the featured score — producing inconsistent display. Migration `20260402000012` was necessary to repair this after migration 000015 introduced a kind-mismatch bug that silently wrote NULL.
**Legacy `ScoringCriterion` kept but deprecated.** The old `ScoringCriterion` interface in `features/entities/types.ts` is retained with a `@deprecated` tag because existing entity type configs may reference it in their `scoring.criteria` array. New code should use criteria sets instead.
## Related Modules
- [Entity System](/docs/features/entity-system) — `entity.content` is the target of promotion; entity types define the schema that drives auto-generated criteria sets
- [Actions](/docs/features/actions) — field-population work is authored as visible actions
- [Sessions](/docs/features/sessions) — session execution produces response values and transcript evidence
- [Block System](/docs/features/block-system) — `response-form` is a registered block type rendered inside workspace views