@kontourai/survey 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -103,6 +103,8 @@ One observation, one chain: the page it came from, what the extractor read, who
103
103
 
104
104
  Keep producer operational state outside Survey. Queue status, reviewer form state, retries, source caches, and product policy decisions belong in the producer's own data model. Survey carries only the portable evidence chain records needed by Surface.
105
105
 
106
+ Need Anthropic-backed `MappingProposer`/`UtteranceClaimExtractor` implementations for step 1? `@kontourai/survey/anthropic` ships production adapters as proposers-only — every output still goes through the normal review path. It's a separate subpath (not re-exported from the package root) so consumers without `@anthropic-ai/sdk` installed pay nothing.
107
+
106
108
  When you build an `authorized-action` authorizing block outside the workbench, pair `buildAuthorizedActionAuthorizing` with `buildPromptRef({ module, component, version?, scheme? })` — `buildPromptRef` formats a well-formed, versioned `promptRef` (bare `"review-workbench/decision-card@v1"` or scheme-prefixed `"survey://<module>/<component>@v1"`) that `buildAuthorizedActionAuthorizing` accepts directly, instead of hand-formatting the string.
107
109
 
108
110
  ## Review lifecycle
@@ -201,12 +203,18 @@ npx survey-review-mcp --session path/to/session.json
201
203
 
202
204
  Three tools: `survey_review_queue` (queue state), `survey_review_item` (item detail), and `survey_review_decide` (record a decision). Each queue and item call includes an embedded, fully self-contained review card with Accept / Hold / Reject / Could not confirm actions. See [docs/review-mcp.md](docs/review-mcp.md).
203
205
 
204
- At viewports ≤ 980 px, the queue panel becomes a slide-in drawer with a compact progress bar. At narrow container widths, `cqi`-based type scaling keeps candidate values from overflowing at 360 px. CSS custom properties (`--k-*`) inherit through the shadow boundary so the host can theme either mode without forking styles.
206
+ The embedded card is a fixed-size resource (a preferred frame of 420×560) that follows the host's light/dark `prefers-color-scheme`; it does not resize its layout at other breakpoints. For a card that adapts its layout to available width — stacking the current → proposed diff vertically and growing tap targets via CSS container queries below 620px/480px, with a viewport `@media` fallback — embed the [Review Workbench](#review-workbench-embed) directly instead.
205
207
 
206
208
  Server code persisting browser-submitted review events should use `persistReviewSessionEvents`, then `deriveReviewSessionApplyResultForSnapshot` (or the composed `deriveServerReviewSessionApplyResult` from `@kontourai/survey/review-workbench/server-review-session`) before applying product policy — write results derive from pre-decision snapshots plus persisted events, never from browser-computed decisions.
207
209
 
208
210
  The [Consumer Integration Guide](docs/consumer-integration-guide.md) covers the full path from `ReviewItem` construction through persisted review events, exported results, Surface projection, and the full `--k-*` theming token list. Test-covered examples are under [`examples/review-workbench/`](examples/review-workbench/). To run the standalone demo locally, see [Review Workbench Prototype](docs/review-workbench-prototype.md).
209
211
 
212
+ Portable extraction imports can also attach a source-linked, read-only inspector
213
+ to the same workbench. It verifies resolved artifact identity before showing
214
+ exact highlights, keeps unresolved material visibly non-grounded, and exports
215
+ with source text and excerpts redacted by default. See
216
+ [Extraction Envelope Import](docs/extraction-envelope-import.md#source-linked-inspection).
217
+
210
218
 
211
219
  ## Standalone review console
212
220
 
@@ -309,7 +309,7 @@ body {
309
309
  </button>
310
310
  </div>
311
311
  </header>
312
- <main id="review-workbench" class="workbench" data-testid="review-workbench"></main>
312
+ <main id="review-workbench" class="workbench survey-workbench-embed" data-testid="review-workbench"></main>
313
313
  <script type="module">
314
314
  import { mountReviewWorkbench, replayReviewSessionEvents, defaultReviewSessionName, buildReviewSessionEvents } from "${workbenchJsPath}";
315
315
 
@@ -162,3 +162,5 @@ export declare function buildReviewItemsFromExtractionEnvelopeImport(record: Ext
162
162
  export declare function exportExtractionEnvelopeImport(record: ExtractionEnvelopeImport): string;
163
163
  export declare function reimportExtractionEnvelope(serialized: string): ExtractionEnvelopeImport;
164
164
  export declare function createExtractionEnvelopeResolutionIdentity(record: ExtractionEnvelopeImport, proposalIndex: number): ExtractionEnvelopeResolutionIdentity;
165
+ /** Validate and defensively clone a persisted or browser-delivered import. */
166
+ export declare function validateExtractionEnvelopeImport(value: unknown): ExtractionEnvelopeImport;
@@ -1,4 +1,4 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { sha256Hex } from "./sha256.js";
2
2
  import { reviewResourceApiVersion } from "./review-resource.js";
3
3
  import { canonicalJson } from "./review-workbench/canonical.js";
4
4
  /** The upstream-owned portable extraction-result wire identifiers accepted by this adapter. */
@@ -62,7 +62,7 @@ export function createExtractionEnvelopeResolutionIdentity(record, proposalIndex
62
62
  throw new Error(`Extraction envelope import ${record.metadata.name} has no proposal at index ${proposalIndex}.`);
63
63
  }
64
64
  const base = identityHash(identityInputs(record, record.spec.envelope.result.proposals[proposalIndex], proposalIndex));
65
- const nonce = randomUUID();
65
+ const nonce = globalThis.crypto.randomUUID();
66
66
  return { evidenceId: `survey.extraction.${base}.resolution-evidence.${nonce}`, eventId: `survey.extraction.${base}.resolution-event.${nonce}` };
67
67
  }
68
68
  function buildReviewItem(record, proposal, index) {
@@ -165,6 +165,11 @@ function validateImport(value) {
165
165
  if (canonicalJson(status.diagnostics) !== canonicalJson(expected) || status.state !== (expected.length ? "unresolved" : "grounded"))
166
166
  throw new Error("Import status does not match envelope state.");
167
167
  }
168
+ /** Validate and defensively clone a persisted or browser-delivered import. */
169
+ export function validateExtractionEnvelopeImport(value) {
170
+ validateImport(value);
171
+ return cloneJson(value);
172
+ }
168
173
  function validateEnvelope(input) {
169
174
  jsonSafe(input, "Portable extraction envelope");
170
175
  const e = obj(input, "envelope");
@@ -251,7 +256,7 @@ function validateOccurrence(input, start, end) { const o = obj(input, "occurrenc
251
256
  function validateArtifact(input) { const a = obj(input, "preparedArtifact"); exact(a, ["format", "version", "digest", "ref", "preparationMode", "preparationVersion", "contentLength"], "preparedArtifact", ["sourceSnapshotRef"]); if (a.format !== "traverse-prepared-artifact" || a.version !== 1)
252
257
  throw new Error("prepared artifact format is invalid."); digest(a.digest, "artifact.digest", false); if (!PREPARATION_MODES.has(a.preparationMode))
253
258
  throw new Error("artifact.preparationMode is invalid."); nonEmpty(a.preparationVersion, "artifact.preparationVersion"); integer(a.contentLength, "artifact.contentLength"); if (a.sourceSnapshotRef !== undefined)
254
- wireNonEmpty(a.sourceSnapshotRef, "artifact.sourceSnapshotRef"); const binding = JSON.stringify({ format: a.format, version: a.version, digest: a.digest, preparationMode: a.preparationMode, preparationVersion: a.preparationVersion, contentLength: a.contentLength, sourceSnapshotRef: a.sourceSnapshotRef ?? null }); const expectedRef = `traverse-prepared-artifact:v1:sha256:${createHash("sha256").update(binding).digest("hex")}`; if (a.ref !== expectedRef)
259
+ wireNonEmpty(a.sourceSnapshotRef, "artifact.sourceSnapshotRef"); const binding = JSON.stringify({ format: a.format, version: a.version, digest: a.digest, preparationMode: a.preparationMode, preparationVersion: a.preparationVersion, contentLength: a.contentLength, sourceSnapshotRef: a.sourceSnapshotRef ?? null }); const expectedRef = `traverse-prepared-artifact:v1:sha256:${sha256Hex(binding)}`; if (a.ref !== expectedRef)
255
260
  throw new Error("prepared artifact ref does not match its identity binding."); return cloneJson(a); }
256
261
  function validateArtifactState(input, artifact) { if (!artifact)
257
262
  throw new Error("prepared artifact state requires prepared artifact."); const s = obj(input, "preparedArtifactState"); const status = s.status; if (status === "digest-mismatch") {
@@ -358,7 +363,7 @@ function stableIdentity(v, subject) { wireNonEmpty(v, subject); if (!STABLE_IDEN
358
363
  function preparedReference(v, subject) { safeReference(v, subject); if (!/^traverse-prepared-artifact:v1:sha256:[a-f0-9]{64}$/.test(v))
359
364
  throw new Error(`${subject} must be a prepared-artifact reference.`); }
360
365
  function cloneJson(v) { return JSON.parse(JSON.stringify(v)); }
361
- function identityHash(v) { return createHash("sha256").update(canonicalJson(v)).digest("hex").slice(0, 20); }
366
+ function identityHash(v) { return sha256Hex(canonicalJson(v)).slice(0, 20); }
362
367
  function jsonSafe(v, subject, seen = new Set()) { if (v === null || typeof v === "boolean")
363
368
  return; if (typeof v === "string") {
364
369
  if (!isWellFormedUnicode(v))
@@ -1,7 +1,9 @@
1
1
  export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, EscalationDimension, EscalationRecord, Extraction, Interpretation, LocatorScheme, ProvenanceResolution, RawSource, RawSourceKind, ReviewAuthorizing, ReviewAuthorizingAuthorizedAction, ReviewAuthorizingExchange, ReviewAuthorizingExplicitStatement, ReviewAuthorizingKind, ReviewOutcome, ReviewResolution, ReviewStatus, SurveyInput, } from "./types.js";
2
2
  export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
3
3
  export { reviewResourceApiVersion } from "./review-resource.js";
4
- export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, } from "./extraction-envelope.js";
4
+ export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
5
+ export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
6
+ export type { ExtractionAlignmentState, ArtifactUnavailableCode, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
5
7
  export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionOccurrence, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
6
8
  export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
7
9
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
package/dist/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
2
2
  export { reviewResourceApiVersion } from "./review-resource.js";
3
- export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, } from "./extraction-envelope.js";
3
+ export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
4
+ export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
4
5
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
5
6
  export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js";
6
7
  export { reviewedCurrentProposedResolution } from "./reviewed-current-proposed-resolution.js";
@@ -0,0 +1,77 @@
1
+ import { type ExtractionEnvelopeImportResult } from "../extraction-envelope.js";
2
+ export type ExtractionAlignmentState = "aligned" | "excerpt-mismatch" | "artifact-unavailable" | "digest-mismatch";
3
+ export type ArtifactUnavailableCode = "not-found" | "storage-error" | "access-denied" | "invalid-artifact" | "unknown";
4
+ export type ResolvedExtractionArtifact = {
5
+ status: "available";
6
+ text: string;
7
+ actualDigest: string;
8
+ } | {
9
+ status: "unavailable";
10
+ code: ArtifactUnavailableCode;
11
+ } | {
12
+ status: "digest-mismatch";
13
+ actualDigest: string;
14
+ };
15
+ export interface ExtractionInspectorEntry {
16
+ /** Result returned by importExtractionEnvelope; includes authoritative ReviewItem identities. */
17
+ importResult: ExtractionEnvelopeImportResult;
18
+ artifact: ResolvedExtractionArtifact;
19
+ /** Optional producer-declared pass label, kept outside the upstream envelope. */
20
+ pass?: string;
21
+ }
22
+ export type ExtractionInspectorInput = ExtractionInspectorEntry | {
23
+ imports: ExtractionInspectorEntry[];
24
+ };
25
+ export interface ExtractionInspectorCandidate {
26
+ id: string;
27
+ sourceKey: string;
28
+ reviewItemName: string;
29
+ proposalIndex: number;
30
+ field: string;
31
+ provider: string;
32
+ model?: string;
33
+ attempt: string;
34
+ pass?: string;
35
+ valueType: string;
36
+ inferenceType: "explicit" | "inferred";
37
+ start: number;
38
+ end: number;
39
+ excerpt: string;
40
+ alignment: ExtractionAlignmentState;
41
+ }
42
+ export interface ExtractionInspectorSource {
43
+ key: string;
44
+ importName: string;
45
+ artifactRef?: string;
46
+ expectedDigest?: string;
47
+ actualDigest?: string;
48
+ artifactText?: string;
49
+ alignment: ExtractionAlignmentState;
50
+ message: string;
51
+ }
52
+ export interface ExtractionInspectorModel {
53
+ sources: ExtractionInspectorSource[];
54
+ candidates: ExtractionInspectorCandidate[];
55
+ }
56
+ export interface ExtractionInspectorFilters {
57
+ field?: string;
58
+ provider?: string;
59
+ model?: string;
60
+ attempt?: string;
61
+ pass?: string;
62
+ inferenceType?: "explicit" | "inferred";
63
+ alignment?: ExtractionAlignmentState;
64
+ }
65
+ export interface ExtractionInspectorExportOptions {
66
+ includePreparedText?: boolean;
67
+ includeExcerpts?: boolean;
68
+ }
69
+ /**
70
+ * Build a read-only view from one or more results already produced by Survey's
71
+ * public import boundary. The function rechecks the result/ReviewItem binding
72
+ * and fails closed if mutable caller data has drifted since import.
73
+ */
74
+ export declare function buildExtractionInspectorModel(input: ExtractionInspectorInput): ExtractionInspectorModel;
75
+ export declare function filterExtractionInspectorCandidates(model: ExtractionInspectorModel, filters: ExtractionInspectorFilters): ExtractionInspectorCandidate[];
76
+ export declare function exportExtractionInspector(model: ExtractionInspectorModel, options?: ExtractionInspectorExportOptions): string;
77
+ export declare function mountExtractionInspector(container: HTMLElement, model: ExtractionInspectorModel): () => void;
@@ -0,0 +1,198 @@
1
+ import { buildReviewItemsFromExtractionEnvelopeImport, validateExtractionEnvelopeImport } from "../extraction-envelope.js";
2
+ import { canonicalJson } from "./canonical.js";
3
+ import { sha256Hex } from "../sha256.js";
4
+ /**
5
+ * Build a read-only view from one or more results already produced by Survey's
6
+ * public import boundary. The function rechecks the result/ReviewItem binding
7
+ * and fails closed if mutable caller data has drifted since import.
8
+ */
9
+ export function buildExtractionInspectorModel(input) {
10
+ const entries = "imports" in input ? input.imports : [input];
11
+ if (!Array.isArray(entries) || entries.length === 0)
12
+ throw new Error("Extraction inspector requires at least one validated import result.");
13
+ const sources = [];
14
+ const candidates = [];
15
+ const sourceKeys = new Set();
16
+ for (const [entryIndex, entry] of entries.entries()) {
17
+ if (!entry.importResult || typeof entry.importResult !== "object")
18
+ throw new Error("Invalid extraction import result.");
19
+ const record = validateExtractionEnvelopeImport(entry.importResult.record);
20
+ assertImportedResult(entry.importResult, record);
21
+ assertResolvedArtifact(entry.artifact);
22
+ const envelope = record.spec.envelope;
23
+ const prepared = envelope.result.preparedArtifact;
24
+ const sourceKey = `${record.metadata.producerNamespace}:${record.metadata.name}:${entryIndex}`;
25
+ if (sourceKeys.has(sourceKey))
26
+ throw new Error("Extraction inspector source identity collision.");
27
+ sourceKeys.add(sourceKey);
28
+ const source = sourceModel(sourceKey, record.metadata.name, prepared, record.status.state, entry.artifact);
29
+ sources.push(source);
30
+ const candidateStart = candidates.length;
31
+ envelope.result.proposals.forEach((proposal, proposalIndex) => {
32
+ const item = entry.importResult.reviewItems[proposalIndex];
33
+ if (!item)
34
+ return; // unresolved imports legitimately produce no ReviewItems
35
+ candidates.push(candidateModel(source, item.metadata.name, proposal, proposalIndex, envelope.result.provider, envelope.result.model, envelope.result.runId, entry.pass));
36
+ });
37
+ if (source.alignment === "excerpt-mismatch") {
38
+ delete source.artifactText;
39
+ for (let index = candidateStart; index < candidates.length; index += 1)
40
+ candidates[index].alignment = "excerpt-mismatch";
41
+ }
42
+ }
43
+ return { sources, candidates };
44
+ }
45
+ function assertResolvedArtifact(artifact) {
46
+ if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
47
+ throw new Error("Invalid resolved extraction artifact.");
48
+ const keys = Object.keys(artifact).sort();
49
+ if (artifact.status === "available") {
50
+ if (keys.join(",") !== "actualDigest,status,text" || typeof artifact.text !== "string" || !/^[a-f0-9]{64}$/.test(artifact.actualDigest))
51
+ throw new Error("Invalid available extraction artifact.");
52
+ }
53
+ else if (artifact.status === "digest-mismatch") {
54
+ if (keys.join(",") !== "actualDigest,status" || !/^[a-f0-9]{64}$/.test(artifact.actualDigest))
55
+ throw new Error("Invalid digest-mismatch extraction artifact.");
56
+ }
57
+ else if (artifact.status === "unavailable") {
58
+ if (keys.join(",") !== "code,status" || !["not-found", "storage-error", "access-denied", "invalid-artifact", "unknown"].includes(artifact.code))
59
+ throw new Error("Invalid unavailable extraction artifact.");
60
+ }
61
+ else
62
+ throw new Error("Invalid resolved extraction artifact status.");
63
+ }
64
+ function assertImportedResult(result, record) {
65
+ if (!result || typeof result !== "object" || !result.record || !Array.isArray(result.reviewItems))
66
+ throw new Error("Invalid extraction import result.");
67
+ const { reviewItems } = result;
68
+ if (record.apiVersion !== "survey.kontourai.io/v1alpha1" || record.kind !== "ExtractionEnvelopeImport")
69
+ throw new Error("Invalid extraction import resource identity.");
70
+ if (!record.metadata?.name || !record.metadata.producerNamespace || !record.spec?.envelope?.result || !Array.isArray(record.spec.envelope.result.proposals))
71
+ throw new Error("Malformed extraction import result.");
72
+ const grounded = record.status?.state === "grounded";
73
+ if ((!grounded && reviewItems.length !== 0) || (grounded && reviewItems.length !== record.spec.envelope.result.proposals.length))
74
+ throw new Error("Extraction import ReviewItems do not match its grounding state.");
75
+ const canonicalItems = buildReviewItemsFromExtractionEnvelopeImport(record);
76
+ if (canonicalJson(reviewItems) !== canonicalJson(canonicalItems))
77
+ throw new Error("Extraction import ReviewItems do not match their canonical identities and bindings.");
78
+ reviewItems.forEach((item, index) => {
79
+ const proposal = record.spec.envelope.result.proposals[index];
80
+ const metadata = item.metadata?.producer?.["survey.kontourai.io/extraction-envelope"];
81
+ const candidate = item.spec?.candidates?.[0];
82
+ const binding = candidate?.producer?.["survey.kontourai.io/extraction-envelope"];
83
+ if (item.kind !== "ReviewItem" || !item.metadata.name || item.spec.candidates.length !== 1
84
+ || metadata?.importName !== record.metadata.name || binding?.importName !== record.metadata.name
85
+ || binding.proposalIndex !== index || binding.runId !== record.spec.envelope.result.runId || binding.provider !== record.spec.envelope.result.provider
86
+ || item.spec.target !== proposal.fieldPath || candidate?.locator?.locator !== proposal.provenance.locator || candidate.locator.excerpt !== proposal.provenance.excerpt) {
87
+ throw new Error(`Extraction import ReviewItem ${index} is inconsistent with its validated proposal.`);
88
+ }
89
+ });
90
+ }
91
+ function sourceModel(key, importName, prepared, state, artifact) {
92
+ let alignment;
93
+ let message;
94
+ if (state !== "grounded" || artifact.status === "unavailable") {
95
+ alignment = "artifact-unavailable";
96
+ message = `Prepared artifact unavailable (${artifact.status === "unavailable" ? artifact.code : "invalid-artifact"}). Candidates are not grounded.`;
97
+ }
98
+ else if (artifact.status === "digest-mismatch" || !prepared || artifact.actualDigest !== prepared.digest
99
+ || sha256Hex(artifact.text) !== artifact.actualDigest) {
100
+ alignment = "digest-mismatch";
101
+ message = "Prepared artifact digest does not match the extraction artifact. Candidates are not grounded.";
102
+ }
103
+ else if (artifact.text.length !== prepared.contentLength) {
104
+ alignment = "artifact-unavailable";
105
+ message = "Prepared artifact content has the wrong length. Candidates are not grounded.";
106
+ }
107
+ else {
108
+ alignment = "aligned";
109
+ message = "Prepared artifact identity verified. Exact source spans are available.";
110
+ }
111
+ return { key, importName, ...(prepared?.ref ? { artifactRef: prepared.ref } : {}), ...(prepared?.digest ? { expectedDigest: prepared.digest } : {}), ...("actualDigest" in artifact ? { actualDigest: artifact.actualDigest } : {}), ...(alignment === "aligned" && artifact.status === "available" ? { artifactText: artifact.text } : {}), alignment, message };
112
+ }
113
+ function candidateModel(source, reviewItemName, proposal, index, provider, model, attempt, pass) {
114
+ const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
115
+ if (!match)
116
+ throw new Error(`Extraction proposal ${index} has an invalid text span.`);
117
+ const start = Number(match[1]), end = Number(match[2]);
118
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || end < start)
119
+ throw new Error(`Extraction proposal ${index} has an invalid text span.`);
120
+ const alignment = source.alignment === "aligned" && source.artifactText.slice(start, end) !== proposal.provenance.excerpt ? "excerpt-mismatch" : source.alignment;
121
+ if (alignment === "excerpt-mismatch") {
122
+ source.alignment = alignment;
123
+ source.message = "One or more source spans do not match their recorded excerpts. Affected candidates are not grounded.";
124
+ }
125
+ return { id: `${source.key}:proposal:${index}`, sourceKey: source.key, reviewItemName, proposalIndex: index, field: proposal.fieldPath, provider, ...(model ? { model } : {}), attempt, ...(pass ? { pass } : {}), valueType: proposal.valueType ?? inferValueType(proposal.candidateValue), inferenceType: proposal.inferenceType ?? "inferred", start, end, excerpt: proposal.provenance.excerpt, alignment };
126
+ }
127
+ function inferValueType(value) { if (Array.isArray(value))
128
+ return "array"; if (value !== null && typeof value === "object")
129
+ return "object"; return typeof value === "number" ? "number" : typeof value === "boolean" ? "boolean" : "string"; }
130
+ export function filterExtractionInspectorCandidates(model, filters) {
131
+ return model.candidates.filter((c) => (!filters.field || c.field === filters.field) && (!filters.provider || c.provider === filters.provider) && (!filters.model || c.model === filters.model) && (!filters.attempt || c.attempt === filters.attempt) && (!filters.pass || c.pass === filters.pass) && (!filters.inferenceType || c.inferenceType === filters.inferenceType) && (!filters.alignment || c.alignment === filters.alignment));
132
+ }
133
+ export function exportExtractionInspector(model, options = {}) {
134
+ return canonicalJson({ apiVersion: "survey.kontourai.io/v1alpha1", kind: "ExtractionInspectorExport", spec: {
135
+ redaction: { preparedTextIncluded: options.includePreparedText === true, excerptsIncluded: options.includeExcerpts === true },
136
+ sources: model.sources.map(({ artifactText, message: _message, ...source }) => ({ ...source, preparedText: options.includePreparedText ? artifactText ?? null : "[redacted]" })),
137
+ candidates: model.candidates.map((candidate) => ({ ...candidate, excerpt: options.includeExcerpts ? candidate.excerpt : "[redacted]" })),
138
+ } });
139
+ }
140
+ export function mountExtractionInspector(container, model) {
141
+ const root = document.createElement("section");
142
+ root.className = "extraction-inspector";
143
+ root.setAttribute("aria-label", "Source-linked extraction inspector");
144
+ const options = (values) => [...new Set(values.filter((v) => Boolean(v)))].map((v) => `<option value="${escapeHtml(v)}">${escapeHtml(v)}</option>`).join("");
145
+ root.innerHTML = `<div class="inspector-heading"><div><p class="eyebrow">Source inspector</p><h2>Extraction evidence</h2></div><div class="inspector-postures" aria-live="polite"></div></div><div class="inspector-filters" aria-label="Extraction filters">${filterSelect("field", "Field", options(model.candidates.map(c => c.field)))}${filterSelect("provider", "Provider", options(model.candidates.map(c => c.provider)))}${filterSelect("model", "Model", options(model.candidates.map(c => c.model)))}${filterSelect("attempt", "Attempt", options(model.candidates.map(c => c.attempt)))}${filterSelect("pass", "Pass", options(model.candidates.map(c => c.pass)))}${filterSelect("inferenceType", "Type origin", '<option value="explicit">explicit</option><option value="inferred">inferred</option>')}${filterSelect("alignment", "Alignment", options(model.candidates.map(c => c.alignment)))}</div><div class="inspector-layout"><ol class="inspector-candidates" aria-label="Extraction candidates"></ol><div class="inspector-sources"></div></div>`;
146
+ container.appendChild(root);
147
+ const list = root.querySelector("ol"), sourcesRoot = root.querySelector(".inspector-sources"), postures = root.querySelector(".inspector-postures");
148
+ const filters = {};
149
+ const render = () => {
150
+ const visible = filterExtractionInspectorCandidates(model, filters);
151
+ list.innerHTML = visible.map(c => `<li><button type="button" class="inspector-candidate" id="candidate-${safeId(c.id)}" data-candidate-id="${escapeHtml(c.id)}" aria-controls="highlight-${safeId(c.id)}"><strong>${escapeHtml(c.field)}</strong><span>${escapeHtml(c.provider)}${c.model ? ` / ${escapeHtml(c.model)}` : ""}</span><span>${escapeHtml(c.inferenceType)} ${escapeHtml(c.valueType)} · ${escapeHtml(c.alignment)}</span></button></li>`).join("") || "<li>No candidates match these filters.</li>";
152
+ postures.innerHTML = model.sources.map(s => `<div class="inspector-posture ${s.alignment}" role="status"><strong>${escapeHtml(s.importName)}: ${escapeHtml(s.alignment)}</strong><span>${escapeHtml(s.message)}</span></div>`).join("");
153
+ sourcesRoot.innerHTML = model.sources.map(s => { const candidates = visible.filter(c => c.sourceKey === s.key); return `<div class="inspector-source" aria-label="Prepared source for ${escapeHtml(s.importName)}"><h3>${escapeHtml(s.importName)}</h3><pre tabindex="0">${s.artifactText === undefined ? `<span class="source-unavailable">${escapeHtml(s.message)}</span>` : renderSource(s.artifactText, candidates)}</pre></div>`; }).join("");
154
+ };
155
+ root.querySelectorAll("select").forEach(select => select.addEventListener("change", event => { event.stopPropagation(); const key = select.dataset.filter; if (select.value)
156
+ filters[key] = select.value;
157
+ else
158
+ delete filters[key]; render(); }));
159
+ const activateCandidate = (id) => { const candidate = model.candidates.find(c => c.id === id); if (!candidate)
160
+ return; root.dispatchEvent(new CustomEvent("survey-extraction-candidate-activate", { bubbles: true, composed: true, detail: { candidateId: id, reviewItemName: candidate.reviewItemName } })); };
161
+ root.addEventListener("click", event => { const candidate = event.target.closest("button[data-candidate-id]"); if (candidate) {
162
+ event.preventDefault();
163
+ event.stopPropagation();
164
+ activateCandidate(candidate.dataset.candidateId);
165
+ return;
166
+ } const highlight = event.target.closest("button[data-highlight-candidate-id]"); if (highlight) {
167
+ event.preventDefault();
168
+ event.stopPropagation();
169
+ root.querySelector(`#candidate-${CSS.escape(safeId(highlight.dataset.highlightCandidateId))}`)?.focus();
170
+ } });
171
+ root.addEventListener("keydown", event => { const button = event.target.closest("button[data-candidate-id]"); if (button && (event.key === "Enter" || event.key === " ")) {
172
+ event.preventDefault();
173
+ event.stopPropagation();
174
+ activateCandidate(button.dataset.candidateId);
175
+ } });
176
+ render();
177
+ return () => root.remove();
178
+ }
179
+ function renderSource(text, candidates) {
180
+ const boundaries = new Set([0, text.length]);
181
+ candidates.forEach(c => { boundaries.add(c.start); boundaries.add(c.end); });
182
+ const points = [...boundaries].sort((a, b) => a - b);
183
+ let html = "";
184
+ const starts = new Map();
185
+ candidates.forEach(c => starts.set(c.start, [...(starts.get(c.start) ?? []), c]));
186
+ for (let i = 0; i < points.length - 1; i++) {
187
+ const start = points[i], end = points[i + 1];
188
+ for (const c of starts.get(start) ?? [])
189
+ html += `<button type="button" class="highlight-anchor" id="highlight-${safeId(c.id)}" data-highlight-candidate-id="${escapeHtml(c.id)}" aria-label="Source highlight for ${escapeHtml(c.field)}; activate to return to candidate"></button>`;
190
+ const segment = escapeHtml(text.slice(start, end));
191
+ const active = candidates.filter(c => c.start < end && c.end > start);
192
+ html += active.length ? `<mark aria-label="Highlighted for ${active.map(c => escapeHtml(c.field)).join(", ")}">${segment}</mark>` : segment;
193
+ }
194
+ return html;
195
+ }
196
+ function filterSelect(key, label, choices) { return `<label>${escapeHtml(label)}<select data-filter="${key}"><option value="">All</option>${choices}</select></label>`; }
197
+ function safeId(value) { return value.replace(/[^a-zA-Z0-9_-]/g, "-"); }
198
+ function escapeHtml(text) { return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;"); }
@@ -187,6 +187,25 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
187
187
  color: var(--k-text);
188
188
  }
189
189
 
190
+ /* Read-only evidence pane attached to the existing ReviewItem queue. */
191
+ .survey-workbench-embed .extraction-inspector{ container-type: inline-size; margin-top: 1rem; padding: 1rem; border: 1px solid var(--k-line); border-radius: var(--k-radius); background: var(--k-panel); color: var(--k-text); }
192
+ .survey-workbench-embed .inspector-heading, .survey-workbench-embed .inspector-layout{ display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
193
+ .survey-workbench-embed .inspector-heading h2{ margin: 0; }
194
+ .survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
195
+ .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
196
+ .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
197
+ .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
198
+ .survey-workbench-embed .inspector-filters select{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
199
+ .survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
200
+ .survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
201
+ .survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
202
+ .survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
203
+ .survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
204
+ .survey-workbench-embed .highlight-anchor{ display: inline-block; width: 1px; height: 1em; }
205
+ .survey-workbench-embed .highlight-anchor:focus{ outline: 3px solid var(--k-active); }
206
+ .survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
207
+ @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
208
+
190
209
  /* Re-assert the base structural tokens for light mode. themes.css's \`.theme-*\`
191
210
  preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
192
211
  works" without requiring [data-theme] to be set), and a bare class selector
@@ -34,6 +34,7 @@
34
34
  */
35
35
  import { type ReviewQueueSessionState, type ReviewWorkbenchState } from "./review-workbench.js";
36
36
  import type { ReviewPresentationAdapter } from "./review-presentation.js";
37
+ import { type ExtractionInspectorInput } from "./extraction-inspector.js";
37
38
  export declare class SurveyReviewWorkbenchElement extends HTMLElement {
38
39
  #private;
39
40
  static readonly observedAttributes: string[];
@@ -44,6 +45,9 @@ export declare class SurveyReviewWorkbenchElement extends HTMLElement {
44
45
  /** Optional presentation adapter for custom labels, value summaries, and links. */
45
46
  get presentationAdapter(): ReviewPresentationAdapter | undefined;
46
47
  set presentationAdapter(value: ReviewPresentationAdapter | undefined);
48
+ /** Optional read-only source pane attached to this workbench's existing review lifecycle. */
49
+ get extractionInspector(): ExtractionInspectorInput | null;
50
+ set extractionInspector(value: ExtractionInspectorInput | null | undefined);
47
51
  connectedCallback(): void;
48
52
  attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
49
53
  }
@@ -33,6 +33,7 @@
33
33
  * src — URL to fetch a JSON-serialised ReviewQueueSessionState
34
34
  */
35
35
  import { mountReviewWorkbench, } from "./review-workbench.js";
36
+ import { buildExtractionInspectorModel, mountExtractionInspector, } from "./extraction-inspector.js";
36
37
  import { REVIEW_WORKBENCH_CSS } from "./review-workbench-css.generated.js";
37
38
  /** @internal Second adopted stylesheet: host token defaults + inheritance delegation.
38
39
  *
@@ -167,6 +168,8 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
167
168
  static observedAttributes = ["theme", "color-scheme", "src"];
168
169
  #session = null;
169
170
  #presentationAdapter = undefined;
171
+ #extractionInspector = null;
172
+ #unmountInspector;
170
173
  #root;
171
174
  #mountRoot;
172
175
  #mounted = false;
@@ -285,6 +288,18 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
285
288
  this.#mountRoot = document.createElement("div");
286
289
  this.#mountRoot.className = "workbench survey-workbench-embed";
287
290
  this.#root.appendChild(this.#mountRoot);
291
+ this.#mountRoot.addEventListener("survey-extraction-candidate-activate", (event) => {
292
+ if (!this.#session || !("items" in this.#session))
293
+ return;
294
+ const detail = event.detail;
295
+ const item = this.#session.items.find((candidate) => candidate.metadata.name === detail.reviewItemName);
296
+ if (!item)
297
+ return;
298
+ if (this.#session.activeItemName !== item.metadata.name)
299
+ this.#session = { ...this.#session, activeItemName: item.metadata.name };
300
+ this.#remount();
301
+ queueMicrotask(() => this.#root.querySelector(`#highlight-${CSS.escape(detail.candidateId.replace(/[^a-zA-Z0-9_-]/g, "-"))}`)?.focus());
302
+ });
288
303
  }
289
304
  /** The review queue session to display. Setting this property re-mounts the workbench. */
290
305
  get session() {
@@ -302,6 +317,21 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
302
317
  this.#presentationAdapter = value;
303
318
  this.#remount();
304
319
  }
320
+ /** Optional read-only source pane attached to this workbench's existing review lifecycle. */
321
+ get extractionInspector() { return this.#extractionInspector; }
322
+ set extractionInspector(value) {
323
+ const next = value ?? null;
324
+ try {
325
+ if (next)
326
+ buildExtractionInspectorModel(next);
327
+ this.#extractionInspector = next;
328
+ this.#remount();
329
+ }
330
+ catch {
331
+ this.#extractionInspector = null;
332
+ this.#renderError("Extraction inspector input is invalid and was not rendered.");
333
+ }
334
+ }
305
335
  connectedCallback() {
306
336
  // Rescue a `session` set before the element was upgraded so the property
307
337
  // assignment reaches the class accessor instead of being shadowed by an
@@ -375,6 +405,10 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
375
405
  : {};
376
406
  this.#applyThemeClasses();
377
407
  mountReviewWorkbench(this.#mountRoot, this.#session, options);
408
+ this.#unmountInspector?.();
409
+ this.#unmountInspector = this.#extractionInspector
410
+ ? mountExtractionInspector(this.#mountRoot, buildExtractionInspectorModel(this.#extractionInspector))
411
+ : undefined;
378
412
  this.#mounted = true;
379
413
  }
380
414
  #renderEmpty() {
@@ -184,6 +184,25 @@
184
184
  color: var(--k-text);
185
185
  }
186
186
 
187
+ /* Read-only evidence pane attached to the existing ReviewItem queue. */
188
+ .survey-workbench-embed .extraction-inspector{ container-type: inline-size; margin-top: 1rem; padding: 1rem; border: 1px solid var(--k-line); border-radius: var(--k-radius); background: var(--k-panel); color: var(--k-text); }
189
+ .survey-workbench-embed .inspector-heading, .survey-workbench-embed .inspector-layout{ display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
190
+ .survey-workbench-embed .inspector-heading h2{ margin: 0; }
191
+ .survey-workbench-embed .inspector-posture{ display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
192
+ .survey-workbench-embed .inspector-posture.digest-mismatch, .survey-workbench-embed .inspector-posture.artifact-unavailable, .survey-workbench-embed .inspector-posture.excerpt-mismatch{ background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
193
+ .survey-workbench-embed .inspector-filters{ display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
194
+ .survey-workbench-embed .inspector-filters label{ display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
195
+ .survey-workbench-embed .inspector-filters select{ color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
196
+ .survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
197
+ .survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
198
+ .survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
199
+ .survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
200
+ .survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
201
+ .survey-workbench-embed .highlight-anchor{ display: inline-block; width: 1px; height: 1em; }
202
+ .survey-workbench-embed .highlight-anchor:focus{ outline: 3px solid var(--k-active); }
203
+ .survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
204
+ @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
205
+
187
206
  /* Re-assert the base structural tokens for light mode. themes.css's `.theme-*`
188
207
  preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
189
208
  works" without requiring [data-theme] to be set), and a bare class selector
@@ -2,6 +2,7 @@ import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type Review
2
2
  import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
3
3
  import { type ReviewPresentationAdapter } from "./review-presentation.js";
4
4
  import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
5
+ export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
5
6
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
6
7
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
7
8
  export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
@@ -7,6 +7,7 @@ import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "
7
7
  import { reviewResourceApiVersion, } from "../review-resource.js";
8
8
  import { validateAuthorizing, buildAuthorizedActionAuthorizing } from "../review-authorizing.js";
9
9
  import { humanizeIdentifier } from "./review-presentation.js";
10
+ export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, } from "./extraction-inspector.js";
10
11
  export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
11
12
  export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
12
13
  export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
@@ -26,13 +27,14 @@ export function buildReviewDecision(state) {
26
27
  reviewOutcomeId: candidate.projection?.reviewOutcomeId
27
28
  ?? `${state.item.metadata.name}:${state.decision}:review-outcome`,
28
29
  };
30
+ const authorizing = buildDecisionCardAuthorizing(state);
29
31
  const reviewDecision = {
30
32
  apiVersion: reviewResourceApiVersion,
31
33
  kind: "ReviewDecision",
32
34
  metadata: {
33
35
  name: `${state.item.metadata.name}-${state.decision}`,
34
- labels: state.item.metadata.labels,
35
- producer: state.item.metadata.producer,
36
+ ...(state.item.metadata.labels ? { labels: state.item.metadata.labels } : {}),
37
+ ...(state.item.metadata.producer ? { producer: state.item.metadata.producer } : {}),
36
38
  },
37
39
  spec: {
38
40
  reviewItemName: state.item.metadata.name,
@@ -50,12 +52,14 @@ export function buildReviewDecision(state) {
50
52
  },
51
53
  reviewedAt: state.reviewedAt,
52
54
  rationale: state.note,
53
- authorizing: buildDecisionCardAuthorizing(state),
54
- projection,
55
- editedValue: state.decision === "accept-proposed" ? state.editedValue : undefined,
55
+ ...(authorizing !== undefined ? { authorizing } : {}),
56
+ ...(projection !== undefined ? { projection } : {}),
57
+ ...(state.decision === "accept-proposed" && state.editedValue !== undefined
58
+ ? { editedValue: state.editedValue }
59
+ : {}),
56
60
  },
57
61
  status: {
58
- appliedToClaimIds: candidate.projection?.claimId ? [candidate.projection.claimId] : undefined,
62
+ ...(candidate.projection?.claimId ? { appliedToClaimIds: [candidate.projection.claimId] } : {}),
59
63
  },
60
64
  };
61
65
  assertReviewResolutionConsistency(`ReviewDecision ${reviewDecision.metadata.name}`, {
@@ -33,6 +33,25 @@
33
33
  color: var(--k-text);
34
34
  }
35
35
 
36
+ /* Read-only evidence pane attached to the existing ReviewItem queue. */
37
+ .extraction-inspector { container-type: inline-size; margin-top: 1rem; padding: 1rem; border: 1px solid var(--k-line); border-radius: var(--k-radius); background: var(--k-panel); color: var(--k-text); }
38
+ .inspector-heading, .inspector-layout { display: grid; grid-template-columns: minmax(15rem, .7fr) minmax(0, 1.3fr); gap: 1rem; }
39
+ .inspector-heading h2 { margin: 0; }
40
+ .inspector-posture { display: flex; flex-direction: column; padding: .75rem; border-radius: var(--k-radius-sm); background: var(--k-positive-wash); }
41
+ .inspector-posture.digest-mismatch, .inspector-posture.artifact-unavailable, .inspector-posture.excerpt-mismatch { background: var(--k-negative-wash); color: var(--k-negative); border: 2px solid currentColor; }
42
+ .inspector-filters { display: flex; flex-wrap: wrap; gap: .6rem; margin: 1rem 0; }
43
+ .inspector-filters label { display: grid; gap: .25rem; font-size: .75rem; color: var(--k-text-muted); }
44
+ .inspector-filters select { color: var(--k-text); background: var(--k-sunken); border: 1px solid var(--k-line); padding: .4rem; }
45
+ .inspector-candidates { margin: 0; padding-left: 1.5rem; }
46
+ .inspector-candidate { width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
47
+ .inspector-source pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
48
+ .inspector-source mark { background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
49
+ .inspector-candidate:focus { outline: 3px solid var(--k-active); outline-offset: 2px; }
50
+ .highlight-anchor { display: inline-block; width: 1px; height: 1em; }
51
+ .highlight-anchor:focus { outline: 3px solid var(--k-active); }
52
+ .source-unavailable { color: var(--k-negative); font-weight: 700; }
53
+ @container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
54
+
36
55
  /* Re-assert the base structural tokens for light mode. themes.css's `.theme-*`
37
56
  preset classes hardcode a dark --k-bg/--k-panel/etc (so the preset "just
38
57
  works" without requiring [data-theme] to be set), and a bare class selector
@@ -0,0 +1,2 @@
1
+ /** Dependency-free synchronous SHA-256 for browser and Node parity. */
2
+ export declare function sha256Hex(text: string): string;
@@ -0,0 +1,40 @@
1
+ /** Dependency-free synchronous SHA-256 for browser and Node parity. */
2
+ export function sha256Hex(text) {
3
+ const bytes = new TextEncoder().encode(text), bitLength = bytes.length * 8, length = Math.ceil((bytes.length + 9) / 64) * 64, data = new Uint8Array(length);
4
+ data.set(bytes);
5
+ data[bytes.length] = 0x80;
6
+ const view = new DataView(data.buffer);
7
+ view.setUint32(length - 8, Math.floor(bitLength / 0x100000000));
8
+ view.setUint32(length - 4, bitLength >>> 0);
9
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
10
+ const k = new Uint32Array([0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]), w = new Uint32Array(64), rotr = (v, n) => (v >>> n) | (v << (32 - n));
11
+ for (let offset = 0; offset < length; offset += 64) {
12
+ for (let i = 0; i < 16; i++)
13
+ w[i] = view.getUint32(offset + i * 4);
14
+ for (let i = 16; i < 64; i++) {
15
+ const x = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3), y = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
16
+ w[i] = (w[i - 16] + x + w[i - 7] + y) >>> 0;
17
+ }
18
+ let [a, b, c, d, e, f, g, z] = h;
19
+ for (let i = 0; i < 64; i++) {
20
+ const x = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25), ch = (e & f) ^ (~e & g), t1 = (z + x + ch + k[i] + w[i]) >>> 0, s0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22), maj = (a & b) ^ (a & c) ^ (b & c), t2 = (s0 + maj) >>> 0;
21
+ z = g;
22
+ g = f;
23
+ f = e;
24
+ e = (d + t1) >>> 0;
25
+ d = c;
26
+ c = b;
27
+ b = a;
28
+ a = (t1 + t2) >>> 0;
29
+ }
30
+ h[0] = (h[0] + a) >>> 0;
31
+ h[1] = (h[1] + b) >>> 0;
32
+ h[2] = (h[2] + c) >>> 0;
33
+ h[3] = (h[3] + d) >>> 0;
34
+ h[4] = (h[4] + e) >>> 0;
35
+ h[5] = (h[5] + f) >>> 0;
36
+ h[6] = (h[6] + g) >>> 0;
37
+ h[7] = (h[7] + z) >>> 0;
38
+ }
39
+ return [...h].map(v => v.toString(16).padStart(8, "0")).join("");
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",