Review Resource Contract#
Survey review resources are producer-neutral envelopes for UI prototypes and adapter tests. They sit beside the existing record contract and keep Survey out of producer policy, queue state, reviewer form state, and product-specific field catalogs.
Each resource uses a Kubernetes-inspired shape:
{
apiVersion: "survey.kontourai.io/v1alpha1",
kind: "ReviewItem" | "ReviewDecision" | "ReviewSession" | "ReviewSessionEvent",
metadata: { name, uid?, labels?, annotations?, producer? },
spec: { ...producer-declared intent },
status: { ...Survey-readable observation hints }
}
Resources#
ReviewItem describes one reviewable target and its candidates. It carries
source references, locator or excerpt context, extraction confidence, candidate
roles, claim target hints, and optional projection hints for Survey records.
Roles include current and proposed, but also neutral roles such as
alternative, source-version, and computed so regulated-document reviews
do not have to pretend every comparison is a current/proposed pair.
ReviewItem.spec also carries two optional review-UX hints for the Review
Workbench, both producer-owned and both defaulting to today's behavior when
absent: valueDescriptor (a neutral value-type descriptor that selects a typed
editor and validates a reviewer's edit) and editable (a boolean; false
turns off inline proposed-value editing for that item entirely — the workbench
renders no editor and the decision is keep/use/reject only, so effectiveValue
is always the selected candidate's own value). editable defaults to true.
ReviewCandidate is embedded inside ReviewItem. It is intentionally not a
top-level resource because candidate lifecycle belongs to the producer and the
portable contract only needs the serializable candidate payload.
ReviewDecision describes the reviewer decision for a ReviewItem: candidate,
status, optional explicit resolution, actor, reviewed time, rationale, evidence
ids, comfort-zone notes, and projection hints. The could_not_confirm
resolution requires resolutionReason, may carry attemptEvidenceIds, keeps
status proposed/assumed, and ends the review round without a product apply
action or candidate-set escalation.
Because it selects no product mutation, could-not-confirm deliberately bypasses
producerPolicy.decisionMode; those modes constrain candidate-selection actions.
ReviewSession is a portable envelope for one review session. It carries the
session snapshot hash, item count, event count, and status so downstream systems
can validate replay without importing workbench mechanics. A session does not own
queue position, assignment, retry, lock, or workflow state — those remain in the
producer's own system.
ReviewSessionEvent is a single event in a review session, such as a decision,
undo, or session-complete marker. Events are replayed against the session
snapshot to derive the final apply result. When a reviewer inline-edits a
proposed value (an accept-proposed decision), the edit is carried on the
decision event as spec.data.workbenchEditedValue, and replayReviewSessionEvents
reconstructs editedValuesByItemName from it — so snapshot + persisted events
is a complete record of reviewer intent and the derived effectiveValue
reflects the edit with no separate edit channel. A decision that moves off
accept-proposed, or an accept with no carried edit, clears any prior edit for
that item on replay. See
consumer-integration-guide.md for the full
replay and apply boundary.
Ownership#
Producers own acquisition, parsing, candidate ranking, review UX, vertical policy, field catalogs, reviewer assignment, and operational state. Survey owns the portable source, extraction, candidate, review, claim target, and projection record shapes needed to build a Surface Trust Bundle.
Field ownership:
| Field area | Owner | Notes |
|---|---|---|
metadata.name, labels, annotations |
producer | Stable producer identity and grouping labels. |
metadata.producer, spec.producerPolicy, candidate.producer |
producer | Domain context and policy hints; Survey treats these as opaque data. As of this delivery, the well-known decisionMode sub-key is typed (ReviewDecisionMode) and can be optionally enforced via applyReviewSession's enforceProducerPolicy option or assertReviewDecisionModeAllows directly; all other keys remain opaque, and enforcement is off by default — unset producerPolicy/decisionMode never changes behavior. |
spec.valueDescriptor, spec.editable |
producer | Optional Review Workbench UX hints. valueDescriptor selects a typed proposed-value editor and validation; editable: false removes the inline editor entirely (keep/use/reject only). Both default to today's behavior when absent (editable defaults true); neither changes the portable record shapes or the derived apply result beyond which value a reviewer can produce. |
ReviewSessionEvent.spec.data.workbenchEditedValue |
reviewer input, Survey-replayed | The reviewer's inline Proposed-Value Edit carried on an accept-proposed decision event. replayReviewSessionEvents reconstructs editedValuesByItemName from it so the server apply boundary derives the edited effectiveValue from snapshot + events alone. Absent → no edit (unchanged behavior). |
candidate.source |
producer declares, Survey maps | Maps to RawSource when an adapter emits Survey records. |
candidate.locator, candidate.extraction |
producer declares, Survey maps | Maps to Extraction, including locator, excerpt, confidence, extractor, and extracted time. |
candidate.role, spec.selectedCandidateId |
producer declares | Survey does not enforce current/proposed-only policy by default. A producer may opt in via producerPolicy.decisionMode (see Producer decision mode). |
candidate.rejectionReason |
producer declares | Optional rationale for a candidate the producer already treats as non-selected, superseded, or rejected; Survey records it without ranking candidates or defining rejection policy. A rejection reason is not a comfort-zone signal by itself. |
candidate.claimTarget |
shared boundary | Producer identifies the desired Surface claim target; Survey preserves compatible ClaimTarget fields. |
ReviewDecision.spec |
producer reviewer event | Maps to ReviewOutcome without bringing producer queues into Survey. |
projection hints |
Survey-readable | Optional ids linking resources to RawSource, Extraction, CandidateSet, ReviewOutcome, and ClaimTarget records. |
Mapping To Survey Records#
| Resource field | Survey record |
|---|---|
ReviewCandidate.source.sourceRef, kind, observedAt, checksum, locatorScheme |
RawSource |
ReviewCandidate.extraction.target, confidence, extractor, extractedAt plus locator |
Extraction |
ReviewItem.spec.target, candidates, selectedCandidateId, candidateSetStatus, rationale, candidate.rejectionReason |
CandidateSet and Candidate |
ReviewDecision.spec.status, resolution, resolutionReason, attemptEvidenceIds, actor, reviewedAt, rationale, evidenceIds, withinComfortZone |
ReviewOutcome |
ReviewCandidate.claimTarget |
ClaimTarget |
projection |
Optional id bridge for tests and adapters |
ReviewSession.spec.snapshot, itemCount, eventCount |
Replay envelope for session validation |
ReviewSessionEvent.spec.type, reviewItemName, candidateId, status, actor |
Replayable event log for derive/apply |
Session resource mapping and the snapshot-safe replay/export helpers are covered
in detail in consumer-integration-guide.md.
Adapters should emit normal SurveyInput records and then call
buildSurveyTrustBundle. Review resources are a durable neutral contract for
review payloads, not a second Surface projection path.
Canonical reviewed TrustInput#
After applyReviewSession returns ok: true, a producer can project its
server-owned snapshot and derived results without maintaining a second,
hand-written field mapper:
import {
buildCanonicalReviewedTrustInput,
buildSurveyTrustBundle,
} from "@kontourai/survey";
const reviewed = buildCanonicalReviewedTrustInput({
source: "example-producer/review-session-42",
generatedAt: new Date().toISOString(),
projectionContextId: "review-session-42",
items: applied.replayedSession.items,
results: applied.results,
});
const trustBundle = buildSurveyTrustBundle(reviewed.surveyInput, {
projectionContextId: reviewed.projectionContextId,
});
Only pass results from the successful server apply boundary, never a
browser-computed result. The helper treats the canonical ReviewItem and
ReviewWorkbenchResult as authoritative and derives status, the effective
edited value, target and claim identity, all candidate/source/extraction
provenance, reviewer timing and rationale, evidence references, comfort-zone
posture, authorizing evidence, and projection identifiers. It rejects missing
results, duplicate identities with different content, and any selected or
unselected candidate data that differs from the canonical item. Consumers do
not supply an override SurveyInput, so a claim status or provenance field
cannot silently contradict the applied review.
projectionContextId is returned beside surveyInput rather than added to the
existing SurveyInput wire shape. Passing it to buildSurveyTrustBundle
preserves repeated-projection identity while omission of this new helper leaves
all existing buildSurveyTrustBundle behavior unchanged.
Rejected candidates and comfort-zone review posture are separate signals.
Ordinary rejected-candidate feedback should stay on the candidate/review record
and, when Survey supports it, project as rejected-candidate learning. It should
not be modeled as withinComfortZone: false just to produce
learning.comfort-zone. Use withinComfortZone: false only when the reviewer
explicitly records that the conclusion is outside their authority or domain
comfort and needs a different authority to confirm.
Examples#
The public-directory example demonstrates a current/proposed field review. The regulated-document example demonstrates multi-candidate source-version and computed roles without requiring current/proposed semantics. Both examples are plain serializable TypeScript objects and avoid private downstream product names.
Prototype#
See review-workbench-prototype.md for the
example-backed browser prototype that renders a browser-safe copy of the
public-directory ReviewItem, guarded against drift from the canonical example,
and emits local in-memory ReviewDecision payloads for accept proposed, keep
current, reject proposed, and could-not-confirm decisions.
See consumer-integration-guide.md for the
recommended consumer path from ReviewItem construction through persisted
review events, exported results, and optional Surface projection. A generic
review adapter builder is deliberately deferred until another producer proof
shows repeated, policy-free Survey-shape friction.
Collection provenance#
A ReviewDecision can carry an authorizing block inside its spec (mapped
from ReviewOutcome.authorizing). This block records how the reviewer was asked
and what action they took — the testimony provenance that makes a decision
self-contained for downstream admissibility checks.
Three kinds are admissible: explicit-statement (reviewer typed a free-form
statement), exchange (a prompt was shown and the reviewer responded — both
halves required), and authorized-action (reviewer clicked a named action
against a versioned prompt; requires promptRef, renderedPrompt, action,
and authorityRef).
Vertical UIs inherit correct collection by using the workbench. buildReviewDecision
now populates authorizing automatically on every workbench decision. The block
kind is authorized-action with:
promptRef:"review-workbench/decision-card@v1"— a stable versioned identifier for the decision card control.renderedPrompt: the review question rendered for that item, including the target label and both candidate values, so the block is self-contained.action:"affirmed-control"for a pure button click,"typed"when the reviewer also supplied a rationale note.authorityRef:"actor:<actorId>"— the actor identity already on the outcome.
The provenance logic lives in the workbench boundary, not in the vertical UI, so
consumer products do not need to re-implement it. The authorizing field is
optional; existing records without it remain valid.
If buildAuthorizedActionAuthorizing returns an invalid block (e.g., an empty
actorId during testing), the workbench records the outcome without authorizing
and emits a console.warn. This is a transparency gap, not a hard block, per
ADR 0004.
For consumers building outcomes outside the workbench, buildAuthorizedActionAuthorizing
is exported from @kontourai/survey. It constructs and validates the block,
throwing on invalid inputs so callers catch configuration errors at build time.
Validation is available via validateAuthorizing(block) from
@kontourai/survey. It returns structured issues for transparency-gap reporting;
it does not hard-block decisions. Gaps are flagged for human review, never
silently resolved by model judgment.
For consumers building an authorized-action promptRef outside the workbench,
use buildPromptRef({ module, component, version?, scheme? }) from
@kontourai/survey to construct a well-formed promptRef for
buildAuthorizedActionAuthorizing instead of hand-formatting the string. Without
a scheme it yields the bare workbench form
("review-workbench/decision-card@v1"); with a scheme it yields the prefixed
form ("survey://rules-admin/keep-current@v1").
Producer decision mode#
producerPolicy.decisionMode declares how a ReviewItem is allowed to be
resolved. It is typed as ReviewDecisionMode and takes one of three values:
keep-current— only a keep-current decision is admissible.current-proposed— only the current or proposed candidate may be selected.free-select— any candidate declared on the item may be selected.
Enforcement is opt-in. Survey never inspects decisionMode unless a consumer
asks it to, either by passing enforceProducerPolicy: true to applyReviewSession
or by calling assertReviewDecisionModeAllows(item, result) /
validateReviewDecisionMode(item, result) from @kontourai/survey/review-workbench
directly. When producerPolicy or decisionMode is absent, the validators are a
no-op and behavior is unchanged.
Enforcement fails closed: an unrecognized decisionMode string reports an
unknown-decision-mode issue rather than being silently ignored. All other
producerPolicy keys (for example sourceAuthorityProjection, feedbackTags)
remain opaque and are never inspected.
TypeScript migration note: decisionMode is now a literal union#
ProducerPolicy.decisionMode is typed as ReviewDecisionMode, a 3-value
string-literal union ("keep-current" | "current-proposed" | "free-select"),
not string. Object literals using one of the three literal values (the shape
both known real consumers already produce) keep typechecking unchanged. The
index signature on ProducerPolicy still tolerates unknown keys, but it does
not widen decisionMode back to string — this is a source-breaking
narrowing for a TypeScript caller that assigns a plain string-typed value
(for example, a value read from configuration, or a switch default branch)
to decisionMode:
declare const dynamicMode: string;
// Before this delivery: producerPolicy was Record<string, unknown>, so this
// compiled unconditionally.
const policy: ProducerPolicy = {
decisionMode: dynamicMode, // ts(2322): Type 'string' is not assignable to type 'ReviewDecisionMode | undefined'.
};
Fix by narrowing the value to the literal union before assigning it — either
validate it explicitly, or assert it with as const/a type assertion once you
know it is one of the three allowed values:
const policy: ProducerPolicy = {
decisionMode: dynamicMode as ReviewDecisionMode, // caller-verified narrowing
};
Producers that always assign one of the three literal values directly (as both known real consumers do) are unaffected and require no changes.
See also Consuming decisionEffects safely
in the upgrade guide for the related defineProductVocabulary
vocabulary-object-specific gotcha this note does not cover.