@kontourai/survey 2.2.4 → 2.4.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/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +1 -0
- package/dist/src/review-resource.d.ts +13 -0
- package/dist/src/review-resource.js +21 -0
- package/dist/src/review-workbench/audit-rows.d.ts +72 -0
- package/dist/src/review-workbench/audit-rows.js +133 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +67 -2
- package/dist/src/review-workbench/extraction-inspector.js +233 -22
- package/dist/src/review-workbench/queue-binding.d.ts +127 -0
- package/dist/src/review-workbench/queue-binding.js +324 -0
- package/dist/src/review-workbench/review-presentation.js +28 -1
- package/dist/src/review-workbench/review-queue-session.d.ts +11 -0
- package/dist/src/review-workbench/review-queue-session.js +13 -1
- package/dist/src/review-workbench/review-surface-preview.js +2 -1
- package/dist/src/review-workbench/review-workbench-css.generated.js +45 -11
- package/dist/src/review-workbench/review-workbench-element.js +24 -1
- package/dist/src/review-workbench/review-workbench.css +45 -11
- package/dist/src/review-workbench/review-workbench.d.ts +3 -1
- package/dist/src/review-workbench/review-workbench.js +173 -60
- package/dist/src/review-workbench/review-workbench.standalone.css +45 -11
- package/dist/src/review-workbench/server-review-session.d.ts +14 -0
- package/dist/src/review-workbench/server-review-session.js +7 -0
- package/package.json +2 -1
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue-binding attestation: a decision is only projectable against the exact
|
|
3
|
+
* queue bytes it was recorded against.
|
|
4
|
+
*
|
|
5
|
+
* The binding is a small, serializable record created ONCE, when a review
|
|
6
|
+
* round/session opens, and carried unchanged by every later write. Validation
|
|
7
|
+
* compares a presented queue against that stored record. The division of labor
|
|
8
|
+
* is deliberate and is the entire security property:
|
|
9
|
+
*
|
|
10
|
+
* - **The consumer persists the binding beside the queue and never recomputes
|
|
11
|
+
* it.** A digest a writer recomputes as it saves attests nothing — both sides
|
|
12
|
+
* of the comparison then regenerate from the same mutable bytes and agree by
|
|
13
|
+
* construction. That tautology shipped in a real consumer and let a
|
|
14
|
+
* post-decision edit export a substituted value (kontourai/fieldwork#60).
|
|
15
|
+
* - **Survey owns the rule and the refusal.** What a queue edit looks like, in
|
|
16
|
+
* both directions, and when a comparison is vacuous, is decided here so no
|
|
17
|
+
* consumer rediscovers it four adversarial review rounds at a time
|
|
18
|
+
* (kontourai/survey#213).
|
|
19
|
+
*
|
|
20
|
+
* The binding alone cannot survive a writer who edits the queue AND re-binds —
|
|
21
|
+
* hashing mutated bytes yields a self-consistent pair. For queues imported from
|
|
22
|
+
* an extraction envelope, {@link validateReviewQueueAgainstExtractionImport}
|
|
23
|
+
* narrows that hole to the record: it re-derives the canonical items from the
|
|
24
|
+
* presented import record and refuses a queue that diverges from it in either
|
|
25
|
+
* direction, so a queue edited independently of its record fails. What it
|
|
26
|
+
* attests is queue-to-record CONSISTENCY, not record integrity. The portable
|
|
27
|
+
* envelope carries the prepared artifact's digest and contentLength, never the
|
|
28
|
+
* prepared bytes, so a library handed only the record cannot verify proposal
|
|
29
|
+
* bytes against the digested artifact — a writer who edits the RECORD's
|
|
30
|
+
* proposals and re-derives the queue from the edited record presents a
|
|
31
|
+
* self-consistent pair this module blesses (pinned as a boundary test and by
|
|
32
|
+
* check:guards). Keeping the stored record equal to the record originally
|
|
33
|
+
* imported is the caller's storage obligation, and validating prepared bytes
|
|
34
|
+
* against `result.preparedArtifact.digest` does NOT discharge it: that digest
|
|
35
|
+
* covers the prepared artifact only, so it protects artifact integrity and
|
|
36
|
+
* stays green through a proposal rewrite. Preserving record integrity takes
|
|
37
|
+
* one of: a record digest/MAC anchored where the record's writer cannot
|
|
38
|
+
* reach, immutable or authenticated record storage, or independently
|
|
39
|
+
* re-deriving the proposals from trusted prepared bytes and comparing.
|
|
40
|
+
*
|
|
41
|
+
* Four bypasses from fieldwork#60's rounds, each a design input here:
|
|
42
|
+
* 1. Self-agreement (above) — the binding's origin is the open, not the save.
|
|
43
|
+
* 2. One-way set checks — validation walks BOTH directions, because checking
|
|
44
|
+
* only the items present can never notice one was removed.
|
|
45
|
+
* 3. Trusting a mutable label over the identity it claims — out of scope here
|
|
46
|
+
* by construction: nothing in this module dispatches on a label; recheck
|
|
47
|
+
* semantics stay with the consumer that owns them.
|
|
48
|
+
* 4. Vacuous success — an empty queue cannot be bound and never validates,
|
|
49
|
+
* because a receipt over nothing certifies nothing.
|
|
50
|
+
*/
|
|
51
|
+
import { canonicalJson } from "./canonical.js";
|
|
52
|
+
import { sha256Hex } from "../sha256.js";
|
|
53
|
+
import { reviewResourceApiVersion } from "../review-resource.js";
|
|
54
|
+
import { buildReviewItemsFromExtractionEnvelopeImport, validateExtractionEnvelopeImport, } from "../extraction-envelope.js";
|
|
55
|
+
export class UnattestedReviewQueueError extends Error {
|
|
56
|
+
name = "UnattestedReviewQueueError";
|
|
57
|
+
issues;
|
|
58
|
+
constructor(issues) {
|
|
59
|
+
super(`Review queue is not attested by its binding: ${issues.map((issue) => issue.message).join(" ")}`);
|
|
60
|
+
this.issues = issues;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The digest a binding stores: sha256 over the canonical JSON of the whole
|
|
65
|
+
* open-time session state. Byte-identical to server-review-session's
|
|
66
|
+
* `hashReviewSessionSnapshot` (pinned by test), so a consumer already
|
|
67
|
+
* persisting that digest adopts the binding without invalidating stored state.
|
|
68
|
+
*/
|
|
69
|
+
export function hashReviewQueueSnapshot(snapshot) {
|
|
70
|
+
return sha256Hex(canonicalJson(snapshot));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Take the binding for a queue, once, when the round/session opens.
|
|
74
|
+
*
|
|
75
|
+
* Call this at queue construction and persist the result beside the queue.
|
|
76
|
+
* Calling it again later, on bytes that may have changed, produces a binding
|
|
77
|
+
* that agrees with whatever it was given — which is the self-agreement bypass,
|
|
78
|
+
* not an attestation.
|
|
79
|
+
*
|
|
80
|
+
* Refuses an empty queue: a binding over nothing validates nothing, and every
|
|
81
|
+
* later check against it would be vacuously green. Refuses duplicate item
|
|
82
|
+
* names: the binding's set comparison is by name, so an ambiguous name would
|
|
83
|
+
* let two different items answer for one membership.
|
|
84
|
+
*/
|
|
85
|
+
export function bindReviewQueue(snapshot, options) {
|
|
86
|
+
if (!options.sessionName) {
|
|
87
|
+
throw new Error("bindReviewQueue requires a non-empty sessionName.");
|
|
88
|
+
}
|
|
89
|
+
if (snapshot.items.length === 0) {
|
|
90
|
+
throw new Error("bindReviewQueue refuses an empty queue: a binding over nothing attests nothing.");
|
|
91
|
+
}
|
|
92
|
+
const names = snapshot.items.map((item) => item.metadata.name);
|
|
93
|
+
const unique = new Set(names);
|
|
94
|
+
if (unique.size !== names.length) {
|
|
95
|
+
const duplicate = names.find((name, index) => names.indexOf(name) !== index);
|
|
96
|
+
throw new Error(`bindReviewQueue refuses a queue with duplicate ReviewItem name ${duplicate}: set membership by name must be unambiguous.`);
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
apiVersion: reviewResourceApiVersion,
|
|
100
|
+
kind: "ReviewQueueBinding",
|
|
101
|
+
spec: {
|
|
102
|
+
sessionName: options.sessionName,
|
|
103
|
+
snapshotHash: hashReviewQueueSnapshot(snapshot),
|
|
104
|
+
itemNames: [...unique].sort(),
|
|
105
|
+
boundAt: options.boundAt instanceof Date ? options.boundAt.toISOString() : options.boundAt ?? new Date().toISOString(),
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Compare a presented queue against its stored binding.
|
|
111
|
+
*
|
|
112
|
+
* `binding` must be the record persisted when the round opened — passing one
|
|
113
|
+
* derived from `snapshot` here checks nothing (see module doc). `snapshot` is
|
|
114
|
+
* the base queue the binding was taken over, not the state after event replay:
|
|
115
|
+
* decisions live in the event log precisely so the bound bytes never move.
|
|
116
|
+
*
|
|
117
|
+
* A malformed binding fails closed with `binding-malformed` rather than
|
|
118
|
+
* skipping the checks it cannot perform.
|
|
119
|
+
*/
|
|
120
|
+
export function validateReviewQueueBinding(binding, snapshot, options = {}) {
|
|
121
|
+
const structural = structuralBindingIssues(binding);
|
|
122
|
+
if (structural.length > 0) {
|
|
123
|
+
return structural;
|
|
124
|
+
}
|
|
125
|
+
const issues = [];
|
|
126
|
+
if (options.sessionName !== undefined && binding.spec.sessionName !== options.sessionName) {
|
|
127
|
+
issues.push({
|
|
128
|
+
code: "session-name-mismatch",
|
|
129
|
+
message: `Binding names session ${binding.spec.sessionName}, but this queue belongs to session ${options.sessionName}.`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (snapshot.items.length === 0) {
|
|
133
|
+
issues.push({
|
|
134
|
+
code: "empty-queue",
|
|
135
|
+
message: "The presented queue is empty. The binding covers items this queue no longer carries, and an empty queue attests nothing.",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const presentNames = snapshot.items.map((item) => item.metadata.name);
|
|
139
|
+
const present = new Set(presentNames);
|
|
140
|
+
if (present.size !== presentNames.length) {
|
|
141
|
+
const duplicate = presentNames.find((name, index) => presentNames.indexOf(name) !== index);
|
|
142
|
+
issues.push({
|
|
143
|
+
code: "ambiguous-item-identity",
|
|
144
|
+
message: `The presented queue carries duplicate ReviewItem name ${duplicate}; set membership by name is ambiguous.`,
|
|
145
|
+
itemName: duplicate,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
// Set equality in BOTH directions. Walking only the items present can never
|
|
149
|
+
// notice an item was removed; walking only the bound names can never notice
|
|
150
|
+
// one was added. Each direction is a different edit.
|
|
151
|
+
const bound = new Set(binding.spec.itemNames);
|
|
152
|
+
for (const name of binding.spec.itemNames) {
|
|
153
|
+
if (!present.has(name)) {
|
|
154
|
+
issues.push({
|
|
155
|
+
code: "item-removed",
|
|
156
|
+
message: `ReviewItem ${name} is covered by the binding but missing from the presented queue.`,
|
|
157
|
+
itemName: name,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const name of present) {
|
|
162
|
+
if (!bound.has(name)) {
|
|
163
|
+
issues.push({
|
|
164
|
+
code: "item-added",
|
|
165
|
+
message: `ReviewItem ${name} is in the presented queue but not covered by the binding.`,
|
|
166
|
+
itemName: name,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const actualHash = hashReviewQueueSnapshot(snapshot);
|
|
171
|
+
if (binding.spec.snapshotHash !== actualHash) {
|
|
172
|
+
issues.push({
|
|
173
|
+
code: "snapshot-hash-mismatch",
|
|
174
|
+
message: `The presented queue's bytes do not match the binding (expected ${binding.spec.snapshotHash}, got ${actualHash}). The queue changed after the round opened.`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return issues;
|
|
178
|
+
}
|
|
179
|
+
export function assertReviewQueueBinding(binding, snapshot, options = {}) {
|
|
180
|
+
const issues = validateReviewQueueBinding(binding, snapshot, options);
|
|
181
|
+
if (issues.length > 0) {
|
|
182
|
+
throw new UnattestedReviewQueueError(issues);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export class UnattestedExtractionQueueError extends Error {
|
|
186
|
+
name = "UnattestedExtractionQueueError";
|
|
187
|
+
issues;
|
|
188
|
+
constructor(issues) {
|
|
189
|
+
super(`Review queue is not attested by its extraction import: ${issues.map((issue) => issue.message).join(" ")}`);
|
|
190
|
+
this.issues = issues;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Check a stored queue for consistency with the extraction import record it
|
|
195
|
+
* was derived from.
|
|
196
|
+
*
|
|
197
|
+
* The queue binding alone cannot catch a writer who edits the queue and
|
|
198
|
+
* re-binds: hashing mutated bytes yields a self-consistent pair. This check
|
|
199
|
+
* closes the QUEUE half of that hole: it revalidates the presented record
|
|
200
|
+
* through the public import boundary (a forged `grounded` status throws; an
|
|
201
|
+
* ungrounded import is refused), re-derives the canonical ReviewItems from it,
|
|
202
|
+
* and requires the stored queue to be the SAME SET, byte-identically per item,
|
|
203
|
+
* in both directions. A queue edited independently of its record fails.
|
|
204
|
+
*
|
|
205
|
+
* What it does NOT attest: the record itself. The envelope carries the
|
|
206
|
+
* prepared artifact's digest and contentLength, never the prepared bytes, so
|
|
207
|
+
* a library handed only the record cannot verify a proposal's bytes against
|
|
208
|
+
* the digested artifact. A writer who edits the record's proposals and
|
|
209
|
+
* re-derives the queue from the edited record presents a pair this check
|
|
210
|
+
* blesses, while `result.preparedArtifact.digest` still names the honest
|
|
211
|
+
* bytes. Record integrity is therefore the caller's storage obligation, and
|
|
212
|
+
* checking prepared bytes against that digest does not meet it — the digest
|
|
213
|
+
* covers the artifact, not the proposals, so it stays green through the
|
|
214
|
+
* rewrite. Meeting it takes one of: a record digest/MAC anchored where the
|
|
215
|
+
* record's writer cannot reach, immutable or authenticated record storage,
|
|
216
|
+
* or independently re-deriving the proposals from trusted prepared bytes and
|
|
217
|
+
* comparing. This limit is pinned by a boundary test and by
|
|
218
|
+
* scripts/check-guards.mjs.
|
|
219
|
+
*
|
|
220
|
+
* This is the whole-extraction rule: it applies to a queue whose items all come
|
|
221
|
+
* from one import. A consumer whose rounds mix in items the extraction cannot
|
|
222
|
+
* attest (recheck rounds against a prior observation, for one) owns that
|
|
223
|
+
* dispatch and those semantics — deciding which attestation applies to which
|
|
224
|
+
* item from a mutable label is bypass 3, and it stays with the data that can
|
|
225
|
+
* cross-check the label.
|
|
226
|
+
*/
|
|
227
|
+
export function validateReviewQueueAgainstExtractionImport(items, importResult) {
|
|
228
|
+
const record = validateExtractionEnvelopeImport(importResult.record);
|
|
229
|
+
if (record.status.state !== "grounded") {
|
|
230
|
+
return [{
|
|
231
|
+
code: "import-not-grounded",
|
|
232
|
+
message: `Extraction import ${record.metadata.name} is ${record.status.state}, not grounded; it cannot attest a review queue.`,
|
|
233
|
+
}];
|
|
234
|
+
}
|
|
235
|
+
if (items.length === 0) {
|
|
236
|
+
return [{
|
|
237
|
+
code: "empty-queue",
|
|
238
|
+
message: "The stored queue is empty; there is nothing it can certify against this extraction.",
|
|
239
|
+
}];
|
|
240
|
+
}
|
|
241
|
+
// Re-derived through the public import boundary, not read from the caller's
|
|
242
|
+
// importResult.reviewItems: the record is validated above, and the canonical
|
|
243
|
+
// items are a pure function of it, so the comparison is against what the
|
|
244
|
+
// presented record actually says — not against a reviewItems array the
|
|
245
|
+
// caller could have edited separately from it. The record itself is
|
|
246
|
+
// caller-supplied and is NOT attested here; see the function doc.
|
|
247
|
+
const attesting = new Map(buildReviewItemsFromExtractionEnvelopeImport(record).map((item) => [item.metadata.name, item]));
|
|
248
|
+
const stored = new Map(items.map((item) => [item.metadata.name, item]));
|
|
249
|
+
const issues = [];
|
|
250
|
+
for (const [name, item] of attesting) {
|
|
251
|
+
const found = stored.get(name);
|
|
252
|
+
if (!found) {
|
|
253
|
+
issues.push({
|
|
254
|
+
code: "item-missing-from-queue",
|
|
255
|
+
message: `ReviewItem ${name} is in the extraction but missing from the stored queue.`,
|
|
256
|
+
itemName: name,
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (canonicalJson(found) !== canonicalJson(item)) {
|
|
261
|
+
issues.push({
|
|
262
|
+
code: "item-diverges-from-extraction",
|
|
263
|
+
message: `ReviewItem ${name} does not match the extraction it was imported from.`,
|
|
264
|
+
itemName: name,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const name of stored.keys()) {
|
|
269
|
+
if (!attesting.has(name)) {
|
|
270
|
+
issues.push({
|
|
271
|
+
code: "item-not-in-extraction",
|
|
272
|
+
message: `ReviewItem ${name} is in the stored queue but not in the extraction.`,
|
|
273
|
+
itemName: name,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return issues;
|
|
278
|
+
}
|
|
279
|
+
export function assertReviewQueueAgainstExtractionImport(items, importResult) {
|
|
280
|
+
const issues = validateReviewQueueAgainstExtractionImport(items, importResult);
|
|
281
|
+
if (issues.length > 0) {
|
|
282
|
+
throw new UnattestedExtractionQueueError(issues);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const SHA256_HEX = /^[a-f0-9]{64}$/;
|
|
286
|
+
function structuralBindingIssues(binding) {
|
|
287
|
+
const malformed = (message) => [{
|
|
288
|
+
code: "binding-malformed",
|
|
289
|
+
message: `Malformed ReviewQueueBinding: ${message}`,
|
|
290
|
+
}];
|
|
291
|
+
if (typeof binding !== "object" || binding === null || Array.isArray(binding)) {
|
|
292
|
+
return malformed("not an object.");
|
|
293
|
+
}
|
|
294
|
+
if (binding.apiVersion !== reviewResourceApiVersion || binding.kind !== "ReviewQueueBinding") {
|
|
295
|
+
return malformed(`unexpected identity ${String(binding.apiVersion)}/${String(binding.kind)}.`);
|
|
296
|
+
}
|
|
297
|
+
const spec = binding.spec;
|
|
298
|
+
if (typeof spec !== "object" || spec === null) {
|
|
299
|
+
return malformed("missing spec.");
|
|
300
|
+
}
|
|
301
|
+
if (typeof spec.sessionName !== "string" || spec.sessionName.length === 0) {
|
|
302
|
+
return malformed("sessionName must be a non-empty string.");
|
|
303
|
+
}
|
|
304
|
+
if (typeof spec.snapshotHash !== "string" || !SHA256_HEX.test(spec.snapshotHash)) {
|
|
305
|
+
return malformed("snapshotHash must be a 64-character lowercase hex sha256.");
|
|
306
|
+
}
|
|
307
|
+
if (typeof spec.boundAt !== "string" || spec.boundAt.length === 0) {
|
|
308
|
+
return malformed("boundAt must be a non-empty ISO timestamp string.");
|
|
309
|
+
}
|
|
310
|
+
if (!Array.isArray(spec.itemNames) || spec.itemNames.length === 0) {
|
|
311
|
+
return malformed("itemNames must be a non-empty array: a binding over nothing attests nothing.");
|
|
312
|
+
}
|
|
313
|
+
for (const name of spec.itemNames) {
|
|
314
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
315
|
+
return malformed("itemNames must contain only non-empty strings.");
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const sorted = [...spec.itemNames].sort();
|
|
319
|
+
if (new Set(spec.itemNames).size !== spec.itemNames.length
|
|
320
|
+
|| spec.itemNames.some((name, index) => name !== sorted[index])) {
|
|
321
|
+
return malformed("itemNames must be unique and sorted.");
|
|
322
|
+
}
|
|
323
|
+
return [];
|
|
324
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findSoleCandidateById } from "../review-resource.js";
|
|
1
2
|
import { formatValue } from "./review-surface-preview.js";
|
|
2
3
|
export function buildReviewItemPresentation(item, adapter = {}) {
|
|
3
4
|
const context = { item };
|
|
@@ -34,7 +35,7 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
|
|
|
34
35
|
const targetLabel = item && itemContext
|
|
35
36
|
? adapter.labelForTarget?.(target, itemContext) ?? humanizeIdentifier(target)
|
|
36
37
|
: humanizeIdentifier(target);
|
|
37
|
-
const selectedCandidate = item
|
|
38
|
+
const selectedCandidate = item ? selectedCandidateForResult(item, result) : undefined;
|
|
38
39
|
return {
|
|
39
40
|
result,
|
|
40
41
|
item,
|
|
@@ -53,6 +54,32 @@ export function buildReviewResultPresentation(result, item, adapter = {}) {
|
|
|
53
54
|
: [{ label: "Survey ReviewItem", value: result.reviewItemName, kind: "review-item" }],
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The candidate a result selected, resolved by its complete identity.
|
|
59
|
+
*
|
|
60
|
+
* `find(role === … || id === …)` returned whichever candidate matched EITHER
|
|
61
|
+
* half, so on an item carrying a repeated candidate id it could return a
|
|
62
|
+
* different candidate than the result names — presenting one candidate's value
|
|
63
|
+
* against another's decision, which is exactly what it did.
|
|
64
|
+
*
|
|
65
|
+
* The id is the identity; {@link findSoleCandidateById} makes it fail closed
|
|
66
|
+
* rather than pick a winner when it is ambiguous, and the declared role has to
|
|
67
|
+
* agree when the result states one. Falling back to the role alone is kept for
|
|
68
|
+
* results that carry no id, and requires the role to be unambiguous too.
|
|
69
|
+
*/
|
|
70
|
+
function selectedCandidateForResult(item, result) {
|
|
71
|
+
if (result.selectedCandidateId) {
|
|
72
|
+
const candidate = findSoleCandidateById(item, result.selectedCandidateId);
|
|
73
|
+
if (candidate && (result.selectedCandidateRole === undefined || candidate.role === result.selectedCandidateRole)) {
|
|
74
|
+
return candidate;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (result.selectedCandidateRole === undefined) {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const byRole = item.spec.candidates.filter((candidate) => candidate.role === result.selectedCandidateRole);
|
|
81
|
+
return byRole.length === 1 ? byRole[0] : undefined;
|
|
82
|
+
}
|
|
56
83
|
export function humanizeIdentifier(value) {
|
|
57
84
|
return value
|
|
58
85
|
.replace(/[_-]+/g, " ")
|
|
@@ -91,6 +91,17 @@ export declare function reviewSessionSummary(session: ReviewQueueSessionState):
|
|
|
91
91
|
* would invent a prior value, with provenance, that the source never had.
|
|
92
92
|
*/
|
|
93
93
|
export declare function keepActionDecision(item: ReviewItem, flaggedWrong: boolean): ReviewWorkbenchDecision | undefined;
|
|
94
|
+
/**
|
|
95
|
+
* The candidate a workbench decision applies to.
|
|
96
|
+
*
|
|
97
|
+
* Selection is by role, but the id this returns is what every caller makes
|
|
98
|
+
* durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
|
|
99
|
+
* replay expectation. So the id has to name exactly one candidate before it
|
|
100
|
+
* leaves here. Guarding the render path alone let the workbench emit an
|
|
101
|
+
* undecidable decision through the export path and then present a different
|
|
102
|
+
* candidate's value against it; this is the shared selector all of those go
|
|
103
|
+
* through, which is why the check belongs here rather than at each of them.
|
|
104
|
+
*/
|
|
94
105
|
export declare function candidateForDecision(item: ReviewItem, decision: ReviewWorkbenchDecision): ReviewCandidate;
|
|
95
106
|
/**
|
|
96
107
|
* The value that should actually be applied for a decision: the reviewer's inline
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
|
|
2
2
|
import { assertReviewResolutionConsistency } from "../producer-discipline.js";
|
|
3
|
-
import { reviewResourceApiVersion, } from "../../src/review-resource.js";
|
|
3
|
+
import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
|
|
4
4
|
export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
|
|
5
5
|
export const defaultReviewSessionName = "review-workbench-session";
|
|
6
6
|
export const workbenchDecisionDefinitions = {
|
|
@@ -148,12 +148,24 @@ export function keepActionDecision(item, flaggedWrong) {
|
|
|
148
148
|
}
|
|
149
149
|
return "keep-current";
|
|
150
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* The candidate a workbench decision applies to.
|
|
153
|
+
*
|
|
154
|
+
* Selection is by role, but the id this returns is what every caller makes
|
|
155
|
+
* durable — a ReviewDecision's `candidateId`, a session event's, a result's, a
|
|
156
|
+
* replay expectation. So the id has to name exactly one candidate before it
|
|
157
|
+
* leaves here. Guarding the render path alone let the workbench emit an
|
|
158
|
+
* undecidable decision through the export path and then present a different
|
|
159
|
+
* candidate's value against it; this is the shared selector all of those go
|
|
160
|
+
* through, which is why the check belongs here rather than at each of them.
|
|
161
|
+
*/
|
|
151
162
|
export function candidateForDecision(item, decision) {
|
|
152
163
|
const definition = workbenchDecisionDefinitions[decision];
|
|
153
164
|
const candidate = item.spec.candidates.find((entry) => entry.role === definition.candidateRole);
|
|
154
165
|
if (!candidate) {
|
|
155
166
|
throw new Error(`ReviewItem ${item.metadata.name} has no ${definition.candidateRole} candidate.`);
|
|
156
167
|
}
|
|
168
|
+
assertSoleCandidateId(item, candidate.id);
|
|
157
169
|
return candidate;
|
|
158
170
|
}
|
|
159
171
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { findSoleCandidateById } from "../review-resource.js";
|
|
1
2
|
export function buildSurfaceProjectionPreview(item, decision, presentationAdapter = {}) {
|
|
2
3
|
const selectedCandidate = selectedPreviewCandidate(item, decision);
|
|
3
4
|
if (!selectedCandidate || !decision) {
|
|
@@ -38,7 +39,7 @@ function selectedPreviewCandidate(item, decision) {
|
|
|
38
39
|
if (!decision?.spec.candidateId) {
|
|
39
40
|
return undefined;
|
|
40
41
|
}
|
|
41
|
-
const candidate = item
|
|
42
|
+
const candidate = findSoleCandidateById(item, decision.spec.candidateId);
|
|
42
43
|
if (!candidate) {
|
|
43
44
|
throw new Error(`ReviewItem ${item.metadata.name} has no candidate ${decision.spec.candidateId}.`);
|
|
44
45
|
}
|
|
@@ -202,11 +202,37 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
202
202
|
.survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
|
|
203
203
|
.survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
|
|
204
204
|
.survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
|
|
205
|
-
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
|
|
205
|
+
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; line-height: 2.2; }
|
|
206
206
|
.survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
|
|
207
207
|
.survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
/* An inert link target, one per candidate in the model, so a host's
|
|
209
|
+
\`href="#<highlightElementId>"\` always resolves. It is not a control and must
|
|
210
|
+
not behave like one: no size, no tab stop, nothing painted. */
|
|
211
|
+
.survey-workbench-embed .highlight-anchor{
|
|
212
|
+
display: inline;
|
|
213
|
+
width: 0;
|
|
214
|
+
height: 0;
|
|
215
|
+
overflow: hidden;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/* The highlight IS the return control — the only part of this surface a reader
|
|
219
|
+
can see, so it is the thing to aim at. Full phrase width, already visibly
|
|
220
|
+
marked, one tab stop per painted highlight. */
|
|
221
|
+
.survey-workbench-embed .source-highlight{
|
|
222
|
+
cursor: pointer;
|
|
223
|
+
border-radius: 2px;
|
|
224
|
+
/* Vertical padding on an inline box grows the hit area without moving the
|
|
225
|
+
line box, and the prepared text's line-height below leaves room for it, so
|
|
226
|
+
the target reaches ~24px tall without lines overlapping each other. The
|
|
227
|
+
phrase itself supplies the width. */
|
|
228
|
+
padding: 5px 3px;
|
|
229
|
+
margin: 0 -3px;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.survey-workbench-embed .source-highlight:focus-visible{
|
|
233
|
+
outline: 3px solid var(--k-active);
|
|
234
|
+
outline-offset: 1px;
|
|
235
|
+
}
|
|
210
236
|
.survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
|
|
211
237
|
@container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
|
|
212
238
|
|
|
@@ -639,6 +665,22 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
639
665
|
display: none;
|
|
640
666
|
}
|
|
641
667
|
|
|
668
|
+
/* Why a decision was refused, next to the button that refused it. The input
|
|
669
|
+
that satisfies the precondition can be inside the collapsed audit accordion,
|
|
670
|
+
so the message cannot live only with the input (kontourai/survey#208). */
|
|
671
|
+
.survey-workbench-embed .derr{
|
|
672
|
+
display: block;
|
|
673
|
+
margin-top: 6px;
|
|
674
|
+
text-align: right;
|
|
675
|
+
font-size: 12px;
|
|
676
|
+
font-weight: 600;
|
|
677
|
+
color: var(--k-negative);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
.survey-workbench-embed .derr[hidden]{
|
|
681
|
+
display: none;
|
|
682
|
+
}
|
|
683
|
+
|
|
642
684
|
/* confidence + provenance */
|
|
643
685
|
|
|
644
686
|
.survey-workbench-embed .prov{
|
|
@@ -1080,14 +1122,6 @@ export const REVIEW_WORKBENCH_CSS = `/* Bundled, scoped Survey Review Workbench
|
|
|
1080
1122
|
text-transform: uppercase;
|
|
1081
1123
|
}
|
|
1082
1124
|
|
|
1083
|
-
.survey-workbench-embed .preview-section.is-neutral{
|
|
1084
|
-
background: var(--k-raised);
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
.survey-workbench-embed .preview-section.is-neutral h3{
|
|
1088
|
-
color: var(--k-faint);
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
1125
|
.survey-workbench-embed .reference-details{
|
|
1092
1126
|
margin-top: 8px;
|
|
1093
1127
|
}
|
|
@@ -298,7 +298,30 @@ export class SurveyReviewWorkbenchElement extends HTMLElement {
|
|
|
298
298
|
if (this.#session.activeItemName !== item.metadata.name)
|
|
299
299
|
this.#session = { ...this.#session, activeItemName: item.metadata.name };
|
|
300
300
|
this.#remount();
|
|
301
|
-
|
|
301
|
+
// Both lookups below are published contracts, used as published.
|
|
302
|
+
// Reconstructing the element id from candidateId — which this did — meant
|
|
303
|
+
// carrying a copy of Survey's private id sanitizer and ignoring the
|
|
304
|
+
// collision suffix that makes the id unique, which is exactly what the
|
|
305
|
+
// consumer guide tells embedders not to do.
|
|
306
|
+
const { highlightElementId } = detail;
|
|
307
|
+
if (!highlightElementId)
|
|
308
|
+
return;
|
|
309
|
+
queueMicrotask(() => {
|
|
310
|
+
// Routed on the highlight element id, which is unique by construction.
|
|
311
|
+
// candidateId is the candidate's own identity and a caller-authored
|
|
312
|
+
// model may repeat it, so a `[data-…="<candidateId>"]` lookup can select
|
|
313
|
+
// a different candidate's highlight — confidently wrong, on the surface
|
|
314
|
+
// whose job is showing which span a value came from. `~=` matches one
|
|
315
|
+
// whitespace-separated token, so a mark over a shared span is found too.
|
|
316
|
+
const highlight = this.#root.querySelector(`[data-highlight-return-to~="${CSS.escape(highlightElementId)}"]`);
|
|
317
|
+
if (highlight) {
|
|
318
|
+
highlight.focus();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
// Off the current page there is nothing painted; bring the link target
|
|
322
|
+
// into view instead of leaving the reader where they were.
|
|
323
|
+
this.#root.querySelector(`#${CSS.escape(highlightElementId)}`)?.scrollIntoView({ block: "center" });
|
|
324
|
+
});
|
|
302
325
|
});
|
|
303
326
|
}
|
|
304
327
|
/** The review queue session to display. Setting this property re-mounts the workbench. */
|
|
@@ -199,11 +199,37 @@
|
|
|
199
199
|
.survey-workbench-embed .inspector-pager button:disabled, .survey-workbench-embed .queue-pager button:disabled{ opacity: .45; }
|
|
200
200
|
.survey-workbench-embed .inspector-candidates{ margin: 0; padding-left: 1.5rem; }
|
|
201
201
|
.survey-workbench-embed .inspector-candidate{ width: 100%; display: grid; gap: .2rem; text-align: left; padding: .65rem; color: var(--k-text); background: transparent; border: 1px solid var(--k-line); }
|
|
202
|
-
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; }
|
|
202
|
+
.survey-workbench-embed .inspector-source pre{ white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; padding: 1rem; background: var(--k-sunken); border: 1px solid var(--k-line); min-height: 8rem; line-height: 2.2; }
|
|
203
203
|
.survey-workbench-embed .inspector-source mark{ background: var(--k-brand-wash); color: var(--k-text); outline: 1px solid var(--k-brand); }
|
|
204
204
|
.survey-workbench-embed .inspector-candidate:focus{ outline: 3px solid var(--k-active); outline-offset: 2px; }
|
|
205
|
-
|
|
206
|
-
|
|
205
|
+
/* An inert link target, one per candidate in the model, so a host's
|
|
206
|
+
`href="#<highlightElementId>"` always resolves. It is not a control and must
|
|
207
|
+
not behave like one: no size, no tab stop, nothing painted. */
|
|
208
|
+
.survey-workbench-embed .highlight-anchor{
|
|
209
|
+
display: inline;
|
|
210
|
+
width: 0;
|
|
211
|
+
height: 0;
|
|
212
|
+
overflow: hidden;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* The highlight IS the return control — the only part of this surface a reader
|
|
216
|
+
can see, so it is the thing to aim at. Full phrase width, already visibly
|
|
217
|
+
marked, one tab stop per painted highlight. */
|
|
218
|
+
.survey-workbench-embed .source-highlight{
|
|
219
|
+
cursor: pointer;
|
|
220
|
+
border-radius: 2px;
|
|
221
|
+
/* Vertical padding on an inline box grows the hit area without moving the
|
|
222
|
+
line box, and the prepared text's line-height below leaves room for it, so
|
|
223
|
+
the target reaches ~24px tall without lines overlapping each other. The
|
|
224
|
+
phrase itself supplies the width. */
|
|
225
|
+
padding: 5px 3px;
|
|
226
|
+
margin: 0 -3px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.survey-workbench-embed .source-highlight:focus-visible{
|
|
230
|
+
outline: 3px solid var(--k-active);
|
|
231
|
+
outline-offset: 1px;
|
|
232
|
+
}
|
|
207
233
|
.survey-workbench-embed .source-unavailable{ color: var(--k-negative); font-weight: 700; }
|
|
208
234
|
@container (max-width: 720px) { .inspector-heading, .inspector-layout { grid-template-columns: 1fr !important; } }
|
|
209
235
|
|
|
@@ -636,6 +662,22 @@
|
|
|
636
662
|
display: none;
|
|
637
663
|
}
|
|
638
664
|
|
|
665
|
+
/* Why a decision was refused, next to the button that refused it. The input
|
|
666
|
+
that satisfies the precondition can be inside the collapsed audit accordion,
|
|
667
|
+
so the message cannot live only with the input (kontourai/survey#208). */
|
|
668
|
+
.survey-workbench-embed .derr{
|
|
669
|
+
display: block;
|
|
670
|
+
margin-top: 6px;
|
|
671
|
+
text-align: right;
|
|
672
|
+
font-size: 12px;
|
|
673
|
+
font-weight: 600;
|
|
674
|
+
color: var(--k-negative);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
.survey-workbench-embed .derr[hidden]{
|
|
678
|
+
display: none;
|
|
679
|
+
}
|
|
680
|
+
|
|
639
681
|
/* confidence + provenance */
|
|
640
682
|
|
|
641
683
|
.survey-workbench-embed .prov{
|
|
@@ -1077,14 +1119,6 @@
|
|
|
1077
1119
|
text-transform: uppercase;
|
|
1078
1120
|
}
|
|
1079
1121
|
|
|
1080
|
-
.survey-workbench-embed .preview-section.is-neutral{
|
|
1081
|
-
background: var(--k-raised);
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
.survey-workbench-embed .preview-section.is-neutral h3{
|
|
1085
|
-
color: var(--k-faint);
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
1122
|
.survey-workbench-embed .reference-details{
|
|
1089
1123
|
margin-top: 8px;
|
|
1090
1124
|
}
|
|
@@ -2,7 +2,9 @@ import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type Review
|
|
|
2
2
|
import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
|
|
3
3
|
import { type ReviewPresentationAdapter } from "./review-presentation.js";
|
|
4
4
|
import { type ReviewCandidate, type ReviewDecision, type ReviewItem, type ReviewSession, type ReviewSessionEvent, type ReviewValueDescriptor } from "../review-resource.js";
|
|
5
|
-
export {
|
|
5
|
+
export { reviewAuditRowKeys, type ReviewAuditRowKey } from "./audit-rows.js";
|
|
6
|
+
export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, type BindReviewQueueOptions, type ReviewQueueBinding, type ReviewQueueBindingIssue, type ReviewQueueBindingIssueCode, type ReviewQueueExtractionIssue, type ReviewQueueExtractionIssueCode, type ValidateReviewQueueBindingOptions, } from "./queue-binding.js";
|
|
7
|
+
export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, mountExtractionInspector, type ExtractionAlignmentState, type ArtifactUnavailableCode, type BuiltExtractionInspectorCandidate, type BuiltExtractionInspectorModel, type ExtractionInspectorCandidate, type ExtractionInspectorEntry, type ExtractionInspectorExportOptions, type ExtractionInspectorFilters, type ExtractionInspectorInput, type ExtractionInspectorMountOptions, type ExtractionInspectorModel, type ExtractionInspectorSource, type ResolvedExtractionArtifact, } from "./extraction-inspector.js";
|
|
6
8
|
export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, keepActionDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
|
|
7
9
|
export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
|
|
8
10
|
export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
|