@kontourai/survey 7.0.0 → 8.1.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.
Files changed (40) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +2 -1
  3. package/dist/src/calibration.d.ts +15 -0
  4. package/dist/src/calibration.js +11 -0
  5. package/dist/src/canonical-reviewed-trust-input.d.ts +10 -0
  6. package/dist/src/canonical-reviewed-trust-input.js +15 -0
  7. package/dist/src/console/review-console-server.d.ts +20 -0
  8. package/dist/src/console/review-console-server.js +75 -4
  9. package/dist/src/decision-carry-forward.d.ts +157 -0
  10. package/dist/src/decision-carry-forward.js +295 -0
  11. package/dist/src/extraction-envelope.d.ts +4 -3
  12. package/dist/src/extraction-envelope.js +3 -3
  13. package/dist/src/field-states.d.ts +100 -0
  14. package/dist/src/field-states.js +169 -0
  15. package/dist/src/index.d.ts +3 -1
  16. package/dist/src/index.js +2 -0
  17. package/dist/src/mcp/review-mcp.js +44 -13
  18. package/dist/src/review-resource.d.ts +29 -0
  19. package/dist/src/review-session-file.d.ts +6 -0
  20. package/dist/src/review-session-file.js +2 -0
  21. package/dist/src/review-workbench/audit-sample.d.ts +32 -0
  22. package/dist/src/review-workbench/audit-sample.js +53 -0
  23. package/dist/src/review-workbench/extraction-inspector.d.ts +1 -1
  24. package/dist/src/review-workbench/extraction-inspector.js +1 -1
  25. package/dist/src/review-workbench/field-state-view.d.ts +15 -0
  26. package/dist/src/review-workbench/field-state-view.js +84 -0
  27. package/dist/src/review-workbench/review-queue-session.d.ts +46 -1
  28. package/dist/src/review-workbench/review-queue-session.js +74 -3
  29. package/dist/src/review-workbench/review-session-replay.d.ts +1 -1
  30. package/dist/src/review-workbench/review-session-replay.js +15 -1
  31. package/dist/src/review-workbench/review-workbench-css.generated.d.ts +9 -0
  32. package/dist/src/review-workbench/review-workbench-css.generated.js +453 -43
  33. package/dist/src/review-workbench/review-workbench-element.js +68 -212
  34. package/dist/src/review-workbench/review-workbench.css +386 -42
  35. package/dist/src/review-workbench/review-workbench.d.ts +13 -1
  36. package/dist/src/review-workbench/review-workbench.js +41 -15
  37. package/dist/src/review-workbench/review-workbench.standalone.css +37 -23
  38. package/dist/src/review-workbench/vendor/kontourai-ui/tokens/themes.css +189 -10
  39. package/dist/src/review-workbench/vendor/kontourai-ui/tokens/tokens.css +156 -5
  40. package/package.json +13 -8
@@ -0,0 +1,157 @@
1
+ import type { ReviewDecision, ReviewItem } from "./review-resource.js";
2
+ /**
3
+ * Decisions across review rounds (#295).
4
+ *
5
+ * A new extraction of the same document gives every review item a new identity,
6
+ * so on its own every re-extraction sends every field back to review. Given the
7
+ * producer's stable slot identity and per-candidate version identity, a
8
+ * decision on a slot whose candidates did not change is carried forward to the
9
+ * new round, and a decision that a later one replaces is marked superseded.
10
+ *
11
+ * Vocabulary follows the round receipts producers already keep: a decision is
12
+ * `carried-forward` (made in an earlier round and still holding because its
13
+ * candidates are unchanged) or `affirmed` (made against this round's item).
14
+ *
15
+ * Survey does not invent slot or version identities: they come from the
16
+ * producer, and every item and candidate must carry one. Survey additionally
17
+ * compares each candidate's value, full locator, source evidence and full
18
+ * claim target, so a
19
+ * decision is never carried across a candidate whose content changed, even if a
20
+ * producer reused a version identity.
21
+ *
22
+ * Both records are content-addressed (`id` is a digest of every other field)
23
+ * and immutable: a record that changes is a new record.
24
+ */
25
+ export declare const decisionCarryForwardKind: "survey.decision-carry-forward";
26
+ export declare const decisionSupersessionKind: "survey.decision-supersession";
27
+ /** How a decision reads in a round. */
28
+ export type DecisionBasis = "carried-forward" | "affirmed";
29
+ /** The terminal resolution a decision reached, as carry-forward reads it. */
30
+ export type CarriedResolution = "accepted" | "rejected" | "could_not_confirm";
31
+ /** One review item in a round, with the producer's slot and version identities. */
32
+ export interface RoundReviewItem {
33
+ readonly item: ReviewItem;
34
+ /** Producer slot identity: the same field of the same source lineage across rounds. */
35
+ readonly slotId: string;
36
+ /** Producer version identity of every candidate of `item`, keyed by candidate id. */
37
+ readonly candidateVersionIds: Readonly<Record<string, string>>;
38
+ }
39
+ /** A decision made in an earlier round, with the round item it was made on. */
40
+ export interface PriorRoundDecision extends RoundReviewItem {
41
+ readonly decision: ReviewDecision;
42
+ }
43
+ /** A decision named by content: its resource name and the digest of its canonical bytes. */
44
+ export interface DecisionReference {
45
+ readonly name: string;
46
+ readonly digest: string;
47
+ }
48
+ export interface DecisionCarryForward {
49
+ /** `sha256:` digest of every other field; recomputed on validation. */
50
+ readonly id: string;
51
+ readonly kind: typeof decisionCarryForwardKind;
52
+ readonly schemaVersion: 1;
53
+ readonly priorDecision: DecisionReference;
54
+ readonly slotId: string;
55
+ /** Sorted, unique version ids of the slot's candidates, identical in both rounds. */
56
+ readonly versionIds: readonly string[];
57
+ readonly roundId: string;
58
+ /** The new round's review item the decision now applies to. */
59
+ readonly reviewItemName: string;
60
+ /** The new round's candidate the prior decision selected, when it selected one. */
61
+ readonly candidateId?: string;
62
+ readonly resolution: CarriedResolution;
63
+ readonly basis: "carried-forward";
64
+ }
65
+ /**
66
+ * Why a decision was superseded: its slot's candidates changed and the new
67
+ * version was decided (`candidate-changed`), or the unchanged slot was
68
+ * reviewed again and decided anew (`re-reviewed`).
69
+ */
70
+ export type SupersessionReason = "candidate-changed" | "re-reviewed";
71
+ export interface DecisionSupersession {
72
+ /** `sha256:` digest of every other field; recomputed on validation. */
73
+ readonly id: string;
74
+ readonly kind: typeof decisionSupersessionKind;
75
+ readonly schemaVersion: 1;
76
+ readonly priorDecision: DecisionReference;
77
+ readonly newDecision: DecisionReference;
78
+ readonly slotId: string;
79
+ readonly reason: SupersessionReason;
80
+ }
81
+ /**
82
+ * Which prior resolutions may be carried forward. Only `accepted` is carried
83
+ * unless a policy names others explicitly: a rejection or a could-not-confirm
84
+ * is a judgment about evidence a new round may have changed around it.
85
+ */
86
+ export interface CarryForwardPolicy {
87
+ readonly carry: readonly CarriedResolution[];
88
+ }
89
+ export declare const defaultCarryForwardPolicy: CarryForwardPolicy;
90
+ export type NeedsReviewReason =
91
+ /** No prior decision was made on this slot. */
92
+ "no-prior-decision"
93
+ /** The slot's candidate versions differ from the ones the prior decision saw. */
94
+ | "candidate-changed"
95
+ /** A version id matched, but the candidate's value, locator, source or claim target did not. */
96
+ | "candidate-content-changed"
97
+ /** The prior decision's resolution is not one the policy carries. */
98
+ | "resolution-not-carried"
99
+ /** More than one live prior decision names this slot. */
100
+ | "ambiguous-prior-decisions";
101
+ export interface CarriedForwardItem {
102
+ readonly item: RoundReviewItem;
103
+ readonly carryForward: DecisionCarryForward;
104
+ }
105
+ export interface NeedsReviewItem {
106
+ readonly item: RoundReviewItem;
107
+ readonly reason: NeedsReviewReason;
108
+ /** The prior decision the item could not inherit, when there was one. */
109
+ readonly priorDecision?: DecisionReference;
110
+ }
111
+ export interface SplitRoundForCarryForwardInput {
112
+ /** Identity of the new round. */
113
+ readonly roundId: string;
114
+ readonly items: readonly RoundReviewItem[];
115
+ readonly priorDecisions: readonly PriorRoundDecision[];
116
+ /** Supersessions already recorded: a prior decision they name is no longer live. */
117
+ readonly supersessions?: readonly DecisionSupersession[];
118
+ readonly policy?: CarryForwardPolicy;
119
+ }
120
+ export interface SplitRoundForCarryForwardResult {
121
+ readonly carriedForward: readonly CarriedForwardItem[];
122
+ readonly needsReview: readonly NeedsReviewItem[];
123
+ }
124
+ /**
125
+ * Splits a new round's items into those that inherit a prior decision
126
+ * (with a {@link DecisionCarryForward} record naming it) and those that need
127
+ * review. Pure; performs no I/O. An item carries forward only when exactly one
128
+ * live prior decision names its slot, the slot's candidates have the same
129
+ * version ids in both rounds, each candidate's value, locator, excerpt and
130
+ * source are unchanged, and the policy carries the prior resolution.
131
+ */
132
+ export declare function splitRoundForCarryForward(input: SplitRoundForCarryForwardInput): SplitRoundForCarryForwardResult;
133
+ export interface BuildDecisionSupersessionInput {
134
+ readonly prior: PriorRoundDecision;
135
+ /** The new round's item the new decision was made on. */
136
+ readonly item: RoundReviewItem;
137
+ readonly decision: ReviewDecision;
138
+ }
139
+ /**
140
+ * Records that `decision`, made on the new round's item for the same slot,
141
+ * replaces `prior`. The reason is derived: `candidate-changed` when the slot's
142
+ * candidate versions or content differ, `re-reviewed` when they do not.
143
+ */
144
+ export declare function buildDecisionSupersession(input: BuildDecisionSupersessionInput): DecisionSupersession;
145
+ /** The name and canonical digest of a decision. */
146
+ export declare function decisionReference(decision: ReviewDecision): DecisionReference;
147
+ /**
148
+ * The terminal resolution a decision reached: `could_not_confirm`, `rejected`
149
+ * for a rejected status, `accepted` for a verified or assumed one. Undefined
150
+ * for a decision that reached none (a `proposed` status without
151
+ * could-not-confirm).
152
+ */
153
+ export declare function decisionResolution(decision: ReviewDecision): CarriedResolution | undefined;
154
+ /** Validates a stored carry-forward record: its shape and its content-addressed id. */
155
+ export declare function validateDecisionCarryForward(value: unknown): DecisionCarryForward;
156
+ /** Validates a stored supersession record: its shape and its content-addressed id. */
157
+ export declare function validateDecisionSupersession(value: unknown): DecisionSupersession;
@@ -0,0 +1,295 @@
1
+ import { sha256Hex } from "./sha256.js";
2
+ import { canonicalJson } from "./review-workbench/canonical.js";
3
+ /**
4
+ * Decisions across review rounds (#295).
5
+ *
6
+ * A new extraction of the same document gives every review item a new identity,
7
+ * so on its own every re-extraction sends every field back to review. Given the
8
+ * producer's stable slot identity and per-candidate version identity, a
9
+ * decision on a slot whose candidates did not change is carried forward to the
10
+ * new round, and a decision that a later one replaces is marked superseded.
11
+ *
12
+ * Vocabulary follows the round receipts producers already keep: a decision is
13
+ * `carried-forward` (made in an earlier round and still holding because its
14
+ * candidates are unchanged) or `affirmed` (made against this round's item).
15
+ *
16
+ * Survey does not invent slot or version identities: they come from the
17
+ * producer, and every item and candidate must carry one. Survey additionally
18
+ * compares each candidate's value, full locator, source evidence and full
19
+ * claim target, so a
20
+ * decision is never carried across a candidate whose content changed, even if a
21
+ * producer reused a version identity.
22
+ *
23
+ * Both records are content-addressed (`id` is a digest of every other field)
24
+ * and immutable: a record that changes is a new record.
25
+ */
26
+ export const decisionCarryForwardKind = "survey.decision-carry-forward";
27
+ export const decisionSupersessionKind = "survey.decision-supersession";
28
+ export const defaultCarryForwardPolicy = Object.freeze({ carry: Object.freeze(["accepted"]) });
29
+ /**
30
+ * Splits a new round's items into those that inherit a prior decision
31
+ * (with a {@link DecisionCarryForward} record naming it) and those that need
32
+ * review. Pure; performs no I/O. An item carries forward only when exactly one
33
+ * live prior decision names its slot, the slot's candidates have the same
34
+ * version ids in both rounds, each candidate's value, locator, excerpt and
35
+ * source are unchanged, and the policy carries the prior resolution.
36
+ */
37
+ export function splitRoundForCarryForward(input) {
38
+ const roundId = nonEmpty(input.roundId, "roundId");
39
+ const policy = normalizePolicy(input.policy ?? defaultCarryForwardPolicy);
40
+ const superseded = new Set((input.supersessions ?? []).map((record) => validateDecisionSupersession(record).priorDecision.digest));
41
+ const priorBySlot = new Map();
42
+ for (const prior of input.priorDecisions) {
43
+ assertRoundItem(prior, "prior decision");
44
+ assertDecisionNamesItem(prior.decision, prior.item);
45
+ if (superseded.has(decisionReference(prior.decision).digest))
46
+ continue;
47
+ priorBySlot.set(prior.slotId, [...(priorBySlot.get(prior.slotId) ?? []), prior]);
48
+ }
49
+ const seenSlots = new Set();
50
+ const carriedForward = [];
51
+ const needsReview = [];
52
+ for (const item of input.items) {
53
+ assertRoundItem(item, "round item");
54
+ if (seenSlots.has(item.slotId))
55
+ throw new Error(`Round ${roundId} has two items for slot ${item.slotId}.`);
56
+ seenSlots.add(item.slotId);
57
+ const priors = priorBySlot.get(item.slotId) ?? [];
58
+ if (priors.length === 0) {
59
+ needsReview.push({ item, reason: "no-prior-decision" });
60
+ continue;
61
+ }
62
+ if (priors.length > 1) {
63
+ needsReview.push({ item, reason: "ambiguous-prior-decisions" });
64
+ continue;
65
+ }
66
+ const prior = priors[0];
67
+ const priorDecision = decisionReference(prior.decision);
68
+ const priorVersions = sortedVersions(prior);
69
+ const versions = sortedVersions(item);
70
+ if (canonicalJson(priorVersions) !== canonicalJson(versions)) {
71
+ needsReview.push({ item, reason: "candidate-changed", priorDecision });
72
+ continue;
73
+ }
74
+ if (!sameCandidateContent(prior, item)) {
75
+ needsReview.push({ item, reason: "candidate-content-changed", priorDecision });
76
+ continue;
77
+ }
78
+ const resolution = decisionResolution(prior.decision);
79
+ if (resolution === undefined || !policy.carry.includes(resolution)) {
80
+ needsReview.push({ item, reason: "resolution-not-carried", priorDecision });
81
+ continue;
82
+ }
83
+ const priorCandidateId = prior.decision.spec.candidateId;
84
+ const candidateId = priorCandidateId === undefined ? undefined : candidateWithVersion(item, prior.candidateVersionIds[priorCandidateId]).id;
85
+ carriedForward.push({
86
+ item,
87
+ carryForward: sealCarryForward({
88
+ kind: decisionCarryForwardKind,
89
+ schemaVersion: 1,
90
+ priorDecision,
91
+ slotId: item.slotId,
92
+ versionIds: versions,
93
+ roundId,
94
+ reviewItemName: item.item.metadata.name,
95
+ ...(candidateId !== undefined ? { candidateId } : {}),
96
+ resolution,
97
+ basis: "carried-forward",
98
+ }),
99
+ });
100
+ }
101
+ return Object.freeze({ carriedForward: Object.freeze(carriedForward), needsReview: Object.freeze(needsReview) });
102
+ }
103
+ /**
104
+ * Records that `decision`, made on the new round's item for the same slot,
105
+ * replaces `prior`. The reason is derived: `candidate-changed` when the slot's
106
+ * candidate versions or content differ, `re-reviewed` when they do not.
107
+ */
108
+ export function buildDecisionSupersession(input) {
109
+ assertRoundItem(input.prior, "prior decision");
110
+ assertRoundItem(input.item, "round item");
111
+ assertDecisionNamesItem(input.prior.decision, input.prior.item);
112
+ assertDecisionNamesItem(input.decision, input.item.item);
113
+ if (input.prior.slotId !== input.item.slotId)
114
+ throw new Error(`A supersession needs one slot; the prior decision is on ${input.prior.slotId} and the new one on ${input.item.slotId}.`);
115
+ const priorDecision = decisionReference(input.prior.decision);
116
+ const newDecision = decisionReference(input.decision);
117
+ if (priorDecision.digest === newDecision.digest)
118
+ throw new Error("A decision cannot supersede itself.");
119
+ const unchanged = canonicalJson(sortedVersions(input.prior)) === canonicalJson(sortedVersions(input.item)) && sameCandidateContent(input.prior, input.item);
120
+ return sealSupersession({
121
+ kind: decisionSupersessionKind,
122
+ schemaVersion: 1,
123
+ priorDecision,
124
+ newDecision,
125
+ slotId: input.item.slotId,
126
+ reason: unchanged ? "re-reviewed" : "candidate-changed",
127
+ });
128
+ }
129
+ /** The name and canonical digest of a decision. */
130
+ export function decisionReference(decision) {
131
+ if (decision?.kind !== "ReviewDecision")
132
+ throw new Error("A decision reference needs a ReviewDecision.");
133
+ return { name: nonEmpty(decision.metadata?.name, "decision name"), digest: `sha256:${sha256Hex(canonicalJson(decision))}` };
134
+ }
135
+ /**
136
+ * The terminal resolution a decision reached: `could_not_confirm`, `rejected`
137
+ * for a rejected status, `accepted` for a verified or assumed one. Undefined
138
+ * for a decision that reached none (a `proposed` status without
139
+ * could-not-confirm).
140
+ */
141
+ export function decisionResolution(decision) {
142
+ if (decision.spec.resolution === "could_not_confirm")
143
+ return "could_not_confirm";
144
+ if (decision.spec.status === "rejected")
145
+ return "rejected";
146
+ if (decision.spec.status === "verified" || decision.spec.status === "assumed")
147
+ return "accepted";
148
+ return undefined;
149
+ }
150
+ /** Validates a stored carry-forward record: its shape and its content-addressed id. */
151
+ export function validateDecisionCarryForward(value) {
152
+ const record = object(value, "DecisionCarryForward");
153
+ exactKeys(record, ["id", "kind", "schemaVersion", "priorDecision", "slotId", "versionIds", "roundId", "reviewItemName", "resolution", "basis"], ["candidateId"], "DecisionCarryForward");
154
+ if (record.kind !== decisionCarryForwardKind || record.schemaVersion !== 1 || record.basis !== "carried-forward")
155
+ throw new Error("DecisionCarryForward kind, schemaVersion or basis is invalid.");
156
+ validateReference(record.priorDecision, "priorDecision");
157
+ nonEmpty(record.slotId, "slotId");
158
+ nonEmpty(record.roundId, "roundId");
159
+ nonEmpty(record.reviewItemName, "reviewItemName");
160
+ if (record.candidateId !== undefined)
161
+ nonEmpty(record.candidateId, "candidateId");
162
+ if (!Array.isArray(record.versionIds) || record.versionIds.length === 0)
163
+ throw new Error("DecisionCarryForward versionIds must be a non-empty array.");
164
+ record.versionIds.forEach((entry, index) => {
165
+ nonEmpty(entry, "versionId");
166
+ if (index > 0 && !(record.versionIds[index - 1] < entry))
167
+ throw new Error("DecisionCarryForward versionIds must be sorted and unique.");
168
+ });
169
+ if (!["accepted", "rejected", "could_not_confirm"].includes(record.resolution))
170
+ throw new Error("DecisionCarryForward resolution is invalid.");
171
+ const { id, ...payload } = record;
172
+ if (id !== recordDigest(decisionCarryForwardKind, payload))
173
+ throw new Error("DecisionCarryForward id is not the digest of its fields.");
174
+ return deepFreeze(structuredClone(record));
175
+ }
176
+ /** Validates a stored supersession record: its shape and its content-addressed id. */
177
+ export function validateDecisionSupersession(value) {
178
+ const record = object(value, "DecisionSupersession");
179
+ exactKeys(record, ["id", "kind", "schemaVersion", "priorDecision", "newDecision", "slotId", "reason"], [], "DecisionSupersession");
180
+ if (record.kind !== decisionSupersessionKind || record.schemaVersion !== 1)
181
+ throw new Error("DecisionSupersession kind or schemaVersion is invalid.");
182
+ validateReference(record.priorDecision, "priorDecision");
183
+ validateReference(record.newDecision, "newDecision");
184
+ nonEmpty(record.slotId, "slotId");
185
+ if (record.reason !== "candidate-changed" && record.reason !== "re-reviewed")
186
+ throw new Error("DecisionSupersession reason is invalid.");
187
+ if (record.priorDecision.digest === record.newDecision.digest)
188
+ throw new Error("A decision cannot supersede itself.");
189
+ const { id, ...payload } = record;
190
+ if (id !== recordDigest(decisionSupersessionKind, payload))
191
+ throw new Error("DecisionSupersession id is not the digest of its fields.");
192
+ return deepFreeze(structuredClone(record));
193
+ }
194
+ function sealCarryForward(payload) {
195
+ return deepFreeze({ id: recordDigest(decisionCarryForwardKind, payload), ...payload });
196
+ }
197
+ function sealSupersession(payload) {
198
+ return deepFreeze({ id: recordDigest(decisionSupersessionKind, payload), ...payload });
199
+ }
200
+ function recordDigest(kind, payload) {
201
+ return `sha256:${sha256Hex(canonicalJson({ kind: `${kind}/v1`, payload }))}`;
202
+ }
203
+ /** Every candidate of the item has exactly one non-empty version id, and ids are unique within the item. */
204
+ function assertRoundItem(entry, label) {
205
+ nonEmpty(entry.slotId, `${label} slotId`);
206
+ const candidateIds = entry.item.spec.candidates.map((candidate) => candidate.id);
207
+ if (new Set(candidateIds).size !== candidateIds.length)
208
+ throw new Error(`ReviewItem ${entry.item.metadata.name} has duplicate candidate ids.`);
209
+ const versionKeys = Object.keys(entry.candidateVersionIds ?? {}).sort();
210
+ if (canonicalJson(versionKeys) !== canonicalJson([...candidateIds].sort())) {
211
+ throw new Error(`ReviewItem ${entry.item.metadata.name} (${label}) must carry a producer version id for exactly its candidates.`);
212
+ }
213
+ const versions = Object.values(entry.candidateVersionIds);
214
+ versions.forEach((version) => nonEmpty(version, `${label} versionId`));
215
+ if (new Set(versions).size !== versions.length)
216
+ throw new Error(`ReviewItem ${entry.item.metadata.name} (${label}) has two candidates with one version id.`);
217
+ }
218
+ function assertDecisionNamesItem(decision, item) {
219
+ if (decision.spec.reviewItemName !== item.metadata.name)
220
+ throw new Error(`ReviewDecision ${decision.metadata.name} names ${decision.spec.reviewItemName}, not ${item.metadata.name}.`);
221
+ if (decision.spec.candidateId !== undefined && !item.spec.candidates.some((candidate) => candidate.id === decision.spec.candidateId)) {
222
+ throw new Error(`ReviewDecision ${decision.metadata.name} selects candidate ${decision.spec.candidateId}, which ReviewItem ${item.metadata.name} does not hold.`);
223
+ }
224
+ }
225
+ function sortedVersions(entry) {
226
+ return Object.values(entry.candidateVersionIds).sort();
227
+ }
228
+ function candidateWithVersion(entry, versionId) {
229
+ const id = Object.keys(entry.candidateVersionIds).find((candidateId) => entry.candidateVersionIds[candidateId] === versionId);
230
+ return entry.item.spec.candidates.find((candidate) => candidate.id === id);
231
+ }
232
+ /** For each version id, the prior and new candidates carry the same value and evidence. */
233
+ function sameCandidateContent(prior, next) {
234
+ return Object.entries(prior.candidateVersionIds).every(([candidateId, versionId]) => {
235
+ const before = prior.item.spec.candidates.find((candidate) => candidate.id === candidateId);
236
+ const after = candidateWithVersion(next, versionId);
237
+ return canonicalJson(candidateContent(before)) === canonicalJson(candidateContent(after));
238
+ });
239
+ }
240
+ /** What a decision was made about: the value, the evidence behind it, and the claim it is for. */
241
+ function candidateContent(candidate) {
242
+ return {
243
+ role: candidate.role ?? null,
244
+ value: candidate.value,
245
+ // The whole locator (scheme, locator, excerpt) and the whole claim target
246
+ // (subject, field, impact and the rest): a decision is about this value,
247
+ // at this place in the source, for this claim.
248
+ locator: candidate.locator ?? null,
249
+ claimTarget: candidate.claimTarget,
250
+ sourceRef: candidate.source.sourceRef,
251
+ sourceChecksum: candidate.source.checksum ?? null,
252
+ };
253
+ }
254
+ function normalizePolicy(policy) {
255
+ if (!policy || !Array.isArray(policy.carry))
256
+ throw new Error("A carry-forward policy must list the resolutions it carries.");
257
+ for (const resolution of policy.carry) {
258
+ if (!["accepted", "rejected", "could_not_confirm"].includes(resolution))
259
+ throw new Error(`Carry-forward policy names an unknown resolution: ${String(resolution)}.`);
260
+ }
261
+ return policy;
262
+ }
263
+ function validateReference(value, label) {
264
+ const reference = object(value, label);
265
+ exactKeys(reference, ["name", "digest"], [], label);
266
+ nonEmpty(reference.name, `${label}.name`);
267
+ if (typeof reference.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(reference.digest))
268
+ throw new Error(`${label}.digest must be a sha256:<64 hex> digest.`);
269
+ }
270
+ function object(value, label) {
271
+ if (!value || typeof value !== "object" || Array.isArray(value))
272
+ throw new Error(`${label} must be an object.`);
273
+ return value;
274
+ }
275
+ function exactKeys(value, required, optional, label) {
276
+ for (const key of required)
277
+ if (!Object.hasOwn(value, key))
278
+ throw new Error(`${label}.${key} is required.`);
279
+ for (const key of Object.keys(value))
280
+ if (!required.includes(key) && !optional.includes(key))
281
+ throw new Error(`${label}.${key} is unexpected.`);
282
+ }
283
+ function nonEmpty(value, label) {
284
+ if (typeof value !== "string" || value.trim() === "")
285
+ throw new Error(`${label} must be a non-empty string.`);
286
+ return value;
287
+ }
288
+ function deepFreeze(value) {
289
+ if (value && typeof value === "object") {
290
+ for (const nested of Object.values(value))
291
+ deepFreeze(nested);
292
+ Object.freeze(value);
293
+ }
294
+ return value;
295
+ }
@@ -197,8 +197,9 @@ export type ResolvedExtractionArtifact = {
197
197
  * artifact text. `verified` means the supplied text matched the envelope's
198
198
  * digest and length and every proposal that produced a ReviewItem matched its
199
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.
200
+ * (a diagnostic then says why). Required: a record without it, or with any
201
+ * other value, is refused, so a record whose provenance was stripped cannot
202
+ * pass as the one-item-per-proposal shape Survey wrote before 6.0.0.
202
203
  */
203
204
  export type ExtractionEnvelopeImportProvenance = "verified" | "unverified";
204
205
  /**
@@ -253,7 +254,7 @@ export interface ExtractionEnvelopeImport {
253
254
  status: {
254
255
  state: "grounded" | "unresolved";
255
256
  diagnostics: ExtractionEnvelopeImportDiagnostic[];
256
- provenance?: ExtractionEnvelopeImportProvenance;
257
+ provenance: ExtractionEnvelopeImportProvenance;
257
258
  };
258
259
  }
259
260
  export interface ExtractionEnvelopeImportResult {
@@ -333,8 +333,6 @@ function excerptMismatchDiagnostic(index, proposal) {
333
333
  * written: each diagnostic is rebuilt from its own fields and must match.
334
334
  */
335
335
  function verificationDiagnosticsAreCoherent(envelope, provenance, diagnostics) {
336
- if (provenance === undefined)
337
- return diagnostics.length === 0;
338
336
  const prepared = envelope.result.preparedArtifact;
339
337
  const rebuild = (value) => {
340
338
  if (!value || typeof value !== "object" || Array.isArray(value) || !prepared)
@@ -422,7 +420,9 @@ function validateImport(value) {
422
420
  if (targets.length !== envelope.result.proposals.length)
423
421
  throw new Error("spec.claimTargets must align with proposals.");
424
422
  const status = obj(record.status, "status");
425
- exact(status, ["state", "diagnostics"], "status", ["provenance"]);
423
+ exact(status, ["state", "diagnostics", "provenance"], "status");
424
+ if (status.provenance !== "verified" && status.provenance !== "unverified")
425
+ throw new Error("status.provenance must be \"verified\" or \"unverified\".");
426
426
  const diagnostics = array(status.diagnostics, "status.diagnostics");
427
427
  const expected = diagnosticsFor(envelope);
428
428
  if (canonicalJson(diagnostics.slice(0, expected.length)) !== canonicalJson(expected)
@@ -0,0 +1,100 @@
1
+ import { type CandidateVerification } from "./candidate-verification.js";
2
+ import { type DecisionBasis, type DecisionCarryForward, type DecisionSupersession } from "./decision-carry-forward.js";
3
+ import { type ExtractionEnvelopeImport } from "./extraction-envelope.js";
4
+ import type { ReviewDecision } from "./review-resource.js";
5
+ /**
6
+ * Per-field content and lifecycle states (#294), derived from records Survey
7
+ * holds and never stored as labels.
8
+ *
9
+ * Only states with a producer ship. `not_found` (the source was read in full
10
+ * and holds no value for the field) needs per-field chunk coverage and a
11
+ * per-field dropped-proposal count from the extractor, which no producer
12
+ * reports yet, so a field with no candidate in a complete run has no content
13
+ * state at all. Absence of a state never means verified.
14
+ */
15
+ /** The claim-metadata key under which the Surface projection carries a field's states. */
16
+ export declare const FIELD_STATE_METADATA_KEY = "survey.kontourai.io/field-state";
17
+ /**
18
+ * What the extraction says about a field.
19
+ *
20
+ * - `value`: at least one candidate with evidence.
21
+ * - `conflicting`: two or more distinct candidate values for the one claim.
22
+ * - `unsupported`: every candidate has a verifier record saying contradicted
23
+ * or not-addressed for its current value, and none saying supported.
24
+ * - `excluded`: the extraction proposed values for the field, but the import
25
+ * left every one out because its excerpt was not at its cited span. The
26
+ * field was read; its proposals are unverifiable, not disproven.
27
+ * - `not_covered`: no proposal at all, and the extraction reported that part
28
+ * of the content was not read (a partial or failed run).
29
+ */
30
+ export type FieldContentState = "value" | "conflicting" | "unsupported" | "excluded" | "not_covered";
31
+ /**
32
+ * Where review stands for a field: `pending` (no decision), `accepted`,
33
+ * `rejected`, `could_not_confirm`, or `superseded` (a supersession record
34
+ * names the decision as replaced).
35
+ */
36
+ export type FieldLifecycleState = "pending" | "accepted" | "rejected" | "could_not_confirm" | "superseded";
37
+ /** A claim slot: the claim a proposal would project to, at its path indices. */
38
+ export interface FieldSlot {
39
+ readonly subjectType: string;
40
+ readonly subjectId: string;
41
+ readonly facet: string;
42
+ readonly claimType: string;
43
+ readonly fieldOrBehavior: string;
44
+ readonly claimId?: string;
45
+ readonly pathIndices?: readonly number[];
46
+ }
47
+ /** Facts about a field that are not states. */
48
+ export interface FieldStateSignals {
49
+ /** The run that produced this import stopped short or failed: other text may hold values. */
50
+ readonly incompleteRun?: true;
51
+ /** Proposals for this slot the import left out because their excerpt was not at their span. */
52
+ readonly excludedProposals?: number;
53
+ /** The import could not ground any candidate (its prepared artifact did not resolve or verify). */
54
+ readonly unresolvedImport?: true;
55
+ }
56
+ export interface FieldState {
57
+ readonly importName: string;
58
+ readonly slot: FieldSlot;
59
+ /** The ReviewItem for this slot, when the import produced one. */
60
+ readonly reviewItemName?: string;
61
+ /** Absent when no producer can state one (see the module note on `not_found`). */
62
+ readonly content?: FieldContentState;
63
+ /** Absent when the slot has no review item, so there is nothing to decide. */
64
+ readonly lifecycle?: FieldLifecycleState;
65
+ /** The decision the lifecycle comes from, when there is one. */
66
+ readonly decisionName?: string;
67
+ /** Whether that decision was made on this round's item or carried forward from an earlier one. */
68
+ readonly decisionBasis?: DecisionBasis;
69
+ readonly signals: FieldStateSignals;
70
+ }
71
+ export interface FieldStateImport {
72
+ readonly record: ExtractionEnvelopeImport;
73
+ /**
74
+ * The fields the extraction was asked for, so a field with no proposal is
75
+ * reported too. Without it only slots that have a proposal appear.
76
+ */
77
+ readonly expectedFields?: readonly FieldSlot[];
78
+ }
79
+ export interface DeriveFieldStatesInput {
80
+ readonly imports: readonly FieldStateImport[];
81
+ /** Decisions on this round's items. At most one per item. */
82
+ readonly decisions?: readonly ReviewDecision[];
83
+ /** Verifier records for this round's candidates (see `CandidateVerification`). */
84
+ readonly verifications?: readonly CandidateVerification[];
85
+ /** Records that the decisions they name as prior were replaced. */
86
+ readonly supersessions?: readonly DecisionSupersession[];
87
+ /** Earlier decisions carried forward onto this round's items. */
88
+ readonly carryForwards?: readonly DecisionCarryForward[];
89
+ }
90
+ /**
91
+ * Derives the content and lifecycle state of every field in the given
92
+ * imports. Pure: reads only its input. Each state appears only when the input
93
+ * that produces it is present: no verifications, no `unsupported`; no
94
+ * supersessions, no `superseded`.
95
+ */
96
+ export declare function deriveFieldStates(input: DeriveFieldStatesInput): FieldState[];
97
+ /** The lifecycle a workbench decision gives its item, as `deriveFieldStates` reads it. */
98
+ export declare function lifecycleForDecision(decision: ReviewDecision | undefined): Exclude<FieldLifecycleState, "superseded">;
99
+ /** The claim metadata the Surface projection carries for one field state. */
100
+ export declare function fieldStateClaimMetadata(state: FieldState): Record<string, unknown>;