@action-state-group/capsule-viewer 0.0.0 → 0.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.
- package/LICENSE +201 -0
- package/LICENSE-BSD-3-Clause +28 -0
- package/README.md +23 -1
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +11 -0
- package/dist/browser.js.map +1 -0
- package/dist/compliance-presentation.d.ts +51 -0
- package/dist/compliance-presentation.js +140 -0
- package/dist/compliance-presentation.js.map +1 -0
- package/dist/compliance-styles.d.ts +8 -0
- package/dist/compliance-styles.js +66 -0
- package/dist/compliance-styles.js.map +1 -0
- package/dist/compliance-view.d.ts +6 -0
- package/dist/compliance-view.js +394 -0
- package/dist/compliance-view.js.map +1 -0
- package/dist/compliance.d.ts +92 -0
- package/dist/compliance.js +333 -0
- package/dist/compliance.js.map +1 -0
- package/dist/composed-view.d.ts +24 -0
- package/dist/composed-view.js +179 -0
- package/dist/composed-view.js.map +1 -0
- package/dist/emitter-shell.html +136 -0
- package/dist/emitter.d.ts +69 -0
- package/dist/emitter.js +266 -0
- package/dist/emitter.js.map +1 -0
- package/dist/evidence-graph-view.d.ts +96 -0
- package/dist/evidence-graph-view.js +1480 -0
- package/dist/evidence-graph-view.js.map +1 -0
- package/dist/evidence-graph.iife.js +11373 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/outcome-report-styles.d.ts +16 -0
- package/dist/outcome-report-styles.js +225 -0
- package/dist/outcome-report-styles.js.map +1 -0
- package/dist/outcome-report-view.d.ts +5 -0
- package/dist/outcome-report-view.js +885 -0
- package/dist/outcome-report-view.js.map +1 -0
- package/dist/presentation-builder.d.ts +247 -0
- package/dist/presentation-builder.js +552 -0
- package/dist/presentation-builder.js.map +1 -0
- package/dist/presentation-mount.d.ts +26 -0
- package/dist/presentation-mount.js +45 -0
- package/dist/presentation-mount.js.map +1 -0
- package/package.json +47 -4
|
@@ -0,0 +1,1480 @@
|
|
|
1
|
+
// SPDX-License-Identifier: BSD-3-Clause
|
|
2
|
+
import { buildVerifiedBundleContext, disclosureOf, isVerifiedBundleContext, withCountersigners, } from "@action-state-group/agent-action-capsule";
|
|
3
|
+
import { BUILTIN_MANIFEST_COMPOSED, BUILTIN_MANIFEST_EVALUATION_SUMMARY_GRAPH, BUILTIN_MANIFEST_NO_AGGREGATE, BUILTIN_MANIFEST_REPORT_ROWS, BUILTIN_MANIFEST_RESULT, BUILTIN_MANIFEST_RESULT_COMPLIANCE, BUILTIN_MANIFEST_RESULT_OUTCOME_REPORT, } from "@action-state-group/agent-action-capsule";
|
|
4
|
+
import { classifyCountersignatures, declaredProducerKeys, } from "@action-state-group/agent-action-capsule";
|
|
5
|
+
import { buildEvidenceGraph, EvidenceGraphError, zoneStatement, } from "@action-state-group/agent-action-capsule";
|
|
6
|
+
import { digestCapability } from "@action-state-group/agent-action-capsule";
|
|
7
|
+
import { renderOutcomeReportPage } from "./outcome-report-view.js";
|
|
8
|
+
import { readOutcomeReportPresentation, } from "@action-state-group/agent-action-capsule";
|
|
9
|
+
import { renderCompliancePage } from "./compliance-view.js";
|
|
10
|
+
import { composedSectionModule } from "./composed-view.js";
|
|
11
|
+
import { readCompliancePresentation, } from "./compliance-presentation.js";
|
|
12
|
+
import { readPresentationBlock } from "@action-state-group/agent-action-capsule";
|
|
13
|
+
import { bundleVerified, PresentationAmbiguityError, PresentationRegistry, } from "@action-state-group/agent-action-capsule";
|
|
14
|
+
import { buildReportRows, } from "@action-state-group/agent-action-capsule";
|
|
15
|
+
import { buildResultRoot, isResultRoot, UNVERIFIED_KEY_LABEL, } from "@action-state-group/agent-action-capsule";
|
|
16
|
+
import { buildVerificationPageModel, CHECKED_ON_THIS_PAGE, CHECKS_SCOPE_LINE, FAILED_ON_THIS_PAGE, FULL_VERIFIER_LINE, NOT_CHECKED_ON_THIS_PAGE, RECEIPT_GRADE_DISPLAY, verificationCheckWords, unboundRecordIds, } from "@action-state-group/agent-action-capsule";
|
|
17
|
+
function element(tag, text) {
|
|
18
|
+
const value = document.createElement(tag);
|
|
19
|
+
if (text !== undefined)
|
|
20
|
+
value.textContent = text;
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function display(value) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return String(value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function appendValue(parent, label, value) {
|
|
32
|
+
parent.append(element("dt", label));
|
|
33
|
+
parent.append(element("dd", display(value)));
|
|
34
|
+
}
|
|
35
|
+
// A written time is printed exactly as the source wrote it. When it states no
|
|
36
|
+
// zone, a visible marker follows it; no zone is ever assigned and no `Z` or
|
|
37
|
+
// UTC label is ever printed on a time that did not carry one.
|
|
38
|
+
function renderTime(value) {
|
|
39
|
+
const zone = zoneStatement(value);
|
|
40
|
+
const time = element("span", value);
|
|
41
|
+
time.dataset.tz = zone;
|
|
42
|
+
if (zone === "not-stated") {
|
|
43
|
+
const marker = element("span", " (timezone not stated)");
|
|
44
|
+
marker.dataset.tzMarker = "not-stated";
|
|
45
|
+
time.append(marker);
|
|
46
|
+
}
|
|
47
|
+
return time;
|
|
48
|
+
}
|
|
49
|
+
function appendTime(parent, label, value, absent) {
|
|
50
|
+
parent.append(element("dt", label));
|
|
51
|
+
const cell = element("dd");
|
|
52
|
+
if (value === undefined) {
|
|
53
|
+
cell.textContent = absent;
|
|
54
|
+
cell.dataset.time = "not-stated";
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
cell.append(renderTime(value));
|
|
58
|
+
}
|
|
59
|
+
parent.append(cell);
|
|
60
|
+
}
|
|
61
|
+
const recordsWord = (count) => `${count} ${count === 1 ? "record" : "records"}`;
|
|
62
|
+
// The banner is drawn from the verification result alone and precedes every
|
|
63
|
+
// row in the DOM; an unverified bundle gets the refusal line here and no
|
|
64
|
+
// rows at all, so a reader never meets a payload before the verdict on the
|
|
65
|
+
// bundle that carries it. It says what IS proven: a verified bundle with
|
|
66
|
+
// records outside the checkpoint names their count up front.
|
|
67
|
+
function renderVerificationBanner(root, verified, coverage) {
|
|
68
|
+
const banner = element("p", verified
|
|
69
|
+
? coverage.uncheckpointed === 0
|
|
70
|
+
? "Bundle verification passed"
|
|
71
|
+
: `Bundle verification passed; ${coverage.uncheckpointed} of ${recordsWord(coverage.total)} uncheckpointed`
|
|
72
|
+
: "Bundle verification failed");
|
|
73
|
+
banner.dataset.verify = verified ? "verified" : "failed";
|
|
74
|
+
banner.dataset.uncheckpointed = String(coverage.uncheckpointed);
|
|
75
|
+
root.append(banner);
|
|
76
|
+
if (verified)
|
|
77
|
+
return banner;
|
|
78
|
+
const refusal = element("p", "This bundle did not verify. Its records, rows and payloads are not shown; the verification page below lists which checks failed.");
|
|
79
|
+
refusal.dataset.refusal = "unverified-bundle";
|
|
80
|
+
root.append(refusal);
|
|
81
|
+
return banner;
|
|
82
|
+
}
|
|
83
|
+
/** The words a page says when this viewer cannot verify the bundle at all. */
|
|
84
|
+
export const UNABLE_TO_VERIFY_LINES = {
|
|
85
|
+
banner: "Unable to verify in this viewer",
|
|
86
|
+
reason: "This viewer cannot compute the SHA-256 digests the checks need, so nothing was checked and no records are shown. This is not a failed check: the file may well be valid.",
|
|
87
|
+
command: "To check this file, run:",
|
|
88
|
+
noCommand: "To check this file, open it from a secure (https) page, or check it with a verifier outside this viewer.",
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Render the "unable to verify in this viewer" state into `root`: drawn,
|
|
92
|
+
* in place of any verification result, when this runtime has no SHA-256
|
|
93
|
+
* (`digestCapability() === "none"`). It never says the bundle failed, and
|
|
94
|
+
* it shows no record. `verifyCommand` is the exact command a reader runs
|
|
95
|
+
* to check the file elsewhere; without one, the page says where to check
|
|
96
|
+
* it instead.
|
|
97
|
+
*/
|
|
98
|
+
export function renderUnableToVerify(root, options = {}) {
|
|
99
|
+
root.replaceChildren();
|
|
100
|
+
const banner = element("p", UNABLE_TO_VERIFY_LINES.banner);
|
|
101
|
+
banner.dataset.verify = "unable";
|
|
102
|
+
const reason = element("p", UNABLE_TO_VERIFY_LINES.reason);
|
|
103
|
+
reason.dataset.refusal = "unable-to-verify";
|
|
104
|
+
root.append(banner, reason);
|
|
105
|
+
if (options.verifyCommand === undefined) {
|
|
106
|
+
root.append(element("p", UNABLE_TO_VERIFY_LINES.noCommand));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const command = element("code", options.verifyCommand);
|
|
110
|
+
command.dataset.verifyCommand = "";
|
|
111
|
+
const block = element("pre");
|
|
112
|
+
block.append(command);
|
|
113
|
+
root.append(element("p", UNABLE_TO_VERIFY_LINES.command), block);
|
|
114
|
+
}
|
|
115
|
+
// Drawn in the outcome-report card's own look when that card renders
|
|
116
|
+
// (OUTCOME_REPORT_CSS's .oi-banner rules); the words and data attributes are
|
|
117
|
+
// identical either way -- the class is presentation only.
|
|
118
|
+
function styleBanner(banner, verified) {
|
|
119
|
+
banner.className = `oi oi-banner ${verified ? "oi-banner-ok" : "oi-banner-failed"}`;
|
|
120
|
+
}
|
|
121
|
+
// presentation/v1 is rendered here, in the header only, and nowhere else in
|
|
122
|
+
// this module. Only readPresentationBlock's three known fields ever reach
|
|
123
|
+
// the DOM -- the chrome rule holds structurally, not by a rendering-site
|
|
124
|
+
// convention that a future edit could bypass.
|
|
125
|
+
function renderPresentationHeader(root, bundle) {
|
|
126
|
+
const presentation = readPresentationBlock(bundle);
|
|
127
|
+
if (presentation === undefined)
|
|
128
|
+
return;
|
|
129
|
+
const header = element("header");
|
|
130
|
+
header.dataset.presentation = "header";
|
|
131
|
+
if (presentation.logoDataUrl !== undefined) {
|
|
132
|
+
const logo = document.createElement("img");
|
|
133
|
+
logo.src = presentation.logoDataUrl;
|
|
134
|
+
logo.alt = presentation.producerDisplayName ?? "producer logo";
|
|
135
|
+
header.append(logo);
|
|
136
|
+
}
|
|
137
|
+
if (presentation.producerDisplayName !== undefined) {
|
|
138
|
+
const name = element("span", presentation.producerDisplayName);
|
|
139
|
+
name.dataset.presentationField = "producer-display-name";
|
|
140
|
+
header.append(name);
|
|
141
|
+
}
|
|
142
|
+
if (presentation.title !== undefined) {
|
|
143
|
+
const title = element("strong", presentation.title);
|
|
144
|
+
title.dataset.presentationField = "title";
|
|
145
|
+
header.append(title);
|
|
146
|
+
}
|
|
147
|
+
root.append(header);
|
|
148
|
+
}
|
|
149
|
+
function stampText(stamp) {
|
|
150
|
+
switch (stamp.kind) {
|
|
151
|
+
case "hollow":
|
|
152
|
+
return "Countersigned: none";
|
|
153
|
+
case "unverified":
|
|
154
|
+
return stamp.reason === "no-ed25519"
|
|
155
|
+
? `a ${stamp.type} countersignature is present; its signature is not checked in this viewer`
|
|
156
|
+
: `a ${stamp.type} countersignature is present; this viewer does not verify that type`;
|
|
157
|
+
case "invalid":
|
|
158
|
+
return "a countersignature is present but failed to verify";
|
|
159
|
+
case "not-independent":
|
|
160
|
+
return `countersigned by the producer — not independent · recomputed ${stamp.statement.recomputedAt}`;
|
|
161
|
+
case "unresolved-signer":
|
|
162
|
+
return `countersigned by an unlisted signer, not in any countersigner list consulted · recomputed ${stamp.statement.recomputedAt}`;
|
|
163
|
+
case "resolved":
|
|
164
|
+
return `Countersigned by ${stamp.name} · recomputed ${stamp.statement.recomputedAt}`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Each check and result is the signer's statement, listed as the signer gave
|
|
168
|
+
// it: never totalled, never presented as this viewer's own finding.
|
|
169
|
+
function renderSignerStatement(item, signer, statement) {
|
|
170
|
+
const label = element("p", `${signer}'s statement of what it recomputed:`);
|
|
171
|
+
item.append(label);
|
|
172
|
+
const checks = element("ul");
|
|
173
|
+
checks.dataset.countersignStatement = "checks";
|
|
174
|
+
statement.checks.forEach((check) => {
|
|
175
|
+
const row = element("li", `${check.name}: ${check.result}`);
|
|
176
|
+
row.dataset.checkResult = check.result;
|
|
177
|
+
checks.append(row);
|
|
178
|
+
});
|
|
179
|
+
item.append(checks);
|
|
180
|
+
if (statement.receipt === "unverified") {
|
|
181
|
+
const receipt = element("p", "receipt present, not checked by this viewer");
|
|
182
|
+
receipt.dataset.countersignReceipt = "unverified";
|
|
183
|
+
item.append(receipt);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function renderStamps(host, stamps) {
|
|
187
|
+
host.append(element("h4", "Countersignatures"));
|
|
188
|
+
const list = element("ul");
|
|
189
|
+
stamps.forEach((stamp) => {
|
|
190
|
+
const item = element("li");
|
|
191
|
+
item.dataset.stampKind = stamp.kind;
|
|
192
|
+
item.append(element("span", stampText(stamp)));
|
|
193
|
+
if (stamp.kind === "resolved") {
|
|
194
|
+
renderSignerStatement(item, stamp.name, stamp.statement);
|
|
195
|
+
}
|
|
196
|
+
else if (stamp.kind === "not-independent") {
|
|
197
|
+
renderSignerStatement(item, "The producer", stamp.statement);
|
|
198
|
+
}
|
|
199
|
+
else if (stamp.kind === "unresolved-signer") {
|
|
200
|
+
renderSignerStatement(item, "The unlisted signer", stamp.statement);
|
|
201
|
+
}
|
|
202
|
+
list.append(item);
|
|
203
|
+
});
|
|
204
|
+
host.append(list);
|
|
205
|
+
}
|
|
206
|
+
function renderReceipts(host, receipts) {
|
|
207
|
+
host.append(element("h4", "Receipts"));
|
|
208
|
+
if (receipts.length === 0) {
|
|
209
|
+
host.append(element("p", "no receipts disclosed"));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const list = element("ul");
|
|
213
|
+
receipts.forEach((receipt) => {
|
|
214
|
+
const item = element("li", `${receipt.witness} · ${RECEIPT_GRADE_DISPLAY[receipt.grade]} · `);
|
|
215
|
+
item.append(renderTime(receipt.time));
|
|
216
|
+
list.append(item);
|
|
217
|
+
});
|
|
218
|
+
host.append(list);
|
|
219
|
+
}
|
|
220
|
+
const COVERAGE_LABEL = Object.freeze({
|
|
221
|
+
checkpointed: "checkpointed",
|
|
222
|
+
uncheckpointed: "uncheckpointed",
|
|
223
|
+
membership_invalid: "membership invalid",
|
|
224
|
+
unverified: "membership unverified",
|
|
225
|
+
});
|
|
226
|
+
// One row per supplied record, in bundle order, each with its own standing
|
|
227
|
+
// under the checkpoint. When the claim established coverage the count is
|
|
228
|
+
// stated in words above the list. When it did not, the line says so and
|
|
229
|
+
// names the verifier's reason -- never "0 records uncheckpointed" on a
|
|
230
|
+
// bundle whose memberships were never verified -- and when the claim was
|
|
231
|
+
// withheld (no checkpoint to stand under) there is no list at all.
|
|
232
|
+
function renderCheckpointCoverage(host, records, uncheckpointed, coverage) {
|
|
233
|
+
host.append(element("h4", "Checkpoint coverage"));
|
|
234
|
+
if (coverage.status === "established") {
|
|
235
|
+
const count = element("p", `${recordsWord(uncheckpointed)} uncheckpointed of ${recordsWord(records.length)} supplied`);
|
|
236
|
+
count.dataset.coverage = "uncheckpointed";
|
|
237
|
+
count.dataset.count = String(uncheckpointed);
|
|
238
|
+
host.append(count);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
const line = element("p", `coverage not established: ${coverage.reason}`);
|
|
242
|
+
line.dataset.coverage = "not-established";
|
|
243
|
+
line.dataset.claim = coverage.status;
|
|
244
|
+
host.append(line);
|
|
245
|
+
if (coverage.status === "withheld")
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
host.append(renderCoverageStatusCounts(records));
|
|
249
|
+
const details = element("details");
|
|
250
|
+
details.dataset.records = "coverage-detail";
|
|
251
|
+
const summary = element("summary", `${recordsWord(records.length)}, by capsule id`);
|
|
252
|
+
details.append(summary);
|
|
253
|
+
const list = element("ul");
|
|
254
|
+
list.dataset.records = "coverage";
|
|
255
|
+
for (const record of records) {
|
|
256
|
+
const item = element("li", `${record.capsuleId} · `);
|
|
257
|
+
const status = element("span", COVERAGE_LABEL[record.status]);
|
|
258
|
+
status.dataset.recordStatus = record.status;
|
|
259
|
+
status.className = `seal-${record.status}`;
|
|
260
|
+
item.dataset.capsuleId = record.capsuleId;
|
|
261
|
+
item.append(status);
|
|
262
|
+
list.append(item);
|
|
263
|
+
}
|
|
264
|
+
details.append(list);
|
|
265
|
+
host.append(details);
|
|
266
|
+
}
|
|
267
|
+
// Counts by seal status -- the line a reader actually needs (how many of
|
|
268
|
+
// each) without scrolling a list that is one row per record (500+ rows on a
|
|
269
|
+
// real day's book: 50 cases x 9 criteria + 50 cases + 1 Close). The full,
|
|
270
|
+
// per-capsule-id list stays available (renderCheckpointCoverage wraps it in
|
|
271
|
+
// a collapsed <details>), never removed, just not the first thing rendered.
|
|
272
|
+
function renderCoverageStatusCounts(records) {
|
|
273
|
+
const counts = new Map();
|
|
274
|
+
for (const record of records) {
|
|
275
|
+
counts.set(record.status, (counts.get(record.status) ?? 0) + 1);
|
|
276
|
+
}
|
|
277
|
+
const list = element("ul");
|
|
278
|
+
list.dataset.records = "coverage-summary";
|
|
279
|
+
for (const status of Object.keys(COVERAGE_LABEL)) {
|
|
280
|
+
const count = counts.get(status) ?? 0;
|
|
281
|
+
if (count === 0)
|
|
282
|
+
continue;
|
|
283
|
+
const item = element("li", `${recordsWord(count)} ${COVERAGE_LABEL[status]}`);
|
|
284
|
+
item.dataset.coverageStatus = status;
|
|
285
|
+
item.dataset.count = String(count);
|
|
286
|
+
list.append(item);
|
|
287
|
+
}
|
|
288
|
+
return list;
|
|
289
|
+
}
|
|
290
|
+
function renderCompletenessStatement(host, completeness) {
|
|
291
|
+
host.append(element("h4", "Completeness statement"));
|
|
292
|
+
const details = element("dl");
|
|
293
|
+
appendValue(details, "closure depth", completeness?.closureDepth ?? "unstated");
|
|
294
|
+
appendValue(details, "records mode", completeness?.recordsMode ?? "unstated");
|
|
295
|
+
appendValue(details, "payloads mode", completeness?.payloadsMode ?? "unstated");
|
|
296
|
+
appendValue(details, "suppressed fields", completeness?.suppressedFields ?? []);
|
|
297
|
+
host.append(details);
|
|
298
|
+
}
|
|
299
|
+
function renderChecks(host, checks) {
|
|
300
|
+
host.append(element("h4", "The ten checks"));
|
|
301
|
+
const list = element("ol");
|
|
302
|
+
checks.forEach((check) => {
|
|
303
|
+
const item = element("li");
|
|
304
|
+
item.dataset.checkStatus = check.status;
|
|
305
|
+
item.append(element("strong", check.name));
|
|
306
|
+
item.append(element("span", `: ${check.result}`));
|
|
307
|
+
list.append(item);
|
|
308
|
+
});
|
|
309
|
+
host.append(list);
|
|
310
|
+
}
|
|
311
|
+
// One row per extension: id, integrity, semantics. The id is the bundle's
|
|
312
|
+
// own member name, set as text only. Integrity and semantics are separate
|
|
313
|
+
// cells so "covered" can never read as "understood": an uninterpreted block
|
|
314
|
+
// says so in words (EXTENSION_NOT_INTERPRETED). A row whose meaning a refused
|
|
315
|
+
// presentation module would have applied says that instead (presentation
|
|
316
|
+
// contract section 3.2), and names the module and the reason. A refused
|
|
317
|
+
// module with no extension of its own gets one line after the table. Nothing
|
|
318
|
+
// is drawn for a bundle with no extensions and no refusal.
|
|
319
|
+
function renderExtensions(host, rows, refusals) {
|
|
320
|
+
renderExtensionTable(host, rows);
|
|
321
|
+
for (const notice of refusals) {
|
|
322
|
+
const line = element("p", notice.line);
|
|
323
|
+
line.dataset.presentationRefused = notice.id;
|
|
324
|
+
line.dataset.refusal = notice.reason;
|
|
325
|
+
host.append(line);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function renderExtensionTable(host, rows) {
|
|
329
|
+
if (rows.length === 0)
|
|
330
|
+
return;
|
|
331
|
+
host.append(element("h4", "Extensions"));
|
|
332
|
+
const table = element("table");
|
|
333
|
+
table.dataset.extensions = "";
|
|
334
|
+
const head = element("tr");
|
|
335
|
+
for (const label of ["extension", "integrity", "semantics"])
|
|
336
|
+
head.append(element("th", label));
|
|
337
|
+
const thead = element("thead");
|
|
338
|
+
thead.append(head);
|
|
339
|
+
const body = element("tbody");
|
|
340
|
+
for (const row of rows) {
|
|
341
|
+
const tr = element("tr");
|
|
342
|
+
tr.dataset.extensionId = row.id;
|
|
343
|
+
tr.dataset.integrity = row.integrity;
|
|
344
|
+
tr.dataset.semantics =
|
|
345
|
+
row.refusal !== undefined
|
|
346
|
+
? "refused"
|
|
347
|
+
: row.interpreter === undefined
|
|
348
|
+
? "uninterpreted"
|
|
349
|
+
: "interpreted";
|
|
350
|
+
if (row.refusal !== undefined) {
|
|
351
|
+
tr.dataset.refusedModule = row.refusal.id;
|
|
352
|
+
tr.dataset.refusal = row.refusal.reason;
|
|
353
|
+
}
|
|
354
|
+
tr.append(element("td", row.id), element("td", row.integrity), element("td", row.semantics));
|
|
355
|
+
body.append(tr);
|
|
356
|
+
}
|
|
357
|
+
table.append(thead, body);
|
|
358
|
+
host.append(table);
|
|
359
|
+
}
|
|
360
|
+
// The page's own checks in up to three lists (presentation contract section
|
|
361
|
+
// 6.1): what ran here and passed, what ran here and failed, and what the
|
|
362
|
+
// bundle calls for that this page did not run. A list with no entries is not
|
|
363
|
+
// drawn. The not-checked list ends with the neutral pointer to a full
|
|
364
|
+
// verifier and, when the host supplied one, its own verifier text (shown as
|
|
365
|
+
// text, never markup).
|
|
366
|
+
function renderCheckLists(host, lists, verifierHint) {
|
|
367
|
+
const groups = [
|
|
368
|
+
["page", CHECKED_ON_THIS_PAGE, lists.page],
|
|
369
|
+
["failed", FAILED_ON_THIS_PAGE, lists.failed],
|
|
370
|
+
["not-checked", NOT_CHECKED_ON_THIS_PAGE, lists.notChecked],
|
|
371
|
+
];
|
|
372
|
+
for (const [which, heading, ids] of groups) {
|
|
373
|
+
if (ids.length === 0)
|
|
374
|
+
continue;
|
|
375
|
+
host.append(element("h4", heading));
|
|
376
|
+
const list = element("ul");
|
|
377
|
+
list.dataset.checks = which;
|
|
378
|
+
for (const id of ids) {
|
|
379
|
+
const item = element("li", verificationCheckWords(id, which === "not-checked" ? "notChecked" : which, lists));
|
|
380
|
+
item.dataset.check = id;
|
|
381
|
+
list.append(item);
|
|
382
|
+
}
|
|
383
|
+
host.append(list);
|
|
384
|
+
if (which === "not-checked") {
|
|
385
|
+
const pointer = element("p", FULL_VERIFIER_LINE);
|
|
386
|
+
pointer.dataset.checks = "full-verifier";
|
|
387
|
+
host.append(pointer);
|
|
388
|
+
if (verifierHint !== undefined && verifierHint !== "") {
|
|
389
|
+
const hint = element("pre", verifierHint);
|
|
390
|
+
hint.dataset.verifierHint = "";
|
|
391
|
+
host.append(hint);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
const scope = element("p", CHECKS_SCOPE_LINE);
|
|
396
|
+
scope.dataset.checks = "scope";
|
|
397
|
+
host.append(scope);
|
|
398
|
+
}
|
|
399
|
+
// The viewer-owned verification page: the last page of the rendering, drawn
|
|
400
|
+
// entirely from VERIFIED data (the already-computed BundleVerificationResult
|
|
401
|
+
// and the countersignature stamp classification), never from bundle-supplied
|
|
402
|
+
// markup. It is never labeled a certificate.
|
|
403
|
+
//
|
|
404
|
+
// `styled` (true only when the outcome-report card rendered above it) draws
|
|
405
|
+
// this same page in that card's look: the section takes the card's `.oi`
|
|
406
|
+
// scope plus `.oi-vp`, and its content goes inside one `.sec` panel like
|
|
407
|
+
// every card section. Content, order and data attributes are identical
|
|
408
|
+
// either way, and it stays the last element of the rendering.
|
|
409
|
+
//
|
|
410
|
+
// `applied` is the set of extension interpreters this rendering ran; an
|
|
411
|
+
// extension row says "interpreted by" only for one of them. `refused` is
|
|
412
|
+
// every module this runtime refused that matched (page, then section).
|
|
413
|
+
async function renderVerificationPage(root, context, styled, applied, refused, citedSigners, verifierHint) {
|
|
414
|
+
const { bundle, verification: verified, countersigners } = context;
|
|
415
|
+
const section = element("section");
|
|
416
|
+
section.dataset.page = "verification";
|
|
417
|
+
let page = section;
|
|
418
|
+
if (styled) {
|
|
419
|
+
section.className = "oi oi-vp";
|
|
420
|
+
page = element("div");
|
|
421
|
+
page.className = "sec";
|
|
422
|
+
section.append(page);
|
|
423
|
+
}
|
|
424
|
+
page.append(element("h2", "Verification"));
|
|
425
|
+
const stamps = await classifyCountersignatures(context.countersignatures.map((entry) => entry.value), verified.bundleDigest, declaredProducerKeys(bundle), countersigners);
|
|
426
|
+
const model = buildVerificationPageModel(bundle, verified, applied, refused, {
|
|
427
|
+
stamps,
|
|
428
|
+
citedSigners,
|
|
429
|
+
});
|
|
430
|
+
const summary = element("dl");
|
|
431
|
+
appendValue(summary, "bundle digest", model.bundleDigest ?? "uncomputable");
|
|
432
|
+
appendValue(summary, "checkpoint root", model.checkpointRoot ?? "absent");
|
|
433
|
+
appendValue(summary, "checkpoint size", model.checkpointSize ?? "absent");
|
|
434
|
+
page.append(summary);
|
|
435
|
+
renderReceipts(page, model.receipts);
|
|
436
|
+
if (model.selfWitnessed) {
|
|
437
|
+
// A checkpoint with no transparency-service receipt was witnessed by
|
|
438
|
+
// nobody but the log that produced it -- stated here in those words.
|
|
439
|
+
const witness = element("p", "self-witnessed: no transparency-service receipt");
|
|
440
|
+
witness.dataset.witness = "self";
|
|
441
|
+
page.append(witness);
|
|
442
|
+
}
|
|
443
|
+
renderCheckpointCoverage(page, model.records, model.uncheckpointedCount, model.coverage);
|
|
444
|
+
renderStamps(page, stamps);
|
|
445
|
+
renderCompletenessStatement(page, model.completeness);
|
|
446
|
+
renderChecks(page, model.checks);
|
|
447
|
+
renderExtensions(page, model.extensions, model.presentationRefusals);
|
|
448
|
+
renderCheckLists(page, model.checkLists, verifierHint);
|
|
449
|
+
page.append(element("p", model.verifyIndependentlyLine));
|
|
450
|
+
root.append(section);
|
|
451
|
+
}
|
|
452
|
+
function metRate(report) {
|
|
453
|
+
const required = report.outcomes.filter((outcome) => outcome.role === "required");
|
|
454
|
+
if (required.length === 0)
|
|
455
|
+
return "met rate unavailable";
|
|
456
|
+
const passed = required.filter((outcome) => outcome.aggregate === "pass").length;
|
|
457
|
+
return `met rate ${passed}/${required.length}`;
|
|
458
|
+
}
|
|
459
|
+
function renderCalibration(calibration) {
|
|
460
|
+
const section = element("section");
|
|
461
|
+
section.append(element("h2", "Human check"));
|
|
462
|
+
if (calibration === undefined) {
|
|
463
|
+
section.append(element("p", "no human-check data"));
|
|
464
|
+
return section;
|
|
465
|
+
}
|
|
466
|
+
const details = element("dl");
|
|
467
|
+
appendValue(details, "confusion matrix", calibration.confusion);
|
|
468
|
+
appendCount(details, "agreement", calibration.agreement);
|
|
469
|
+
appendCount(details, "corrected rate", calibration.correctedRate);
|
|
470
|
+
appendValue(details, "period window", calibration.periodWindow);
|
|
471
|
+
section.append(details);
|
|
472
|
+
return section;
|
|
473
|
+
}
|
|
474
|
+
// A calibration figure is printed as the integers it is made of, "k of n";
|
|
475
|
+
// no rate is computed from them here, and a figure the producer stated only
|
|
476
|
+
// as a rate is said to be that, not converted.
|
|
477
|
+
function appendCount(parent, label, count) {
|
|
478
|
+
parent.append(element("dt", label));
|
|
479
|
+
const cell = element("dd");
|
|
480
|
+
if (count === undefined) {
|
|
481
|
+
cell.textContent = "not stated";
|
|
482
|
+
cell.dataset.count = "not-stated";
|
|
483
|
+
}
|
|
484
|
+
else if ("rate" in count) {
|
|
485
|
+
cell.textContent = "rate given, k and n not stated";
|
|
486
|
+
cell.dataset.count = "not-stated";
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
cell.textContent = `${count.k} of ${count.n}`;
|
|
490
|
+
cell.dataset.k = String(count.k);
|
|
491
|
+
cell.dataset.n = String(count.n);
|
|
492
|
+
}
|
|
493
|
+
parent.append(cell);
|
|
494
|
+
}
|
|
495
|
+
// Both of a record's times are shown, each labelled and each as written. The
|
|
496
|
+
// action time is the source's own; when the record states none it says so --
|
|
497
|
+
// the seal time (the capsule's registration timestamp) never stands in for it.
|
|
498
|
+
function renderProvenance(capsuleId, coordinates, times) {
|
|
499
|
+
const panel = element("section");
|
|
500
|
+
panel.append(element("h4", "Provenance"));
|
|
501
|
+
const details = element("dl");
|
|
502
|
+
appendValue(details, "capsule ID", capsuleId);
|
|
503
|
+
if (times.provenanceMode !== undefined)
|
|
504
|
+
appendValue(details, "provenance", times.provenanceMode);
|
|
505
|
+
appendTime(details, "action time", times.actionTime, "action time not stated");
|
|
506
|
+
appendTime(details, "seal time", times.sealTime, "seal time not stated");
|
|
507
|
+
// The seal status is this record's own: coordinates exist only for a
|
|
508
|
+
// record whose inclusion proof verified (the bundle renders only then), so
|
|
509
|
+
// a record without them sits outside the checkpoint and says so.
|
|
510
|
+
details.append(element("dt", "seal status"));
|
|
511
|
+
const seal = element("dd", coordinates === undefined ? "uncheckpointed" : "checkpointed");
|
|
512
|
+
seal.dataset.seal =
|
|
513
|
+
coordinates === undefined ? "uncheckpointed" : "checkpointed";
|
|
514
|
+
seal.className = `seal-${seal.dataset.seal}`;
|
|
515
|
+
details.append(seal);
|
|
516
|
+
if (coordinates !== undefined) {
|
|
517
|
+
appendValue(details, "log ID", coordinates.logId);
|
|
518
|
+
appendValue(details, "sequence", coordinates.seq);
|
|
519
|
+
appendValue(details, "leaf", coordinates.leafIndex);
|
|
520
|
+
}
|
|
521
|
+
panel.append(details);
|
|
522
|
+
return panel;
|
|
523
|
+
}
|
|
524
|
+
function renderJudgment(judgment) {
|
|
525
|
+
const item = element("li");
|
|
526
|
+
item.append(element("strong", `${judgment.axisId}: ${judgment.status}`));
|
|
527
|
+
item.append(element("p", judgment.rationale));
|
|
528
|
+
item.append(element("p", `evidence: ${judgment.evidenceIds.join(", ")}`));
|
|
529
|
+
return item;
|
|
530
|
+
}
|
|
531
|
+
function renderAct(act) {
|
|
532
|
+
const section = element("section");
|
|
533
|
+
section.append(element("h4", `Turn ${act.turnIdx}`));
|
|
534
|
+
if (act.agentInput !== undefined)
|
|
535
|
+
section.append(element("pre", display(act.agentInput)));
|
|
536
|
+
if (act.agentOutput !== undefined)
|
|
537
|
+
section.append(element("pre", display(act.agentOutput)));
|
|
538
|
+
section.append(renderProvenance(act.capsuleId, act.logCoordinates, act));
|
|
539
|
+
return section;
|
|
540
|
+
}
|
|
541
|
+
function object(value) {
|
|
542
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
543
|
+
? value
|
|
544
|
+
: {};
|
|
545
|
+
}
|
|
546
|
+
function renderWithheldActs(acts, host) {
|
|
547
|
+
for (const act of acts) {
|
|
548
|
+
if (act.agentInput !== undefined && act.agentOutput !== undefined)
|
|
549
|
+
continue;
|
|
550
|
+
const committed = object({
|
|
551
|
+
agent_input_digest: act.agentInputDigest,
|
|
552
|
+
agent_output_digest: act.agentOutputDigest,
|
|
553
|
+
});
|
|
554
|
+
const evidence = element("section");
|
|
555
|
+
evidence.append(element("h4", "Undisclosed payload evidence"));
|
|
556
|
+
const details = element("dl");
|
|
557
|
+
appendValue(details, "capsule ID", act.capsuleId);
|
|
558
|
+
for (const field of ["agent_input_digest", "agent_output_digest"]) {
|
|
559
|
+
if (typeof committed[field] === "string")
|
|
560
|
+
appendValue(details, field, committed[field]);
|
|
561
|
+
}
|
|
562
|
+
evidence.append(details);
|
|
563
|
+
// A supplied value that does not hash to the committed digest is never
|
|
564
|
+
// shown; the reader sees the digest above and this note, nothing else.
|
|
565
|
+
for (const [field, state] of [
|
|
566
|
+
["agent_input", act.agentInputDisclosure],
|
|
567
|
+
["agent_output", act.agentOutputDisclosure],
|
|
568
|
+
]) {
|
|
569
|
+
if (state !== "disclosure_mismatch")
|
|
570
|
+
continue;
|
|
571
|
+
const note = element("p", `${field}: the disclosed value does not match the committed digest and is withheld`);
|
|
572
|
+
note.dataset.disclosure = state;
|
|
573
|
+
evidence.append(note);
|
|
574
|
+
}
|
|
575
|
+
host.append(evidence);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
function renderCase(caseNode, host) {
|
|
579
|
+
host.replaceChildren();
|
|
580
|
+
host.append(element("h3", `Case ${caseNode.caseId}`));
|
|
581
|
+
const judgments = element("ul");
|
|
582
|
+
caseNode.judgments.forEach((judgment) => judgments.append(renderJudgment(judgment)));
|
|
583
|
+
host.append(element("h4", "Axis judgments"), judgments);
|
|
584
|
+
host.append(element("h4", "Disclosed transcript"));
|
|
585
|
+
caseNode.acts.forEach((act) => host.append(renderAct(act)));
|
|
586
|
+
renderWithheldActs(caseNode.acts, host);
|
|
587
|
+
}
|
|
588
|
+
function renderReport(report, host, _records) {
|
|
589
|
+
host.replaceChildren();
|
|
590
|
+
const heading = element("h2", "Cases for ");
|
|
591
|
+
heading.append(renderTime(report.date));
|
|
592
|
+
host.append(heading);
|
|
593
|
+
const outcomes = element("ul");
|
|
594
|
+
report.outcomes.forEach((outcome) => {
|
|
595
|
+
outcomes.append(element("li", `${outcome.outcomeId} (${outcome.role}): ${outcome.aggregate ?? "unrated"}`));
|
|
596
|
+
});
|
|
597
|
+
host.append(element("h3", "Outcome rollup"), outcomes);
|
|
598
|
+
renderWithheldActs(report.withheldActs, host);
|
|
599
|
+
const cases = element("section");
|
|
600
|
+
const detail = element("section");
|
|
601
|
+
report.cases.forEach((caseNode) => {
|
|
602
|
+
const item = element("button", `${caseNode.taskId}, trial ${caseNode.trial}: ${caseNode.aggregate ?? "unrated"}`);
|
|
603
|
+
item.setAttribute("type", "button");
|
|
604
|
+
item.dataset.caseId = caseNode.caseId;
|
|
605
|
+
item.addEventListener("click", () => renderCase(caseNode, detail));
|
|
606
|
+
cases.append(item);
|
|
607
|
+
});
|
|
608
|
+
host.append(cases, detail, renderProvenance(report.capsuleId, report.logCoordinates, report));
|
|
609
|
+
}
|
|
610
|
+
// report/v1: the generic row path. Every row is rendered from its own data
|
|
611
|
+
// -- label, status, reason, citations -- never from report-specific markup,
|
|
612
|
+
// so a pack this module has never heard of (obligations, Consumer Duty,
|
|
613
|
+
// anything else) renders the same way an outcomes report does.
|
|
614
|
+
function renderCitation(citation) {
|
|
615
|
+
const section = element("section");
|
|
616
|
+
section.append(renderProvenance(citation.capsuleId, citation.logCoordinates, citation));
|
|
617
|
+
if (citation.disclosure === "disclosed") {
|
|
618
|
+
section.append(element("pre", display(citation.disclosedPayload)));
|
|
619
|
+
}
|
|
620
|
+
else {
|
|
621
|
+
const note = element("p", citation.disclosure === "disclosure_mismatch"
|
|
622
|
+
? "withheld: the disclosed value does not match the committed digest"
|
|
623
|
+
: "withheld");
|
|
624
|
+
note.dataset.disclosure = citation.disclosure;
|
|
625
|
+
section.append(note);
|
|
626
|
+
}
|
|
627
|
+
return section;
|
|
628
|
+
}
|
|
629
|
+
function renderReportRow(row, host) {
|
|
630
|
+
host.replaceChildren();
|
|
631
|
+
host.append(element("h3", row.label));
|
|
632
|
+
const status = element("p", row.status.replaceAll("_", " "));
|
|
633
|
+
status.dataset.rowStatus = row.status;
|
|
634
|
+
host.append(status);
|
|
635
|
+
if (row.reason !== undefined)
|
|
636
|
+
host.append(element("p", row.reason));
|
|
637
|
+
host.append(element("h4", "Evidence"));
|
|
638
|
+
if (row.citations.length === 0) {
|
|
639
|
+
host.append(element("p", "no citation"));
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
row.citations.forEach((citation) => host.append(renderCitation(citation)));
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
function renderReportRowsTable(reportRows, root) {
|
|
646
|
+
const section = element("section");
|
|
647
|
+
section.dataset.page = "report-rows";
|
|
648
|
+
section.append(element("h1", reportRows.title ?? "Report"));
|
|
649
|
+
const table = document.createElement("table");
|
|
650
|
+
const detail = element("section");
|
|
651
|
+
reportRows.rows.forEach((row) => {
|
|
652
|
+
const tr = document.createElement("tr");
|
|
653
|
+
const labelCell = document.createElement("td");
|
|
654
|
+
const button = element("button", row.label);
|
|
655
|
+
button.setAttribute("type", "button");
|
|
656
|
+
button.dataset.rowId = row.rowId;
|
|
657
|
+
button.addEventListener("click", () => renderReportRow(row, detail));
|
|
658
|
+
labelCell.append(button);
|
|
659
|
+
const statusCell = element("td", row.status.replaceAll("_", " "));
|
|
660
|
+
statusCell.dataset.rowStatus = row.status;
|
|
661
|
+
tr.append(labelCell, statusCell);
|
|
662
|
+
table.append(tr);
|
|
663
|
+
});
|
|
664
|
+
section.append(table, detail, renderProvenance(reportRows.capsuleId, reportRows.logCoordinates, reportRows));
|
|
665
|
+
root.append(section);
|
|
666
|
+
}
|
|
667
|
+
// Result v0 root: headlines from the root Result, drill-downs from the
|
|
668
|
+
// records it cites. A cited record is drawn from its own data -- provenance,
|
|
669
|
+
// each committed member as disclosed or as `withheld · <digest>`, and the
|
|
670
|
+
// records it cites in turn -- so aggregate → daily → case → act is a walk
|
|
671
|
+
// over citations, never report-specific markup.
|
|
672
|
+
function renderCitedMember(host, field, resolution, committed) {
|
|
673
|
+
if (resolution.state !== "disclosed" && committed === undefined)
|
|
674
|
+
return;
|
|
675
|
+
host.append(element("h5", field));
|
|
676
|
+
if (resolution.state === "disclosed") {
|
|
677
|
+
host.append(element("pre", display(resolution.payload)));
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
// A withheld member shows the digest the record committed to; a supplied
|
|
681
|
+
// value that does not hash to it is never shown, only noted.
|
|
682
|
+
const note = element("p", resolution.state === "disclosure_mismatch"
|
|
683
|
+
? `withheld · ${committed}: the disclosed value does not match the committed digest`
|
|
684
|
+
: `withheld · ${committed}`);
|
|
685
|
+
note.dataset.disclosure = resolution.state;
|
|
686
|
+
host.append(note);
|
|
687
|
+
}
|
|
688
|
+
function renderCitedRecord(record, records, host) {
|
|
689
|
+
const section = element("section");
|
|
690
|
+
section.dataset.citedRecord = record.capsuleId;
|
|
691
|
+
section.append(renderProvenance(record.capsuleId, record.logCoordinates, record));
|
|
692
|
+
renderCitedMember(section, "agent_input", record.agentInput, record.agentInputDigest);
|
|
693
|
+
renderCitedMember(section, "agent_output", record.agentOutput, record.agentOutputDigest);
|
|
694
|
+
if (record.cites.length > 0) {
|
|
695
|
+
section.append(element("h5", "Cites"));
|
|
696
|
+
renderCitationList(record.cites, records, section);
|
|
697
|
+
}
|
|
698
|
+
host.append(section);
|
|
699
|
+
}
|
|
700
|
+
// One row per cited id, in citation order: a record supplied in this bundle
|
|
701
|
+
// opens under the list on click; one that is not says so and stays.
|
|
702
|
+
function renderCitationList(ids, records, host) {
|
|
703
|
+
const list = element("ul");
|
|
704
|
+
const detail = element("section");
|
|
705
|
+
for (const id of ids) {
|
|
706
|
+
const item = element("li");
|
|
707
|
+
const target = records.get(id);
|
|
708
|
+
if (target === undefined) {
|
|
709
|
+
item.textContent = `${id} · not in this bundle`;
|
|
710
|
+
item.dataset.citation = "missing";
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
const button = element("button", id);
|
|
714
|
+
button.setAttribute("type", "button");
|
|
715
|
+
button.dataset.citedId = id;
|
|
716
|
+
button.addEventListener("click", () => {
|
|
717
|
+
detail.replaceChildren();
|
|
718
|
+
renderCitedRecord(target, records, detail);
|
|
719
|
+
});
|
|
720
|
+
item.append(button);
|
|
721
|
+
}
|
|
722
|
+
list.append(item);
|
|
723
|
+
}
|
|
724
|
+
host.append(list, detail);
|
|
725
|
+
return list;
|
|
726
|
+
}
|
|
727
|
+
const CLAIM_TYPE_LABEL = (claim) => claim.recognized ? claim.type : `unrecognized (${claim.type})`;
|
|
728
|
+
// A recomputed headline number never wears the producer's value. When the
|
|
729
|
+
// two disagree the recomputed value is drawn and a `count mismatch` marker
|
|
730
|
+
// sits beside it; the stated value is kept on the marker's data attribute,
|
|
731
|
+
// never in the text a reader takes as the number.
|
|
732
|
+
function countMismatchMarker(result, field) {
|
|
733
|
+
const mismatch = result.countMismatches.find((entry) => entry.field === field);
|
|
734
|
+
if (mismatch === undefined)
|
|
735
|
+
return undefined;
|
|
736
|
+
const marker = element("span", "count mismatch");
|
|
737
|
+
marker.dataset.countMismatch = field;
|
|
738
|
+
marker.dataset.stated = String(mismatch.stated);
|
|
739
|
+
marker.dataset.recomputed = String(mismatch.recomputed);
|
|
740
|
+
return marker;
|
|
741
|
+
}
|
|
742
|
+
// The close state a reader sees is the one this bundle's links read
|
|
743
|
+
// whenever the cited Close is supplied; the Result's own value is drawn
|
|
744
|
+
// only when it is not, and then under a `producer-asserted` marker. When
|
|
745
|
+
// the two disagree the recomputed state is drawn with a `state mismatch`
|
|
746
|
+
// marker; the asserted value stays on the data attribute, never in the
|
|
747
|
+
// text. AGREED carries no mark of its own -- its label and the
|
|
748
|
+
// acknowledging peer's record are the whole affordance.
|
|
749
|
+
function appendCloseState(parent, close, tag) {
|
|
750
|
+
const cell = element(tag, close.state);
|
|
751
|
+
cell.dataset.closeState = close.state;
|
|
752
|
+
cell.dataset.closeDerivation = close.derivation;
|
|
753
|
+
cell.dataset.assertedState = close.asserted;
|
|
754
|
+
if (close.stateMismatch) {
|
|
755
|
+
const marker = element("span", "state mismatch");
|
|
756
|
+
marker.dataset.stateMismatch = "close_state";
|
|
757
|
+
marker.dataset.asserted = close.asserted;
|
|
758
|
+
marker.dataset.recomputed = close.state;
|
|
759
|
+
cell.append(" ", marker);
|
|
760
|
+
}
|
|
761
|
+
else if (close.derivation === "producer-asserted") {
|
|
762
|
+
const marker = element("span", "producer-asserted");
|
|
763
|
+
marker.dataset.producerAsserted = "close_state";
|
|
764
|
+
cell.append(" ", marker);
|
|
765
|
+
}
|
|
766
|
+
if (close.peerRefMismatch) {
|
|
767
|
+
const marker = element("span", "peer_close_ref carries no such link");
|
|
768
|
+
marker.dataset.peerRefMismatch = "peer_close_ref";
|
|
769
|
+
cell.append(" ", marker);
|
|
770
|
+
}
|
|
771
|
+
parent.append(cell);
|
|
772
|
+
return cell;
|
|
773
|
+
}
|
|
774
|
+
function renderClose(close, result, host) {
|
|
775
|
+
host.append(element("h4", "Close"));
|
|
776
|
+
const details = element("dl");
|
|
777
|
+
if (close.period !== undefined)
|
|
778
|
+
appendValue(details, "period", `${close.period.start} → ${close.period.end}`);
|
|
779
|
+
details.append(element("dt", "state"));
|
|
780
|
+
appendCloseState(details, close, "dd");
|
|
781
|
+
details.append(element("dt", "derivation"));
|
|
782
|
+
const derivation = element("dd", close.derivation === "recomputed"
|
|
783
|
+
? `recomputed from ${close.links.length} counterparty ${close.links.length === 1 ? "link" : "links"} to the cited Close in this bundle${close.ignored.length === 0 ? "" : ` (${close.ignored.length} other ${close.ignored.length === 1 ? "link" : "links"} ignored)`}`
|
|
784
|
+
: "producer-asserted: the cited Close is not a record in this bundle, so its links could not be read");
|
|
785
|
+
derivation.dataset.closeDerivationNote = close.derivation;
|
|
786
|
+
details.append(derivation);
|
|
787
|
+
if (close.peer !== undefined)
|
|
788
|
+
appendValue(details, "peer", close.peer);
|
|
789
|
+
if (close.bookId !== undefined)
|
|
790
|
+
appendValue(details, "book", close.bookId);
|
|
791
|
+
// A signer is drawn as verified only when its Producer Envelope verified
|
|
792
|
+
// under the key_id (result-root.ts `signerOf`); otherwise it carries the
|
|
793
|
+
// label, never a bare key.
|
|
794
|
+
if (close.keyId !== undefined) {
|
|
795
|
+
details.append(element("dt", "signer"));
|
|
796
|
+
const signer = element("dd", close.keyVerified === true
|
|
797
|
+
? `${close.keyId} (verified)`
|
|
798
|
+
: `${close.keyId} · ${UNVERIFIED_KEY_LABEL}`);
|
|
799
|
+
signer.dataset.keyVerified = String(close.keyVerified === true);
|
|
800
|
+
details.append(signer);
|
|
801
|
+
}
|
|
802
|
+
host.append(details);
|
|
803
|
+
host.append(element("h5", "Cited Close"));
|
|
804
|
+
renderCitationList([close.closeRef], result.records, host);
|
|
805
|
+
if (close.peerCloseRef !== undefined) {
|
|
806
|
+
host.append(element("h5", "Peer record"));
|
|
807
|
+
renderCitationList([close.peerCloseRef], result.records, host);
|
|
808
|
+
}
|
|
809
|
+
if (close.links.length > 0) {
|
|
810
|
+
host.append(element("h5", "Links to the cited Close"));
|
|
811
|
+
const list = element("ul");
|
|
812
|
+
for (const link of close.links) {
|
|
813
|
+
const item = element("li", `${link.type} · ${link.recordId} · signer ${link.keyId} (verified)`);
|
|
814
|
+
item.dataset.closeLink = link.type;
|
|
815
|
+
item.dataset.linkRecord = link.recordId;
|
|
816
|
+
list.append(item);
|
|
817
|
+
}
|
|
818
|
+
host.append(list);
|
|
819
|
+
}
|
|
820
|
+
// Inbound links that made no state -- from the Close's own book, a book
|
|
821
|
+
// that is not the named peer, or the Close's own key -- are listed with
|
|
822
|
+
// the reason, never counted: a reader sees why AGREED was not read.
|
|
823
|
+
if (close.ignored.length > 0) {
|
|
824
|
+
host.append(element("h5", "Links ignored (not from the counterparty)"));
|
|
825
|
+
const list = element("ul");
|
|
826
|
+
for (const link of close.ignored) {
|
|
827
|
+
const item = element("li", `${link.type} · ${link.recordId} · ${link.reason}`);
|
|
828
|
+
item.dataset.ignoredLink = link.type;
|
|
829
|
+
item.dataset.linkRecord = link.recordId;
|
|
830
|
+
list.append(item);
|
|
831
|
+
}
|
|
832
|
+
host.append(list);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
// The verdict a reader sees is the Result's own only when every cited id
|
|
836
|
+
// resolves in this bundle and the claim stands. A claim that FAILED
|
|
837
|
+
// verification (its close state is not what the links read) reads
|
|
838
|
+
// `failed` -- never `met`, never its stated verdict, which is kept on
|
|
839
|
+
// `data-stated-verdict` only. Otherwise an unresolved citation reads
|
|
840
|
+
// `unsupported`. The row stays in every case.
|
|
841
|
+
function appendVerdict(parent, claim) {
|
|
842
|
+
const shown = claim.failed
|
|
843
|
+
? "failed"
|
|
844
|
+
: claim.support === "supported"
|
|
845
|
+
? claim.verdict
|
|
846
|
+
: "unsupported";
|
|
847
|
+
const cell = element(parent.tagName === "TR" ? "td" : "dd", shown);
|
|
848
|
+
cell.dataset.verdict = shown;
|
|
849
|
+
cell.dataset.support = claim.support;
|
|
850
|
+
if (claim.failed) {
|
|
851
|
+
cell.dataset.statedVerdict = claim.verdict;
|
|
852
|
+
cell.className = "claim-failed";
|
|
853
|
+
}
|
|
854
|
+
else if (claim.support === "unsupported")
|
|
855
|
+
cell.className = "claim-unsupported";
|
|
856
|
+
parent.append(cell);
|
|
857
|
+
return cell;
|
|
858
|
+
}
|
|
859
|
+
// A failed claim's sufficiency is the producer's word too: the cell reads
|
|
860
|
+
// `failed`, the stated value on `data-stated-sufficiency` only.
|
|
861
|
+
function appendSufficiency(parent, claim) {
|
|
862
|
+
const shown = claim.failed ? "failed" : claim.sufficiency;
|
|
863
|
+
const cell = element(parent.tagName === "TR" ? "td" : "dd", shown);
|
|
864
|
+
cell.dataset.sufficiency = shown;
|
|
865
|
+
if (claim.failed) {
|
|
866
|
+
cell.dataset.statedSufficiency = claim.sufficiency;
|
|
867
|
+
cell.className = "claim-failed";
|
|
868
|
+
}
|
|
869
|
+
parent.append(cell);
|
|
870
|
+
return cell;
|
|
871
|
+
}
|
|
872
|
+
function renderClaim(claim, result, host) {
|
|
873
|
+
host.replaceChildren();
|
|
874
|
+
host.append(element("h3", `Claim ${claim.id}`));
|
|
875
|
+
const details = element("dl");
|
|
876
|
+
appendValue(details, "contract", claim.contractRef);
|
|
877
|
+
appendValue(details, "requirement", claim.requirementRef);
|
|
878
|
+
details.append(element("dt", "type"));
|
|
879
|
+
const type = element("dd", CLAIM_TYPE_LABEL(claim));
|
|
880
|
+
type.dataset.claimType = claim.recognized ? claim.type : "unrecognized";
|
|
881
|
+
if (!claim.recognized)
|
|
882
|
+
type.className = "claim-unrecognized";
|
|
883
|
+
details.append(type);
|
|
884
|
+
appendValue(details, "tier", claim.tier);
|
|
885
|
+
appendValue(details, "grade", claim.grade);
|
|
886
|
+
details.append(element("dt", "sufficiency"));
|
|
887
|
+
appendSufficiency(details, claim);
|
|
888
|
+
details.append(element("dt", "verdict"));
|
|
889
|
+
appendVerdict(details, claim);
|
|
890
|
+
host.append(details);
|
|
891
|
+
if (claim.failed) {
|
|
892
|
+
const note = element("p", `failed: ${claim.failure ?? "verification failed"}; sufficiency and verdict withheld`);
|
|
893
|
+
note.dataset.claimFailed = claim.failedOn ?? "close_state";
|
|
894
|
+
host.append(note);
|
|
895
|
+
}
|
|
896
|
+
if (claim.close !== undefined)
|
|
897
|
+
renderClose(claim.close, result, host);
|
|
898
|
+
if (claim.support === "unsupported") {
|
|
899
|
+
const note = element("p", claim.missing.length === 0
|
|
900
|
+
? "unsupported: this claim cites no evidence"
|
|
901
|
+
: `unsupported: cited evidence not in this bundle: ${claim.missing.join(", ")}`);
|
|
902
|
+
note.dataset.claimMissing = String(claim.missing.length);
|
|
903
|
+
host.append(note);
|
|
904
|
+
}
|
|
905
|
+
host.append(element("h4", "Presentation"));
|
|
906
|
+
const carrier = element("dl");
|
|
907
|
+
appendValue(carrier, "carrier", claim.presentation.kind);
|
|
908
|
+
appendValue(carrier, "status", claim.presentation.status);
|
|
909
|
+
if (claim.presentation.summary !== undefined)
|
|
910
|
+
appendValue(carrier, "summary", claim.presentation.summary);
|
|
911
|
+
if (claim.presentation.narrative !== undefined)
|
|
912
|
+
appendValue(carrier, "narrative", claim.presentation.narrative);
|
|
913
|
+
host.append(carrier);
|
|
914
|
+
// A disclosure carrier names the digests it discloses. They are drawn in
|
|
915
|
+
// the same row shape as any other citation, resolved against this bundle;
|
|
916
|
+
// the claim's own `evidence[]` below is the list the verdict rests on.
|
|
917
|
+
if (claim.presentation.evidence !== undefined) {
|
|
918
|
+
host.append(element("h5", "Carrier evidence"));
|
|
919
|
+
const list = renderCitationList(claim.presentation.evidence, result.records, host);
|
|
920
|
+
list.dataset.carrierEvidence = String(claim.presentation.evidence.length);
|
|
921
|
+
}
|
|
922
|
+
host.append(element("h4", "Proofs"));
|
|
923
|
+
if (claim.proofs.length === 0) {
|
|
924
|
+
host.append(element("p", "no proof cited"));
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
const proofs = element("ul");
|
|
928
|
+
for (const proof of claim.proofs)
|
|
929
|
+
proofs.append(element("li", `${proof.kind} · ${proof.digest} · not resolved here`));
|
|
930
|
+
host.append(proofs);
|
|
931
|
+
}
|
|
932
|
+
host.append(element("h4", "Evidence"));
|
|
933
|
+
for (const ref of claim.evidence) {
|
|
934
|
+
const record = result.records.get(ref.digest);
|
|
935
|
+
if (record === undefined) {
|
|
936
|
+
const missing = element("p", `${ref.digest} · not in this bundle`);
|
|
937
|
+
missing.dataset.evidence = "missing";
|
|
938
|
+
host.append(missing);
|
|
939
|
+
}
|
|
940
|
+
else {
|
|
941
|
+
renderCitedRecord(record, result.records, host);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const BUCKETS = [
|
|
946
|
+
["met", "met"],
|
|
947
|
+
["notMet", "not met"],
|
|
948
|
+
["notEvaluable", "not evaluable"],
|
|
949
|
+
];
|
|
950
|
+
// Coverage is the first thing in the Result section after its heading, and
|
|
951
|
+
// no number precedes it within the section; then the three buckets, never a
|
|
952
|
+
// single figure; then one row per claim with its own tier and grade. The
|
|
953
|
+
// bundle-level verification banner, drawn before every section, is the one
|
|
954
|
+
// thing above coverage that can carry digits ("N of M records
|
|
955
|
+
// uncheckpointed"). Every number drawn here is the recomputed one
|
|
956
|
+
// (`result.coverage`, `result.bucketCounts`); a producer figure the claims
|
|
957
|
+
// do not bear out shows as a `count mismatch` marker beside the recomputed
|
|
958
|
+
// value. `excluded as not applicable` is the one figure carried as stated:
|
|
959
|
+
// no claim backs it, by construction.
|
|
960
|
+
function renderResultPage(result, root) {
|
|
961
|
+
const section = element("section");
|
|
962
|
+
section.dataset.page = "result";
|
|
963
|
+
section.append(element("h1", "Evidence result"));
|
|
964
|
+
const coverage = element("p");
|
|
965
|
+
coverage.append(`coverage: ${result.coverage.evaluatedPopulation} requirements evaluated`);
|
|
966
|
+
const evaluatedMarker = countMismatchMarker(result, "evaluated_population");
|
|
967
|
+
if (evaluatedMarker !== undefined)
|
|
968
|
+
coverage.append(" ", evaluatedMarker);
|
|
969
|
+
coverage.append(` · ${result.coverage.excludedNotApplicable} excluded as not applicable · ${result.coverage.unknownCount} unresolved`);
|
|
970
|
+
const unknownMarker = countMismatchMarker(result, "unknown_count");
|
|
971
|
+
if (unknownMarker !== undefined)
|
|
972
|
+
coverage.append(" ", unknownMarker);
|
|
973
|
+
coverage.dataset.coverage = "result";
|
|
974
|
+
coverage.dataset.evaluated = String(result.coverage.evaluatedPopulation);
|
|
975
|
+
coverage.dataset.excluded = String(result.coverage.excludedNotApplicable);
|
|
976
|
+
coverage.dataset.excludedBasis = "stated";
|
|
977
|
+
coverage.dataset.unknown = String(result.coverage.unknownCount);
|
|
978
|
+
section.append(coverage);
|
|
979
|
+
const byId = new Map(result.claims.map((claim) => [claim.id, claim]));
|
|
980
|
+
const buckets = element("section");
|
|
981
|
+
buckets.dataset.buckets = "verdict";
|
|
982
|
+
buckets.append(element("h2", "Claims by verdict"));
|
|
983
|
+
for (const [key, label] of BUCKETS) {
|
|
984
|
+
const count = result.bucketCounts[key];
|
|
985
|
+
const heading = element("h3", `${label}: ${count === 0 ? "none" : count}`);
|
|
986
|
+
heading.dataset.bucketCount = String(count);
|
|
987
|
+
heading.dataset.bucketOf = key;
|
|
988
|
+
const marker = countMismatchMarker(result, `buckets.${key === "notMet" ? "not_met" : key === "notEvaluable" ? "not_evaluable" : "met"}`);
|
|
989
|
+
if (marker !== undefined)
|
|
990
|
+
heading.append(" ", marker);
|
|
991
|
+
buckets.append(heading);
|
|
992
|
+
const ids = result.buckets[key];
|
|
993
|
+
if (ids.length === 0) {
|
|
994
|
+
buckets.append(element("p", "none"));
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
const list = element("ul");
|
|
998
|
+
list.dataset.bucket = key;
|
|
999
|
+
for (const id of ids) {
|
|
1000
|
+
const claim = byId.get(id);
|
|
1001
|
+
const item = element("li", claim?.failed
|
|
1002
|
+
? `${id} · failed`
|
|
1003
|
+
: claim?.support === "unsupported"
|
|
1004
|
+
? `${id} · unsupported`
|
|
1005
|
+
: id);
|
|
1006
|
+
item.dataset.claimRef = id;
|
|
1007
|
+
if (claim?.failed)
|
|
1008
|
+
item.className = "claim-failed";
|
|
1009
|
+
else if (claim?.support === "unsupported")
|
|
1010
|
+
item.className = "claim-unsupported";
|
|
1011
|
+
list.append(item);
|
|
1012
|
+
}
|
|
1013
|
+
buckets.append(list);
|
|
1014
|
+
}
|
|
1015
|
+
// The verifier's own state, after the producer's three buckets: claims
|
|
1016
|
+
// that failed verification (a close state the links contradict). Drawn
|
|
1017
|
+
// always, `none` when empty, so a reader never has to infer from an
|
|
1018
|
+
// absent heading that nothing failed. Not a verdict bucket: a failed
|
|
1019
|
+
// claim is counted here and under no verdict, whatever bucket the
|
|
1020
|
+
// producer listed it in (that listing stays above, marked).
|
|
1021
|
+
const failedHeading = element("h3", `failed: ${result.bucketCounts.failed === 0 ? "none" : result.bucketCounts.failed}`);
|
|
1022
|
+
failedHeading.dataset.bucketCount = String(result.bucketCounts.failed);
|
|
1023
|
+
failedHeading.dataset.bucketOf = "failed";
|
|
1024
|
+
buckets.append(failedHeading);
|
|
1025
|
+
const failedIds = result.claims
|
|
1026
|
+
.filter((claim) => claim.failed)
|
|
1027
|
+
.map((claim) => claim.id);
|
|
1028
|
+
if (failedIds.length === 0)
|
|
1029
|
+
buckets.append(element("p", "none"));
|
|
1030
|
+
else {
|
|
1031
|
+
const list = element("ul");
|
|
1032
|
+
list.dataset.bucket = "failed";
|
|
1033
|
+
for (const id of failedIds) {
|
|
1034
|
+
const item = element("li", id);
|
|
1035
|
+
item.dataset.claimRef = id;
|
|
1036
|
+
item.className = "claim-failed";
|
|
1037
|
+
list.append(item);
|
|
1038
|
+
}
|
|
1039
|
+
buckets.append(list);
|
|
1040
|
+
}
|
|
1041
|
+
section.append(buckets);
|
|
1042
|
+
const table = document.createElement("table");
|
|
1043
|
+
table.dataset.claims = "rows";
|
|
1044
|
+
const detail = element("section");
|
|
1045
|
+
for (const claim of result.claims) {
|
|
1046
|
+
const tr = document.createElement("tr");
|
|
1047
|
+
tr.dataset.claimRow = claim.id;
|
|
1048
|
+
tr.dataset.support = claim.support;
|
|
1049
|
+
if (claim.support === "unsupported")
|
|
1050
|
+
tr.className = "claim-unsupported";
|
|
1051
|
+
if (claim.failed) {
|
|
1052
|
+
tr.classList.add("claim-failed");
|
|
1053
|
+
tr.dataset.failed = claim.failedOn ?? "close_state";
|
|
1054
|
+
}
|
|
1055
|
+
const idCell = document.createElement("td");
|
|
1056
|
+
const button = element("button", claim.id);
|
|
1057
|
+
button.setAttribute("type", "button");
|
|
1058
|
+
button.dataset.claimId = claim.id;
|
|
1059
|
+
button.addEventListener("click", () => renderClaim(claim, result, detail));
|
|
1060
|
+
idCell.append(button);
|
|
1061
|
+
tr.append(idCell, element("td", claim.requirementRef));
|
|
1062
|
+
appendSufficiency(tr, claim);
|
|
1063
|
+
appendVerdict(tr, claim);
|
|
1064
|
+
const tier = element("td", claim.tier);
|
|
1065
|
+
tier.dataset.tier = claim.tier;
|
|
1066
|
+
const grade = element("td", claim.grade);
|
|
1067
|
+
grade.dataset.grade = claim.grade;
|
|
1068
|
+
const type = element("td", CLAIM_TYPE_LABEL(claim));
|
|
1069
|
+
type.dataset.claimType = claim.recognized ? claim.type : "unrecognized";
|
|
1070
|
+
if (!claim.recognized)
|
|
1071
|
+
type.className = "claim-unrecognized";
|
|
1072
|
+
if (claim.close !== undefined) {
|
|
1073
|
+
type.append(" · ");
|
|
1074
|
+
appendCloseState(type, claim.close, "span");
|
|
1075
|
+
}
|
|
1076
|
+
tr.append(tier, grade, type);
|
|
1077
|
+
table.append(tr);
|
|
1078
|
+
}
|
|
1079
|
+
section.append(table, detail, renderProvenance(result.capsuleId, result.logCoordinates, result));
|
|
1080
|
+
root.append(section);
|
|
1081
|
+
}
|
|
1082
|
+
function renderGraph(graph, root, records) {
|
|
1083
|
+
const aggregate = element("section");
|
|
1084
|
+
aggregate.append(element("h1", "Evidence graph"));
|
|
1085
|
+
const metrics = element("dl");
|
|
1086
|
+
appendValue(metrics, "per-axis", graph.aggregate.perAxis);
|
|
1087
|
+
appendValue(metrics, "counts", graph.aggregate.counts);
|
|
1088
|
+
aggregate.append(metrics);
|
|
1089
|
+
root.append(aggregate, renderCalibration(graph.calibration));
|
|
1090
|
+
const calendar = element("section");
|
|
1091
|
+
calendar.append(element("h2", "Daily reports"));
|
|
1092
|
+
const detail = element("section");
|
|
1093
|
+
[...graph.reports]
|
|
1094
|
+
.sort((left, right) => left.date.localeCompare(right.date))
|
|
1095
|
+
.forEach((report) => {
|
|
1096
|
+
// The label is the report's date as the producer wrote it -- a bare
|
|
1097
|
+
// day stays a day, a timestamp stays a timestamp, and one that states
|
|
1098
|
+
// no zone is marked so rather than tiled under an assigned one.
|
|
1099
|
+
const tile = element("button");
|
|
1100
|
+
tile.append(renderTime(report.date), `: ${metRate(report)}`);
|
|
1101
|
+
tile.setAttribute("type", "button");
|
|
1102
|
+
tile.dataset.reportDate = report.date;
|
|
1103
|
+
tile.addEventListener("click", () => renderReport(report, detail, records));
|
|
1104
|
+
calendar.append(tile);
|
|
1105
|
+
});
|
|
1106
|
+
root.append(calendar, detail);
|
|
1107
|
+
}
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
// The built-in presentations (spec/presentation-contract-v0.md appendix A).
|
|
1110
|
+
// Which one renders is decided by their manifests (builtin-manifests.ts),
|
|
1111
|
+
// never here: each module only builds its model and draws it.
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
// A model built by canRender is kept for buildModel, per context, so a
|
|
1114
|
+
// module never builds twice and canRender stays the one place that decides.
|
|
1115
|
+
function memo(build) {
|
|
1116
|
+
const cache = new WeakMap();
|
|
1117
|
+
return (context) => {
|
|
1118
|
+
let value = cache.get(context);
|
|
1119
|
+
if (value === undefined) {
|
|
1120
|
+
value = build(context);
|
|
1121
|
+
cache.set(context, value);
|
|
1122
|
+
}
|
|
1123
|
+
return value;
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
const reportRowsOf = memo(buildReportRows);
|
|
1127
|
+
// A Result root whose document is malformed makes buildResultRoot throw. The
|
|
1128
|
+
// throw rejects renderEvidenceGraph before anything is written, exactly as
|
|
1129
|
+
// before the registry: a malformed Result is refused, never rendered as one.
|
|
1130
|
+
const resultRootOf = memo(async (context) => (await isResultRoot(context)) ? buildResultRoot(context) : undefined);
|
|
1131
|
+
// EvidenceGraphError means "no evaluation-summary graph here" (the module
|
|
1132
|
+
// declines and the fallback renders); any other error propagates.
|
|
1133
|
+
const evidenceGraphOf = memo(async (context) => {
|
|
1134
|
+
try {
|
|
1135
|
+
return await buildEvidenceGraph(context);
|
|
1136
|
+
}
|
|
1137
|
+
catch (err) {
|
|
1138
|
+
if (!(err instanceof EvidenceGraphError))
|
|
1139
|
+
throw err;
|
|
1140
|
+
return undefined;
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
function defined(value, id) {
|
|
1144
|
+
if (value === undefined)
|
|
1145
|
+
throw new Error(`${id}: buildModel called although canRender was false`);
|
|
1146
|
+
return value;
|
|
1147
|
+
}
|
|
1148
|
+
const reportRowsModule = {
|
|
1149
|
+
manifest: BUILTIN_MANIFEST_REPORT_ROWS,
|
|
1150
|
+
canRender: async (context) => (await reportRowsOf(context)) !== undefined,
|
|
1151
|
+
buildModel: async (context) => defined(await reportRowsOf(context), BUILTIN_MANIFEST_REPORT_ROWS.id),
|
|
1152
|
+
render: (model, host) => renderReportRowsTable(model, host.L1),
|
|
1153
|
+
};
|
|
1154
|
+
const resultOutcomeReportModule = {
|
|
1155
|
+
manifest: BUILTIN_MANIFEST_RESULT_OUTCOME_REPORT,
|
|
1156
|
+
canRender: async (context) => readOutcomeReportPresentation(context.bundle) !== undefined &&
|
|
1157
|
+
(await isResultRoot(context)),
|
|
1158
|
+
buildModel: async (context) => ({
|
|
1159
|
+
result: defined(await resultRootOf(context), BUILTIN_MANIFEST_RESULT_OUTCOME_REPORT.id),
|
|
1160
|
+
card: defined(readOutcomeReportPresentation(context.bundle), BUILTIN_MANIFEST_RESULT_OUTCOME_REPORT.id),
|
|
1161
|
+
bundle: context.bundle,
|
|
1162
|
+
verification: context.verification,
|
|
1163
|
+
}),
|
|
1164
|
+
render: async (model, host) => {
|
|
1165
|
+
// The banner and the verification page take the card's look.
|
|
1166
|
+
host.setChromeClass("oi");
|
|
1167
|
+
await renderOutcomeReportPage(model.result, model.card, model.bundle, model.verification, host.L1);
|
|
1168
|
+
},
|
|
1169
|
+
};
|
|
1170
|
+
const resultComplianceModule = {
|
|
1171
|
+
manifest: BUILTIN_MANIFEST_RESULT_COMPLIANCE,
|
|
1172
|
+
canRender: async (context) => readCompliancePresentation(context.bundle) !== undefined &&
|
|
1173
|
+
(await isResultRoot(context)),
|
|
1174
|
+
buildModel: async (context) => ({
|
|
1175
|
+
result: defined(await resultRootOf(context), BUILTIN_MANIFEST_RESULT_COMPLIANCE.id),
|
|
1176
|
+
card: defined(readCompliancePresentation(context.bundle), BUILTIN_MANIFEST_RESULT_COMPLIANCE.id),
|
|
1177
|
+
bundle: context.bundle,
|
|
1178
|
+
verification: context.verification,
|
|
1179
|
+
}),
|
|
1180
|
+
render: (model, host) => renderCompliancePage(model.result, model.card, model.bundle, model.verification, host.L1),
|
|
1181
|
+
};
|
|
1182
|
+
const resultModule = {
|
|
1183
|
+
manifest: BUILTIN_MANIFEST_RESULT,
|
|
1184
|
+
canRender: (context) => isResultRoot(context),
|
|
1185
|
+
buildModel: async (context) => defined(await resultRootOf(context), BUILTIN_MANIFEST_RESULT.id),
|
|
1186
|
+
render: (model, host) => renderResultPage(model, host.L1),
|
|
1187
|
+
};
|
|
1188
|
+
const evaluationSummaryGraphModule = {
|
|
1189
|
+
manifest: BUILTIN_MANIFEST_EVALUATION_SUMMARY_GRAPH,
|
|
1190
|
+
canRender: async (context) => (await evidenceGraphOf(context)) !== undefined,
|
|
1191
|
+
buildModel: async (context) => {
|
|
1192
|
+
const records = object(context.bundle).records;
|
|
1193
|
+
return {
|
|
1194
|
+
graph: defined(await evidenceGraphOf(context), BUILTIN_MANIFEST_EVALUATION_SUMMARY_GRAPH.id),
|
|
1195
|
+
records: Array.isArray(records) ? records : [],
|
|
1196
|
+
};
|
|
1197
|
+
},
|
|
1198
|
+
render: (model, host) => renderGraph(model.graph, host.L1, model.records),
|
|
1199
|
+
};
|
|
1200
|
+
// The evaluation-summary/v1 aggregate is optional: a verified bundle whose
|
|
1201
|
+
// root is none of the other families (a deal root, for example) renders
|
|
1202
|
+
// without the aggregate panel and says so, instead of rendering nothing.
|
|
1203
|
+
const noAggregateModule = {
|
|
1204
|
+
manifest: BUILTIN_MANIFEST_NO_AGGREGATE,
|
|
1205
|
+
canRender: () => true,
|
|
1206
|
+
buildModel: () => null,
|
|
1207
|
+
render: (_model, host) => {
|
|
1208
|
+
const note = document.createElement("p");
|
|
1209
|
+
note.setAttribute("data-notice", "no-aggregate");
|
|
1210
|
+
note.textContent =
|
|
1211
|
+
"This bundle carries no evaluation summary, so there is no aggregate view. The records and their verification are below.";
|
|
1212
|
+
host.L1.append(note);
|
|
1213
|
+
},
|
|
1214
|
+
};
|
|
1215
|
+
// The extension interpreters each built-in module runs when it renders: a
|
|
1216
|
+
// Result page reads the book payload extensions, and each card reads its own
|
|
1217
|
+
// block. The rows table, the graph and the no-aggregate note read none.
|
|
1218
|
+
const BUILTIN_MODULE_INTERPRETERS = Object.freeze({
|
|
1219
|
+
[BUILTIN_MANIFEST_RESULT_OUTCOME_REPORT.id]: [
|
|
1220
|
+
"result-root",
|
|
1221
|
+
"outcome-report-card",
|
|
1222
|
+
],
|
|
1223
|
+
[BUILTIN_MANIFEST_RESULT_COMPLIANCE.id]: ["result-root", "compliance-card"],
|
|
1224
|
+
[BUILTIN_MANIFEST_RESULT.id]: ["result-root"],
|
|
1225
|
+
[BUILTIN_MANIFEST_COMPOSED.id]: ["aac.builtin.composed/v0"],
|
|
1226
|
+
});
|
|
1227
|
+
/** The six built-in modules, registered by default. */
|
|
1228
|
+
export const BUILTIN_PRESENTATIONS = Object.freeze([
|
|
1229
|
+
reportRowsModule,
|
|
1230
|
+
resultOutcomeReportModule,
|
|
1231
|
+
resultComplianceModule,
|
|
1232
|
+
resultModule,
|
|
1233
|
+
evaluationSummaryGraphModule,
|
|
1234
|
+
noAggregateModule,
|
|
1235
|
+
]);
|
|
1236
|
+
/** A fresh registry holding the six built-in modules. */
|
|
1237
|
+
export function createPresentationRegistry() {
|
|
1238
|
+
const registry = new PresentationRegistry();
|
|
1239
|
+
for (const module of BUILTIN_PRESENTATIONS)
|
|
1240
|
+
registry.register(module);
|
|
1241
|
+
return registry;
|
|
1242
|
+
}
|
|
1243
|
+
const defaultRegistry = createPresentationRegistry();
|
|
1244
|
+
/**
|
|
1245
|
+
* The built-in sections: modules resolved after the page module, in their
|
|
1246
|
+
* own registry, and rendered after its content. One today, the composition
|
|
1247
|
+
* section; see BUILTIN_MANIFEST_COMPOSED for why it is not a page module.
|
|
1248
|
+
*/
|
|
1249
|
+
export const BUILTIN_SECTIONS = Object.freeze([
|
|
1250
|
+
composedSectionModule,
|
|
1251
|
+
]);
|
|
1252
|
+
/**
|
|
1253
|
+
* A fresh section registry holding the built-in sections. The section
|
|
1254
|
+
* registry applies the presentation ABI as the page registry does: under a
|
|
1255
|
+
* `runtime` that refuses a section, the section is registered as refused,
|
|
1256
|
+
* never drawn, and every page it matches names it.
|
|
1257
|
+
*/
|
|
1258
|
+
export function createSectionRegistry(runtime) {
|
|
1259
|
+
const registry = new PresentationRegistry(runtime);
|
|
1260
|
+
for (const module of BUILTIN_SECTIONS)
|
|
1261
|
+
registry.register(module);
|
|
1262
|
+
return registry;
|
|
1263
|
+
}
|
|
1264
|
+
const defaultSections = createSectionRegistry();
|
|
1265
|
+
/**
|
|
1266
|
+
* Register a module with the registry `renderEvidenceGraph` uses by default
|
|
1267
|
+
* (the module-slot entry point). Throws when its manifest is malformed,
|
|
1268
|
+
* dead, a duplicate, or co-matchable with a registered module of its tier.
|
|
1269
|
+
* A module whose `presentation_api` this runtime does not implement (or
|
|
1270
|
+
* whose `runtime_min` it does not meet) is registered as refused: it is
|
|
1271
|
+
* never selected, and every page it would have rendered names it.
|
|
1272
|
+
*/
|
|
1273
|
+
export function registerPresentation(module) {
|
|
1274
|
+
return defaultRegistry.register(module);
|
|
1275
|
+
}
|
|
1276
|
+
/** Resolve over the default registry (spec section 4.3). */
|
|
1277
|
+
export function resolvePresentation(context, audience = DEFAULT_AUDIENCE, format = DEFAULT_FORMAT) {
|
|
1278
|
+
return defaultRegistry.resolve(context, audience, format);
|
|
1279
|
+
}
|
|
1280
|
+
/** `"*"`: no particular audience, so only modules serving every audience. */
|
|
1281
|
+
const DEFAULT_AUDIENCE = "*";
|
|
1282
|
+
const DEFAULT_FORMAT = "html";
|
|
1283
|
+
/**
|
|
1284
|
+
* True when the rendered page module checked the producer signature of the
|
|
1285
|
+
* records it cites as signers: the built-in Result page, on a Close (or a
|
|
1286
|
+
* record linking to it) that carries a `key_id` (result-root.ts `signerOf`).
|
|
1287
|
+
*/
|
|
1288
|
+
function citesCheckedSigners(selected) {
|
|
1289
|
+
if (selected === undefined || selected.module !== resultModule)
|
|
1290
|
+
return false;
|
|
1291
|
+
return selected.model.claims.some((claim) => claim.close !== undefined &&
|
|
1292
|
+
(claim.close.keyId !== undefined ||
|
|
1293
|
+
[...claim.close.links, ...claim.close.ignored].some((link) => link.keyId !== undefined)));
|
|
1294
|
+
}
|
|
1295
|
+
const OUTCOME_CHROME = "oi";
|
|
1296
|
+
function presentationServices(context) {
|
|
1297
|
+
return Object.freeze({
|
|
1298
|
+
details(summary, open = false) {
|
|
1299
|
+
const details = document.createElement("details");
|
|
1300
|
+
if (open)
|
|
1301
|
+
details.open = true;
|
|
1302
|
+
details.append(element("summary", summary));
|
|
1303
|
+
return details;
|
|
1304
|
+
},
|
|
1305
|
+
badge(state) {
|
|
1306
|
+
const badge = element("span", state);
|
|
1307
|
+
badge.dataset.badge = state;
|
|
1308
|
+
return badge;
|
|
1309
|
+
},
|
|
1310
|
+
disclosure(capsuleId, member) {
|
|
1311
|
+
return disclosureOf(context, capsuleId, member).state;
|
|
1312
|
+
},
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
// The refusal for a verified bundle whose presentation could not be resolved
|
|
1316
|
+
// (an ambiguity) or failed (a module threw while rendering): the core's
|
|
1317
|
+
// words, no module content, the verification page below (contract I1).
|
|
1318
|
+
const PRESENTATION_REFUSALS = {
|
|
1319
|
+
"presentation-unresolved": "The presentation for this bundle could not be resolved. No presentation content is shown; the verification page below lists the checks.",
|
|
1320
|
+
"presentation-failed": "The presentation for this bundle failed. No presentation content is shown; the verification page below lists the checks.",
|
|
1321
|
+
};
|
|
1322
|
+
export async function renderEvidenceGraph(input, root, countersigners, options = {}) {
|
|
1323
|
+
// With no SHA-256 nothing can be verified: say so, never "failed".
|
|
1324
|
+
if (!isVerifiedBundleContext(input) && digestCapability() === "none") {
|
|
1325
|
+
renderUnableToVerify(root, options.verifyCommand === undefined
|
|
1326
|
+
? {}
|
|
1327
|
+
: { verifyCommand: options.verifyCommand });
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
// Verify first. Nothing reaches the DOM until the verification result is
|
|
1331
|
+
// in hand, and no module is consulted for a bundle that did not verify.
|
|
1332
|
+
const context = isVerifiedBundleContext(input)
|
|
1333
|
+
? countersigners === undefined
|
|
1334
|
+
? input
|
|
1335
|
+
: withCountersigners(input, countersigners)
|
|
1336
|
+
: await buildVerifiedBundleContext(input, {
|
|
1337
|
+
...(countersigners === undefined ? {} : { countersigners }),
|
|
1338
|
+
});
|
|
1339
|
+
const { bundle, verification } = context;
|
|
1340
|
+
const verified = bundleVerified(verification);
|
|
1341
|
+
const registry = options.registry ?? defaultRegistry;
|
|
1342
|
+
let refusal;
|
|
1343
|
+
let selected;
|
|
1344
|
+
let resolution;
|
|
1345
|
+
try {
|
|
1346
|
+
resolution = await registry.resolve(context, options.audience ?? DEFAULT_AUDIENCE, options.format ?? DEFAULT_FORMAT);
|
|
1347
|
+
}
|
|
1348
|
+
catch (err) {
|
|
1349
|
+
if (!(err instanceof PresentationAmbiguityError))
|
|
1350
|
+
throw err;
|
|
1351
|
+
resolution = { kind: "no-presentation", refused: [] };
|
|
1352
|
+
refusal = "presentation-unresolved";
|
|
1353
|
+
}
|
|
1354
|
+
// A module this runtime refused (spec section 3.2) is never silent: the
|
|
1355
|
+
// page root names every one that matched, page and section alike, and the
|
|
1356
|
+
// verification page shows the contract's words in the extension rows
|
|
1357
|
+
// (presentationRefusalRow) or, for a module requiring no extension, in a
|
|
1358
|
+
// line after them (presentationRefusalLine).
|
|
1359
|
+
const refused = resolution.kind === "refusal" ? [] : [...resolution.refused];
|
|
1360
|
+
if (resolution.kind === "refusal" && verified)
|
|
1361
|
+
throw new Error("resolver refused a bundle that verified");
|
|
1362
|
+
if (resolution.kind === "module" && !verified)
|
|
1363
|
+
throw new Error("resolver selected a module for an unverified bundle");
|
|
1364
|
+
// A module's model is built before the page is written, so a model that
|
|
1365
|
+
// cannot be built rejects with nothing drawn (as it always has).
|
|
1366
|
+
if (resolution.kind === "module")
|
|
1367
|
+
selected = {
|
|
1368
|
+
module: resolution.module,
|
|
1369
|
+
model: await resolution.module.buildModel(context),
|
|
1370
|
+
};
|
|
1371
|
+
// The section registry is resolved the same way, after the page and only
|
|
1372
|
+
// for a verified bundle whose page resolved: a section never stands in for
|
|
1373
|
+
// a refusal (contract I1), and its ambiguity is the page's refusal too.
|
|
1374
|
+
let section;
|
|
1375
|
+
if (verified && refusal === undefined) {
|
|
1376
|
+
let sectionResolution;
|
|
1377
|
+
try {
|
|
1378
|
+
sectionResolution = await (options.sections ?? defaultSections).resolve(context, options.audience ?? DEFAULT_AUDIENCE, options.format ?? DEFAULT_FORMAT);
|
|
1379
|
+
}
|
|
1380
|
+
catch (err) {
|
|
1381
|
+
if (!(err instanceof PresentationAmbiguityError))
|
|
1382
|
+
throw err;
|
|
1383
|
+
sectionResolution = { kind: "no-presentation", refused: [] };
|
|
1384
|
+
selected = undefined;
|
|
1385
|
+
refusal = "presentation-unresolved";
|
|
1386
|
+
}
|
|
1387
|
+
// A refused section is named like a refused page module, never skipped.
|
|
1388
|
+
if (sectionResolution.kind !== "refusal")
|
|
1389
|
+
refused.push(...sectionResolution.refused);
|
|
1390
|
+
if (sectionResolution.kind === "module")
|
|
1391
|
+
section = {
|
|
1392
|
+
module: sectionResolution.module,
|
|
1393
|
+
model: await sectionResolution.module.buildModel(context),
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
const records = object(bundle).records;
|
|
1397
|
+
const coverage = {
|
|
1398
|
+
uncheckpointed: unboundRecordIds(verification).length,
|
|
1399
|
+
total: Array.isArray(records) ? records.length : 0,
|
|
1400
|
+
};
|
|
1401
|
+
const drawFrame = () => {
|
|
1402
|
+
root.replaceChildren();
|
|
1403
|
+
renderPresentationHeader(root, bundle);
|
|
1404
|
+
return renderVerificationBanner(root, verified, coverage);
|
|
1405
|
+
};
|
|
1406
|
+
let banner = drawFrame();
|
|
1407
|
+
if (refused.length > 0)
|
|
1408
|
+
root.dataset.presentationRefused = refused.map((r) => r.id).join(" ");
|
|
1409
|
+
else
|
|
1410
|
+
delete root.dataset.presentationRefused;
|
|
1411
|
+
let chrome;
|
|
1412
|
+
if (selected !== undefined) {
|
|
1413
|
+
const host = {
|
|
1414
|
+
L0: root,
|
|
1415
|
+
L1: root,
|
|
1416
|
+
L2: root,
|
|
1417
|
+
depth: options.depth ?? "L2",
|
|
1418
|
+
setChromeClass(name) {
|
|
1419
|
+
if (name !== OUTCOME_CHROME)
|
|
1420
|
+
throw new Error(`unknown chrome class ${JSON.stringify(name)}`);
|
|
1421
|
+
chrome = name;
|
|
1422
|
+
styleBanner(banner, verified);
|
|
1423
|
+
},
|
|
1424
|
+
};
|
|
1425
|
+
try {
|
|
1426
|
+
await selected.module.render(selected.model, host, presentationServices(context));
|
|
1427
|
+
}
|
|
1428
|
+
catch {
|
|
1429
|
+
// Everything the module wrote is discarded; no other module is tried.
|
|
1430
|
+
chrome = undefined;
|
|
1431
|
+
banner = drawFrame();
|
|
1432
|
+
refusal = "presentation-failed";
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (refusal === undefined && resolution.kind === "no-presentation") {
|
|
1436
|
+
const note = element("p", "No presentation is available for this bundle, audience and format. The records and their verification are below.");
|
|
1437
|
+
note.dataset.notice = "no-presentation";
|
|
1438
|
+
root.append(note);
|
|
1439
|
+
}
|
|
1440
|
+
// The section renders after the page module, into the same regions. If it
|
|
1441
|
+
// throws, everything any module wrote is discarded for the refusal.
|
|
1442
|
+
if (section !== undefined && refusal === undefined) {
|
|
1443
|
+
try {
|
|
1444
|
+
await section.module.render(section.model, {
|
|
1445
|
+
L0: root,
|
|
1446
|
+
L1: root,
|
|
1447
|
+
L2: root,
|
|
1448
|
+
depth: "L2",
|
|
1449
|
+
setChromeClass() {
|
|
1450
|
+
throw new Error("a section has no chrome");
|
|
1451
|
+
},
|
|
1452
|
+
}, presentationServices(context));
|
|
1453
|
+
}
|
|
1454
|
+
catch {
|
|
1455
|
+
chrome = undefined;
|
|
1456
|
+
banner = drawFrame();
|
|
1457
|
+
refusal = "presentation-failed";
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
if (refusal !== undefined) {
|
|
1461
|
+
const note = element("p", PRESENTATION_REFUSALS[refusal]);
|
|
1462
|
+
note.dataset.refusal = refusal;
|
|
1463
|
+
root.append(note);
|
|
1464
|
+
}
|
|
1465
|
+
// The interpreters this rendering actually ran (see extension-interpreters.ts):
|
|
1466
|
+
// the presentation header and the countersignature stamp always run; a
|
|
1467
|
+
// module's own interpreters only when that module rendered.
|
|
1468
|
+
const applied = new Set([
|
|
1469
|
+
"presentation-header",
|
|
1470
|
+
"countersignature-stamp",
|
|
1471
|
+
]);
|
|
1472
|
+
if (refusal === undefined)
|
|
1473
|
+
for (const ran of [selected, section])
|
|
1474
|
+
if (ran !== undefined)
|
|
1475
|
+
for (const id of BUILTIN_MODULE_INTERPRETERS[ran.module.manifest.id] ??
|
|
1476
|
+
[])
|
|
1477
|
+
applied.add(id);
|
|
1478
|
+
await renderVerificationPage(root, context, chrome === OUTCOME_CHROME, applied, refused, refusal === undefined && citesCheckedSigners(selected), options.verifierHint);
|
|
1479
|
+
}
|
|
1480
|
+
//# sourceMappingURL=evidence-graph-view.js.map
|