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:
- Entity schema /
entity.content— the canonical typed record (current values). - Criteria sets — define how a user or agent proposes data (
record,assessment, ortemporaltemplates). - Entity responses — record who submitted what values when for a given entity and criteria set (append-only history).
- 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.
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 valuesassessment— scoring/evaluation rubricstemporal— repeated snapshots keyed bytemporal_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.
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:
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:
- A user fills the
ResponseFormblock in the workspace, or an agent calls a submission server action. submitEntityResponse()validates the values against the criteria set dimensions, runscomputeResponseScore(), snapshots the criteria definition, and inserts the row withstatus: "submitted".ResponsesTabon the entity detail page lists all responses for the entity, grouped by criteria set, with per-row promotion controls.- A reviewer clicks Promote on a dimension value. The client calls
promoteField()→promote_field_valueRPC →entity.contentis updated atomically. The response row'spromoted_fieldsarray andstatusare updated in the same transaction.
Field-population flow:
- Field-population work starts from an action/task and executes in a
sessionsrow with orderedsession_eventsfor auditability. - The produced value is submitted as an
entity_responsesrow withsource: "extraction"or the more specific agent/API source. - Reviewers promote accepted response values into
entity.content; rejection feedback triggers a rerun session against the same response context. - 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
ResponseFormdriven 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.
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
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→numberboolean→selectwith["true", "false"]- other
stringvalues →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 —
entity.contentis the target of promotion; entity types define the schema that drives auto-generated criteria sets - Actions — field-population work is authored as visible actions
- Sessions — session execution produces response values and transcript evidence
- Block System —
response-formis a registered block type rendered inside workspace views