@kontourai/survey 4.0.0 → 6.0.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.
@@ -24,6 +24,11 @@ export function prepareFacilityCredentialServerApply(input) {
24
24
  if (!currentCandidate || JSON.stringify(currentCandidate.value) !== JSON.stringify(input.currentRecord.credential)) {
25
25
  return { ok: false, message: "Current credential no longer matches the review session snapshot." };
26
26
  }
27
+ // A decision that selects no candidate (reject-all or could-not-confirm on a
28
+ // conflict) has no value to apply.
29
+ if (result.selectedCandidateId === undefined) {
30
+ return { ok: false, message: "Review result selects no candidate, so there is nothing to apply." };
31
+ }
27
32
  if (input.currentRecord.appliedReviewItemNames.includes(result.reviewItemName)) {
28
33
  return { ok: false, message: "Review result was already applied." };
29
34
  }
@@ -1,6 +1,6 @@
1
1
  import { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
2
2
  import { canonicalJson } from "./review-workbench/canonical.js";
3
- import { workbenchDecisionDefinitions } from "./review-workbench/review-queue-session.js";
3
+ import { decisionSelectsNoCandidate, workbenchDecisionDefinitions } from "./review-workbench/review-queue-session.js";
4
4
  /**
5
5
  * Projects server-applied review records into the complete SurveyInput consumed
6
6
  * by buildSurveyTrustBundle. The ReviewItem and ReviewWorkbenchResult are the
@@ -33,15 +33,24 @@ export function buildCanonicalReviewedTrustInput(options) {
33
33
  throw new Error(`ReviewItem ${item.metadata.name} has no canonical server-applied result.`);
34
34
  }
35
35
  assertCanonicalResult(item, result);
36
+ // Reject-all or could-not-confirm on a conflict selects no candidate: the
37
+ // set, the review outcome and the claim then name none of its values.
38
+ const selectsNone = decisionSelectsNoCandidate(item, result.decision);
39
+ const rejectsAll = selectsNone && result.decision === "reject-proposed";
40
+ const rejectedRole = workbenchDecisionDefinitions[result.decision].candidateRole;
41
+ const rejectAllReason = result.rationale?.trim() || "Every proposed value for this claim was rejected.";
36
42
  const candidates = item.spec.candidates.map((candidate) => {
37
- const records = projectCandidate(item, candidate);
43
+ const records = projectCandidate(item, rejectsAll && candidate.role === rejectedRole
44
+ ? { ...candidate, rejectionReason: candidate.rejectionReason ?? rejectAllReason }
45
+ : candidate);
38
46
  addConsistent(rawSources, records.rawSource, "raw source");
39
47
  addConsistent(extractions, records.extraction, "extraction");
40
48
  return records.candidate;
41
49
  });
42
- const selected = item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
43
- const selectedRecordId = selected.projection?.candidateId ?? selected.id;
44
- const candidateSetId = selected.projection?.candidateSetId
50
+ const selected = selectsNone ? undefined : item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
51
+ const selectedRecordId = selected ? selected.projection?.candidateId ?? selected.id : undefined;
52
+ // Every candidate carries the same candidate-set id (checked below).
53
+ const candidateSetId = (selected ?? item.spec.candidates[0])?.projection?.candidateSetId
45
54
  ?? item.spec.projection?.candidateSetId
46
55
  ?? `${item.metadata.name}.candidates`;
47
56
  if (candidates.some((candidate) => candidate.metadata?.candidateSetId !== candidateSetId)) {
@@ -56,10 +65,10 @@ export function buildCanonicalReviewedTrustInput(options) {
56
65
  ? { metadata: Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "candidateSetId")) }
57
66
  : {}),
58
67
  })),
59
- selectedCandidateId: selectedRecordId,
68
+ ...(selectedRecordId !== undefined ? { selectedCandidateId: selectedRecordId } : {}),
60
69
  status: result.decision === "could-not-confirm"
61
70
  ? (item.spec.candidateSetStatus ?? "needs-review")
62
- : "resolved",
71
+ : rejectsAll ? "rejected" : "resolved",
63
72
  ...(result.rationale ?? item.spec.rationale
64
73
  ? { rationale: result.rationale ?? item.spec.rationale }
65
74
  : {}),
@@ -67,12 +76,12 @@ export function buildCanonicalReviewedTrustInput(options) {
67
76
  addConsistent(candidateSets, candidateSet, "candidate set");
68
77
  const decision = result.reviewDecision.spec;
69
78
  const reviewOutcomeId = decision.projection?.reviewOutcomeId
70
- ?? selected.projection?.reviewOutcomeId
79
+ ?? (selected ?? item.spec).projection?.reviewOutcomeId
71
80
  ?? `${item.metadata.name}.${result.decision}.review-outcome`;
72
81
  const reviewOutcome = {
73
82
  id: reviewOutcomeId,
74
83
  candidateSetId,
75
- candidateId: selectedRecordId,
84
+ ...(selectedRecordId !== undefined ? { candidateId: selectedRecordId } : {}),
76
85
  status: result.status,
77
86
  ...(decision.resolution ? { resolution: decision.resolution } : {}),
78
87
  ...(decision.resolutionReason ? { resolutionReason: decision.resolutionReason } : {}),
@@ -90,34 +99,42 @@ export function buildCanonicalReviewedTrustInput(options) {
90
99
  },
91
100
  };
92
101
  addConsistent(reviewOutcomes, reviewOutcome, "review outcome");
93
- const hint = selected.claimTarget;
102
+ // Every candidate names the same claim target (checked by assertCanonicalResult).
103
+ const hint = (selected ?? item.spec.candidates[0]).claimTarget;
94
104
  assertSingleProjectionId("claim", item.metadata.name, [
95
105
  decision.projection?.claimId,
96
106
  item.spec.projection?.claimId,
97
107
  ...item.spec.candidates.flatMap((candidate) => [candidate.projection?.claimId, candidate.claimTarget.claimId]),
98
108
  ]);
99
109
  const claimId = decision.projection?.claimId
100
- ?? selected.projection?.claimId
110
+ ?? selected?.projection?.claimId
101
111
  ?? item.spec.projection?.claimId
102
112
  ?? hint.claimId
103
113
  ?? `${item.metadata.name}.claim`;
104
114
  const claim = {
105
115
  id: claimId,
106
116
  candidateSetId,
107
- candidateId: selectedRecordId,
117
+ ...(selectedRecordId !== undefined ? { candidateId: selectedRecordId } : {}),
108
118
  subjectType: hint.subjectType,
109
119
  subjectId: hint.subjectId,
110
120
  facet: hint.facet,
111
121
  claimType: hint.claimType,
112
122
  fieldOrBehavior: hint.fieldOrBehavior,
113
- value: result.effectiveValue,
114
- status: result.status,
123
+ // Surface requires a claim value; a claim that selects none of its
124
+ // candidates carries null, and buildSurveyTrustBundle lists every value.
125
+ value: selectsNone ? null : result.effectiveValue,
126
+ // Could-not-confirm keeps the pre-review posture, and a conflicting or
127
+ // escalated candidate set is disputed before any review (see
128
+ // docs/decisions/could-not-confirm.md), never merely proposed.
129
+ status: result.decision === "could-not-confirm" && (candidateSet.status === "conflict" || candidateSet.status === "escalated")
130
+ ? "disputed"
131
+ : result.status,
115
132
  impactLevel: hint.impactLevel,
116
133
  updatedAt: decision.reviewedAt ?? options.generatedAt,
117
134
  ...(hint.evidenceType ? { evidenceType: hint.evidenceType } : {}),
118
135
  ...(hint.evidenceMethod ? { evidenceMethod: hint.evidenceMethod } : {}),
119
136
  ...(hint.derivedFrom ? { derivedFrom: [...hint.derivedFrom] } : {}),
120
- collectedBy: hint.collectedBy ?? selected.extraction.extractor ?? options.source,
137
+ collectedBy: hint.collectedBy ?? sharedExtractor(selected ? [selected] : item.spec.candidates) ?? options.source,
121
138
  ...(decision.actor?.id ? { actor: decision.actor.id } : {}),
122
139
  };
123
140
  addConsistent(claims, claim, "claim target");
@@ -137,24 +154,38 @@ export function buildCanonicalReviewedTrustInput(options) {
137
154
  };
138
155
  }
139
156
  function assertCanonicalResult(item, result) {
140
- const selected = item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
141
- if (!selected) {
142
- throw new Error(`Review result ${result.reviewItemName} selects an unknown candidate.`);
143
- }
144
- if (canonicalJson(selected) !== canonicalJson(result.selectedCandidate)) {
145
- throw new Error(`Review result ${result.reviewItemName} selected candidate does not match its canonical ReviewItem.`);
146
- }
147
- const unselected = item.spec.candidates.filter((candidate) => candidate.id !== selected.id);
148
- if (canonicalJson(unselected) !== canonicalJson(result.unselectedCandidates)) {
149
- throw new Error(`Review result ${result.reviewItemName} unselected candidates do not match its canonical ReviewItem.`);
157
+ const selectsNone = decisionSelectsNoCandidate(item, result.decision);
158
+ let selected;
159
+ if (selectsNone) {
160
+ if (result.selectedCandidate !== undefined || result.selectedCandidateId !== undefined || result.selectedCandidateRole !== undefined
161
+ || result.selectedValue !== undefined || result.selectedDisplayValue !== undefined
162
+ || result.effectiveValue !== undefined || result.effectiveDisplayValue !== undefined || result.editedValue !== undefined) {
163
+ throw new Error(`Review result ${result.reviewItemName} names a selected value, but its ${result.decision} decision selects no candidate.`);
164
+ }
165
+ if (canonicalJson(item.spec.candidates) !== canonicalJson(result.unselectedCandidates)) {
166
+ throw new Error(`Review result ${result.reviewItemName} unselected candidates do not match its canonical ReviewItem.`);
167
+ }
150
168
  }
151
- if (result.selectedCandidateRole !== selected.role || canonicalJson(result.selectedValue) !== canonicalJson(selected.value)) {
152
- throw new Error(`Review result ${result.reviewItemName} selected identity does not match its canonical ReviewItem.`);
169
+ else {
170
+ selected = item.spec.candidates.find((candidate) => candidate.id === result.selectedCandidateId);
171
+ if (!selected) {
172
+ throw new Error(`Review result ${result.reviewItemName} selects an unknown candidate.`);
173
+ }
174
+ if (canonicalJson(selected) !== canonicalJson(result.selectedCandidate)) {
175
+ throw new Error(`Review result ${result.reviewItemName} selected candidate does not match its canonical ReviewItem.`);
176
+ }
177
+ const unselected = item.spec.candidates.filter((candidate) => candidate.id !== selected.id);
178
+ if (canonicalJson(unselected) !== canonicalJson(result.unselectedCandidates)) {
179
+ throw new Error(`Review result ${result.reviewItemName} unselected candidates do not match its canonical ReviewItem.`);
180
+ }
181
+ if (result.selectedCandidateRole !== selected.role || canonicalJson(result.selectedValue) !== canonicalJson(selected.value)) {
182
+ throw new Error(`Review result ${result.reviewItemName} selected identity does not match its canonical ReviewItem.`);
183
+ }
153
184
  }
154
185
  const decision = result.reviewDecision.spec;
155
186
  const definition = workbenchDecisionDefinitions[result.decision];
156
187
  if (decision.reviewItemName !== item.metadata.name
157
- || decision.candidateId !== result.selectedCandidateId
188
+ || decision.candidateId !== selected?.id
158
189
  || decision.status !== result.status
159
190
  || decision.status !== definition.status
160
191
  || decision.rationale !== result.rationale
@@ -164,14 +195,17 @@ function assertCanonicalResult(item, result) {
164
195
  if ((result.decision === "could-not-confirm") !== (decision.resolution === "could_not_confirm")) {
165
196
  throw new Error(`Review result ${result.reviewItemName} contradicts its canonical review resolution.`);
166
197
  }
167
- const expectedEffective = result.editedValue !== undefined && result.decision === "accept-proposed"
168
- ? result.editedValue
169
- : selected.value;
170
- if (canonicalJson(expectedEffective) !== canonicalJson(result.effectiveValue)) {
171
- throw new Error(`Review result ${result.reviewItemName} effective value is not canonical.`);
198
+ if (selected) {
199
+ const expectedEffective = result.editedValue !== undefined && result.decision === "accept-proposed"
200
+ ? result.editedValue
201
+ : selected.value;
202
+ if (canonicalJson(expectedEffective) !== canonicalJson(result.effectiveValue)) {
203
+ throw new Error(`Review result ${result.reviewItemName} effective value is not canonical.`);
204
+ }
172
205
  }
206
+ const reference = selected ?? item.spec.candidates[0];
173
207
  for (const candidate of item.spec.candidates) {
174
- if (canonicalJson(claimTargetIdentity(candidate.claimTarget)) !== canonicalJson(claimTargetIdentity(selected.claimTarget))) {
208
+ if (canonicalJson(claimTargetIdentity(candidate.claimTarget)) !== canonicalJson(claimTargetIdentity(reference.claimTarget))) {
175
209
  throw new Error(`ReviewItem ${item.metadata.name} candidates carry conflicting claim targets.`);
176
210
  }
177
211
  }
@@ -263,3 +297,8 @@ function assertSingleProjectionId(label, itemName, values) {
263
297
  throw new Error(`ReviewItem ${itemName} carries conflicting ${label} projection ids.`);
264
298
  }
265
299
  }
300
+ /** The extractor every candidate shares, or undefined when they differ. */
301
+ function sharedExtractor(candidates) {
302
+ const extractors = new Set(candidates.map((candidate) => candidate.extraction.extractor));
303
+ return extractors.size === 1 ? [...extractors][0] : undefined;
304
+ }
@@ -38,7 +38,12 @@ export interface PortableExtractionEvidenceMatch {
38
38
  export interface PortableExtractionProposal {
39
39
  fieldPath: string;
40
40
  candidateValue: unknown;
41
- confidence: number;
41
+ /**
42
+ * The proposer's own uncalibrated self-report in `0..1`, absent when the
43
+ * proposer reported none. Survey carries it only when present and never
44
+ * substitutes a default.
45
+ */
46
+ confidence?: number;
42
47
  provenance: {
43
48
  excerpt: string;
44
49
  locator: string;
@@ -71,6 +76,25 @@ export type PortablePreparedArtifactState = {
71
76
  actualDigest: string;
72
77
  actualContentLength: number;
73
78
  };
79
+ /**
80
+ * Why a run stopped short. The first four are early stops; the last three mean
81
+ * a dispatched chunk was not (fully) read or answered, and such an envelope
82
+ * names the affected ranges in `result.coverage`.
83
+ */
84
+ export type PortableExtractionPartialReason = "cancelled" | "max-provider-calls" | "max-total-tokens" | "max-chunks" | "provider-failure" | "content-truncated" | "output-truncated";
85
+ /**
86
+ * One prepared-text range (UTF-16 offsets, the space of `chars:` locators) and
87
+ * whether it was read and answered. `reason` is present exactly when `status`
88
+ * is `unread`, and then covers exactly the unread span.
89
+ */
90
+ export interface PortableExtractionCoverageEntry {
91
+ /** 1-based chunk number. */
92
+ chunk: number;
93
+ start: number;
94
+ end: number;
95
+ status: "complete" | "unread" | "output-truncated";
96
+ reason?: "provider-failure" | "content-truncated" | "missing-tool-call" | "not-dispatched";
97
+ }
74
98
  export interface PortableExtractionResultEnvelope {
75
99
  format: typeof portableExtractionResultFormat;
76
100
  version: typeof portableExtractionResultVersion;
@@ -90,7 +114,7 @@ export interface PortableExtractionResultEnvelope {
90
114
  status: "success";
91
115
  } | {
92
116
  status: "partial";
93
- reason: "cancelled" | "max-provider-calls" | "max-total-tokens" | "max-chunks";
117
+ reason: PortableExtractionPartialReason;
94
118
  } | {
95
119
  status: "failure";
96
120
  category: "invalid-config" | "invalid-task" | "preparation" | "provider" | "unexpected";
@@ -104,11 +128,13 @@ export interface PortableExtractionResultEnvelope {
104
128
  providerCalls: number;
105
129
  totalTokensUsed: number;
106
130
  partial?: {
107
- reason: "cancelled" | "max-provider-calls" | "max-total-tokens" | "max-chunks";
131
+ reason: PortableExtractionPartialReason;
108
132
  completedChunks: number;
109
133
  remainingChunks: number;
110
134
  tokenOvershoot?: number;
111
135
  };
136
+ /** Per-chunk read coverage, ordered by `start`; ranges may overlap. Requires `preparedArtifact`. */
137
+ coverage?: PortableExtractionCoverageEntry[];
112
138
  /** `code` is the upstream error code, informational only; `kind` stays authoritative. */
113
139
  providerFailures?: Array<{
114
140
  provider: string;
@@ -142,11 +168,52 @@ export interface ExtractionEnvelopeImportOptions {
142
168
  sourceKind: RawSource["kind"];
143
169
  /** Survey meaning is supplied at the boundary; it is not added to the upstream wire contract. */
144
170
  claimTarget: (proposal: PortableExtractionProposal, index: number) => ClaimTargetHint;
171
+ /**
172
+ * The prepared artifact as the caller resolved it (the same shape the source
173
+ * inspector takes). When supplied, the import checks the text against the
174
+ * envelope's prepared-artifact digest and length, and every proposal's span
175
+ * against its excerpt: a proposal whose span does not match gets no
176
+ * ReviewItem and an `excerpt-mismatch` diagnostic, and an artifact that does
177
+ * not verify makes the import `unresolved`. The text is not stored in the
178
+ * record. Without it the import records `status.provenance: "unverified"`.
179
+ */
180
+ artifact?: ResolvedExtractionArtifact;
145
181
  }
182
+ export type ArtifactUnavailableCode = "not-found" | "storage-error" | "access-denied" | "invalid-artifact" | "unknown";
183
+ /** A prepared artifact as a caller resolved it: its text and digest, or why it could not be read. */
184
+ export type ResolvedExtractionArtifact = {
185
+ status: "available";
186
+ text: string;
187
+ actualDigest: string;
188
+ } | {
189
+ status: "unavailable";
190
+ code: ArtifactUnavailableCode;
191
+ } | {
192
+ status: "digest-mismatch";
193
+ actualDigest: string;
194
+ };
195
+ /**
196
+ * Whether the import checked its proposals' excerpts against the prepared
197
+ * artifact text. `verified` means the supplied text matched the envelope's
198
+ * digest and length and every proposal that produced a ReviewItem matched its
199
+ * span; `unverified` means no text was supplied, or the text did not verify
200
+ * (a diagnostic then says why). Records written before this field existed have
201
+ * no `provenance` and are unverified.
202
+ */
203
+ export type ExtractionEnvelopeImportProvenance = "verified" | "unverified";
204
+ /**
205
+ * Why an import is `unresolved`: its prepared artifact did not resolve, or the
206
+ * extraction itself produced nothing reviewable. `extraction-failed` is a
207
+ * failure outcome; `extraction-incomplete` is a partial outcome that proposed
208
+ * nothing, so no candidate can carry the reason. Either way the import is not
209
+ * a complete run that found no values.
210
+ */
146
211
  export type ExtractionEnvelopeImportDiagnostic = {
147
212
  kind: "artifact-unavailable";
148
213
  status: "unavailable" | "storage-error" | "identity-mismatch" | "invalid-artifact";
149
214
  artifactRef?: string;
215
+ /** The caller's resolver code, present when the artifact supplied at import could not be read. */
216
+ code?: ArtifactUnavailableCode;
150
217
  message: string;
151
218
  } | {
152
219
  kind: "digest-mismatch";
@@ -154,6 +221,22 @@ export type ExtractionEnvelopeImportDiagnostic = {
154
221
  expectedDigest: string;
155
222
  actualDigest: string;
156
223
  message: string;
224
+ } | {
225
+ kind: "extraction-failed";
226
+ category: string;
227
+ code: string;
228
+ message: string;
229
+ } | {
230
+ kind: "extraction-incomplete";
231
+ reason: PortableExtractionPartialReason;
232
+ message: string;
233
+ }
234
+ /** The prepared text at this proposal's span is not its excerpt; the proposal produced no ReviewItem. */
235
+ | {
236
+ kind: "excerpt-mismatch";
237
+ proposalIndex: number;
238
+ locator: string;
239
+ message: string;
157
240
  };
158
241
  export interface ExtractionEnvelopeImport {
159
242
  apiVersion: typeof extractionEnvelopeImportApiVersion;
@@ -170,6 +253,7 @@ export interface ExtractionEnvelopeImport {
170
253
  status: {
171
254
  state: "grounded" | "unresolved";
172
255
  diagnostics: ExtractionEnvelopeImportDiagnostic[];
256
+ provenance?: ExtractionEnvelopeImportProvenance;
173
257
  };
174
258
  }
175
259
  export interface ExtractionEnvelopeImportResult {
@@ -182,9 +266,47 @@ export interface ExtractionEnvelopeResolutionIdentity {
182
266
  }
183
267
  /** Parse an untrusted upstream document and create Survey's durable import projection. */
184
268
  export declare function importExtractionEnvelope(serialized: string | PortableExtractionResultEnvelope, options: ExtractionEnvelopeImportOptions): ExtractionEnvelopeImportResult;
269
+ /**
270
+ * One ReviewItem per claim slot: every proposal whose claim target names the
271
+ * same claim (subject, facet, claim type, field or behavior, and claim id when
272
+ * one is set) at the same `pathIndices` is one candidate set, so two values for
273
+ * one claim can never be accepted as two separate verified claims. The slot is
274
+ * the claim the proposal would project to, not the upstream `fieldPath` (two
275
+ * field paths mapped to one claim are one slot) and not the value.
276
+ *
277
+ * Within a slot there is one candidate per distinct canonical value; further
278
+ * proposals of an already-seen value are recorded on that candidate as
279
+ * `sameValueProposals`. Two or more distinct values make the set `conflict`.
280
+ * Distinct `pathIndices` (array items) or distinct claim ids are separate slots,
281
+ * which is how a producer declares a multi-valued field.
282
+ */
185
283
  export declare function buildReviewItemsFromExtractionEnvelopeImport(record: ExtractionEnvelopeImport): ReviewItem[];
186
284
  export declare function exportExtractionEnvelopeImport(record: ExtractionEnvelopeImport): string;
187
285
  export declare function reimportExtractionEnvelope(serialized: string): ExtractionEnvelopeImport;
188
286
  export declare function createExtractionEnvelopeResolutionIdentity(record: ExtractionEnvelopeImport, proposalIndex: number): ExtractionEnvelopeResolutionIdentity;
287
+ /** Proposals the import excluded because their span did not match their excerpt. */
288
+ export declare function excerptMismatchProposalIndices(record: ExtractionEnvelopeImport): Set<number>;
289
+ /** Validate the caller-resolved artifact shape; resolver detail beyond the code is refused. */
290
+ export declare function assertResolvedExtractionArtifact(artifact: unknown): asserts artifact is ResolvedExtractionArtifact;
291
+ export type PreparedArtifactCheck = {
292
+ status: "aligned";
293
+ text: string;
294
+ } | {
295
+ status: "unavailable";
296
+ code: ArtifactUnavailableCode;
297
+ } | {
298
+ status: "digest-mismatch";
299
+ actualDigest: string;
300
+ } | {
301
+ status: "wrong-length";
302
+ };
303
+ /**
304
+ * Checks resolved text against the envelope's prepared-artifact identity: the
305
+ * caller's reported digest, the digest of the text itself, and the declared
306
+ * length. Shared by import-time verification and the source inspector.
307
+ */
308
+ export declare function checkPreparedArtifact(prepared: PortableExtractionResultEnvelope["result"]["preparedArtifact"], artifact: ResolvedExtractionArtifact): PreparedArtifactCheck;
309
+ /** Whether the verified prepared text at a proposal's `chars:` span is exactly its excerpt. */
310
+ export declare function proposalSpanMatches(text: string, proposal: PortableExtractionProposal): boolean;
189
311
  /** Validate and defensively clone a persisted or browser-delivered import. */
190
312
  export declare function validateExtractionEnvelopeImport(value: unknown): ExtractionEnvelopeImport;