Consumer Integration Guide#
Survey is the producer-side contract for reviewable claims before they cross into Surface. A consumer should be able to keep its product workflow local while using Survey for the portable source, extraction, candidate, review, and result records.
The canonical integration path is:
- Producer creates
ReviewItemresources from its own queue or reconciliation state. - Producer mounts the review workbench with a
ReviewQueueSessionState. - Producer persists
ReviewSessionEventresources through a product-owned event store. - Producer exports
ReviewWorkbenchResultandReviewDecisionresources. - Producer applies product policy locally and, when appropriate, projects
Survey records into Surface with
buildSurveyTrustBundle.
Survey should not own the producer queue, auth, tenancy, parser policy, source ranking policy, final apply semantics, or product field catalog.
Consumer Adapter Contract#
The reusable boundary is intentionally small:
| Step | Producer owns | Survey owns |
|---|---|---|
| Queue | Which product records need review, who can see them, and tenancy/auth rules. | ReviewQueueSessionState as the portable queue/session shape. |
| Item | Stable ids, field catalog, candidate ranking, source authority posture, and product policy notes. | ReviewItem, ReviewCandidate, source, extraction, locator, claim target, and projection hints. |
| Presentation | Human labels, value summaries, and links back to product records, sources, claims, or traces. | ReviewPresentationAdapter hooks plus deterministic item/result presentation builders. |
| Events | Durable event storage, optimistic concurrency, and reviewer identity from trusted product context. | ReviewSessionEvent resources and replay/validation helpers. |
| Apply | Current-state validation, product policy, mutating writes, audit tables, and downstream jobs. | ReviewWorkbenchResult and ReviewDecision derived from a pre-decision review snapshot plus persisted events. |
| Surface handoff | Which reviewed observations become claims and when to publish them. | Normal Survey observation/claim records and buildSurveyTrustBundle projection into Surface. |
ReviewPresentationAdapter is display-only. It lets a product explain ids and
values without changing canonical ReviewItem data or apply authority.
import {
buildReviewItemPresentation,
buildReviewResultPresentation,
type ReviewPresentationAdapter,
} from "@kontourai/survey/review-workbench";
const presentationAdapter = {
labelForTarget: (target) => target === "operatingLicenseCredential"
? "Operating license credential"
: undefined,
labelForCandidateRole: (role) => role === "current"
? "Current managed credential"
: role === "proposed"
? "Registry candidate"
: undefined,
summarizeValue: (value) => summarizeCredentialValue(value),
linkForReviewItem: (item) => ({
label: typeof item.metadata.producer?.displayName === "string"
? item.metadata.producer.displayName
: "Review item",
href: `/review/items/${encodeURIComponent(item.metadata.name)}`,
}),
linkForSource: (sourceRef) => ({ href: sourceRef }),
linkForTraceRef: (ref) => ref.kind === "claim"
? { label: "Claim target", href: `/claims/${encodeURIComponent(ref.value)}` }
: undefined,
} satisfies ReviewPresentationAdapter;
const itemPresentation = buildReviewItemPresentation(reviewItem, presentationAdapter);
const resultPresentation = buildReviewResultPresentation(result, reviewItem, presentationAdapter);
A server apply path should treat persisted events as the auditable input and derive results again from the pre-decision review queue snapshot. Browser exports and presentation payloads are useful for inspection, not write authority.
Vocabulary And Id Primitives#
Producers that project into Survey repeatedly need the same three primitives:
stable identifiers, a typed product vocabulary, and a ConfidenceBasis. Survey
exports them so consumers do not hand-roll a copy each.
stableId(parts) builds a deterministic, url-safe id from ordered parts —
lowercased, non-alphanumeric runs collapsed to a hyphen, joined with a dot:
import { stableId } from "@kontourai/survey";
const candidateSetId = stableId(["public-directory", "entity-123", "availabilityStatus"]);
// "public-directory.entity-123.availabilitystatus"
defineProductVocabulary(def) freezes a product's subject type, facet,
claim-type names, and decision-effect names into one discoverable value
(the deprecated surface option is still accepted for one release — see
the Upgrade Guide):
import { defineProductVocabulary } from "@kontourai/survey";
const vocabulary = defineProductVocabulary({
subjectType: "public-directory.entity",
facet: "public-directory.entity-profile",
claimTypes: {
scalarField: "public-data.field",
scalarFieldCandidate: "public-data.field-candidate",
},
decisionEffects: {
acceptedCandidateValue: "accepted-candidate-value",
keptCurrentValue: "kept-current-value",
},
});
defineProductVocabulary's type declarations require TypeScript 5.0+ to
parse (const type parameters preserve claimTypes/decisionEffects
literal types without as const); see the Upgrade Guide's TypeScript
5.0+ callout if your
project is still on an older TypeScript compiler.
confidenceBasisForReview(input) maps a reviewed status and impact level into a
Surface ConfidenceBasis. It is conservative by default: sourceQuality
defaults to "unknown" and evidenceStrength defaults to "none" — the
weakest values Surface accepts — unless the caller passes an explicit value.
The only field derived from status without an override is
reviewerAuthority ("operator" when status is "verified", otherwise
"none"). This helper does not reproduce any one app's hand-rolled confidence
algorithm; known consumers use different domain heuristics for sourceQuality
and evidenceStrength (for example, deriving sourceQuality from an
extracted document's source type), so producers with that kind of domain
knowledge should pass sourceQuality/evidenceStrength explicitly rather
than rely on the bare defaults:
import { confidenceBasisForReview } from "@kontourai/survey";
// Bare defaults: conservative floor values, only reviewerAuthority is status-driven.
const conservativeBasis = confidenceBasisForReview({
status: "verified",
impactLevel: "medium",
extractionConfidence: 0.91,
});
// -> { sourceQuality: "unknown", reviewerAuthority: "operator", evidenceStrength: "none",
// impactLevel: "medium", extractionConfidence: 0.91 }
// A producer with domain knowledge of its own source quality passes it explicitly,
// e.g. a source-type-driven mapping (strong for a corrected/high-confidence
// document, moderate for medium-confidence, weak otherwise):
const domainAwareBasis = confidenceBasisForReview({
status: "verified",
impactLevel: "medium",
extractionConfidence: 0.91,
sourceQuality: "strong",
evidenceStrength: "strong",
});
Server-Owned Review Sessions#
For browser-backed review flows, the server should own the review snapshot. A consumer typically stores a local session row or file containing:
- a product session id;
- the Survey
sessionName; - the pre-decision
ReviewQueueSessionStatesnapshot; hashReviewSessionSnapshot(snapshot);- optional event-count or optimistic-concurrency metadata.
The browser may submit ReviewSessionEvent resources and a server session id,
but it should not submit the authoritative snapshot or derived apply result.
Before saving or applying events, validate that the request still matches the
server snapshot:
import {
deriveReviewSessionApplyResultForSnapshot,
} from "@kontourai/survey/review-workbench";
import {
assertServerReviewSessionEvents,
assertServerReviewSessionFreshness,
createServerReviewSessionRecord,
deriveServerReviewSessionApplyResult,
} from "@kontourai/survey/review-workbench/server-review-session";
const record = createServerReviewSessionRecord({
sessionName: "review-workbench-session",
snapshot: reviewSessionSnapshot,
eventCount: persistedEventCount,
updatedAt: storedSessionUpdatedAt,
});
assertServerReviewSessionFreshness(record, rebuildCurrentSnapshot(), persistedEventCount);
assertServerReviewSessionEvents(record, submittedEvents);
const applyResult = deriveServerReviewSessionApplyResult({
record,
currentSnapshot: rebuildCurrentSnapshot(),
events: submittedEvents,
requiredResolvedItems: "all",
});
assertServerReviewSessionFreshness compares stable snapshot hashes and, when
both sides provide an event count, the expected event count. A producer that
synthesizes events server-side from a trusted action can omit event-count
checking and still use the snapshot hash to detect stale ReviewItems.
assertServerReviewSessionEvents reuses Survey replay validation and also
rejects events for the wrong sessionName, unknown ReviewItems, active items,
or candidates outside the server-owned snapshot.
deriveServerReviewSessionApplyResult composes those server-side checks before
deriving the same typed apply result as deriveReviewSessionApplyResultForSnapshot.
import {
buildReviewSessionEvents,
deriveReviewSessionApplyResultForSnapshot,
persistReviewSessionEvents,
} from "@kontourai/survey/review-workbench";
const currentRecord = await loadCurrentProductRecord(recordId);
const reviewSessionSnapshot = await loadReviewSessionSnapshot(reviewId);
const reviewedSession = buildReviewedSession(reviewSessionSnapshot, reviewerInput);
const eventsToPersist = buildReviewSessionEvents(reviewedSession);
const persisted = await persistReviewSessionEvents({
session: reviewedSession,
events: eventsToPersist,
expectedEventCount: await countPersistedReviewEvents(reviewId),
persist: ({ events, expectedEventCount }) =>
saveReviewEvents({ reviewId, events, expectedEventCount }),
});
const applyResult = deriveReviewSessionApplyResultForSnapshot({
snapshot: reviewSessionSnapshot,
events: persisted.events,
requiredResolvedItems: "all",
});
if (!applyResult.ok) {
throw new Error("Review events do not match the review session snapshot.");
}
for (const result of applyResult.results) {
assertProductTargetStillMatches(currentRecord, result);
await applyProductPolicy({
decision: result.decision,
selectedCandidateId: result.selectedCandidateId,
selectedValue: result.selectedValue,
actorId: auth.user.id,
appliedAt: new Date().toISOString(),
});
}
Surface projection is still the normal Survey path. A review result tells the
producer which candidate was selected; producer code then emits the reviewed
source/extraction/candidate/review/claim records it wants to publish and calls
buildSurveyTrustBundle. The workbench also exposes a projection preview for UI
explanation, but that preview is not a separate write path.
persistReviewSessionEvents returns the event array accepted for replay. If a
producer's persistence layer canonicalizes or reads back stored resources, its
persist callback should return { events, eventCount }; otherwise the
callback must atomically commit exactly the supplied array before returning.
The generic, test-covered workbench example lives at
examples/review-workbench/facility-credential-consumer.ts.
It shows a ReviewItem, ReviewPresentationAdapter, persisted
ReviewSessionEvent resources, event replay, derived ReviewWorkbenchResult,
and a Surface projection preview without product-specific policy embedded in
Survey.
For the smallest server apply boundary, see
examples/review-workbench/server-apply-consumer.ts.
That example intentionally keeps the product mutation local: Survey derives the
review result from the pre-decision snapshot and persisted events, while the
consumer validates current state, rejects already-applied results, stamps the
authenticated actor, and prepares its own write.
Public-Directory Example#
A public-directory producer often has an existing value and a proposed value from a crawl or API ingestion pass. The producer owns what "approve" means, but Survey can carry the reviewable candidate shape.
import {
reviewResourceApiVersion,
type ReviewItem,
} from "@kontourai/survey";
const registrationStatusReviewItem = {
apiVersion: reviewResourceApiVersion,
kind: "ReviewItem",
metadata: {
name: "public-record.entity-123.registrationStatus.review-1",
labels: {
domain: "public-directory",
field: "registrationStatus",
},
},
spec: {
target: "registrationStatus",
candidateSetStatus: "needs-review",
producerPolicy: {
decisionMode: "current-proposed",
},
projection: {
candidateSetId: "public-record.entity-123.registrationStatus.candidates",
},
candidates: [
{
id: "public-record.entity-123.registrationStatus.current",
role: "current",
value: "ACTIVE",
source: {
sourceRef: "current-record:entity-123:registrationStatus",
kind: "manual-entry",
observedAt: "2026-06-01T12:00:00.000Z",
locatorScheme: "structured-field",
},
locator: {
scheme: "structured-field",
locator: "field:registrationStatus",
excerpt: "Current reviewed value.",
},
extraction: {
extractionId: "public-record.entity-123.registrationStatus.current.extraction",
target: "registrationStatus",
confidence: 1,
extractor: "current-record",
extractedAt: "2026-06-01T12:00:00.000Z",
},
claimTarget: {
claimId: "public-record.entity-123.registrationStatus.current.claim",
subjectType: "public-record.entity",
subjectId: "entity-123",
facet: "public-directory.profile",
claimType: "public-data.field",
fieldOrBehavior: "registrationStatus",
impactLevel: "medium",
evidenceType: "human_attestation",
evidenceMethod: "observation",
collectedBy: "current-record",
},
projection: {
rawSourceId: "public-record.entity-123.registrationStatus.current.source",
extractionId: "public-record.entity-123.registrationStatus.current.extraction",
candidateSetId: "public-record.entity-123.registrationStatus.candidates",
candidateId: "public-record.entity-123.registrationStatus.current",
claimId: "public-record.entity-123.registrationStatus.current.claim",
},
},
{
id: "public-record.entity-123.registrationStatus.proposed",
role: "proposed",
value: "WAITLIST",
confidence: 0.84,
sourceRank: 1,
source: {
sourceRef: "https://records.example.test/entities/123",
kind: "web-page",
observedAt: "2026-06-01T12:30:00.000Z",
locatorScheme: "html",
},
locator: {
scheme: "html",
locator: "css:#registration-status",
excerpt: "Registration status: waitlist",
},
extraction: {
extractionId: "public-record.entity-123.registrationStatus.proposed.extraction",
target: "registrationStatus",
confidence: 0.84,
extractor: "example-crawler",
extractedAt: "2026-06-01T12:30:00.000Z",
},
claimTarget: {
claimId: "public-record.entity-123.registrationStatus.proposed.claim",
subjectType: "public-record.entity",
subjectId: "entity-123",
facet: "public-directory.profile",
claimType: "public-data.field",
fieldOrBehavior: "registrationStatus",
impactLevel: "medium",
evidenceType: "crawl_observation",
evidenceMethod: "extraction",
collectedBy: "example-crawler",
},
projection: {
rawSourceId: "public-record.entity-123.registrationStatus.proposed.source",
extractionId: "public-record.entity-123.registrationStatus.proposed.extraction",
candidateSetId: "public-record.entity-123.registrationStatus.candidates",
candidateId: "public-record.entity-123.registrationStatus.proposed",
claimId: "public-record.entity-123.registrationStatus.proposed.claim",
},
},
],
},
status: {
observedCandidateCount: 2,
},
} satisfies ReviewItem;
Regulated-Rule Example#
A regulated-rule producer may also have current and proposed values, but the
review semantics are different. The proposed value may come from an official
publication and the current value may be a managed rule value. Product policy
may allow only "keep current" for a particular conflict until a specialist
resolves it. producerPolicy.decisionMode is now typed and optionally
enforceable: by default Survey still treats it as opaque and the producer
validates supported actions before applying a ReviewDecision, so the example
below works exactly as documented. A producer that wants Survey to enforce the
declared mode can opt in with enforceProducerPolicy: true on
applyReviewSession (or call assertReviewDecisionModeAllows directly).
TypeScript migration note:
ProducerPolicy.decisionModeis typed as the 3-value literal unionReviewDecisionMode("keep-current" | "current-proposed" | "free-select"), notstring. Object literals using one of the three literal values keep typechecking unchanged, but assigning a plainstring-typed variable (e.g. read from configuration) todecisionModenow fails to compile. One-line fix: narrow it first, for exampledecisionMode: dynamicMode as ReviewDecisionModeonce you have verified the value is one of the three allowed strings. See Producer decision mode for the full migration note.See also Consuming
decisionEffectssafely in the upgrade guide for the relateddefineProductVocabularyvocabulary-object-specific gotcha this note does not cover.
The same ReviewItem contract works because the candidate shape carries typed
values, source posture, locators, evidence type, claim target hints, and
producer policy without Survey deciding the domain result.
import {
reviewResourceApiVersion,
type ReviewItem,
} from "@kontourai/survey";
const ruleConflictReviewItem = {
apiVersion: reviewResourceApiVersion,
kind: "ReviewItem",
metadata: {
name: "regulated-rule-conflict-standard-threshold",
labels: {
domain: "regulated-rule-source",
},
},
spec: {
target: "standardThreshold",
candidateSetStatus: "conflict",
selectedCandidateId: "regulated-rule-conflict-standard-threshold.current",
rationale: "Extracted source value conflicts with the managed value.",
producerPolicy: {
decisionMode: "keep-current",
policyNote: "Producer validates supported actions before applying a decision.",
sourceAuthorityProjection: "only-for-selected-source-backed-value",
},
projection: {
candidateSetId: "regulated-rule-conflict-standard-threshold.candidates",
},
candidates: [
{
id: "regulated-rule-conflict-standard-threshold.current",
role: "current",
value: 15000,
confidence: 1,
source: {
sourceRef: "managed-rules://example/2026/standardThreshold",
kind: "manual-entry",
observedAt: "2026-06-03T00:00:00.000Z",
locatorScheme: "structured-field",
},
locator: {
scheme: "structured-field",
locator: "managed-rules:path=standardThreshold",
excerpt: "Current managed rule value.",
},
extraction: {
extractionId: "regulated-rule-conflict-standard-threshold.current.extraction",
target: "standardThreshold",
confidence: 1,
extractor: "example-rule-manager",
extractedAt: "2026-06-03T00:00:00.000Z",
},
claimTarget: {
claimId: "regulated-rule.example.2026.standard-threshold.current",
subjectType: "regulated-rule-source",
subjectId: "example:2026:standardThreshold",
facet: "regulated.rules",
claimType: "regulated.rule-source-value",
fieldOrBehavior: "standardThreshold",
impactLevel: "high",
evidenceType: "human_attestation",
evidenceMethod: "attestation",
collectedBy: "example-rule-manager",
},
},
{
id: "regulated-rule-conflict-standard-threshold.proposed",
role: "proposed",
value: 16000,
confidence: 0.95,
source: {
sourceRef: "https://example.test/regulatory-bulletins/2026-thresholds.pdf",
kind: "uploaded-document",
observedAt: "2026-06-03T00:30:00.000Z",
locatorScheme: "pdf",
},
locator: {
scheme: "pdf",
locator: "pdf:page=12;section=Standard%20Threshold",
excerpt: "Example Individual Standard Threshold $16,000",
},
extraction: {
extractionId: "regulated-rule-conflict-standard-threshold.proposed.extraction",
target: "standardThreshold",
confidence: 0.95,
extractor: "example-rule-source-parser",
extractedAt: "2026-06-03T00:30:00.000Z",
},
claimTarget: {
claimId: "regulated-rule.example.2026.standard-threshold.proposed",
subjectType: "regulated-rule-source",
subjectId: "example:2026:standardThreshold",
facet: "regulated.rules",
claimType: "regulated.rule-source-value",
fieldOrBehavior: "standardThreshold",
impactLevel: "high",
evidenceType: "policy_rule",
evidenceMethod: "extraction",
collectedBy: "example-rule-source-parser",
},
producer: {
sourceAuthority: {
authorityClass: "official_publication",
declaredBy: "Example regulatory source registry",
scope: "standardThreshold rule value for example 2026",
},
},
},
],
},
status: {
observedCandidateCount: 2,
selectedCandidateId: "regulated-rule-conflict-standard-threshold.current",
},
} satisfies ReviewItem;
The same ReviewItem can be assembled with currentProposedReviewItem, which
owns the generic envelope, candidate ids, roles, and candidate-set wiring while
the producer keeps its domain value and claim vocabulary:
import { currentProposedReviewItem } from "@kontourai/survey";
const ruleConflictItem = currentProposedReviewItem({
name: "regulated-rule-conflict-standard-threshold",
target: "standardThreshold",
candidateSetStatus: "conflict",
selectedCandidateRole: "current",
labels: { domain: "regulated-rule-source" },
rationale: "Extracted source value conflicts with the managed value.",
producerPolicy: {
decisionMode: "keep-current",
sourceAuthorityProjection: "only-for-selected-source-backed-value",
},
current: currentRuleCandidate, // domain value/claim shaping stays with the producer
proposed: proposedRuleCandidate,
});
Because this item declares decisionMode: "keep-current", a consumer can ask
Survey to enforce it at apply time. With enforceProducerPolicy: true, a
synthetic accept-proposed decision for this item is rejected as a
decision-mode-violation instead of being applied:
import { applyReviewSession } from "@kontourai/survey/review-workbench/server-review-session";
const applied = applyReviewSession({
snapshot: reviewSessionSnapshot,
sessionName,
events: persistedEvents, // a synthetic accept-proposed decision, for illustration
requiredResolvedItems: "any",
enforceProducerPolicy: true,
});
if (!applied.ok) {
// applied.issues includes { code: "decision-mode-violation", reviewItemName, ... }
}
Web Component#
@kontourai/survey/review-workbench/element exports a <survey-review-workbench> custom element.
It works like <surface-trust-panel>: data via the .session property or a src attribute
that fetches JSON, shadow DOM isolates styles, and --k-* tokens inherit through the shadow
boundary. The element is self-contained — a single module import is all that is needed; no
separate stylesheet import is required.
Single-import usage
import "@kontourai/survey/review-workbench/element";
// Property assignment — primary API
const el = document.querySelector("survey-review-workbench");
el.session = reviewQueueSession; // ReviewQueueSessionState | ReviewWorkbenchState
el.presentationAdapter = myAdapter; // ReviewPresentationAdapter | undefined
<survey-review-workbench theme="survey" color-scheme="dark"></survey-review-workbench>
src attribute
Set a src attribute to fetch a JSON-serialised ReviewQueueSessionState from a URL.
The element fetches the URL, parses the JSON, and calls this.session = parsed — identical
to setting the property directly.
<survey-review-workbench src="/api/review-sessions/my-session.json"
theme="survey" color-scheme="dark">
</survey-review-workbench>
Changing the src attribute at runtime re-fetches. If the fetch fails or returns a non-2xx
status, an inline error message is rendered inside the shadow root. While no session is loaded
(before the first assignment or before the fetch resolves) the element renders a neutral empty
state message.
Attributes
| Attribute | Values | Default |
|---|---|---|
theme |
survey console flow surface |
survey |
color-scheme |
dark light |
dark |
src |
URL string | — |
Theming token contract
CSS custom properties inherit through the shadow boundary. Set any --k-* token
on survey-review-workbench or an ancestor to override the shadow defaults.
The element declares default token values on :host so host-page rules always win.
See the "Theming" section below for the full token list and a worked example.
Responsive layout
At viewports ≤ 620 px (or when the embed container width is that narrow), each
field card's Current → Proposed diff stacks vertically instead of side by side,
and the decision row wraps. There is no separate mobile mode to opt into — the
same markup and CSS handle every width via @media/container queries.
Theming#
The review workbench (both mountReviewWorkbench into a plain element and the
<survey-review-workbench> custom element) is themed entirely through --k-*
CSS custom properties. A host app can re-skin the whole surface — backgrounds,
text, borders, the brand accent, and the accept/keep/reject signal colors —
by overriding tokens; no markup or class-name changes are needed.
The full token set
| Token | Role |
|---|---|
--k-bg |
Page/shell background |
--k-panel |
Card/panel background |
--k-panel-raised (alias --k-raised) |
Raised panel layer (the proposed-value box) |
--k-sunken |
Recessed well background (provenance box, audit details) |
--k-text |
Primary text |
--k-text-muted (alias --k-muted) |
Secondary text |
--k-text-faint (alias --k-faint) |
Tertiary / label text |
--k-line |
Subtle borders |
--k-line-strong |
Visible borders |
--k-brand |
Accent / brand color (Apply button, "Needs review" chip, links) |
--k-brand-contrast (alias --k-brand-ink) |
Text color on a brand-colored background |
--k-brand-wash |
Brand tint wash (behind the "Needs review" chip) |
--k-positive |
Accept / verified indicator |
--k-positive-wash |
Tint wash behind the "Accepted" chip |
--k-caution |
Escalate / low-confidence / no-source indicator |
--k-caution-wash |
Tint wash behind the "No source" flag |
--k-negative |
Reject / flagged-wrong indicator |
--k-negative-wash |
Tint wash behind the "Kept — flagged wrong" chip |
--k-radius-md (alias --k-radius) |
Card/panel border radius |
--k-radius-sm |
Inner element radius (buttons, wells) |
--k-shadow |
Card drop shadow |
--k-font-ui |
UI typeface |
--k-font-mono |
Monospace typeface (confidence %, audit IDs) |
The --k-muted/--k-faint/--k-raised/--k-brand-ink/--k-*-wash/--k-radius
aliases are declared as var() references onto the base token they derive
from (e.g. --k-muted: var(--k-text-muted)), so overriding the base token a
host already knows re-skins the alias automatically — and a host can still
override an alias directly for finer-grained control without touching the
base token.
Preset themes vs. a host's own brand
Four built-in presets ship in @kontourai/ui's themes.css and are selected
with the theme attribute/class: survey, console, flow, surface. Each
sets its own --k-brand (and theme-console swaps the full palette and
typeface). These are conveniences, not the only path to a themed workbench.
The escape hatch — a host's own full palette, no preset required
Pass theme="custom" on the custom element (or omit theme/any of the four
preset names when mounting the plain .survey-workbench-embed container) to
opt out of the presets entirely, then set --k-* tokens directly — either as
inline styles or in the host's own stylesheet. Because none of the preset
classes apply, there is nothing to override or fight:
<style>
/* A host's own brand palette — set on the element or any ancestor. */
survey-review-workbench.acme-brand {
--k-bg: #faf7f3;
--k-panel: #ffffff;
--k-panel-raised: #fffcf8;
--k-sunken: #f3ede4;
--k-text: #241a12;
--k-text-muted: #6b5b4b;
--k-text-faint: #9a8974;
--k-line: #ebe2d6;
--k-line-strong: #d8cbb8;
--k-brand: #c2521e;
--k-brand-contrast: #ffffff;
--k-positive: #2f7d32;
--k-caution: #a9660a;
--k-negative: #c13a31;
}
@media (prefers-color-scheme: dark) {
survey-review-workbench.acme-brand {
--k-bg: #17120d;
--k-panel: #211a13;
--k-panel-raised: #281f16;
--k-sunken: #120e09;
--k-text: #f1e7da;
--k-text-muted: #b6a48f;
--k-text-faint: #7c6c58;
--k-line: #2e2419;
--k-line-strong: #40331f;
--k-brand: #e8823f;
--k-brand-contrast: #1c0f06;
}
}
</style>
<survey-review-workbench class="acme-brand" theme="custom" color-scheme="dark">
</survey-review-workbench>
The same layout, class names, and interactions render — only the tokens change. This is the same technique the approved redesign mockup uses to prove a Survey-default palette and a distinct host palette from one shared markup.
Where the override has to go (light DOM vs. shadow DOM)
The two mount paths differ in one way that matters:
<survey-review-workbench>keeps its defaults on the shadow:host, so a--k-*token set on the element or any ancestor — including a token layer your page already publishes at:root— inherits through the shadow boundary and wins.mountReviewWorkbenchinto a plain.survey-workbench-embedcontainer has no shadow boundary. The bundled stylesheet declares literal token defaults on that container, and a declaration on an element always beats a value inherited from an ancestor — so an ancestor's--k-*does not reach the embed. Declare the tokens on the embed element itself (a rule matching.survey-workbench-embed, or an inlinestyle), which is what the example above does.To make a host token layer flow in unchanged, restate it once on the embed:
.survey-workbench-embed[class][class] { --k-brand: var(--acme-brand); --k-bg: var(--acme-bg); /* …only the tokens you want to hand over */ }A plain
.survey-workbench-embedrule (0,1,0) is enough when the embed carries no preset class and nodata-theme. The bundled preset rules go up to.survey-workbench-embed[data-theme="light"].theme-console(0,3,0), so the repeated[class]above lifts the host rule to (0,3,0) — and adding one more[class]clears them outright regardless of stylesheet order.
Mount The Workbench#
The workbench accepts a queue-shaped session. The producer owns how items are loaded, assigned, filtered, and authorized.
import {
createPersistentReviewSessionEventStore,
mountReviewWorkbench,
} from "@kontourai/survey/review-workbench";
import "@kontourai/survey/review-workbench.css";
const session = {
items: [registrationStatusReviewItem],
activeItemName: registrationStatusReviewItem.metadata.name,
notesByItemName: {},
decisionsByItemName: {},
actorId: "reviewer@example.test",
reviewedAt: new Date().toISOString(),
};
mountReviewWorkbench(document.querySelector("#review")!, session, {
eventStore: createPersistentReviewSessionEventStore({
initialEvents,
persist: ({ events, expectedEventCount }) =>
saveReviewEvents({ events, expectedEventCount }),
onStatusChange: (state) => {
renderPersistenceStatus(state.status);
},
}),
});
expectedEventCount is an optimistic concurrency hint. The producer can reject
a save when another reviewer has already written events for the same review
queue. Survey queues saves and reports persistence status, but the producer
owns the database, conflict response, and retry UX.
Queue-Binding Attestation#
A decision is only projectable against the exact queue bytes it was recorded
against. createServerReviewSessionRecord cannot enforce that by itself: it
computes snapshotHash from whatever snapshot it is handed, so a record rebuilt
from a mutated snapshot carries a hash of the mutated bytes and agrees with
itself. A digest a writer recomputes as it saves attests nothing. That exact
tautology shipped in a consuming application and let a post-decision edit export
a substituted value; it took four rounds of adversarial review to close
(kontourai/fieldwork#60), which is why the rule now lives here
(kontourai/survey#213).
The binding is the digest with an origin: taken once, when the round opens, and carried unchanged by every later write.
import {
assertReviewQueueAgainstExtractionImport,
bindReviewQueue,
} from "@kontourai/survey/review-workbench";
import {
deriveServerReviewSessionApplyResult,
} from "@kontourai/survey/review-workbench/server-review-session";
// WHEN THE ROUND OPENS — once. Persist the binding beside the queue.
const binding = bindReviewQueue(snapshot, { sessionName });
await storage.save({ snapshot, binding, events: [] });
// ON EVERY LATER SERVE OR APPLY — the binding comes from storage, never
// recomputed. Passing a binding makes the derivation refuse a queue whose
// bytes or item set moved after the round opened.
const applyResult = deriveServerReviewSessionApplyResult({
record,
events: storedEvents,
binding: stored.binding,
requiredResolvedItems: "all",
});
// AT EXPORT, for a queue imported from an extraction envelope: check the
// stored queue is exactly what the stored import record derives. This attests
// queue-to-record consistency; the record's own integrity is your storage's
// job (see "What neither side catches" below).
assertReviewQueueAgainstExtractionImport(stored.snapshot.items, importResult);
What each side catches. The binding (assertReviewQueueBinding, or the
binding option above) refuses a one-sided edit: changed bytes
(snapshot-hash-mismatch), a removed item (item-removed), an added item
(item-added), an emptied queue (empty-queue) — set membership is checked in
both directions, because walking only the items present can never notice one
was removed. The binding cannot catch a writer who edits the queue and
re-binds; hashing mutated bytes yields a self-consistent pair. For queues whose
items all come from one importExtractionEnvelope result,
assertReviewQueueAgainstExtractionImport narrows that hole to the record: it
re-derives the canonical items through the public import boundary — which
revalidates the record, so a forged grounded status throws before anything is
compared — and requires the stored queue to be the same set, byte-identical per
item, in both directions. A queue edited independently of its record fails
(item-diverges-from-extraction, item-missing-from-queue,
item-not-in-extraction).
What neither side catches. The cross-check attests queue-to-record
consistency, not record integrity. The portable envelope carries the
prepared artifact's digest, never its bytes, so Survey — handed only the
record — cannot verify a proposal's bytes against the digested artifact. A
writer who edits the stored record's proposals (say, a candidateValue) and
re-derives the queue from the edited record presents a self-consistent pair
that passes the binding and the cross-check, while
result.preparedArtifact.digest still names the honest bytes. That pass is
pinned as a boundary test and by npm run check:guards, so the limit cannot
drift silently. Keeping the stored record equal to the record you originally
imported is your storage obligation — and be precise about what does and
does not meet it. Validating your stored prepared bytes against
result.preparedArtifact.digest protects artifact integrity only: the
digest covers the prepared bytes, not the proposals, so it stays green
through the coordinated rewrite above. No single artifact-digest check closes
this. Preserving record integrity requires one of:
- a digest or MAC over the record itself, anchored somewhere the record's writer cannot reach (a separate trust domain, an append-only log, a signer);
- immutable or authenticated record storage, so the record cannot be rewritten in place; or
- independently re-deriving the proposals from trusted prepared bytes and comparing them to the stored record's proposals.
Survey enforces none of these — it cannot see your storage — which is why this paragraph names them instead of claiming them.
The consumer's half of the contract. Survey cannot see your storage. The
binding attests the queue only if you (1) call bindReviewQueue at queue
construction and never again for that round, (2) persist it beside the queue,
and (3) pass the stored binding to validation. Calling bindReviewQueue at
save time reintroduces the tautology this exists to remove.
What stays yours. Storage layout, transport validation, and any round
semantics the extraction cannot attest. A recheck round mixing items from a
prior observation is the consumer's dispatch: deciding which attestation applies
to which item from a single mutable label was one of fieldwork#60's bypasses,
and the data that can cross-check the label lives with you, not here.
bindReviewQueue refuses an empty queue and duplicate item names outright — a
binding over nothing attests nothing, and an ambiguous name would let two items
answer for one membership.
Every refusal above is fault-injected by npm run check:guards, which removes
each guard in turn and requires the suite covering it to fail — a guard no test
fails without is decoration. Compilation is judged separately: an injection
that fails to compile is a matrix failure (the "catch" would belong to the
compiler, not a test), so a reported catch always means a test went red. The
matrix also pins the documented boundary: the
coordinated record rewrite that the cross-check blesses is asserted as a pass
by a dedicated test, and check:guards fails if that test is removed or stops
observing the pass, so this section cannot quietly claim more than the module
enforces.
Audit Details Rows#
Every row inside a field card's AUDIT DETAILS carries a stable machine name:
<div class="kv" data-audit-row="raw-source-id">
<dt class="field-label">Raw Source ID</dt>
<dd class="field-value">…</dd>
</div>
reviewAuditRowKeys (exported from @kontourai/survey/review-workbench) is the
complete set of keys Survey emits, and ReviewAuditRowKey is its type. Row
labels are display copy and will change; the keys will not. A host that
needs to address one row — restyle it, reorder it, or hide it on its own
surface — selects on the key:
/* This host promotes the locator onto the face of its own card. */
.my-shell .survey-workbench-embed [data-audit-row="locator"] { display: none; }
Deriving a selector by slugging label text is not a supported way to address a row, and this attribute exists so that nobody has to.
Keys are additive: a key may be added, and a row may stop being emitted when it becomes a duplicate or a constant, but a key is never renamed or repointed at a different fact.
Survey does its own deduplication, for four rows. The audit surface prints a
card's identifiers and provenance in several places (the ID stack, Raw Source,
each section's "IDs and trace links"), and where two of those placements report
the same property of the same record, the workbench prints it once — the
first, highest-context placement wins. In practice that is raw-source-id,
extractor, extraction-id and excerpt: the placements that actually
coincide.
One repeat is deliberately left in place. The Unselected candidate history's
"IDs and trace links" prints candidate-id, which on a two-candidate item is
the same id the ID stack already showed as Current candidate ID or Proposed candidate ID. It stays because it is the only row naming which unselected
candidate a value belongs to, it stops being a repeat as soon as there is a
third candidate, and suppressing it made that section's disclosure appear and
disappear with the candidate count — structure a host would then have to key on.
Hide it with [data-audit-row="candidate-id"] if your surface does not want it.
Suppression is keyed on the fact, never on the rendered string. Two rows showing
the same text are not necessarily the same fact: a candidate's
extraction.extractedAt and its source.observedAt are frequently the same
timestamp, and both rows render. A value can only remove a later printing of
the property it came from, and only when that printing agrees with it — a
placement that resolves differently (a projection override versus the
candidate's own field) still renders, because the divergence is the interesting
part. In practice this affects four rows: raw-source-id, extractor,
extraction-id, and excerpt. Deciding to keep the current value makes the
projection preview describe a different candidate from the ID stack above it, so
nothing is suppressed and every row renders.
Sections holding only a constant that reports an absence are not rendered at all: an empty portable authority trace, a card with no unselected candidates, and the review event of a resolution that projects none. Every "IDs and trace links" disclosure keeps at least one reference that can never be suppressed, so the disclosure itself does not appear and disappear with the data.
Hosts should not need to prune duplicates; if you find yourself doing so, that is a defect to report upstream rather than to style around.
Export Results#
Use buildReviewWorkbenchResultsFromSession when the producer wants a compact
view of completed review choices for display, audit export, or trusted
in-process code. Use buildReviewWorkbenchSessionExport when the producer also
wants the replayable session and event resources.
import {
buildReviewWorkbenchResultsFromSession,
buildReviewWorkbenchSessionExport,
replayReviewSessionEvents,
} from "@kontourai/survey/review-workbench";
const replayedSession = replayReviewSessionEvents(session, persistedEvents);
const results = buildReviewWorkbenchResultsFromSession(replayedSession);
const exported = buildReviewWorkbenchSessionExport(replayedSession, persistedEvents);
for (const result of results) {
renderReviewSummary({
reviewItemName: result.reviewItemName,
decision: result.decision,
selectedCandidateId: result.selectedCandidateId,
selectedValue: result.selectedValue,
reviewDecision: result.reviewDecision,
unselectedCandidates: result.unselectedCandidates,
});
}
The producer still decides whether a selected candidate updates a record, creates a rejected-candidate learning signal, triggers a recomputation, or only records an audit event. For web mutation routes, use the server-side apply pattern below instead of trusting browser-computed results.
Apply Review Results#
Survey can derive the selected candidate, review decision resource, and
replayable audit trail. The producer still owns write authority. A server-side
apply path should load the product's current record, rebuild or load the
pre-decision ReviewItem/ReviewSession snapshot that was presented for
review, replay persisted events against that snapshot, derive
ReviewWorkbenchResult values through Survey, validate those values against
the current product state, and only then apply product-specific policy.
A reviewer's inline edit to a proposed value is carried in the decision event
itself (data.workbenchEditedValue) and reconstructed by
replayReviewSessionEvents, so snapshot + persisted events is a complete
record: the derived effectiveValue reflects the edit without any separate
edit channel. Persist the events (which the workbench emits) and you have the
edit; you do not need to capture editedValuesByItemName out of band.
For server-side replay, prefer the snapshot-safe apply preparation helper:
import {
deriveReviewSessionApplyResultForSnapshot,
} from "@kontourai/survey/review-workbench";
const applyResult = deriveReviewSessionApplyResultForSnapshot({
snapshot: reviewSessionSnapshot,
events: persistedEvents,
requiredResolvedItems: "all",
});
if (!applyResult.ok) {
throw new Error(applyResult.issues.map((issue) => issue.message).join(" "));
}
for (const result of applyResult.results) {
assertProductRecordStillMatchesReviewTarget(result);
applyProductPolicy({
reviewItemName: result.reviewItemName,
selectedCandidateId: result.selectedCandidateId,
selectedValue: result.selectedValue,
status: result.status,
});
}
applyReviewSession is the recommended one-call entry point for this path. It
collapses the resolve-record → derive → normalize-errors → enforce-policy →
map-to-actions choreography into a single call that returns a discriminated
{ ok } result instead of throwing, and it never changes behavior unless you opt
in to enforceProducerPolicy. The manual deriveServerReviewSessionApplyResult
and mapReviewWorkbenchResultsToApplyActions pieces above remain available for
custom choreography:
import { applyReviewSession } from "@kontourai/survey/review-workbench/server-review-session";
const applied = applyReviewSession({
snapshot: reviewSessionSnapshot,
sessionName,
events: persistedEvents,
requiredResolvedItems: "all",
mapActions: {
requireUniqueTargets: true,
map: ({ result, target }) =>
result.decision === "accept-proposed"
? { kind: "apply-field", target }
: { kind: "leave-current", target },
},
});
if (!applied.ok) {
// applied.issues carries normalized { code } values: "stale-session",
// "invalid-events", "unresolved-review-item", "decision-mode-violation",
// or "action-mapping-failed".
throw new Error(applied.issues.map((issue) => issue.message).join(" "));
}
for (const { action } of applied.actions) {
applyProductAction(action);
}
Use requiredResolvedItems: "all" for full approval flows and "any" for
partial apply flows where at least one reviewed item must be ready. Survey
returns replay and completion issues as data so product API routes can choose
their own HTTP status, audit logging, and reviewer-facing copy.
Do not accept browser-submitted ReviewDecision resources,
ReviewWorkbenchSessionExport.results, or standalone decision fields as write
authority for web mutations. Those payloads are useful for display,
debugging, local trusted scripts, or audit export, but a product server should
derive write results from trusted session state and persisted events. Events
alone are also insufficient when the server cannot reconstruct the reviewed
candidate values; store the reviewed session snapshot or rebuild it from
server-owned data.
Review actor and write time should come from authenticated server context for mutations. The actor and timestamp in Survey resources describe the review session, but the product owns authorization, tenancy, reviewer assignment, write stamps, and conflict handling.
Project To Surface#
Review resources are not a second Surface projection path. When a producer is
ready to expose trust state, it should emit normal Survey observations or claim
records and then call buildSurveyTrustBundle.
When those records come directly from a successful applyReviewSession, prefer
buildCanonicalReviewedTrustInput. It derives the complete SurveyInput from
the server-owned items and applied results, and returns the stable
projectionContextId to pass to buildSurveyTrustBundle. This avoids a second
consumer-maintained mapping where status, edited values, or provenance could
drift from the canonical review. See
Canonical reviewed TrustInput.
import {
buildSurveyTrustBundle,
reviewedCurrentProposedResolution,
SurveyInputBuilder,
} from "@kontourai/survey";
import { buildTrustReport, validateTrustBundle } from "@kontourai/surface";
const surveyInput = new SurveyInputBuilder({
source: "example-producer.review-session-1",
})
.addClaimRecords(reviewedCurrentProposedResolution({
id: "public-record.entity-123.registrationStatus.review-1",
target: "registrationStatus",
selectedCandidateRole: "proposed",
reviewOutcome: {
status: "verified",
actor: "reviewer@example.test",
reviewedAt: new Date().toISOString(),
rationale: "Source excerpt supports the proposed value.",
},
currentObservation,
proposedObservation,
}))
.build();
const report = buildTrustReport(validateTrustBundle(
buildSurveyTrustBundle(surveyInput),
));
For source-authority claims, prefer
sourceOfAuthorityObservationBuilder. For corrected documents or other
multi-candidate cases, prefer candidateReviewRecord or the lower-level record
contract when current/proposed semantics do not fit.
To emit an empirically-calibrated conclusion probability on affirmed claims, pass
the opt-in calibration option (added in 1.10.0). It sets
conclusionConfidence.value from the affirmation rate of each extractor's proposals
— the produce side of the confidence loop. It is backward-compatible: omit it and
value stays unset, exactly as before.
// Recommended: pass a curve derived over a longer history than this batch.
const history = deriveCalibration({
reviewOutcomes, // prior review outcomes, candidate sets, and extractions
candidateSets, // from more than the current batch
extractions,
});
buildSurveyTrustBundle(surveyInput, { calibration: { metrics: history, minSamples: 20 } });
Calibration is advisory only — it enriches conclusionConfidence, never a claim's
status. See record-contracts.md for
deriveCalibration, the advisory suggestedThreshold for auto-accept policies, and
the honest limits. A runnable end-to-end walkthrough — history → grounded threshold
→ produced value — is in
examples/calibrated-auto-accept.ts.
Boundary Checklist#
Use this checklist before adding Survey to a producer:
- Product queue state stays product-owned.
- Product auth, tenancy, and reviewer assignment stay product-owned.
- Parser and source ranking policy stay product-owned.
ReviewItemvalues stay typed; do not pre-stringify values for display.sourceRef, locator, excerpt, confidence, extractor, and timestamps are carried explicitly.- Source-authority posture is source posture, not Surface
authorityTrace. - Verified claims include review actor and review time.
- Missing source context produces a warning or gap; do not invent evidence.
- Product apply code consumes selected candidates or
ReviewWorkbenchResultinstead of re-deriving review choices from UI state. - Product write routes derive results server-side from pre-decision review snapshots plus
events; they do not trust browser-computed
ReviewDecisionorsessionExport.resultspayloads. - Product write routes stamp mutating actor and time from authenticated server context.
- Surface projection uses
buildSurveyTrustBundle, not private review UI state.