Documentation source
Edge Scoring via Scoped Criteria Sets
Let a criteria set score each connection in a relation field instead of (or in addition to) the entity itself — 1-5 likert, RTE feedback, select, per edge — with zero new tables and zero new dimension types.
# Edge Scoring via Scoped Criteria Sets
> **For the implementing agent:** this spec is self-contained. You should be able to read it top-to-bottom, open the files it cites, and implement the change without needing additional context. Everything you need to understand the existing system is in **§2 Background**. Everything you need to build is in **§4 Design**, **§5 File Inventory**, and **§6 Implementation Order**. If anything in this spec disagrees with the code you find, trust the code and flag the discrepancy before proceeding.
## 1. Summary
Today a [criteria set](/docs/features/response-system) scores one thing: the entity it is attached to. This spec adds an optional **scope** to criteria sets that lets them score **each connection in a relation field** instead. When a criteria set is scoped to a relation field, the response form renders a matrix — one row per connected entity, one column per dimension — and the submitted `entity_responses.values` becomes a two-level map keyed by the connected entity ID.
**Example use cases this unlocks:**
- *"For this product idea, rate each of these 5 target markets on market fit (1-5) and opportunity size (1-5)."*
- *"For this product brief, leave rich-text feedback on each of the 4 feature ideas."*
- *"For this candidate, score each reviewer's evaluation dimensions."*
- Team polling: ship an entity to several colleagues, each submits a matrix response, aggregate later.
**Non-goals (deferred to follow-ups):**
- Promoting per-edge scores back onto `entity_relations.metadata.scores` (we write to `entity_responses` only, for now).
- Aggregation visualisations (heat maps, per-edge progression charts).
- Deprecating the `relation-rank` dimension type that shipped in PR #587 — it keeps working unchanged.
- Cross-entity or cross-tenant ranking.
**Shape of the change:** one new nullable column on `criteria_sets`, one new top-level type (`CriteriaSetScope`), one new form component (`EdgeMatrixForm`), and branched logic in existing validators / scorers / the `submitResponse` AI tool. **No new tables**, **no new dimension types**, **no new block types**, **no new AI tools**.
## 2. Background — what exists today
A coding agent coming in cold needs this map before touching anything. Every bullet is based on the actual code in the repo as of `dev` + PR #587 (feat/relations-first-class).
### 2.1 Criteria sets
**Table:** `criteria_sets` — defined initially in [`supabase/migrations/20260322100000_criteria_sets_and_entity_responses.sql`](../../../../supabase/migrations/20260322100000_criteria_sets_and_entity_responses.sql), with incremental migrations since. Columns relevant to this spec:
```
id uuid PK
tenant_id uuid FK
slug text (unique per tenant)
name, description text
kind text CHECK (kind IN ('record','assessment','temporal'))
entity_type_slugs text[] -- which entity types this set applies to
is_default boolean -- used when an entity has no explicit CS
dimensions jsonb -- array of CriteriaSetDimension
promotion_mode text -- 'auto' | 'manual'
aggregate_display text -- 'bar' | 'radar' | 'table' | 'trend'
temporal_key text -- for temporal aggregation
```
**TypeScript type:** `CriteriaSetRecord` in `features/responses/types.ts:107`.
```ts
export type CriteriaSetRecord = Omit<
Tables<"criteria_sets">,
"kind" | "dimensions" | "promotion_mode" | "is_default" | "aggregate_display"
> & {
kind: CriteriaSetKind;
dimensions: CriteriaSetDimension[];
is_default: boolean;
aggregate_display: string;
promotion_mode: "auto" | "manual";
};
```
**Dimension type:** `CriteriaSetDimension` in `features/responses/types.ts:60`. The dimension types supported today, from `DIMENSION_TYPES` (line 39):
```ts
export const DIMENSION_TYPES = [
"number",
"text",
"rating",
"select",
"richtext",
"relation-rank", // added in PR #587 — ranks a list of related entities
] as const;
```
A dimension has `key`, `label`, `type`, optional `scale`, `step`, `options`, `weight`, `description`, `fieldName`, `required`, `extraction`, and (for `relation-rank` only) `relation`. **This spec does not change the dimension type enum.** We reuse `number`, `rating`, `text`, `richtext`, `select` as-is.
### 2.2 Response submission path
**Server entrypoint:** `finalizeEntityResponseAdmin` in `features/responses/server/actions.ts:864`. Signature:
```ts
export async function finalizeEntityResponseAdmin(
input: ResponseSubmitInput & {
tenant_id: string;
auto_promote_fields?: string[];
reviewer_id?: string | null;
},
): Promise<{
response: EntityResponseRecord;
promotedFields: string[];
}>
```
where `ResponseSubmitInput` (same file, line 39) is:
```ts
interface ResponseSubmitInput {
entity_id: string;
criteria_set_id: string;
source: ResponseSource;
values: Record<string, unknown>;
field_meta?: Record<string, unknown>;
submitted_by_user?: string;
submitted_by_agent?: string;
notes?: string;
parent_response_id?: string;
temporal_value?: string;
metadata?: Record<string, unknown>;
}
```
**The finalizer calls `insertEntityResponse` (line 418) which calls `prepareResponseInsert` (line 374).** `prepareResponseInsert` is the key choke point:
```ts
async function prepareResponseInsert(
client: DbClient,
tenantId: string,
criteriaSetId: string,
values: Record<string, unknown>,
): Promise<PreparedResponseInsert> {
// 1. Load the criteria set from DB
// 2. Run validateResponseValues(values, dimensions) → throws on bad input
// 3. computeResponseScore(normalized, dimensions) → weighted + normalized
// 4. Returns { criteriaSet, normalizedValues, weighted, normalized }
}
```
**Validator:** `validateResponseValues` in `features/responses/validation.ts:74`. Per-dimension logic in `validateValueForDimension` (line 35). Treats missing/empty as OK unless `dim.required`. **Does not yet know about `relation-rank`** — that's an existing gap noted in PR #587 but out of scope here.
**Score computation:** `computeResponseScore` in `features/responses/types.ts:237`. Walks dimensions, ignores non-numeric, weights the rest, returns `{ weighted, normalized }`. **Unchanged by this spec for entity-scoped sets.** For edge-scoped sets we compute one score per edge then take the mean.
**API route:** `POST /api/entities/{entityId}/responses` — backed by the hook in `features/responses/components/response-form.tsx:111`. The route validates the body with Zod and calls `finalizeEntityResponseAdmin`.
**AI tool:** `submitResponse` in `features/tools/response-tools.ts:30`. Schema (line 43):
```ts
z.object({
entityId: z.string().uuid(),
criteriaSetId: z.string().uuid().optional(),
criteriaSetSlug: z.string().optional(),
values: z.record(z.string(), z.unknown()),
notes: z.string().optional(),
source: z.enum(RESPONSE_SOURCES).optional().default("manual"),
})
```
Resolves the criteria set ID (or falls back to the default CS for the entity type), then calls `finalizeEntityResponseAdmin`.
### 2.3 Entity relations after PR #587
**Table:** `entity_relations`. Relevant columns:
```
id uuid PK
tenant_id uuid
from_entity_id uuid
to_entity_id uuid
relationship_type text -- e.g. "owned_by", "target_markets"
metadata jsonb -- { field?, rank?, source? }
```
**Metadata shape:** `EntityRelationMetadata` in `features/entities/lib/relation-fields.ts:31`:
```ts
export interface EntityRelationMetadata {
field?: string; // field key on the source entity type
rank?: number; // 0-indexed when field is rankable
source?: "manual" | "ai" | "extraction" | "response";
}
```
Partial indexes (migration `20260408180000_entity_relations_field_metadata.sql`) exist on `(tenant_id, from_entity_id, metadata->>'field')` and the reverse, so "give me all relations for field X on entity Y" is an indexed lookup.
**Canonical read path:** `getRelationsForField` in `features/entities/server/relations.ts:243`:
```ts
export async function getRelationsForField(
supabase: SupabaseClient,
entityId: string,
tenantId: string,
fieldKey: string,
field: ResolvedRelationField | undefined,
): Promise<string[]>
```
Returns an ordered array of `to_entity_id` for the given source entity and field. Handles new-style rows (matched by `metadata.field === fieldKey`) and legacy rows (matched by `relationship_type` when metadata.field is unset). Sorted by `metadata.rank` (ascending; unranked pushed to the end).
**Relation field configuration:** `ResolvedRelationField` and `getRelationFieldMap` in `features/entities/lib/relation-fields.ts`. Given an entity type, returns a `Map<fieldKey, ResolvedRelationField>`. Each resolved entry has:
```ts
{
key: string, // "target_markets"
label: string,
description?: string,
relation: {
targetTypeSlug: string,
relationshipType?: string,
multiple?: boolean,
rankable?: boolean,
createInline?: boolean,
filter?: { tags?, typeSlugs?, contentEq? },
max?: number,
},
}
```
**This is how we'll know which relation fields a criteria set can be scoped to.**
### 2.4 Response form
`ResponseForm` component in `features/responses/components/response-form.tsx`. Current structure:
- Lines 54–72: local state (`values`, `notes`, `submitting`) initialised via `getDefaultValue` per dimension.
- Lines 77–123: `handleSubmit` — validates required, POSTs to `/api/entities/{id}/responses`, calls `onSubmitted`.
- Lines 129–136: renders one `DimensionInput` per dimension.
- Lines 174–290: `DimensionInput` — a big switch rendering one of `number`, `rating`, `text`, `richtext`, `select`, `relation-rank`.
**The fix from PR #587 just introduced `isEmptyDimensionValue` (lines 63–71).** Reuse it.
### 2.5 Response form block
`features/blocks/components/response-form-block.tsx` exports `ResponseFormBlockView` and `ResponseFormBlockEdit`. Edit mode mounts a `<ResponseForm />` inline. Data shape it receives:
```ts
interface ResponseFormBlockData {
criteriaSet: CriteriaSetRecord | null;
responses: EntityResponseRecord[];
latestScore: number | null;
}
```
It doesn't care about scope today. We'll branch inside `ResponseForm` rather than inside the block so the block stays generic.
### 2.6 Criteria set editor
`features/responses/components/criteria-set-editor.tsx` (701 lines). This is the admin UI for creating/editing criteria sets. It already lets you pick `entity_type_slugs[]` and configure dimensions. We'll add a "Scope" selector section.
### 2.7 The `relation-rank` dimension from PR #587
PR #587 introduced `relation-rank` as a dimension type for scoring a single relation field with a rank order. It's a degenerate case of what this spec builds — one dimension, one implicit "rank" behaviour, hard-coded to a single relation field. **Do not touch it in this PR.** Both systems coexist; a future pass can converge `relation-rank` onto scoped criteria sets, but that is not this spec.
---
## 3. Problem — in precise terms
We want to answer this user request: *"For this [source entity], evaluate each of these [related entities] on [criteria]."*
Today the only way to express that is:
- Hand-roll a custom tool per workflow.
- Submit one response per related entity (N API calls, disconnected from each other, painful to aggregate).
- Abuse `relation-rank` to at least order them, but you cannot attach qualitative feedback to each one.
None of those reuse the response/criteria primitive. They recreate scoring surface area. The goal is: **express edge evaluation as a single criteria set that already lives in the primitive the platform is built around.**
## 4. Design
### 4.1 The scope concept
A criteria set gains one optional field:
```ts
// features/responses/types.ts
/**
* What a criteria set evaluates.
*
* - "entity" (default): the set scores the entity it's attached to. The
* response's `values` map has one value per dimension.
* - "relation": the set scores each connection in the entity's relation
* field. The response's `values` map is two-level: keyed first by the
* connected entity ID, then by dimension key.
*/
export type CriteriaSetScope =
| { type: "entity" }
| { type: "relation"; fieldKey: string };
/** True when the scope is a per-edge (relation) scope. */
export function isRelationScope(
scope: CriteriaSetScope | null | undefined,
): scope is { type: "relation"; fieldKey: string } {
return !!scope && scope.type === "relation";
}
```
And `CriteriaSetRecord` gains the field:
```ts
export type CriteriaSetRecord = Omit<
Tables<"criteria_sets">,
| "kind"
| "dimensions"
| "promotion_mode"
| "is_default"
| "aggregate_display"
| "scope" // NEW: overridden below
> & {
kind: CriteriaSetKind;
dimensions: CriteriaSetDimension[];
is_default: boolean;
aggregate_display: string;
promotion_mode: "auto" | "manual";
/** NEW. Null or { type: "entity" } means "score the entity itself". */
scope: CriteriaSetScope | null;
};
```
Reading convention throughout the codebase:
- `scope == null` **or** `scope.type === "entity"` → legacy behaviour, no code path changes.
- `scope.type === "relation"` → new matrix behaviour.
### 4.2 Data model — migration
**New migration file:** `supabase/migrations/{YYYYMMDDHHMMSS}_criteria_sets_scope.sql` where the timestamp is a fresh one greater than the latest existing migration (as of writing, the latest is `20260408180000_entity_relations_field_metadata.sql`). The implementer should pick a new timestamp the moment they create the file.
```sql
-- Edge scoring: scoped criteria sets
--
-- A criteria set can be scoped to a relation field on the source entity
-- type. When scoped, the response form renders one row per connection
-- and the entity_responses.values map is keyed first by connected entity
-- id, then by dimension key.
--
-- Null scope = legacy (evaluate the entity itself).
ALTER TABLE criteria_sets
ADD COLUMN IF NOT EXISTS scope jsonb;
COMMENT ON COLUMN criteria_sets.scope IS
'Optional scoping contract. Null = entity scope. { "type": "relation", "fieldKey": "..." } = edge scope over a relation field on the source entity type.';
-- Lightweight check: if present, must have a type field. Keep it loose —
-- we validate the full shape in application code so future scope kinds
-- (e.g. "collection", "computed") don't require another migration.
ALTER TABLE criteria_sets
ADD CONSTRAINT criteria_sets_scope_shape
CHECK (
scope IS NULL
OR (jsonb_typeof(scope) = 'object' AND scope ? 'type')
);
-- Partial index for the common query "list scoped sets for a type":
-- used by the criteria set editor when resolving which relation fields
-- are available on a given entity type.
CREATE INDEX IF NOT EXISTS idx_criteria_sets_scoped
ON criteria_sets(tenant_id)
WHERE scope IS NOT NULL;
```
**Down migration** (in a comment at the bottom of the same file, following the migration convention):
```sql
-- down:
-- DROP INDEX IF EXISTS idx_criteria_sets_scoped;
-- ALTER TABLE criteria_sets DROP CONSTRAINT IF EXISTS criteria_sets_scope_shape;
-- ALTER TABLE criteria_sets DROP COLUMN IF EXISTS scope;
```
**After migration: run `pnpm db:types`** to regenerate `lib/supabase/database.types.ts`. The generated type for `criteria_sets.scope` will be `Json | null`, which is why the `CriteriaSetRecord` override in §4.1 tightens it to `CriteriaSetScope | null`.
### 4.3 `entity_responses.values` shape by scope
**Entity-scoped (unchanged):**
```json
{
"market_fit": 8,
"notes": "Looks strong overall"
}
```
**Relation-scoped (new):**
```json
{
"entity-market-enterprise-uuid": {
"market_fit": 4,
"notes": "<p>Perfect alignment — they already buy adjacent products.</p>"
},
"entity-market-healthcare-uuid": {
"market_fit": 2,
"notes": "<p>Regulatory burden; revisit after ISO 13485.</p>"
}
}
```
Keys are UUIDs of connected entities; each inner value is the same per-dimension shape you'd have for an unscoped response.
The reader/writer distinguishes by checking the owning criteria set's `scope` field. **The `entity_responses` table itself doesn't care** — `values jsonb` accepts either shape. We never mix the shapes in a single response.
### 4.4 Validation
Extend the validator in `features/responses/validation.ts`. Add a new export:
```ts
import type { CriteriaSetRecord } from "./types";
import { isRelationScope } from "./types";
/**
* Validate response values against a criteria set, respecting scope.
*
* - Entity scope: same as validateResponseValues (one value per dim).
* - Relation scope: `rawValues` must be a Record<entityId,
* Record<dimKey, value>>. Each inner map is validated with the
* existing per-dimension validator.
*
* `connectedIds` (relation scope only) is the authoritative set of
* connected entity IDs resolved at validation time. Keys in `rawValues`
* that are not in `connectedIds` are rejected (stale submission); keys
* in `connectedIds` that are missing from `rawValues` are allowed
* (partial submission) unless a dimension is `required`.
*/
export function validateScopedResponseValues(
rawValues: Record<string, unknown>,
criteriaSet: Pick<CriteriaSetRecord, "dimensions" | "scope">,
connectedIds?: string[],
): Record<string, unknown> {
if (!isRelationScope(criteriaSet.scope)) {
return validateResponseValues(rawValues, criteriaSet.dimensions);
}
if (!connectedIds) {
throw new Error(
"validateScopedResponseValues: connectedIds required for relation scope",
);
}
const connectedSet = new Set(connectedIds);
const normalized: Record<string, Record<string, unknown>> = {};
// Reject keys that aren't in the current connection set (likely a
// stale client that hasn't refetched after the source entity's
// relations changed).
for (const entityId of Object.keys(rawValues)) {
if (!connectedSet.has(entityId)) {
throw new Error(
`Unknown connected entity "${entityId}" — the relation may have changed since this form loaded`,
);
}
}
// Validate every connected entity's sub-map via the existing
// per-dimension validator. Missing entries are allowed UNLESS any
// dimension is required, in which case the entry must contain every
// required dimension.
const hasRequired = criteriaSet.dimensions.some((d) => d.required);
for (const entityId of connectedIds) {
const raw = rawValues[entityId];
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
if (hasRequired) {
throw new Error(
`Missing required scores for "${entityId}"`,
);
}
continue;
}
normalized[entityId] = validateResponseValues(
raw as Record<string, unknown>,
criteriaSet.dimensions,
);
}
return normalized;
}
```
Co-locate unit tests in `features/responses/validation.test.ts`.
### 4.5 Edge-scope helper module (NEW)
Create `features/responses/server/edge-scope.ts` containing the single place where we resolve "which connected entities belong to this scoped set, right now?" This module is called from the server action and from the AI tool — both need the same answer.
```ts
"use server";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { CriteriaSetRecord } from "../types";
import { isRelationScope } from "../types";
import { getRelationsForField } from "@/features/entities/server/relations";
import { getRelationFieldMap } from "@/features/entities/lib/relation-fields";
import { getCachedEntityTypeBySlug } from "@/features/entities/server/cached-queries";
/**
* Resolve the connected entity IDs that a scoped criteria set will
* score when submitted against `entityId`. Returns null when the
* criteria set is entity-scoped (callers treat this as "no edge scope
* — validate normally").
*
* Errors when:
* - criteriaSet is relation-scoped but the target entity type has no
* relation field by that key.
* - criteriaSet is relation-scoped and the entity type slug cannot be
* resolved.
*/
export async function resolveScopedConnectedIds(
client: SupabaseClient,
tenantId: string,
entityId: string,
entityTypeSlug: string,
criteriaSet: Pick<CriteriaSetRecord, "scope">,
): Promise<string[] | null> {
if (!isRelationScope(criteriaSet.scope)) return null;
const entityType = await getCachedEntityTypeBySlug(tenantId, entityTypeSlug);
if (!entityType) {
throw new Error(`Entity type "${entityTypeSlug}" not found`);
}
const fieldMap = getRelationFieldMap(entityType);
const field = fieldMap.get(criteriaSet.scope.fieldKey);
if (!field) {
throw new Error(
`Criteria set is scoped to relation field "${criteriaSet.scope.fieldKey}" but that field does not exist on entity type "${entityTypeSlug}"`,
);
}
return getRelationsForField(
client,
entityId,
tenantId,
criteriaSet.scope.fieldKey,
field,
);
}
```
Co-locate `features/responses/server/edge-scope.test.ts`.
### 4.6 Write path — `prepareResponseInsert`
Modify `prepareResponseInsert` in `features/responses/server/actions.ts:374`:
1. Select `scope` in addition to `id, dimensions, kind` from `criteria_sets`.
2. Cast `scope` through the new `CriteriaSetScope` shape. Treat legacy `null` as entity scope.
3. If `isRelationScope(scope)`, call `resolveScopedConnectedIds` before validating. Fetch the source entity's type slug first (one extra `entities` query by id).
4. Call `validateScopedResponseValues` instead of `validateResponseValues`. Pass `connectedIds` when present.
5. Compute scores: for entity scope, unchanged. For relation scope, call `computeResponseScoreMean` (see §4.7).
6. Stamp `metadata.scope_snapshot` on the response so readers know the shape without having to re-fetch the criteria set (see §4.8).
Pseudocode (fits in the existing `prepareResponseInsert`, not a rewrite):
```ts
async function prepareResponseInsert(client, tenantId, criteriaSetId, values, opts: { entityId: string }) {
const { data: cs } = await client
.from("criteria_sets")
.select("id, dimensions, kind, scope")
.eq("id", criteriaSetId)
.eq("tenant_id", tenantId)
.maybeSingle();
if (!cs) throw new Error("Criteria set not found");
const scope: CriteriaSetScope | null = (cs.scope as CriteriaSetScope | null) ?? null;
const dimensions = (cs.dimensions ?? []) as CriteriaSetDimension[];
let connectedIds: string[] | null = null;
if (isRelationScope(scope)) {
const { data: entity } = await client
.from("entities")
.select("entity_type_slug")
.eq("id", opts.entityId)
.eq("tenant_id", tenantId)
.maybeSingle();
if (!entity) throw new Error("Entity not found");
connectedIds = await resolveScopedConnectedIds(
client,
tenantId,
opts.entityId,
entity.entity_type_slug,
{ scope },
);
}
const normalizedValues = validateScopedResponseValues(
values,
{ dimensions, scope },
connectedIds ?? undefined,
);
const { weighted, normalized } = isRelationScope(scope)
? computeResponseScoreMean(normalizedValues, dimensions)
: computeResponseScore(normalizedValues, dimensions);
return {
criteriaSet: { id: cs.id, dimensions, kind: cs.kind as CriteriaSetKind, scope },
normalizedValues,
weighted,
normalized,
};
}
```
`prepareResponseInsert` currently doesn't receive the entity id. Callers have it. Update the caller chain (`insertEntityResponse` → `prepareResponseInsert`) to pass it through.
### 4.7 Score computation — `computeResponseScoreMean`
Add alongside `computeResponseScore` in `features/responses/types.ts:237`:
```ts
/**
* Compute the mean weighted/normalized score across all edges in a
* relation-scoped response. `values` here is
* Record<connectedEntityId, Record<dimKey, value>>. We compute the
* entity-scoped score for each edge, then average across edges.
*
* If no edges have any scoreable values, returns { weighted: null,
* normalized: null }.
*/
export function computeResponseScoreMean(
values: Record<string, unknown>,
dimensions: CriteriaSetDimension[],
): { weighted: number | null; normalized: number | null } {
const perEdge = Object.values(values).map((inner) =>
computeResponseScore(
(inner ?? {}) as Record<string, unknown>,
dimensions,
),
);
const valid = perEdge.filter(
(s): s is { weighted: number; normalized: number } =>
s.weighted != null && s.normalized != null,
);
if (valid.length === 0) return { weighted: null, normalized: null };
return {
weighted: valid.reduce((sum, s) => sum + s.weighted, 0) / valid.length,
normalized: valid.reduce((sum, s) => sum + s.normalized, 0) / valid.length,
};
}
```
### 4.8 Response metadata — scope snapshot
To make responses self-descriptive even if the criteria set changes later, write a scope snapshot onto `entity_responses.metadata`:
```ts
// in insertEntityResponse, when inserting the row:
metadata: {
...(input.metadata ?? {}),
scope_snapshot: scope ?? { type: "entity" },
}
```
Readers (aggregation, history, comparison views) should prefer `metadata.scope_snapshot` over re-fetching the criteria set. This matches the existing pattern of `criteria_snapshot` (which freezes dimensions at submission time).
### 4.9 Response form — branching on scope
`ResponseForm` in `features/responses/components/response-form.tsx` currently renders one `DimensionInput` per dimension. Add a branch at the top of the component body:
```tsx
export function ResponseForm({ criteriaSet, entityId, ...rest }: ResponseFormProps) {
if (isRelationScope(criteriaSet.scope)) {
return <EdgeMatrixForm criteriaSet={criteriaSet} entityId={entityId} {...rest} />;
}
// ... existing entity-scoped form below
}
```
The matrix form lives in a new file `features/responses/components/edge-matrix-form.tsx`. Sketch:
```tsx
"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { DimensionInput } from "./dimension-input"; // NEW — see §4.9.1
import type {
CriteriaSetRecord,
EntityResponseRecord,
ResponseSource,
} from "../types";
import { isEmptyDimensionValue } from "./response-form";
interface Props {
criteriaSet: CriteriaSetRecord; // must have scope.type === "relation"
entityId: string;
source?: ResponseSource;
parentResponseId?: string;
onSubmitted?: (r: EntityResponseRecord) => void;
onCancel?: () => void;
}
interface ConnectionSummary {
id: string;
title: string;
typeName?: string | null;
}
export function EdgeMatrixForm({ criteriaSet, entityId, onSubmitted, onCancel, source = "manual", parentResponseId }: Props) {
// 1. Fetch connected entities for the scoped relation field.
// Hit GET /api/entities/{entityId}/connections?field={fieldKey}
// (EXISTING route if it exists — if not, add a lightweight one;
// see §4.9.2).
// 2. Initialize row state: Record<connectionId, Record<dimKey, unknown>>.
// 3. For each connection, render a row with a header (connection title)
// and one DimensionInput per dimension (reuse from §4.9.1).
// 4. On submit, POST /api/entities/{entityId}/responses with the full
// matrix as `values`. The server does the real validation + scoring.
const [connections, setConnections] = useState<ConnectionSummary[] | null>(null);
const [rows, setRows] = useState<Record<string, Record<string, unknown>>>({});
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await fetch(
`/api/entities/${entityId}/connections?field=${encodeURIComponent(
(criteriaSet.scope as { type: "relation"; fieldKey: string }).fieldKey,
)}`,
);
if (!res.ok) throw new Error(`Failed to load connections (${res.status})`);
const data: ConnectionSummary[] = await res.json();
if (cancelled) return;
setConnections(data);
// seed rows with defaults
setRows(
Object.fromEntries(
data.map((c) => [
c.id,
Object.fromEntries(
criteriaSet.dimensions.map((d) => [d.key, getDefaultValue(d)]),
),
]),
),
);
} catch (err) {
setLoadError(err instanceof Error ? err.message : "Load failed");
}
})();
return () => { cancelled = true; };
}, [entityId, criteriaSet]);
function updateCell(connectionId: string, dimKey: string, value: unknown) {
setRows((prev) => ({
...prev,
[connectionId]: { ...prev[connectionId], [dimKey]: value },
}));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
// Client-side required validation — same rules as the entity form.
for (const [connId, values] of Object.entries(rows)) {
for (const dim of criteriaSet.dimensions) {
if (dim.required && isEmptyDimensionValue(values[dim.key])) {
const conn = connections?.find((c) => c.id === connId);
toast.error(`"${dim.label}" is required for ${conn?.title ?? connId}`);
return;
}
}
}
setSubmitting(true);
try {
const body: Record<string, unknown> = {
criteriaSetId: criteriaSet.id,
values: rows,
source,
};
if (notes.trim()) body.notes = notes.trim();
if (parentResponseId) body.parentResponseId = parentResponseId;
const res = await fetch(`/api/entities/${entityId}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(err?.error ?? "Failed to submit response");
}
const response: EntityResponseRecord = await res.json();
toast.success("Response submitted");
onSubmitted?.(response);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit response");
} finally {
setSubmitting(false);
}
}
if (loadError) {
return <p className="text-sm text-destructive py-4">{loadError}</p>;
}
if (connections == null) {
return (
<p className="text-sm text-muted-foreground py-4 flex items-center gap-2">
<Loader2 className="size-3.5 animate-spin" /> Loading connections…
</p>
);
}
if (connections.length === 0) {
return (
<p className="text-sm text-muted-foreground py-4">
No connections yet. Add something to this relation field before
submitting an evaluation.
</p>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<h3 className="text-sm font-semibold">{criteriaSet.name}</h3>
{criteriaSet.description && (
<p className="text-xs text-muted-foreground mt-1">{criteriaSet.description}</p>
)}
</div>
<div className="space-y-4">
{connections.map((conn) => (
<div key={conn.id} className="rounded-md border p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{conn.title}</span>
{conn.typeName && (
<span className="text-[11px] text-muted-foreground">{conn.typeName}</span>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
{criteriaSet.dimensions.map((dim) => (
<DimensionInput
key={dim.key}
dimension={dim}
value={rows[conn.id]?.[dim.key]}
onChange={(val) => updateCell(conn.id, dim.key, val)}
/>
))}
</div>
</div>
))}
</div>
<div className="space-y-1.5">
<Label className="text-sm">Notes (overall)</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional overall notes..."
rows={2}
className="text-sm"
/>
</div>
<div className="flex items-center gap-2 pt-1">
{onCancel && (
<Button type="button" variant="outline" size="sm" onClick={onCancel} disabled={submitting}>
Cancel
</Button>
)}
<Button type="submit" size="sm" disabled={submitting}>
{submitting && <Loader2 className="size-3.5 animate-spin mr-1" />}
Submit Response
</Button>
</div>
</form>
);
}
```
**Keep `EdgeMatrixForm` under 200 lines.** If it grows past that, extract the per-connection row into `EdgeMatrixRow`.
#### 4.9.1 Extract `DimensionInput`
The existing `DimensionInput` lives inside `response-form.tsx` as an internal function (line 174). Extract it to a new file `features/responses/components/dimension-input.tsx` so both `ResponseForm` and `EdgeMatrixForm` can import it. **Do not duplicate the logic.** Net change: the same component, just exported from its own file. Add a co-located test stub if there isn't one already.
#### 4.9.2 Connections endpoint for the form
The matrix form needs a lightweight endpoint that returns just the connected entities for a given (source entity, field key). Check if one already exists:
1. `GET /api/entities/{id}/connections` — **if it exists**, confirm it accepts a `field` query param and returns `{ id, title, typeName }[]` per connection. If not, add the param.
2. **If the route doesn't exist**, add it:
```ts
// app/api/entities/[id]/connections/route.ts
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
createRouteHandler,
jsonResponse,
parseSearchParams,
} from "@/lib/api-route";
import { createClient } from "@/lib/supabase/server";
import { getActiveTenantId } from "@/features/tenant/context";
import { getRelationsForField } from "@/features/entities/server/relations";
import { getCachedEntityTypeBySlug } from "@/features/entities/server/cached-queries";
import { getRelationFieldMap } from "@/features/entities/lib/relation-fields";
const Schema = z.object({
field: z.string().min(1),
});
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
return createRouteHandler({
auth: "auth",
parse: parseSearchParams(Schema),
errorMessage: "Failed to load connections",
handler: async ({ input }) => {
const { id: entityId } = await ctx.params;
const tenantId = await getActiveTenantId();
const supabase = await createClient();
const { data: source } = await supabase
.from("entities")
.select("id, entity_type_slug")
.eq("id", entityId)
.eq("tenant_id", tenantId)
.maybeSingle();
if (!source) return jsonResponse([], 200);
const type = await getCachedEntityTypeBySlug(tenantId, source.entity_type_slug);
const fieldMap = type ? getRelationFieldMap(type) : new Map();
const field = fieldMap.get(input.field);
const ids = await getRelationsForField(supabase, entityId, tenantId, input.field, field);
if (ids.length === 0) return jsonResponse([], 200);
const { data: rows } = await supabase
.from("entities")
.select("id, title, entity_types(name)")
.in("id", ids)
.eq("tenant_id", tenantId);
// Preserve order from getRelationsForField (which respects rank).
const byId = new Map((rows ?? []).map((r) => [r.id, r]));
return jsonResponse(
ids
.map((id) => {
const row = byId.get(id);
if (!row) return null;
const typeRow = Array.isArray(row.entity_types) ? row.entity_types[0] : row.entity_types;
return { id: row.id, title: row.title, typeName: typeRow?.name ?? null };
})
.filter(Boolean),
);
},
})(req);
}
```
Add a co-located `route.test.ts`.
### 4.10 Criteria set editor — scope UI
In `features/responses/components/criteria-set-editor.tsx` add a section after the entity type selector:
```
Scope [ This record ▼ ]
Options:
- This record (default)
- Each connection in: [ field dropdown ]
```
The field dropdown is populated by looking up the first entity type in `entity_type_slugs`, calling `getRelationFieldMap`, and listing keys. If multiple entity types are selected, disable the scope selector and show a hint: *"Scoped criteria sets must target a single record type."*
The rendered dimensions section should NOT change — the same dimension editor is used. Add a small banner above the dimensions list when scope is relation: *"Dimensions below will be collected once per connection in the chosen field."*
Persist `scope` alongside the rest of the criteria set when saving. The save path is an existing API (follow the pattern already in the file — if the file does `manageCriteriaSet`, pass `scope` through).
### 4.11 AI tool — `submitResponse`
Modify `features/tools/response-tools.ts`:
1. **Schema docs** (not structure) should explain the two shapes of `values`:
```
Dimension values.
- For entity-scoped criteria sets (the default): {
"dimension_key": value, ...
}
- For relation-scoped criteria sets: {
"connected_entity_uuid": { "dimension_key": value, ... },
...
}
Call listEntityTypes({ detailed: true }) to see which criteria sets
are scoped and which relation field they target.
```
2. **Behaviour stays the same.** The tool calls `finalizeEntityResponseAdmin`, which now handles both shapes internally via `prepareResponseInsert`. No code change beyond the schema description.
3. **Tests** — add a new test case that submits a matrix response with a mocked relation-scoped criteria set and asserts `finalizeEntityResponseAdmin` is called with the matrix values. Reuse `captureTool` / the existing mock pattern from `response-tools.test.ts`.
### 4.12 Edge cases
| Case | Behaviour |
|---|---|
| Scoped criteria set, source entity has zero connections in the field | Form shows "No connections yet" message. Submit is not offered. The AI tool returns `{ error: "No connections in field {field} to score" }` before calling the finalizer. |
| Relations change between form load and submit | Server validates against live `connectedIds`. Extra keys in `values` (stale connections) are rejected with a clear error; missing keys are allowed unless a dimension is required. |
| Dimensions list changes between form load and submit | The criteria snapshot on the response preserves what was actually collected. Existing behaviour — no change. |
| Required dimension and some connections have no value | `validateScopedResponseValues` throws `Missing required scores for "{entityId}"`. Client catches and toasts. |
| Multiple entity types on a scoped set | Disallowed at the editor UI. If it slips through, server validation at `resolveScopedConnectedIds` errors with a clear message. |
| `relation-rank` dimension inside a scoped set | Disallowed at the editor UI. The matrix form does not render `relation-rank` cells (it's redundant — the scope already lists each connection). If it slips through, `DimensionInput` short-circuits with a `"relation-rank inside a scoped criteria set is not supported"` warning in dev mode. Leave production behaviour permissive but ignored. |
| Existing code that reads `values` assuming a flat map | Anything that reads `response.values[dimKey]` must now check the scope snapshot first. The explicit touchpoints are listed in §5 under "Callers to audit". |
### 4.13 Backwards compatibility
- Old criteria sets have `scope = NULL`. They keep working because every branch in the new code treats null as entity scope.
- Old responses have no `metadata.scope_snapshot`. Readers should fall back to `{ type: "entity" }` when the field is absent.
- The `relation-rank` dimension type is untouched.
---
## 5. File inventory
Organized by "touch" or "create". Paths are relative to repo root. Every new file needs a co-located `.test.ts` per `.claude/rules/testing.md`.
### 5.1 Create
| File | Purpose |
|---|---|
| `supabase/migrations/{ts}_criteria_sets_scope.sql` | Add `scope jsonb` column + shape check + partial index. |
| `features/responses/server/edge-scope.ts` | `resolveScopedConnectedIds`. |
| `features/responses/server/edge-scope.test.ts` | Tests — entity scope returns null; relation scope calls `getRelationsForField`; errors when field missing. |
| `features/responses/components/edge-matrix-form.tsx` | Matrix form component. |
| `features/responses/components/edge-matrix-form.test.ts` | Tests — renders loading / empty / populated matrix, required validation, submit body shape, stale connection rejection. |
| `features/responses/components/dimension-input.tsx` | Extracted `DimensionInput` (shared by entity + matrix forms). |
| `features/responses/components/dimension-input.test.ts` | Tests — each dimension type renders correctly. |
| `app/api/entities/[id]/connections/route.ts` | **Only if the route doesn't already exist.** Confirm first. |
| `app/api/entities/[id]/connections/route.test.ts` | Same. |
### 5.2 Modify
| File | Change |
|---|---|
| `features/responses/types.ts` | Add `CriteriaSetScope` + `isRelationScope` + add `scope` to `CriteriaSetRecord`. Add `computeResponseScoreMean`. |
| `features/responses/types.test.ts` | Add unit tests for `isRelationScope` and `computeResponseScoreMean`. |
| `features/responses/validation.ts` | Add `validateScopedResponseValues`. Do not modify `validateResponseValues`. |
| `features/responses/validation.test.ts` | Add tests for entity-scope passthrough, matrix happy path, stale keys rejected, required per-edge. |
| `features/responses/server/actions.ts` | Wire `scope` through `prepareResponseInsert` → `insertEntityResponse` → `finalizeEntityResponseAdmin`. Stamp `metadata.scope_snapshot`. Thread entity id through the call chain. Select `scope` in the criteria-set query. |
| `features/responses/server/actions.test.ts` | Add tests: scoped finalize calls `resolveScopedConnectedIds`, validates via matrix path, stamps snapshot, computes mean. |
| `features/responses/components/response-form.tsx` | Branch at top to render `EdgeMatrixForm` when scope is relation. Export `getDefaultValue` (already done) and `isEmptyDimensionValue` (already done in PR #587) — no change to the existing function bodies. Import the now-extracted `DimensionInput`. |
| `features/responses/components/response-form.test.ts` | Add test: given relation-scoped CS, `ResponseForm` renders the matrix delegate. |
| `features/responses/components/criteria-set-editor.tsx` | Add scope section (radio + field dropdown). Disable when multiple entity types selected. Persist `scope` on save. |
| `features/responses/components/criteria-set-editor.test.ts` | Add tests: scope section renders, disables on multi-type, resets to null when scope cleared. |
| `features/blocks/components/response-form-block.tsx` | No behavioural change — it already mounts `ResponseForm` which now branches internally. Add a test asserting it passes through to matrix form when scope is set. |
| `features/tools/response-tools.ts` | Update `values` field description in the Zod schema to document the matrix shape. No code change to `execute`. |
| `features/tools/response-tools.test.ts` | Add test: scoped CS + matrix values → `finalizeEntityResponseAdmin` called with matrix shape. |
| `content/docs/features/response-system.mdx` | Document scoped criteria sets. If no `response-system.mdx` exists yet, create it and add its slug to `content/docs/features/meta.json`. If it does exist, add a "Scoped Criteria Sets (Edge Scoring)" section. |
| `documents/CHANGELOG.md` | New entry (type: feature). |
### 5.3 Callers to audit (read-only — flag any that need updates)
Any code that reads `EntityResponseRecord.values` and assumes a flat map needs to check `metadata.scope_snapshot` first. Grep for these patterns before submitting:
- `response.values[` in `features/responses/**` and `features/blocks/**`
- `values as Record<string, unknown>` in the same areas
- `computeResponseScore(` call sites
Known touchpoints (non-exhaustive):
- `features/responses/components/aggregation-view.tsx` — currently flat-map only. Add a guard to skip scoped responses (they don't aggregate the same way). Follow-up PR will add matrix aggregation.
- `features/responses/components/compare-view.tsx` — same treatment.
- `features/responses/components/timeline-view.tsx` — same treatment.
- `features/responses/components/response-row.tsx` — the per-response row display. For scoped responses, show "N edges scored" instead of per-dimension values.
- `promote_field_value` RPC path in `features/responses/server/actions.ts` — add an early return when the response is relation-scoped (nothing to promote for edge scope in the foundation pass).
**For any caller you touch that's in the above list, add a test case covering the scoped response path.** Callers that are too heavy to update in this PR should instead gain a single defensive check that short-circuits on scoped responses with a TODO comment referencing this spec's follow-up work.
### 5.4 Files explicitly NOT touched
- `features/entities/components/entity-picker/*` — unchanged.
- `features/entities/server/relations.ts` — we only read via the existing `getRelationsForField` export.
- The `relation-rank` dimension renderer inside `response-form.tsx` — leave it alone; it's a separate, older code path that keeps working.
- `supabase/migrations/20260322100000_criteria_sets_and_entity_responses.sql` and every other existing migration — we add a new forward migration; never edit old ones.
---
## 6. Implementation order
Execute in this order — each step is independently committable and leaves the repo green (tests + typecheck + build pass).
1. **Types + migration** — add `CriteriaSetScope`, `scope` on `CriteriaSetRecord`, the migration SQL, run `pnpm db:types`. Add `isRelationScope` and tests. **Does not compile-break anything** — the new field is optional.
2. **Score helper** — add `computeResponseScoreMean` + tests.
3. **Validator** — add `validateScopedResponseValues` + tests.
4. **Edge-scope helper** — add `features/responses/server/edge-scope.ts` + tests (pure function with mocked supabase).
5. **Extract `DimensionInput`** — pull the internal function in `response-form.tsx` into its own file. Update the single import site. **No behaviour change.** Run existing response-form tests to confirm.
6. **Server actions** — wire `scope` through `prepareResponseInsert` / `insertEntityResponse` / `finalizeEntityResponseAdmin`, stamp `scope_snapshot`, thread entity id. Add tests. Both entity-scoped and edge-scoped paths must now work server-side.
7. **AI tool schema docs** — update `values` description in `response-tools.ts` + add tests.
8. **`EdgeMatrixForm` component** — build it. Add tests. At this point edge scoring works end-to-end via API + AI tool + matrix form. Verify a manual flow locally if a browser is available.
9. **Response form branching** — add the `isRelationScope` branch in `ResponseForm` to delegate to `EdgeMatrixForm`.
10. **Connections route** — confirm existing or add new `GET /api/entities/[id]/connections?field=...`. Tests.
11. **Criteria set editor UI** — scope section, persistence. Tests.
12. **Caller audit sweep** — guard aggregation/compare/timeline/row/promotion against scoped responses. Tests for each.
13. **Documentation** — update `content/docs/features/response-system.mdx` and add a `CHANGELOG.md` entry.
14. **Release gate** — `pnpm test && pnpm typecheck && pnpm lint && pnpm build`, then push, PR into `dev`.
After step 8 the feature is functionally complete through the AI tool and the server API. Steps 9–14 are presentation and polish on top of a working core.
---
## 7. Test plan
Every new file ships with a co-located test. The critical test cases:
### 7.1 Types (`features/responses/types.test.ts`)
- `isRelationScope` returns true only for `{ type: "relation" }`; false for `null`, `undefined`, and `{ type: "entity" }`.
- `computeResponseScoreMean`:
- empty matrix → `{ weighted: null, normalized: null }`
- single edge with one numeric dimension → equals the per-edge score
- two edges with equal weights → arithmetic mean
- edges with no scoreable values are skipped entirely
- mixed (some edges scoreable, some not) → mean of the scoreable only
### 7.2 Validator (`features/responses/validation.test.ts`)
- Entity-scope passthrough: calling `validateScopedResponseValues` with `scope = null` returns the same result as `validateResponseValues`.
- Matrix happy path: one required numeric dim, two connected IDs, both supplied → normalized map mirrors input.
- Stale keys rejected: values contain an ID not in `connectedIds` → throws with "Unknown connected entity".
- Missing optional values allowed: one of two connected IDs omitted, no required dims → succeeds.
- Missing required values rejected: omitted connected ID when a required dim exists → throws.
- Per-edge type errors: numeric dim with string value throws via the inner `validateResponseValues`.
### 7.3 Edge scope resolver (`features/responses/server/edge-scope.test.ts`)
- Entity scope → returns `null`.
- Relation scope + missing field key on the entity type → throws.
- Relation scope + missing entity type → throws.
- Happy path → calls `getRelationsForField` and returns the IDs.
### 7.4 Server actions (`features/responses/server/actions.test.ts`)
- `finalizeEntityResponseAdmin` with a null-scope CS behaves exactly as today (regression).
- `finalizeEntityResponseAdmin` with relation-scope CS:
- fetches the entity to get `entity_type_slug`
- calls `resolveScopedConnectedIds`
- validates via `validateScopedResponseValues`
- computes score via `computeResponseScoreMean`
- stamps `metadata.scope_snapshot = { type: "relation", fieldKey }` on the inserted row
- returns `promotedFields: []` (no promotion for edge scope in v1)
### 7.5 AI tool (`features/tools/response-tools.test.ts`)
- Existing entity-scope flow unchanged (regression).
- With a relation-scoped CS, passing `values: { "conn-1": { dim: 4 } }` reaches `finalizeEntityResponseAdmin` unchanged.
- Attempting a matrix submission where the CS is entity-scoped throws a clear error via the validator.
### 7.6 UI — matrix form (`features/responses/components/edge-matrix-form.test.ts`)
- Mount with `connections` stubbed to an empty array → renders "No connections yet".
- Mount with 2 connections + a required numeric dim → renders one row per connection.
- Submitting with one required cell empty → error toast, no fetch.
- Submitting with all cells filled → POSTs `{ values: { "conn-1": { ... }, "conn-2": { ... } }, criteriaSetId, source }` and calls `onSubmitted`.
- Stale connection error from server → toasts the server message.
### 7.7 UI — response form router (`features/responses/components/response-form.test.ts`)
- With a null-scope CS, renders the entity-scoped form (regression).
- With a relation-scope CS, renders the matrix form (assert the delegate component mounted).
### 7.8 UI — criteria set editor (`features/responses/components/criteria-set-editor.test.ts`)
- Scope section is disabled when `entity_type_slugs.length !== 1`.
- Selecting "Each connection in" populates the field dropdown from `getRelationFieldMap` for the selected type.
- Save persists the scope value in the payload.
- Clearing the scope back to "This record" sets `scope = null`.
### 7.9 UI — connections route (if new)
- Returns the ordered ID list via `getRelationsForField`.
- Returns `[]` when the entity has no matching connections.
- Rejects missing `field` query param via Zod.
---
## 8. Acceptance criteria
- [ ] A criteria set in the admin UI can be configured with `Scope: Each connection in [field]` for a single-entity-type set.
- [ ] Saving the set persists `scope: { type: "relation", fieldKey }` to `criteria_sets.scope`.
- [ ] Visiting an entity of the appropriate type, the response form for that set shows one row per current connection in the field, with the criteria set's dimensions rendered as inputs per row.
- [ ] Submitting the form POSTs once and creates a single `entity_responses` row whose `values` is `Record<connectedId, Record<dimKey, value>>`.
- [ ] The response's `metadata.scope_snapshot` equals `{ type: "relation", fieldKey }`.
- [ ] `weighted_score` / `normalized_score` on the response row are the means across edges.
- [ ] AI agents can submit matrix responses via `submitResponse` by passing the same two-level shape as `values`.
- [ ] Validation rejects stale `values` keys (connections that no longer exist) with a clear error.
- [ ] Entity-scoped criteria sets continue to behave identically — every existing response test passes without modification.
- [ ] The `relation-rank` dimension from PR #587 continues to work unchanged.
- [ ] `pnpm test && pnpm typecheck && pnpm lint && pnpm build` all pass.
- [ ] No component exceeds 200 lines without justification.
- [ ] No hardcoded entity type slugs anywhere in `features/` (except `features/custom/`).
---
## 9. Out of scope / deferred
Explicitly NOT in this PR. Each is a natural follow-up.
- **Per-edge promotion to `entity_relations.metadata.scores`.** The plumbing is ready but we don't write it in v1. If/when we do, it goes into `metadata.scores[criteriaSetSlug] = { weighted, normalized, values }`.
- **Matrix aggregation view.** `aggregation-view.tsx` will show "N scoped responses" and link to the detail; it won't produce heat maps, radial charts, or dimension-level rollups yet.
- **Response comparison for matrix responses.** `compare-view.tsx` guards on scope and shows a message.
- **`relation-rank` migration.** A future PR can convert all `relation-rank` dimensions into a scoped criteria set with a single `rank` dimension, then remove the dimension type.
- **Criteria sets scoped to multiple fields at once.** Not supported. Model it as multiple scoped sets if you need it.
- **Criteria sets scoped to a collection or view.** Not supported yet (`CriteriaSetScope` is a discriminated union; adding `{ type: "collection", ... }` later is trivial).
- **Collaborative simultaneous editing of the matrix.** Use the existing `response_sessions` table if/when this matters.
- **Score progression over time for scoped responses.** The score progression chart currently works on scalar scores; matrix responses can contribute their mean but per-edge progression is a future pass.
---
## 10. Trade-offs
- **Two shapes for `entity_responses.values`.** The table's column is jsonb so it's free storage-wise, but readers must branch on scope. We mitigate by always writing `metadata.scope_snapshot` so the branch is a single field read.
- **Extra entity fetch in `prepareResponseInsert` for scoped responses.** One extra query when `isRelationScope`. Worth it to avoid threading the type slug through every call site.
- **No DB-level validation that `scope.fieldKey` exists on the selected entity type.** We validate in application code. Rationale: the field map comes from `entity_types.config`, which is jsonb — a DB check would require a function and add failure modes. Application validation in `resolveScopedConnectedIds` is sufficient and gives a better error message.
- **Matrix mean scoring.** Arithmetic mean across edges is the simplest defensible aggregate. Some use cases might want max, min, or weighted-by-rank — defer until we see the need.
- **Disallowing `relation-rank` inside a scoped set.** A ranked dimension inside a per-edge scope is conceptually ambiguous ("rank *what*, within the edge?"). Keeping it flat avoids the confusion.
---
## 11. Appendix — quick reference
### 11.1 Glossary
- **Criteria set** — a reusable definition of *what to evaluate*. Has a name, a list of dimensions, and (now) an optional scope.
- **Dimension** — a single thing being scored within a criteria set. E.g. `{ key: "market_fit", type: "rating", scale: [1, 5] }`.
- **Response** — a single submission of values against a criteria set for a given entity. Persisted in `entity_responses`.
- **Scope** — the new optional field on a criteria set. `null` or `{ type: "entity" }` means "score the entity itself". `{ type: "relation", fieldKey }` means "score each connection in the named field".
- **Edge** — a single connection in a relation field. An edge corresponds to one `entity_relations` row. When a criteria set is relation-scoped, we score one edge at a time.
### 11.2 Key file map (repeated here for quick lookup)
| Concern | File | Key symbols |
|---|---|---|
| Types | `features/responses/types.ts` | `CriteriaSetRecord`, `CriteriaSetDimension`, `DIMENSION_TYPES`, `computeResponseScore` (line 237) |
| Validation | `features/responses/validation.ts` | `validateResponseValues` (line 74), `validateValueForDimension` (line 35) |
| Server actions | `features/responses/server/actions.ts` | `finalizeEntityResponseAdmin` (line 864), `insertEntityResponse` (line 418), `prepareResponseInsert` (line 374), `ResponseSubmitInput` (line 39) |
| Response form | `features/responses/components/response-form.tsx` | `ResponseForm`, `DimensionInput` (line 174), `getDefaultValue` (line 36), `isEmptyDimensionValue` (lines 63–71, added in PR #587) |
| Response form block | `features/blocks/components/response-form-block.tsx` | `ResponseFormBlockView`, `ResponseFormBlockEdit` |
| Criteria set editor | `features/responses/components/criteria-set-editor.tsx` | (701 lines — search for `entity_type_slugs` to find the right place to add scope UI) |
| AI tool | `features/tools/response-tools.ts` | `createResponseToolDefinitions` (line 30, `slug: "submitResponse"`) |
| Relation resolver | `features/entities/lib/relation-fields.ts` | `getRelationFieldMap`, `ResolvedRelationField`, `normalizeFieldConfig` |
| Relation reader | `features/entities/server/relations.ts` | `getRelationsForField` (line 243) |
| Cached entity type | `features/entities/server/cached-queries.ts` | `getCachedEntityTypeBySlug(tenantId, slug)` |
| Initial migration | `supabase/migrations/20260322100000_criteria_sets_and_entity_responses.sql` | Source of truth for the existing criteria_sets schema |
### 11.3 Commit convention
Follow the conventional-commits style used in the repo. Suggested commit split for step-by-step execution:
```
feat(responses): add criteriasetscope type and scope column
feat(responses): compute mean score for relation-scoped responses
feat(responses): validate scoped response values
feat(responses): resolve scoped connection ids via edge-scope helper
refactor(responses): extract dimensioninput to its own file
feat(responses): thread scope through response insertion path
feat(tools): document matrix values shape for submitresponse
feat(responses): edgematrixform component for per-connection scoring
feat(responses): responseform delegates to edgematrixform for scoped sets
feat(api): entity connections endpoint for edge matrix form
feat(admin): scope selector in criteria set editor
fix(responses): guard aggregation/compare/timeline views against scoped responses
docs(responses): scoped criteria sets (edge scoring)
```
### 11.4 Context references
- PR #587 — introduced `relation-rank` dimension type, first-class relation fields, `entity_relations.metadata.field`/`rank`, and the `EntityPicker` component family. **Merge PR #587 before starting this spec.**
- `content/docs/roadmap/specs/relations-first-class.mdx` — the PR #587 spec. Required reading for understanding `FieldConfigRelation` and `EntityRelationMetadata`.
- `.claude/rules/database.md` — migration naming, RLS, `.single()` vs `.maybeSingle()` vs `.select()` rules. Especially: **never `.single()` on UPDATE or DELETE**.
- `.claude/rules/testing.md` — every new file with exported logic gets a co-located test.
- `.claude/rules/performance.md` — cache patterns, `Promise.all` for independent fetches.
---
*End of spec. If you're the implementing agent: start at §6 step 1 and go.*