@kontourai/survey 1.18.1 → 2.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.
@@ -0,0 +1,396 @@
1
+ import { createHash } from "node:crypto";
2
+ import { buildReviewItemsFromExtractionEnvelopeImport } from "./extraction-envelope.js";
3
+ import { reviewResourceApiVersion } from "./review-resource.js";
4
+ import { canonicalJson } from "./review-workbench/canonical.js";
5
+ /**
6
+ * Folds producer-owned disposition records without performing I/O or selecting
7
+ * between conflicting decisions. Byte-equivalent replay is idempotent.
8
+ */
9
+ export function foldExtractionImprovementDispositions(input) {
10
+ const byKey = new Map();
11
+ for (const disposition of input) {
12
+ const records = byKey.get(disposition.dispositionKey) ?? new Map();
13
+ records.set(canonicalJson(disposition), disposition);
14
+ byKey.set(disposition.dispositionKey, records);
15
+ }
16
+ const dispositions = [];
17
+ const conflicts = [];
18
+ for (const dispositionKey of [...byKey.keys()].sort()) {
19
+ const records = [...byKey.get(dispositionKey).entries()]
20
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
21
+ .map(([, disposition]) => disposition);
22
+ if (records.length === 1) {
23
+ dispositions.push(records[0]);
24
+ }
25
+ else {
26
+ conflicts.push(Object.freeze({
27
+ kind: "survey.extraction-improvement-disposition-conflict",
28
+ dispositionKey,
29
+ dispositions: Object.freeze(records),
30
+ }));
31
+ }
32
+ }
33
+ return Object.freeze({
34
+ dispositions: Object.freeze(dispositions),
35
+ conflicts: Object.freeze(conflicts),
36
+ });
37
+ }
38
+ /** Builds a frozen draft from validated canonical records. Performs no I/O. */
39
+ export function buildExtractionImprovementProposal(input) {
40
+ const diagnosis = normalizeDiagnosis(input.diagnosis);
41
+ const lineage = projectValidatedLineage(input);
42
+ const review = projectValidatedReview(input, diagnosis, lineage.proposalId);
43
+ const outcome = exactlyOne(input.survey.reviewOutcomes, ({ id }) => id === input.reviewOutcomeId, "review outcome");
44
+ lineage.recordDigests.reviewOutcome = recordDigest("ReviewOutcome", outcome);
45
+ const payload = {
46
+ kind: "survey.extraction-improvement-proposal",
47
+ schemaVersion: 1,
48
+ state: "draft",
49
+ createdAt: canonicalTimestamp(input.createdAt, "createdAt"),
50
+ lineage,
51
+ diagnosis,
52
+ review,
53
+ };
54
+ return freeze({ id: digest("survey.extraction-improvement-proposal/v1", payload), ...payload });
55
+ }
56
+ /** Emits a reversible activation request, never an activation. */
57
+ export function approveExtractionImprovementProposal(input) {
58
+ assertDraftIntegrity(input.draft);
59
+ if (input.draft.diagnosis.kind === "insufficient-source-evidence") {
60
+ throw new Error("insufficient-source-evidence proposals cannot request a task-spec change");
61
+ }
62
+ const approval = normalizeApproval(input.approval);
63
+ const nextTaskSpec = normalizeTaskSpecReference(input.nextTaskSpec, "nextTaskSpec");
64
+ const rollbackTaskSpec = normalizeTaskSpecReference(input.rollbackTaskSpec, "rollbackTaskSpec");
65
+ if (nextTaskSpec.version === input.draft.lineage.taskSpec.version || nextTaskSpec.digest === input.draft.lineage.taskSpec.digest) {
66
+ throw new Error("nextTaskSpec must be a new version with a new digest");
67
+ }
68
+ if (canonicalJson(rollbackTaskSpec) !== canonicalJson(input.draft.lineage.taskSpec)) {
69
+ throw new Error("rollbackTaskSpec must exactly identify the draft's prior task spec");
70
+ }
71
+ const changes = input.draft.diagnosis.requestedTaskChanges;
72
+ const addsExample = changes.includes("example-addition") || changes.includes("grounded-positive-example");
73
+ const changesGuidance = changes.includes("guidance-update") || changes.includes("guidance-affirmation");
74
+ assertExampleDigestChange(input.draft.lineage.taskSpec.exampleDigests, nextTaskSpec.exampleDigests, addsExample);
75
+ const guidanceChangeProofDigest = input.guidanceChangeProofDigest === undefined
76
+ ? undefined
77
+ : sha256Digest(input.guidanceChangeProofDigest, "guidanceChangeProofDigest");
78
+ if (changesGuidance && guidanceChangeProofDigest === undefined) {
79
+ throw new Error("guidance changes require guidanceChangeProofDigest");
80
+ }
81
+ if (!changesGuidance && guidanceChangeProofDigest !== undefined) {
82
+ throw new Error("guidanceChangeProofDigest is not allowed without a guidance remedy");
83
+ }
84
+ const dispositionKey = dispositionKeyFor(input.draft.id);
85
+ const payload = {
86
+ dispositionKey,
87
+ kind: "survey.extraction-improvement-activation-request",
88
+ schemaVersion: 1,
89
+ state: "approved",
90
+ draftId: input.draft.id,
91
+ approval,
92
+ nextTaskSpec,
93
+ rollbackTaskSpec,
94
+ ...(guidanceChangeProofDigest ? { guidanceChangeProofDigest } : {}),
95
+ };
96
+ return freeze({ id: digest("survey.extraction-improvement-activation-request/v1", payload), ...payload });
97
+ }
98
+ /** Emits an idempotent terminal rejection with the same disposition key as approval. */
99
+ export function rejectExtractionImprovementProposal(input) {
100
+ assertDraftIntegrity(input.draft);
101
+ const rejection = normalizeRejection(input.rejection);
102
+ const payload = {
103
+ dispositionKey: dispositionKeyFor(input.draft.id),
104
+ kind: "survey.extraction-improvement-rejection",
105
+ schemaVersion: 1,
106
+ state: "rejected",
107
+ draftId: input.draft.id,
108
+ rejection,
109
+ };
110
+ return freeze({ id: digest("survey.extraction-improvement-rejection/v1", payload), ...payload });
111
+ }
112
+ function projectValidatedLineage(input) {
113
+ const canonicalItems = buildReviewItemsFromExtractionEnvelopeImport(input.extractionImport);
114
+ if (!Number.isSafeInteger(input.proposalIndex) || input.proposalIndex < 0 || input.proposalIndex >= canonicalItems.length) {
115
+ throw new Error("proposalIndex does not identify a grounded extraction proposal");
116
+ }
117
+ const canonicalItem = canonicalItems[input.proposalIndex];
118
+ if (canonicalJson(canonicalItem) !== canonicalJson(input.reviewItem)) {
119
+ throw new Error("reviewItem is not the canonical projection of extractionImport and proposalIndex");
120
+ }
121
+ const envelope = input.extractionImport.spec.envelope;
122
+ const proposal = envelope.result.proposals[input.proposalIndex];
123
+ const artifact = envelope.result.preparedArtifact;
124
+ if (!envelope.result.taskDigest || !artifact || !envelope.source.snapshotRef) {
125
+ throw new Error("extraction improvement requires task, prepared-artifact, and source-snapshot grounding");
126
+ }
127
+ const candidate = exactlyOne(input.reviewItem.spec.candidates, () => true, "review candidate");
128
+ if (!candidate.extraction.extractionId)
129
+ throw new Error("canonical review candidate requires an extraction id");
130
+ return {
131
+ taskSpec: normalizeTaskSpecReference({
132
+ version: nonEmpty(input.priorTaskSpecVersion, "priorTaskSpecVersion"),
133
+ digest: envelope.result.taskDigest,
134
+ exampleDigests: envelope.result.exampleDigests ?? [],
135
+ }, "lineage.taskSpec"),
136
+ extractionImportName: input.extractionImport.metadata.name,
137
+ extractionId: candidate.extraction.extractionId,
138
+ proposalId: candidate.id,
139
+ reviewItemName: input.reviewItem.metadata.name,
140
+ reviewDecisionName: validateReviewDecision(input.reviewDecision, input.reviewItem),
141
+ reviewOutcomeId: nonEmpty(input.reviewOutcomeId, "reviewOutcomeId"),
142
+ sourceSnapshotRef: envelope.source.snapshotRef,
143
+ preparedArtifact: { ref: artifact.ref, digest: rawSha256Digest(artifact.digest, "preparedArtifact.digest") },
144
+ excerptLocator: nonEmpty(proposal.provenance.locator, "proposal.provenance.locator"),
145
+ recordDigests: {
146
+ extractionImport: recordDigest("ExtractionEnvelopeImport", input.extractionImport),
147
+ reviewItem: recordDigest("ReviewItem", input.reviewItem),
148
+ reviewDecision: recordDigest("ReviewDecision", input.reviewDecision),
149
+ reviewOutcome: "",
150
+ },
151
+ };
152
+ }
153
+ function projectValidatedReview(input, diagnosis, proposalId) {
154
+ const outcome = exactlyOne(input.survey.reviewOutcomes, ({ id }) => id === input.reviewOutcomeId, "review outcome");
155
+ const decision = input.reviewDecision;
156
+ const projection = decision.spec.projection;
157
+ if (!projection || projection.reviewOutcomeId !== outcome.id || projection.candidateSetId !== outcome.candidateSetId) {
158
+ throw new Error("reviewDecision projection must identify the concrete Survey review outcome and candidate set");
159
+ }
160
+ if (decision.spec.candidateId !== outcome.candidateId || projection.candidateId !== outcome.candidateId) {
161
+ throw new Error("reviewDecision and reviewOutcome candidate lineage do not match");
162
+ }
163
+ if (outcome.candidateId !== undefined && outcome.candidateId !== proposalId) {
164
+ throw new Error("any identified review candidate must be the canonical imported proposal");
165
+ }
166
+ assertDecisionOutcomeField(decision.spec.status, outcome.status, "status");
167
+ assertDecisionOutcomeField(decision.spec.resolution, outcome.resolution, "resolution");
168
+ assertDecisionOutcomeField(decision.spec.resolutionReason, outcome.resolutionReason, "resolutionReason");
169
+ assertDecisionOutcomeField(decision.spec.actor?.id, outcome.actor, "actor");
170
+ assertDecisionOutcomeField(decision.spec.reviewedAt, outcome.reviewedAt, "reviewedAt");
171
+ assertDecisionOutcomeField(decision.spec.rationale, outcome.rationale, "rationale");
172
+ assertSetEqual(decision.spec.evidenceIds ?? [], outcome.evidenceIds ?? [], "evidenceIds");
173
+ assertSetEqual(decision.spec.attemptEvidenceIds ?? [], outcome.attemptEvidenceIds ?? [], "attemptEvidenceIds");
174
+ assertDiagnosisReview(diagnosis, outcome);
175
+ const review = {
176
+ resolution: outcome.resolution,
177
+ status: outcome.status,
178
+ reviewedAt: canonicalTimestamp(outcome.reviewedAt, "reviewOutcome.reviewedAt"),
179
+ rationale: nonEmpty(outcome.rationale ?? outcome.resolutionReason, "review rationale"),
180
+ evidenceIds: sortedUnique(outcome.evidenceIds ?? [], "review.evidenceIds", nonEmpty),
181
+ attemptEvidenceIds: sortedUnique(outcome.attemptEvidenceIds ?? [], "review.attemptEvidenceIds", nonEmpty),
182
+ };
183
+ return review;
184
+ }
185
+ function validateReviewDecision(decision, item) {
186
+ if (!decision || decision.apiVersion !== reviewResourceApiVersion || decision.kind !== "ReviewDecision") {
187
+ throw new Error("reviewDecision must be a canonical ReviewDecision resource");
188
+ }
189
+ if (decision.spec.reviewItemName !== item.metadata.name)
190
+ throw new Error("reviewDecision does not join to reviewItem");
191
+ return nonEmpty(decision.metadata.name, "reviewDecision.metadata.name");
192
+ }
193
+ function assertDiagnosisReview(diagnosis, outcome) {
194
+ if (diagnosis.kind === "accepted-extraction") {
195
+ if (outcome.resolution !== "accepted" || (outcome.status !== "verified" && outcome.status !== "assumed")) {
196
+ throw new Error("accepted-extraction requires an accepted verified or assumed review outcome");
197
+ }
198
+ }
199
+ else if (diagnosis.kind === "bad-extraction") {
200
+ if (outcome.resolution !== "rejected" || outcome.status !== "rejected") {
201
+ throw new Error("bad-extraction requires an explicit rejected review outcome");
202
+ }
203
+ }
204
+ else if (outcome.resolution !== "could_not_confirm") {
205
+ throw new Error("insufficient-source-evidence requires an explicit could_not_confirm review outcome");
206
+ }
207
+ }
208
+ function normalizeDiagnosis(value) {
209
+ const diagnosis = requiredObject(value, "diagnosis");
210
+ if (diagnosis.kind === "bad-extraction") {
211
+ const requestedTaskChanges = normalizeChanges(diagnosis.requestedTaskChanges, "diagnosis.requestedTaskChanges", ["example-addition", "guidance-update"]);
212
+ return { kind: "bad-extraction", requestedTaskChanges };
213
+ }
214
+ if (diagnosis.kind === "accepted-extraction") {
215
+ const requestedTaskChanges = normalizeChanges(diagnosis.requestedTaskChanges, "diagnosis.requestedTaskChanges", ["grounded-positive-example", "guidance-affirmation"]);
216
+ return { kind: "accepted-extraction", requestedTaskChanges };
217
+ }
218
+ if (diagnosis.kind === "insufficient-source-evidence") {
219
+ const sourceRemediation = diagnosis.sourceRemediation;
220
+ if (sourceRemediation !== "obtain-authoritative-source" && sourceRemediation !== "refresh-source-snapshot" && sourceRemediation !== "expand-source-scope") {
221
+ throw new Error("insufficient-source-evidence requires an explicit source remediation");
222
+ }
223
+ return { kind: "insufficient-source-evidence", sourceRemediation };
224
+ }
225
+ throw new Error("diagnosis.kind must be explicit");
226
+ }
227
+ function normalizeChanges(value, label, allowed) {
228
+ const changes = sortedUnique(value, label, (change, field) => {
229
+ if (typeof change !== "string" || !allowed.includes(change))
230
+ throw new Error(`${field} contains an unsupported task change`);
231
+ return change;
232
+ });
233
+ if (changes.length === 0)
234
+ throw new Error(`${label} requires at least one explicit task change`);
235
+ return changes;
236
+ }
237
+ function assertExampleDigestChange(prior, next, addsExample) {
238
+ const priorSet = new Set(prior);
239
+ const nextSet = new Set(next);
240
+ const strictSuperset = prior.every((value) => nextSet.has(value)) && next.length > prior.length;
241
+ if (addsExample && !strictSuperset)
242
+ throw new Error("example remedy requires next example digests to be a strict superset");
243
+ if (!addsExample && canonicalJson(prior) !== canonicalJson(next))
244
+ throw new Error("next example digests changed without an example remedy");
245
+ }
246
+ function assertDraftIntegrity(draft) {
247
+ if (!draft || draft.kind !== "survey.extraction-improvement-proposal" || draft.schemaVersion !== 1 || draft.state !== "draft") {
248
+ throw new Error("disposition requires a draft extraction improvement proposal");
249
+ }
250
+ const normalizedDiagnosis = normalizeDiagnosis(draft.diagnosis);
251
+ if (canonicalJson(normalizedDiagnosis) !== canonicalJson(draft.diagnosis))
252
+ throw new Error("draft diagnosis is not canonical");
253
+ const normalizedTask = normalizeTaskSpecReference(draft.lineage.taskSpec, "draft.lineage.taskSpec");
254
+ if (canonicalJson(normalizedTask) !== canonicalJson(draft.lineage.taskSpec))
255
+ throw new Error("draft task reference is not canonical");
256
+ for (const [field, value] of Object.entries(draft.lineage.recordDigests))
257
+ sha256Digest(value, `draft.lineage.recordDigests.${field}`);
258
+ rawSha256Digest(draft.lineage.preparedArtifact.digest, "draft.lineage.preparedArtifact.digest");
259
+ nonEmpty(draft.lineage.preparedArtifact.ref, "draft.lineage.preparedArtifact.ref");
260
+ for (const [field, value] of Object.entries({
261
+ extractionImportName: draft.lineage.extractionImportName,
262
+ extractionId: draft.lineage.extractionId,
263
+ proposalId: draft.lineage.proposalId,
264
+ reviewItemName: draft.lineage.reviewItemName,
265
+ reviewDecisionName: draft.lineage.reviewDecisionName,
266
+ reviewOutcomeId: draft.lineage.reviewOutcomeId,
267
+ sourceSnapshotRef: draft.lineage.sourceSnapshotRef,
268
+ excerptLocator: draft.lineage.excerptLocator,
269
+ }))
270
+ nonEmpty(value, `draft.lineage.${field}`);
271
+ canonicalTimestamp(draft.createdAt, "draft.createdAt");
272
+ canonicalTimestamp(draft.review.reviewedAt, "draft.review.reviewedAt");
273
+ nonEmpty(draft.review.rationale, "draft.review.rationale");
274
+ const evidenceIds = sortedUnique(draft.review.evidenceIds, "draft.review.evidenceIds", nonEmpty);
275
+ const attemptEvidenceIds = sortedUnique(draft.review.attemptEvidenceIds, "draft.review.attemptEvidenceIds", nonEmpty);
276
+ if (canonicalJson(evidenceIds) !== canonicalJson(draft.review.evidenceIds)
277
+ || canonicalJson(attemptEvidenceIds) !== canonicalJson(draft.review.attemptEvidenceIds)) {
278
+ throw new Error("draft review evidence is not canonical");
279
+ }
280
+ assertDraftDiagnosisReview(draft.diagnosis, draft.review);
281
+ const { id, ...payload } = draft;
282
+ if (id !== digest("survey.extraction-improvement-proposal/v1", payload)) {
283
+ throw new Error("extraction improvement draft does not match its immutable identity");
284
+ }
285
+ }
286
+ function assertDraftDiagnosisReview(diagnosis, review) {
287
+ if (diagnosis.kind === "accepted-extraction") {
288
+ if (review.resolution !== "accepted" || (review.status !== "verified" && review.status !== "assumed")) {
289
+ throw new Error("draft accepted-extraction review is inconsistent");
290
+ }
291
+ }
292
+ else if (diagnosis.kind === "bad-extraction") {
293
+ if (review.resolution !== "rejected" || review.status !== "rejected")
294
+ throw new Error("draft bad-extraction review is inconsistent");
295
+ }
296
+ else if (review.resolution !== "could_not_confirm") {
297
+ throw new Error("draft insufficient-source-evidence review is inconsistent");
298
+ }
299
+ }
300
+ function normalizeTaskSpecReference(value, label) {
301
+ const task = requiredObject(value, label);
302
+ return {
303
+ version: nonEmpty(task.version, `${label}.version`),
304
+ digest: sha256Digest(task.digest, `${label}.digest`),
305
+ exampleDigests: sortedUnique(task.exampleDigests, `${label}.exampleDigests`, sha256Digest),
306
+ };
307
+ }
308
+ function normalizeApproval(value) {
309
+ const approval = requiredObject(value, "approval");
310
+ return {
311
+ id: nonEmpty(approval.id, "approval.id"), actor: nonEmpty(approval.actor, "approval.actor"),
312
+ approvedAt: canonicalTimestamp(approval.approvedAt, "approval.approvedAt"),
313
+ rationale: nonEmpty(approval.rationale, "approval.rationale"),
314
+ evidenceIds: sortedUnique(approval.evidenceIds, "approval.evidenceIds", nonEmpty),
315
+ };
316
+ }
317
+ function normalizeRejection(value) {
318
+ const rejection = requiredObject(value, "rejection");
319
+ return {
320
+ id: nonEmpty(rejection.id, "rejection.id"), actor: nonEmpty(rejection.actor, "rejection.actor"),
321
+ rejectedAt: canonicalTimestamp(rejection.rejectedAt, "rejection.rejectedAt"),
322
+ rationale: nonEmpty(rejection.rationale, "rejection.rationale"),
323
+ evidenceIds: sortedUnique(rejection.evidenceIds, "rejection.evidenceIds", nonEmpty),
324
+ };
325
+ }
326
+ function dispositionKeyFor(draftId) {
327
+ return digest("survey.extraction-improvement-disposition/v1", { draftId });
328
+ }
329
+ function recordDigest(kind, value) {
330
+ return digest("survey.extraction-improvement-record/v1", { kind, value });
331
+ }
332
+ function assertDecisionOutcomeField(left, right, label) {
333
+ if (canonicalJson(left ?? null) !== canonicalJson(right ?? null))
334
+ throw new Error(`reviewDecision and reviewOutcome ${label} do not match`);
335
+ }
336
+ function assertSetEqual(left, right, label) {
337
+ const a = sortedUnique(left, `reviewDecision.${label}`, nonEmpty);
338
+ const b = sortedUnique(right, `reviewOutcome.${label}`, nonEmpty);
339
+ if (canonicalJson(a) !== canonicalJson(b))
340
+ throw new Error(`reviewDecision and reviewOutcome ${label} do not match`);
341
+ }
342
+ function exactlyOne(records, predicate, label) {
343
+ const matches = records.filter(predicate);
344
+ if (matches.length !== 1)
345
+ throw new Error(`extraction improvement requires exactly one ${label}; found ${matches.length}`);
346
+ return matches[0];
347
+ }
348
+ function requiredObject(value, label) {
349
+ if (!value || typeof value !== "object" || Array.isArray(value))
350
+ throw new Error(`${label} must be an object`);
351
+ return value;
352
+ }
353
+ function nonEmpty(value, label) {
354
+ if (typeof value !== "string" || value.trim() === "")
355
+ throw new Error(`${label} must be a non-empty string`);
356
+ return value;
357
+ }
358
+ function sha256Digest(value, label) {
359
+ const digestValue = nonEmpty(value, label);
360
+ if (!/^sha256:[a-f0-9]{64}$/.test(digestValue))
361
+ throw new Error(`${label} must be a sha256 digest`);
362
+ return digestValue;
363
+ }
364
+ function rawSha256Digest(value, label) {
365
+ const digestValue = nonEmpty(value, label);
366
+ if (!/^[a-f0-9]{64}$/.test(digestValue))
367
+ throw new Error(`${label} must be an unprefixed sha256 digest`);
368
+ return digestValue;
369
+ }
370
+ function canonicalTimestamp(value, label) {
371
+ const timestamp = nonEmpty(value, label);
372
+ const parsed = new Date(timestamp);
373
+ if (Number.isNaN(parsed.valueOf()) || parsed.toISOString() !== timestamp)
374
+ throw new Error(`${label} must be a canonical ISO timestamp`);
375
+ return timestamp;
376
+ }
377
+ function sortedUnique(values, label, normalize) {
378
+ if (!Array.isArray(values))
379
+ throw new Error(`${label} must be an array`);
380
+ const keyed = values.map((value, index) => normalize(value, `${label}[${index}]`))
381
+ .map((value) => [canonicalJson(value), value]).sort(([left], [right]) => left.localeCompare(right));
382
+ if (keyed.some(([value], index) => index > 0 && keyed[index - 1][0] === value))
383
+ throw new Error(`${label} must not contain duplicate values`);
384
+ return keyed.map(([, value]) => value);
385
+ }
386
+ function digest(domain, payload) {
387
+ return `sha256:${createHash("sha256").update(canonicalJson({ domain, payload })).digest("hex")}`;
388
+ }
389
+ function freeze(value) {
390
+ if (value && typeof value === "object") {
391
+ for (const nested of Object.values(value))
392
+ freeze(nested);
393
+ Object.freeze(value);
394
+ }
395
+ return value;
396
+ }
@@ -14,10 +14,14 @@ export { reviewedCurrentProposedResolution } from "./reviewed-current-proposed-r
14
14
  export type { CurrentProposedCandidateRole, ReviewedCurrentProposedResolutionInput, } from "./reviewed-current-proposed-resolution.js";
15
15
  export { buildSurveyTrustBundle } from "./to-surface.js";
16
16
  export type { BuildSurveyTrustBundleOptions } from "./to-surface.js";
17
+ export { buildCanonicalReviewedTrustInput } from "./canonical-reviewed-trust-input.js";
18
+ export type { BuildCanonicalReviewedTrustInputOptions, CanonicalReviewedTrustInput, } from "./canonical-reviewed-trust-input.js";
17
19
  export { buildSurveyLearningProjections } from "./learning-projections.js";
18
20
  export type { LearningProjection, LearningProjectionKind, LearningProjectionSeverity, LearningProjectionSignal, } from "./learning-projections.js";
19
21
  export { buildReviewedLearningUpdateProposal } from "./learning-update-proposal.js";
20
22
  export type { LearningUpdateEvidenceReference, LearningUpdateProposal, OpaqueEvidenceReference, ProvenanceReference, ReviewedLearningUpdateProposalInput, ReviewProofReference, } from "./learning-update-proposal.js";
23
+ export { approveExtractionImprovementProposal, buildExtractionImprovementProposal, foldExtractionImprovementDispositions, rejectExtractionImprovementProposal, } from "./extraction-improvement-proposal.js";
24
+ export type { ApproveExtractionImprovementProposalInput, AcceptedExtractionDiagnosis, BadExtractionDiagnosis, BuildExtractionImprovementProposalInput, ExtractionImprovementActivationRequest, ExtractionImprovementDiagnosis, ExtractionImprovementDisposition, ExtractionImprovementDispositionConflict, ExtractionImprovementDraft, ExtractionImprovementLineage, ExtractionImprovementRejection, ExtractionImprovementRecordDigests, ExtractionImprovementReview, ExtractionTaskSpecReference, FoldExtractionImprovementDispositionsResult, InsufficientSourceEvidenceDiagnosis, ProducerApproval, RejectExtractionImprovementProposalInput, } from "./extraction-improvement-proposal.js";
21
25
  export { buildCanonicalReviewProofPayload, buildReviewProofAnchor, canonicalReviewProofJson, hashCanonicalReviewProofPayload, verifyCanonicalReviewProofPayload, REVIEW_PROOF_CONTRACT_VERSION, REVIEW_PROOF_PACKAGE_NAME, REVIEW_PROOF_SCHEMA, REVIEW_PROOF_SCHEMA_VERSION, } from "./review-proof.js";
22
26
  export type { CanonicalReviewProofPayload, CanonicalReviewProofPayloadV1, CanonicalReviewProofPayloadV2, ReviewProofInput, } from "./review-proof.js";
23
27
  export { fieldObservation } from "./field-observation.js";
package/dist/src/index.js CHANGED
@@ -6,8 +6,10 @@ export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from
6
6
  export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js";
7
7
  export { reviewedCurrentProposedResolution } from "./reviewed-current-proposed-resolution.js";
8
8
  export { buildSurveyTrustBundle } from "./to-surface.js";
9
+ export { buildCanonicalReviewedTrustInput } from "./canonical-reviewed-trust-input.js";
9
10
  export { buildSurveyLearningProjections } from "./learning-projections.js";
10
11
  export { buildReviewedLearningUpdateProposal } from "./learning-update-proposal.js";
12
+ export { approveExtractionImprovementProposal, buildExtractionImprovementProposal, foldExtractionImprovementDispositions, rejectExtractionImprovementProposal, } from "./extraction-improvement-proposal.js";
11
13
  export { buildCanonicalReviewProofPayload, buildReviewProofAnchor, canonicalReviewProofJson, hashCanonicalReviewProofPayload, verifyCanonicalReviewProofPayload, REVIEW_PROOF_CONTRACT_VERSION, REVIEW_PROOF_PACKAGE_NAME, REVIEW_PROOF_SCHEMA, REVIEW_PROOF_SCHEMA_VERSION, } from "./review-proof.js";
12
14
  export { fieldObservation } from "./field-observation.js";
13
15
  export { repeatedObservation } from "./repeated-observation.js";
@@ -18,6 +18,17 @@ export interface SurveyCalibrationOptions {
18
18
  }
19
19
  export interface BuildSurveyTrustBundleOptions {
20
20
  reviewProofs?: boolean;
21
+ /**
22
+ * Caller-owned identity for one projection or resolution context, such as a
23
+ * review-session, proposal, or append-only persistence record id. When set,
24
+ * Survey folds it into generated Evidence and VerificationEvent ids so
25
+ * repeated projections of the same Claim cannot collide. Claim ids and all
26
+ * legacy output remain unchanged when omitted.
27
+ *
28
+ * Use a portable resource-name fragment: ASCII letters, digits, `.`, `_`,
29
+ * `:`, or `-`, beginning with a letter or digit.
30
+ */
31
+ projectionContextId?: string;
21
32
  /**
22
33
  * Populate `conclusionConfidence.value` from empirical review calibration —
23
34
  * "how often this extractor's proposals at this confidence were affirmed by a
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { buildReviewProofAnchor } from "./review-proof.js";
2
3
  import { assertReviewOutcomeDiscipline } from "./producer-discipline.js";
3
4
  import { deriveCalibration } from "./calibration.js";
@@ -5,6 +6,7 @@ import { deriveCalibration } from "./calibration.js";
5
6
  * accuracy is emitted as a `conclusionConfidence.value`. */
6
7
  const DEFAULT_CALIBRATION_MIN_SAMPLES = 20;
7
8
  export function buildSurveyTrustBundle(input, options = {}) {
9
+ const projectionContextId = validateProjectionContextId(options.projectionContextId);
8
10
  const rawSources = indexById(input.rawSources, "raw source");
9
11
  const extractions = indexById(input.extractions, "extraction");
10
12
  const candidateSets = indexById(input.candidateSets, "candidate set");
@@ -37,7 +39,7 @@ export function buildSurveyTrustBundle(input, options = {}) {
37
39
  const claimValue = projection.value ?? candidate.value;
38
40
  const createdAt = projection.createdAt ?? extraction.extractedAt;
39
41
  const updatedAt = projection.updatedAt ?? projectionReview?.reviewedAt ?? input.generatedAt;
40
- const evidenceId = `${projection.id}.evidence.source`;
42
+ const evidenceId = projectionRecordId(projection.id, projectionContextId, "claim-evidence", "evidence.source");
41
43
  const claim = {
42
44
  id: projection.id,
43
45
  subjectType: projection.subjectType,
@@ -147,7 +149,7 @@ export function buildSurveyTrustBundle(input, options = {}) {
147
149
  });
148
150
  const rationale = projectionReview?.rationale ?? candidateSet.rationale;
149
151
  events.push({
150
- id: `${projection.id}.event.${status}`,
152
+ id: projectionRecordId(projection.id, projectionContextId, "claim-event", `event.${status}`),
151
153
  claimId: projection.id,
152
154
  status,
153
155
  actor: projection.actor ?? projectionReview?.actor ?? projection.collectedBy,
@@ -158,7 +160,7 @@ export function buildSurveyTrustBundle(input, options = {}) {
158
160
  notes: rationale,
159
161
  });
160
162
  }
161
- projectInterpretations({ input, rawSources, claims, evidence, events });
163
+ projectInterpretations({ input, rawSources, claims, evidence, events, projectionContextId });
162
164
  if (input.escalations) {
163
165
  const claimIds = new Set(claims.map((c) => c.id));
164
166
  for (const escalation of input.escalations) {
@@ -170,7 +172,7 @@ export function buildSurveyTrustBundle(input, options = {}) {
170
172
  throw new Error(`Escalation ${escalation.id} references unknown claim ${escalation.attachToClaimId}`);
171
173
  }
172
174
  events.push({
173
- id: `${escalation.id}.event`,
175
+ id: projectionRecordId(escalation.id, projectionContextId, "escalation-event", "event"),
174
176
  claimId: escalation.attachToClaimId,
175
177
  status: "disputed",
176
178
  actor: escalation.raisedBy,
@@ -190,6 +192,22 @@ export function buildSurveyTrustBundle(input, options = {}) {
190
192
  events,
191
193
  };
192
194
  }
195
+ function validateProjectionContextId(contextId) {
196
+ if (contextId === undefined)
197
+ return undefined;
198
+ if (typeof contextId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(contextId)) {
199
+ throw new Error("projectionContextId must be a non-empty portable resource-name fragment");
200
+ }
201
+ return contextId;
202
+ }
203
+ function projectionRecordId(ownerId, contextId, recordKind, legacySuffix) {
204
+ if (!contextId)
205
+ return `${ownerId}.${legacySuffix}`;
206
+ const digest = createHash("sha256")
207
+ .update(JSON.stringify(["survey-projection-context-v1", recordKind, ownerId, contextId]))
208
+ .digest("hex");
209
+ return `survey.projection.v1.${digest}.${legacySuffix}`;
210
+ }
193
211
  function projectInterpretations(input) {
194
212
  const interpretations = input.input.interpretations
195
213
  ? [...indexById(input.input.interpretations, "interpretation").values()]
@@ -204,6 +222,7 @@ function projectInterpretations(input) {
204
222
  claims: input.claims,
205
223
  claimsById,
206
224
  input: input.input,
225
+ projectionContextId: input.projectionContextId,
207
226
  });
208
227
  input.evidence.push(projection.evidence);
209
228
  input.events.push(projection.event);
@@ -227,6 +246,7 @@ function buildInterpretationProjection(input) {
227
246
  claim,
228
247
  anchorSource,
229
248
  policyStandard,
249
+ projectionContextId: input.projectionContextId,
230
250
  });
231
251
  return {
232
252
  claim,
@@ -236,6 +256,7 @@ function buildInterpretationProjection(input) {
236
256
  interpretation: input.interpretation,
237
257
  claim,
238
258
  evidenceId: evidence.id,
259
+ projectionContextId: input.projectionContextId,
239
260
  }),
240
261
  };
241
262
  }
@@ -248,7 +269,7 @@ function requirePolicyStandardAnchor(interpretation, rawSources) {
248
269
  }
249
270
  function createInterpretationAnchorEvidence(input) {
250
271
  return {
251
- id: `${input.interpretation.id}.evidence.anchor`,
272
+ id: projectionRecordId(input.interpretation.id, input.projectionContextId, "interpretation-evidence", "evidence.anchor"),
252
273
  claimId: input.claim.id,
253
274
  evidenceType: "policy_rule",
254
275
  method: "anchoring",
@@ -271,7 +292,7 @@ function createInterpretationAnchorEvidence(input) {
271
292
  }
272
293
  function createInterpretationEvent(input) {
273
294
  return {
274
- id: `${input.interpretation.id}.event`,
295
+ id: projectionRecordId(input.interpretation.id, input.projectionContextId, "interpretation-event", "event"),
275
296
  claimId: input.claim.id,
276
297
  status: input.claim.status ?? "proposed",
277
298
  actor: input.interpretation.actor,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "1.18.1",
3
+ "version": "2.0.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",
@@ -18,10 +18,6 @@
18
18
  },
19
19
  "exports": {
20
20
  ".": "./dist/src/index.js",
21
- "./anthropic": {
22
- "types": "./dist/src/anthropic.d.ts",
23
- "default": "./dist/src/anthropic.js"
24
- },
25
21
  "./review-workbench": {
26
22
  "types": "./dist/src/review-workbench/review-workbench.d.ts",
27
23
  "default": "./dist/src/review-workbench/review-workbench.js"
@@ -55,7 +51,8 @@
55
51
  "typecheck": "tsc --noEmit",
56
52
  "test": "npm run build && node --test dist/tests/*.test.js",
57
53
  "test:browser": "playwright test",
58
- "verify": "node scripts/check-content-boundary.cjs && npm run check:decisions && npm run typecheck && npm test && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser",
54
+ "test:browser:concurrent": "npm run build && node scripts/test-playwright-concurrency.mjs",
55
+ "verify": "node scripts/check-content-boundary.cjs && npm run check:decisions && npm run typecheck && npm test && npm run check:review-workbench-assets && npm run check:review-workbench && npm run test:browser && npm run test:browser:concurrent",
59
56
  "check:content-boundary": "node scripts/check-content-boundary.cjs",
60
57
  "check:decisions": "node scripts/check-decisions.cjs check",
61
58
  "gen:decisions-index": "node scripts/check-decisions.cjs gen-index",
@@ -74,19 +71,14 @@
74
71
  "@kontourai/surface": "^2.9.0"
75
72
  },
76
73
  "peerDependencies": {
77
- "@anthropic-ai/sdk": ">=0.20.0",
78
74
  "typescript": ">=5.0.0"
79
75
  },
80
76
  "peerDependenciesMeta": {
81
- "@anthropic-ai/sdk": {
82
- "optional": true
83
- },
84
77
  "typescript": {
85
78
  "optional": true
86
79
  }
87
80
  },
88
81
  "devDependencies": {
89
- "@anthropic-ai/sdk": "^0.54.0",
90
82
  "@kontourai/traverse": "^0.19.0",
91
83
  "@kontourai/ui": "^1.1.0",
92
84
  "@playwright/test": "^1.60.0",