@kontourai/survey 1.14.0 → 1.16.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
@@ -105,6 +105,24 @@ Keep producer operational state outside Survey. Queue status, reviewer form stat
105
105
 
106
106
  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
107
 
108
+ ## Review lifecycle
109
+
110
+ `ReviewOutcome.resolution` optionally records the terminal result of a review
111
+ round while preserving compatibility with outcomes that infer their result from
112
+ `status`. The explicit `could_not_confirm` resolution requires a non-empty
113
+ `resolutionReason`; `attemptEvidenceIds` may record evidence of what the reviewer
114
+ tried. It is valid only with an unchanged `proposed` or `assumed` status.
115
+ Other explicit resolutions must also agree with status: accepted is
116
+ verified/assumed, rejected is rejected, and held is any non-rejected retained
117
+ posture.
118
+
119
+ Could-not-confirm is terminal for the review round, but it is not rejection,
120
+ verification, or escalation. Surface projection stays quiet: the claim keeps its
121
+ pre-review status, no attempt evidence is projected, and no review-derived
122
+ freshness, validity, gap, or verification posture is added. The outcome remains
123
+ available in Survey review records, canonical review proof v3, and the distinct
124
+ `learning.could-not-confirm` producer-learning signal.
125
+
108
126
  ## Review Workbench embed
109
127
 
110
128
  **Web component** (shadow DOM, no framework required):
@@ -147,6 +165,32 @@ The embedded stylesheet is scoped to `.survey-workbench-embed` and bundles Conso
147
165
 
148
166
  `@kontourai/survey/review-workbench/standalone.css` exists for pages Survey owns entirely.
149
167
 
168
+ ### Theme it as your own brand
169
+
170
+ The bundled `--k-*` token defaults are emitted in overridable form
171
+ (`--k-brand: var(--k-brand, <default>)`), so a host brand is authoritative: set
172
+ any `--k-*` token on an ancestor of the embed (or inline on the embed element,
173
+ or the host element of the web component) and it propagates in — no need to
174
+ out-specificity the embed's own selectors. Unset tokens keep their default, so
175
+ you only declare what you re-brand.
176
+
177
+ ```css
178
+ /* Your app's own palette drives the workbench — no Kontour branding. */
179
+ .survey-workbench-embed {
180
+ --k-brand: var(--brand-accent);
181
+ --k-bg: var(--paper);
182
+ --k-panel: var(--paper-2);
183
+ --k-text: var(--ink);
184
+ --k-line: var(--hairline);
185
+ --k-font-ui: var(--font-body);
186
+ --k-radius-md: 2px;
187
+ }
188
+ ```
189
+
190
+ The full token list is in the [Consumer Integration Guide](docs/consumer-integration-guide.md).
191
+ Prefer this over the built-in `theme-survey`/`theme-console`/… presets when the
192
+ host app should read as itself.
193
+
150
194
  ## Review MCP
151
195
 
152
196
  Drive review-queue decisions from an MCP agent (Claude Desktop, Cursor, or any MCP host):
@@ -155,7 +199,7 @@ Drive review-queue decisions from an MCP agent (Claude Desktop, Cursor, or any M
155
199
  npx survey-review-mcp --session path/to/session.json
156
200
  ```
157
201
 
158
- 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 buttons. See [docs/review-mcp.md](docs/review-mcp.md).
202
+ 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).
159
203
 
160
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.
161
205
 
@@ -151,6 +151,7 @@ export interface CalibrationMetrics {
151
151
  * label. A sample is skipped when it carries no human label or no prediction:
152
152
  *
153
153
  * - status "proposed" (not yet reviewed);
154
+ * - resolution "could_not_confirm" (no human correctness label);
154
155
  * - a machine auto-accept, unless `includeAutoAccepted` is set;
155
156
  * - no `selectedCandidateId`, or the selected candidate / its confidence is
156
157
  * missing or non-finite (no prediction to calibrate).
@@ -37,6 +37,7 @@ const DEFAULT_MIN_BIN_SAMPLES = 1;
37
37
  * label. A sample is skipped when it carries no human label or no prediction:
38
38
  *
39
39
  * - status "proposed" (not yet reviewed);
40
+ * - resolution "could_not_confirm" (no human correctness label);
40
41
  * - a machine auto-accept, unless `includeAutoAccepted` is set;
41
42
  * - no `selectedCandidateId`, or the selected candidate / its confidence is
42
43
  * missing or non-finite (no prediction to calibrate).
@@ -66,6 +67,10 @@ export function deriveCalibration(input, options = {}) {
66
67
  const samples = [];
67
68
  let skippedCount = 0;
68
69
  for (const outcome of input.reviewOutcomes) {
70
+ if (outcome.resolution === "could_not_confirm") {
71
+ skippedCount++;
72
+ continue;
73
+ }
69
74
  if (cutoff !== undefined) {
70
75
  const t = outcome.reviewedAt ? Date.parse(outcome.reviewedAt) : NaN;
71
76
  if (isNaN(t) || t < cutoff) {
@@ -1,4 +1,4 @@
1
- export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, EscalationDimension, EscalationRecord, Extraction, Interpretation, LocatorScheme, ProvenanceResolution, RawSource, RawSourceKind, ReviewAuthorizing, ReviewAuthorizingAuthorizedAction, ReviewAuthorizingExchange, ReviewAuthorizingExplicitStatement, ReviewAuthorizingKind, ReviewOutcome, ReviewStatus, SurveyInput, } from "./types.js";
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
4
  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";
@@ -1,6 +1,6 @@
1
1
  import type { SurveyInput } from "./types.js";
2
- export type LearningProjectionKind = "learning.comfort-zone" | "learning.escalation" | "learning.rejected-candidate";
3
- export type LearningProjectionSignal = "comfort-zone.outside" | "escalation.unresolved" | "rejected-candidate.reason";
2
+ export type LearningProjectionKind = "learning.comfort-zone" | "learning.escalation" | "learning.rejected-candidate" | "learning.could-not-confirm";
3
+ export type LearningProjectionSignal = "comfort-zone.outside" | "escalation.unresolved" | "rejected-candidate.reason" | "could-not-confirm.reason";
4
4
  export type LearningProjectionSeverity = "info" | "attention";
5
5
  export interface LearningProjection {
6
6
  id: string;
@@ -40,6 +40,31 @@ export function buildSurveyLearningProjections(input) {
40
40
  }
41
41
  }
42
42
  for (const reviewOutcome of input.reviewOutcomes) {
43
+ if (reviewOutcome.resolution === "could_not_confirm") {
44
+ const target = candidateSetTargets.get(reviewOutcome.candidateSetId);
45
+ const claim = findClaimForReview(claimsByCandidateSet.get(reviewOutcome.candidateSetId) ?? [], reviewOutcome);
46
+ const resolutionReason = normalizeText(reviewOutcome.resolutionReason) ?? "(no reason recorded)";
47
+ projections.push({
48
+ id: `${reviewOutcome.id}.learning.could-not-confirm`,
49
+ kind: "learning.could-not-confirm",
50
+ source: input.source,
51
+ createdAt: reviewOutcome.reviewedAt ?? input.generatedAt,
52
+ target,
53
+ claimId: claim?.id,
54
+ reviewOutcomeId: reviewOutcome.id,
55
+ signal: "could-not-confirm.reason",
56
+ severity: "attention",
57
+ summary: `Could not confirm: ${resolutionReason}`,
58
+ metadata: {
59
+ couldNotConfirm: {
60
+ reason: resolutionReason,
61
+ ...(reviewOutcome.attemptEvidenceIds?.length
62
+ ? { attemptEvidenceIds: [...reviewOutcome.attemptEvidenceIds] }
63
+ : {}),
64
+ },
65
+ },
66
+ });
67
+ }
43
68
  if (reviewOutcome.withinComfortZone !== false)
44
69
  continue;
45
70
  const target = candidateSetTargets.get(reviewOutcome.candidateSetId);
@@ -30,6 +30,7 @@ const MCP_DECISION_MAP = {
30
30
  accept: "accept-proposed",
31
31
  hold: "keep-current",
32
32
  reject: "reject-proposed",
33
+ "could-not-confirm": "could-not-confirm",
33
34
  };
34
35
  async function readSessionFile(path) {
35
36
  const raw = await readFile(path, "utf8");
@@ -59,7 +60,7 @@ function queueSummaryText(snapshot, events) {
59
60
  `Active item: ${activeItem.metadata.name} (${activeItem.spec.target})`,
60
61
  ...(nextItem ? [`Next unresolved: ${nextItem}`] : ["All items resolved."]),
61
62
  ``,
62
- `Session summary: accepted=${summary.accepted} keptCurrent=${summary.keptCurrent} rejected=${summary.rejected} escalated=${summary.escalated} unresolved=${summary.unresolved}`,
63
+ `Session summary: accepted=${summary.accepted} keptCurrent=${summary.keptCurrent} rejected=${summary.rejected} couldNotConfirm=${summary.couldNotConfirm ?? 0} escalated=${summary.escalated} unresolved=${summary.unresolved}`,
63
64
  ``,
64
65
  `Items:`,
65
66
  ...rows,
@@ -202,12 +203,13 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
202
203
  .note-label{font-size:11px;color:var(--k-text-muted);margin-bottom:4px}
203
204
  .note-input{width:100%;background:var(--k-panel-raised);border:1px solid var(--k-line-strong);border-radius:var(--k-radius-sm);color:var(--k-text);font:inherit;font-size:12px;padding:7px 10px;resize:vertical;min-height:52px}
204
205
  .note-input:focus{outline:2px solid var(--k-brand);outline-offset:1px;border-color:transparent}
205
- .btn-row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:10px}
206
+ .btn-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
206
207
  .btn{padding:9px 4px;border:1px solid var(--k-line-strong);border-radius:var(--k-radius-sm);background:var(--k-panel-raised);color:var(--k-text-muted);font:inherit;font-size:12px;font-weight:600;cursor:pointer;transition:background .12s,color .12s,border-color .12s}
207
208
  .btn:hover{background:var(--k-panel);border-color:var(--k-brand);color:var(--k-text)}
208
209
  .btn-accept:hover,.btn-accept.active{background:color-mix(in srgb,var(--k-positive) 16%,transparent);border-color:var(--k-positive);color:var(--k-positive)}
209
210
  .btn-hold:hover,.btn-hold.active{background:color-mix(in srgb,var(--k-caution) 16%,transparent);border-color:var(--k-caution);color:var(--k-caution)}
210
211
  .btn-reject:hover,.btn-reject.active{background:color-mix(in srgb,var(--k-negative) 16%,transparent);border-color:var(--k-negative);color:var(--k-negative)}
212
+ .btn-unconfirmed:hover,.btn-unconfirmed.active{background:color-mix(in srgb,var(--k-caution) 16%,transparent);border-color:var(--k-caution);color:var(--k-caution)}
211
213
  .feedback{font-size:11px;color:var(--k-text-faint);margin-top:8px;min-height:16px}
212
214
  </style>
213
215
  </head>
@@ -239,13 +241,14 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
239
241
 
240
242
  <div class="divider"></div>
241
243
 
242
- <div class="note-label">Reviewer note (optional)</div>
244
+ <div class="note-label">Reviewer note (required for Could not confirm)</div>
243
245
  <textarea class="note-input" id="note" placeholder="Add a rationale for this decision...">${escapeHtml(current.notesByItemName[item.metadata.name] ?? "")}</textarea>
244
246
 
245
247
  <div class="btn-row">
246
248
  <button class="btn btn-accept${decision === "accept-proposed" ? " active" : ""}" id="btn-accept">Accept proposed</button>
247
249
  <button class="btn btn-hold${decision === "keep-current" ? " active" : ""}" id="btn-hold">Hold / Keep current</button>
248
250
  <button class="btn btn-reject${decision === "reject-proposed" ? " active" : ""}" id="btn-reject">Reject proposed</button>
251
+ <button class="btn btn-unconfirmed${decision === "could-not-confirm" ? " active" : ""}" id="btn-unconfirmed">Could not confirm</button>
249
252
  </div>
250
253
  <div class="feedback" id="feedback"></div>
251
254
 
@@ -256,20 +259,29 @@ h1{font-size:15px;font-weight:700;margin:0 0 4px}
256
259
 
257
260
  function postDecision(decision) {
258
261
  var note = document.getElementById('note').value;
262
+ if (decision === 'could-not-confirm' && !note.trim()) {
263
+ document.getElementById('feedback').textContent = 'A reason is required when you could not confirm.';
264
+ document.getElementById('note').focus();
265
+ return false;
266
+ }
259
267
  window.parent.postMessage({
260
268
  jsonrpc: "2.0",
261
269
  id: msgId++,
262
270
  method: "tools/call",
263
271
  params: {
264
272
  name: "survey_review_decide",
265
- arguments: { itemName: itemName, decision: decision, note: note || undefined }
273
+ arguments: decision === 'could-not-confirm'
274
+ ? { itemName: itemName, decision: decision, reason: note }
275
+ : { itemName: itemName, decision: decision, note: note || undefined }
266
276
  }
267
277
  }, "*");
278
+ return true;
268
279
  }
269
280
 
270
281
  document.getElementById('btn-accept').addEventListener('click', function () { postDecision('accept'); document.getElementById('feedback').textContent = 'Submitting accept…'; });
271
282
  document.getElementById('btn-hold').addEventListener('click', function () { postDecision('hold'); document.getElementById('feedback').textContent = 'Submitting hold…'; });
272
283
  document.getElementById('btn-reject').addEventListener('click', function () { postDecision('reject'); document.getElementById('feedback').textContent = 'Submitting reject…'; });
284
+ document.getElementById('btn-unconfirmed').addEventListener('click', function () { if (postDecision('could-not-confirm')) document.getElementById('feedback').textContent = 'Submitting could not confirm…'; });
273
285
 
274
286
  window.addEventListener('message', function (evt) {
275
287
  var data = evt.data;
@@ -347,10 +359,13 @@ async function toolItem(itemName, options) {
347
359
  }
348
360
  return content;
349
361
  }
350
- async function toolDecide(itemName, mcpDecision, note, options) {
362
+ async function toolDecide(itemName, mcpDecision, note, attemptEvidenceIds, options) {
351
363
  const wbDecision = MCP_DECISION_MAP[mcpDecision];
352
364
  if (!wbDecision) {
353
- throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, hold, or reject.`);
365
+ throw new DomainError(`Invalid decision: ${mcpDecision}. Must be accept, hold, reject, or could-not-confirm.`);
366
+ }
367
+ if (wbDecision === "could-not-confirm" && !note?.trim()) {
368
+ throw new DomainError("survey_review_decide requires a non-empty reason for could-not-confirm");
354
369
  }
355
370
  const file = await readSessionFile(options.sessionPath);
356
371
  const { snapshot, events } = file;
@@ -378,6 +393,14 @@ async function toolDecide(itemName, mcpDecision, note, options) {
378
393
  },
379
394
  }
380
395
  : {}),
396
+ ...(attemptEvidenceIds?.length
397
+ ? {
398
+ attemptEvidenceIdsByItemName: {
399
+ ...current.attemptEvidenceIdsByItemName,
400
+ [itemName]: [...attemptEvidenceIds],
401
+ },
402
+ }
403
+ : {}),
381
404
  };
382
405
  // Use the server session APIs for apply-path validation
383
406
  const record = createServerReviewSessionRecord({
@@ -518,19 +541,29 @@ async function handleLine(line, options, serverVersion) {
518
541
  {
519
542
  name: "survey_review_decide",
520
543
  title: "Record a review decision",
521
- description: "Apply a decision to a review item and persist it to the session file. Decision must be accept (accept-proposed), hold (keep-current), or reject (reject-proposed). Domain failures return isError:true.",
544
+ description: "Apply a decision to a review item and persist it to the session file. Decision must be accept, hold, reject, or could-not-confirm. Could-not-confirm requires a reason. Domain failures return isError:true.",
522
545
  inputSchema: {
523
546
  type: "object",
524
547
  properties: {
525
548
  itemName: { type: "string", description: "The ReviewItem name to decide." },
526
549
  decision: {
527
550
  type: "string",
528
- enum: ["accept", "hold", "reject"],
529
- description: "accept = accept-proposed, hold = keep-current, reject = reject-proposed.",
551
+ enum: ["accept", "hold", "reject", "could-not-confirm"],
552
+ description: "accept = accept-proposed, hold = keep-current, reject = reject-proposed, could-not-confirm = terminal non-answer.",
530
553
  },
531
554
  note: { type: "string", description: "Optional reviewer note / rationale." },
555
+ reason: { type: "string", minLength: 1, description: "Required non-empty reason when decision is could-not-confirm." },
556
+ attemptEvidenceIds: {
557
+ type: "array",
558
+ items: { type: "string" },
559
+ description: "Optional evidence ids recording what was attempted before could-not-confirm.",
560
+ },
532
561
  },
533
562
  required: ["itemName", "decision"],
563
+ allOf: [{
564
+ if: { properties: { decision: { const: "could-not-confirm" } }, required: ["decision"] },
565
+ then: { required: ["reason"] },
566
+ }],
534
567
  },
535
568
  },
536
569
  ],
@@ -587,11 +620,16 @@ async function handleLine(line, options, serverVersion) {
587
620
  const itemName = typeof toolArgs.itemName === "string" ? toolArgs.itemName : "";
588
621
  const decision = typeof toolArgs.decision === "string" ? toolArgs.decision : "";
589
622
  const note = typeof toolArgs.note === "string" ? toolArgs.note : undefined;
623
+ const reason = typeof toolArgs.reason === "string" ? toolArgs.reason : undefined;
624
+ const attemptEvidenceIds = Array.isArray(toolArgs.attemptEvidenceIds)
625
+ && toolArgs.attemptEvidenceIds.every((value) => typeof value === "string")
626
+ ? toolArgs.attemptEvidenceIds
627
+ : undefined;
590
628
  if (!itemName)
591
629
  throw new DomainError("survey_review_decide requires itemName");
592
630
  if (!decision)
593
631
  throw new DomainError("survey_review_decide requires decision");
594
- content = await toolDecide(itemName, decision, note, options);
632
+ content = await toolDecide(itemName, decision, decision === "could-not-confirm" ? reason : note, attemptEvidenceIds, options);
595
633
  }
596
634
  else {
597
635
  send({ jsonrpc: "2.0", id, error: { code: -32602, message: `Unknown tool: ${name || "(missing name)"}` } });
@@ -1,4 +1,5 @@
1
1
  import type { TrustStatus } from "@kontourai/surface";
2
+ import type { CandidateSetStatus, ReviewResolution } from "./types.js";
2
3
  /**
3
4
  * Producer Discipline core — CONTEXT.md "Producer Discipline" /
4
5
  * "Source-of-Authority Observation".
@@ -28,13 +29,25 @@ import type { TrustStatus } from "@kontourai/surface";
28
29
  * src/index.ts.
29
30
  */
30
31
  export interface ReviewOutcomePosture {
32
+ status?: TrustStatus;
31
33
  actor?: string;
32
34
  reviewedAt?: string;
35
+ resolution?: ReviewResolution;
36
+ resolutionReason?: string;
37
+ attemptEvidenceIds?: readonly string[];
33
38
  }
39
+ /** Explicit review resolutions refine (but may not contradict) status:
40
+ * accepted -> verified/assumed; rejected -> rejected; held -> any non-rejected
41
+ * pre-existing posture; could_not_confirm -> proposed/assumed with reviewer,
42
+ * time, and reason. */
43
+ export declare function assertReviewResolutionConsistency(subject: string, review: ReviewOutcomePosture): void;
34
44
  export declare function assertReviewOutcomeDiscipline(input: {
35
45
  /** Message subject, e.g. `Claim ${id}` or `Source-of-authority observation ${id}` —
36
46
  * each call site supplies its own noun so error text is unchanged. */
37
47
  subject: string;
38
48
  status: TrustStatus | undefined;
39
49
  review?: ReviewOutcomePosture;
50
+ /** Candidate-set conflict/escalation is an independent pre-review posture.
51
+ * A could-not-confirm round must preserve it as disputed, never downgrade it. */
52
+ candidateSetStatus?: CandidateSetStatus;
40
53
  }): void;
@@ -1,4 +1,45 @@
1
+ /** Explicit review resolutions refine (but may not contradict) status:
2
+ * accepted -> verified/assumed; rejected -> rejected; held -> any non-rejected
3
+ * pre-existing posture; could_not_confirm -> proposed/assumed with reviewer,
4
+ * time, and reason. */
5
+ export function assertReviewResolutionConsistency(subject, review) {
6
+ if (review.resolution === undefined)
7
+ return;
8
+ const statusAllowed = review.resolution === "accepted"
9
+ ? review.status === "verified" || review.status === "assumed"
10
+ : review.resolution === "rejected"
11
+ ? review.status === "rejected"
12
+ : review.resolution === "held"
13
+ ? review.status === "verified" || review.status === "assumed" || review.status === "proposed"
14
+ : review.status === "proposed" || review.status === "assumed";
15
+ if (!statusAllowed) {
16
+ throw new Error(`${subject} review resolution ${review.resolution} cannot use status ${review.status ?? "undefined"}`);
17
+ }
18
+ if (review.resolution !== "could_not_confirm")
19
+ return;
20
+ if (!review.resolutionReason?.trim()) {
21
+ throw new Error(`${subject} review resolution could_not_confirm requires a non-empty resolutionReason`);
22
+ }
23
+ if (!review.actor?.trim()) {
24
+ throw new Error(`${subject} review resolution could_not_confirm requires a review actor`);
25
+ }
26
+ if (!review.reviewedAt?.trim()) {
27
+ throw new Error(`${subject} review resolution could_not_confirm requires reviewedAt`);
28
+ }
29
+ }
1
30
  export function assertReviewOutcomeDiscipline(input) {
31
+ if (input.review) {
32
+ assertReviewResolutionConsistency(input.subject, input.review);
33
+ }
34
+ if (input.review?.resolution === "could_not_confirm"
35
+ && (input.status === "verified" || input.status === "rejected")) {
36
+ throw new Error(`${input.subject} review resolution could_not_confirm cannot use status ${input.status}`);
37
+ }
38
+ if (input.review?.resolution === "could_not_confirm"
39
+ && (input.candidateSetStatus === "conflict" || input.candidateSetStatus === "escalated")
40
+ && input.status !== "disputed") {
41
+ throw new Error(`${input.subject} review resolution could_not_confirm cannot mask ${input.candidateSetStatus} as ${input.status}`);
42
+ }
2
43
  if (input.status !== "verified" && input.status !== "assumed")
3
44
  return;
4
45
  if (!input.review) {
@@ -1,11 +1,13 @@
1
1
  import type { IntegrityAnchor } from "@kontourai/surface";
2
2
  import type { Candidate, CandidateSet, ClaimTarget, Extraction, RawSource, ReviewAuthorizing, ReviewOutcome } from "./types.js";
3
3
  export declare const REVIEW_PROOF_SCHEMA = "survey.review-proof";
4
- export declare const REVIEW_PROOF_SCHEMA_VERSION = 2;
4
+ export declare const REVIEW_PROOF_SCHEMA_VERSION = 3;
5
5
  export declare const REVIEW_PROOF_PACKAGE_NAME = "@kontourai/survey";
6
- export declare const REVIEW_PROOF_CONTRACT_VERSION = "2";
6
+ export declare const REVIEW_PROOF_CONTRACT_VERSION = "3";
7
7
  declare const LEGACY_REVIEW_PROOF_SCHEMA_VERSION = 1;
8
8
  declare const LEGACY_REVIEW_PROOF_CONTRACT_VERSION = "1";
9
+ declare const REVIEW_PROOF_SCHEMA_VERSION_V2 = 2;
10
+ declare const REVIEW_PROOF_CONTRACT_VERSION_V2 = "2";
9
11
  export interface ReviewProofInput {
10
12
  rawSource: RawSource;
11
13
  extraction: Extraction;
@@ -128,20 +130,32 @@ type CanonicalReviewAuthorizing = (Extract<ReviewAuthorizing, {
128
130
  export type CanonicalReviewProofPayloadV2 = Omit<CanonicalReviewProofPayloadV1, "schemaVersion" | "proof" | "reviewOutcome"> & {
129
131
  schemaVersion: 2;
130
132
  proof: Omit<CanonicalReviewProofPayloadV1["proof"], "schemaVersion" | "packageVersion"> & {
131
- schemaVersion: typeof REVIEW_PROOF_SCHEMA_VERSION;
132
- packageVersion: typeof REVIEW_PROOF_CONTRACT_VERSION;
133
+ schemaVersion: typeof REVIEW_PROOF_SCHEMA_VERSION_V2;
134
+ packageVersion: typeof REVIEW_PROOF_CONTRACT_VERSION_V2;
133
135
  };
134
136
  reviewOutcome?: NonNullable<CanonicalReviewProofPayloadV1["reviewOutcome"]> & {
135
137
  authorizing?: CanonicalReviewAuthorizing;
136
138
  };
137
139
  };
138
- export type CanonicalReviewProofPayload = CanonicalReviewProofPayloadV1 | CanonicalReviewProofPayloadV2;
139
- export declare function buildCanonicalReviewProofPayload(input: ReviewProofInput): CanonicalReviewProofPayloadV2;
140
+ export type CanonicalReviewProofPayloadV3 = Omit<CanonicalReviewProofPayloadV2, "schemaVersion" | "proof" | "reviewOutcome"> & {
141
+ schemaVersion: 3;
142
+ proof: Omit<CanonicalReviewProofPayloadV2["proof"], "schemaVersion" | "packageVersion"> & {
143
+ schemaVersion: typeof REVIEW_PROOF_SCHEMA_VERSION;
144
+ packageVersion: typeof REVIEW_PROOF_CONTRACT_VERSION;
145
+ };
146
+ reviewOutcome?: NonNullable<CanonicalReviewProofPayloadV2["reviewOutcome"]> & {
147
+ resolution?: ReviewOutcome["resolution"];
148
+ resolutionReason?: string;
149
+ attemptEvidenceIds?: string[];
150
+ };
151
+ };
152
+ export type CanonicalReviewProofPayload = CanonicalReviewProofPayloadV1 | CanonicalReviewProofPayloadV2 | CanonicalReviewProofPayloadV3;
153
+ export declare function buildCanonicalReviewProofPayload(input: ReviewProofInput): CanonicalReviewProofPayloadV3;
140
154
  export declare function canonicalReviewProofJson(payload: CanonicalReviewProofPayload): string;
141
155
  export declare function hashCanonicalReviewProofPayload(payload: CanonicalReviewProofPayload): string;
142
156
  /**
143
- * Verifies the integrity hash of a persisted v1 or v2 canonical review proof.
144
- * Envelope compatibility and v2 authorizing admissibility are checked before
157
+ * Verifies the integrity hash of a persisted v1, v2, or v3 canonical review proof.
158
+ * Envelope compatibility and version-specific admissibility are checked before
145
159
  * the expected hash is compared.
146
160
  */
147
161
  export declare function verifyCanonicalReviewProofPayload(payload: unknown, expectedHash: string): boolean;
@@ -1,15 +1,21 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { validateAuthorizing } from "./review-authorizing.js";
3
+ import { assertReviewResolutionConsistency } from "./producer-discipline.js";
3
4
  export const REVIEW_PROOF_SCHEMA = "survey.review-proof";
4
- export const REVIEW_PROOF_SCHEMA_VERSION = 2;
5
+ export const REVIEW_PROOF_SCHEMA_VERSION = 3;
5
6
  export const REVIEW_PROOF_PACKAGE_NAME = "@kontourai/survey";
6
7
  // Version of the review proof contract emitted by this helper. This is intentionally
7
8
  // independent from the npm package release version because it participates in hashes.
8
- export const REVIEW_PROOF_CONTRACT_VERSION = "2";
9
+ export const REVIEW_PROOF_CONTRACT_VERSION = "3";
9
10
  const LEGACY_REVIEW_PROOF_SCHEMA_VERSION = 1;
10
11
  const LEGACY_REVIEW_PROOF_CONTRACT_VERSION = "1";
12
+ const REVIEW_PROOF_SCHEMA_VERSION_V2 = 2;
13
+ const REVIEW_PROOF_CONTRACT_VERSION_V2 = "2";
11
14
  export function buildCanonicalReviewProofPayload(input) {
12
15
  assertCandidateConsistency(input);
16
+ if (input.reviewOutcome) {
17
+ assertReviewResolutionConsistency("Canonical review proof", input.reviewOutcome);
18
+ }
13
19
  return {
14
20
  schemaVersion: REVIEW_PROOF_SCHEMA_VERSION,
15
21
  proof: {
@@ -77,6 +83,13 @@ export function buildCanonicalReviewProofPayload(input) {
77
83
  candidateSetId: input.reviewOutcome.candidateSetId,
78
84
  candidateId: input.reviewOutcome.candidateId,
79
85
  status: input.reviewOutcome.status,
86
+ ...(input.reviewOutcome.resolution !== undefined ? { resolution: input.reviewOutcome.resolution } : {}),
87
+ ...(input.reviewOutcome.resolutionReason !== undefined
88
+ ? { resolutionReason: input.reviewOutcome.resolutionReason }
89
+ : {}),
90
+ ...(input.reviewOutcome.attemptEvidenceIds
91
+ ? { attemptEvidenceIds: [...input.reviewOutcome.attemptEvidenceIds].sort() }
92
+ : {}),
80
93
  actor: input.reviewOutcome.actor,
81
94
  reviewedAt: input.reviewOutcome.reviewedAt,
82
95
  rationale: input.reviewOutcome.rationale,
@@ -187,8 +200,8 @@ export function hashCanonicalReviewProofPayload(payload) {
187
200
  return createHash("sha256").update(canonicalReviewProofJson(payload)).digest("hex");
188
201
  }
189
202
  /**
190
- * Verifies the integrity hash of a persisted v1 or v2 canonical review proof.
191
- * Envelope compatibility and v2 authorizing admissibility are checked before
203
+ * Verifies the integrity hash of a persisted v1, v2, or v3 canonical review proof.
204
+ * Envelope compatibility and version-specific admissibility are checked before
192
205
  * the expected hash is compared.
193
206
  */
194
207
  export function verifyCanonicalReviewProofPayload(payload, expectedHash) {
@@ -210,7 +223,30 @@ export function verifyCanonicalReviewProofPayload(payload, expectedHash) {
210
223
  || packageVersion !== LEGACY_REVIEW_PROOF_CONTRACT_VERSION) {
211
224
  return false;
212
225
  }
213
- if (isRecord(reviewOutcome) && Object.prototype.hasOwnProperty.call(reviewOutcome, "authorizing")) {
226
+ if (isRecord(reviewOutcome)
227
+ && (Object.prototype.hasOwnProperty.call(reviewOutcome, "authorizing")
228
+ || Object.prototype.hasOwnProperty.call(reviewOutcome, "resolution")
229
+ || Object.prototype.hasOwnProperty.call(reviewOutcome, "resolutionReason")
230
+ || Object.prototype.hasOwnProperty.call(reviewOutcome, "attemptEvidenceIds"))) {
231
+ return false;
232
+ }
233
+ }
234
+ else if (schemaVersion === REVIEW_PROOF_SCHEMA_VERSION_V2) {
235
+ if (proofSchemaVersion !== REVIEW_PROOF_SCHEMA_VERSION_V2 || packageVersion !== REVIEW_PROOF_CONTRACT_VERSION_V2) {
236
+ return false;
237
+ }
238
+ if (reviewOutcome !== undefined && !isRecord(reviewOutcome))
239
+ return false;
240
+ if (isRecord(reviewOutcome)
241
+ && Object.prototype.hasOwnProperty.call(reviewOutcome, "authorizing")
242
+ && reviewOutcome.authorizing !== undefined
243
+ && validateAuthorizing(reviewOutcome.authorizing).length > 0) {
244
+ return false;
245
+ }
246
+ if (isRecord(reviewOutcome)
247
+ && (Object.prototype.hasOwnProperty.call(reviewOutcome, "resolution")
248
+ || Object.prototype.hasOwnProperty.call(reviewOutcome, "resolutionReason")
249
+ || Object.prototype.hasOwnProperty.call(reviewOutcome, "attemptEvidenceIds"))) {
214
250
  return false;
215
251
  }
216
252
  }
@@ -226,6 +262,21 @@ export function verifyCanonicalReviewProofPayload(payload, expectedHash) {
226
262
  && validateAuthorizing(reviewOutcome.authorizing).length > 0) {
227
263
  return false;
228
264
  }
265
+ if (isRecord(reviewOutcome)) {
266
+ if (reviewOutcome.resolution !== undefined
267
+ && reviewOutcome.resolution !== "accepted"
268
+ && reviewOutcome.resolution !== "rejected"
269
+ && reviewOutcome.resolution !== "held"
270
+ && reviewOutcome.resolution !== "could_not_confirm") {
271
+ return false;
272
+ }
273
+ if (reviewOutcome.attemptEvidenceIds !== undefined
274
+ && (!Array.isArray(reviewOutcome.attemptEvidenceIds)
275
+ || reviewOutcome.attemptEvidenceIds.some((id) => typeof id !== "string"))) {
276
+ return false;
277
+ }
278
+ assertReviewResolutionConsistency("Canonical review proof", reviewOutcome);
279
+ }
229
280
  }
230
281
  else {
231
282
  return false;
@@ -168,6 +168,9 @@ export interface ReviewDecisionSpec {
168
168
  reviewItemName: string;
169
169
  candidateId?: string;
170
170
  status: ReviewOutcome["status"];
171
+ resolution?: ReviewOutcome["resolution"];
172
+ resolutionReason?: string;
173
+ attemptEvidenceIds?: string[];
171
174
  actor?: ReviewActor;
172
175
  reviewedAt?: string;
173
176
  rationale?: string;
@@ -216,6 +219,9 @@ export interface ReviewSessionEventSpec {
216
219
  reviewDecisionName?: string;
217
220
  candidateId?: string;
218
221
  status?: ReviewOutcome["status"];
222
+ resolution?: ReviewOutcome["resolution"];
223
+ resolutionReason?: string;
224
+ attemptEvidenceIds?: string[];
219
225
  rationale?: string;
220
226
  data?: Record<string, unknown>;
221
227
  }
@@ -15,6 +15,8 @@ const KNOWN_DECISION_MODES = new Set([
15
15
  * `unknown-decision-mode` issue.
16
16
  */
17
17
  export function validateReviewDecisionMode(item, result) {
18
+ if (result.decision === "could-not-confirm")
19
+ return [];
18
20
  const decisionMode = item.spec.producerPolicy?.decisionMode;
19
21
  if (decisionMode === undefined) {
20
22
  return [];
@@ -1,6 +1,6 @@
1
1
  import { type ReviewCandidate, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewSessionEventSpec } from "../../src/review-resource.js";
2
- export type ReviewWorkbenchDecision = "accept-proposed" | "keep-current" | "reject-proposed";
3
- export type ReviewQueueRowStatus = "pending" | "in-review" | "resolved" | "rejected" | "escalated";
2
+ export type ReviewWorkbenchDecision = "accept-proposed" | "keep-current" | "reject-proposed" | "could-not-confirm";
3
+ export type ReviewQueueRowStatus = "pending" | "in-review" | "resolved" | "rejected" | "could-not-confirm" | "escalated";
4
4
  export declare const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
5
5
  export declare const defaultReviewSessionName = "review-workbench-session";
6
6
  export interface ReviewWorkbenchState {
@@ -15,6 +15,7 @@ export interface ReviewWorkbenchState {
15
15
  * proposed candidate's original value applies.
16
16
  */
17
17
  readonly editedValue?: unknown;
18
+ readonly attemptEvidenceIds?: readonly string[];
18
19
  }
19
20
  export interface ReviewQueueSessionState {
20
21
  readonly items: readonly ReviewItem[];
@@ -30,11 +31,13 @@ export interface ReviewQueueSessionState {
30
31
  * original value").
31
32
  */
32
33
  readonly editedValuesByItemName?: Readonly<Record<string, unknown>>;
34
+ readonly attemptEvidenceIdsByItemName?: Readonly<Record<string, readonly string[]>>;
33
35
  }
34
36
  export interface ReviewSessionSummary {
35
37
  readonly accepted: number;
36
38
  readonly keptCurrent: number;
37
39
  readonly rejected: number;
40
+ readonly couldNotConfirm?: number;
38
41
  readonly escalated: number;
39
42
  readonly unresolved: number;
40
43
  }
@@ -57,6 +60,12 @@ export declare const workbenchDecisionDefinitions: {
57
60
  candidateRole: "proposed";
58
61
  status: "rejected";
59
62
  };
63
+ "could-not-confirm": {
64
+ label: string;
65
+ effect: string;
66
+ candidateRole: "proposed";
67
+ status: "proposed";
68
+ };
60
69
  };
61
70
  export declare function initialReviewWorkbenchState(item?: ReviewItem): ReviewWorkbenchState;
62
71
  export declare function initialReviewQueueSessionState(items?: readonly ReviewItem[]): ReviewQueueSessionState;