@knolo/cli 6.1.0 → 6.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1156 @@
1
+ //! V5 KAR session, verification, and dossier rendering.
2
+ //! SATISFIED means the evidence requirements were met. It does not mean a claim is proven.
3
+
4
+ use crate::kar::closure::{close_frontiers, witness_json};
5
+ use crate::kar::cover::covers_plan;
6
+ use crate::kar::evaluate::{certificate_json, evaluate_bound, status_code, BoundInputs, KarResult};
7
+ use crate::kar::json::{canonicalize, digest_json, parse_json, stringify, Json};
8
+ use crate::kar::model::{
9
+ build_indexes, graph_json, is_root, only_keys, plan_json, prepare_graph, prepare_image,
10
+ semantic_root_of, validate_plan, Graph, Indexes, Plan, PreparedImage, FRONTIERS, KAR_API,
11
+ KAR_VERSION,
12
+ };
13
+ use crate::{mount_knowledge_image, CborValue};
14
+
15
+ const DOSSIER_FORMAT: &str = "kar-evidence-dossier-1";
16
+ const STATUSES: [&str; 8] = [
17
+ "SATISFIED",
18
+ "UNSATISFIED_EVIDENCE_REQUIREMENTS",
19
+ "SEARCH_BOUND_EXCEEDED",
20
+ "CLOSURE_BOUND_EXCEEDED",
21
+ "ANCHOR_REJECTED",
22
+ "GRAPH_NOT_BOUND",
23
+ "GRAPH_INVALID",
24
+ "PLAN_INVALID",
25
+ ];
26
+ const ROOT_FIELDS: [&str; 10] = [
27
+ "knowledgeRoot",
28
+ "semanticRoot",
29
+ "queryRoot",
30
+ "planRoot",
31
+ "anchorRoot",
32
+ "frontierRoot",
33
+ "frontierWitnessRoot",
34
+ "evidenceSetRoot",
35
+ "decisionRoot",
36
+ "karRoot",
37
+ ];
38
+
39
+ #[derive(Debug, Clone)]
40
+ pub struct KarRunError {
41
+ pub code: String,
42
+ pub message: String,
43
+ }
44
+
45
+ #[derive(Debug, Clone)]
46
+ pub struct KarEvaluation {
47
+ pub status: String,
48
+ pub code: Option<String>,
49
+ pub proposition: String,
50
+ pub anchor_mode: Option<String>,
51
+ pub frontiers: Vec<(String, Vec<String>)>,
52
+ pub selected_len: usize,
53
+ pub knowledge_root: String,
54
+ pub semantic_root: String,
55
+ pub kar_root: String,
56
+ pub json: String,
57
+ }
58
+
59
+ #[derive(Debug, Clone)]
60
+ pub struct KarVerification {
61
+ pub ok: bool,
62
+ pub code: String,
63
+ pub reason: String,
64
+ pub status: Option<String>,
65
+ }
66
+
67
+ #[derive(Debug, Clone)]
68
+ pub struct KarDossier {
69
+ pub status_statement: String,
70
+ pub markdown: String,
71
+ pub manifest_json: String,
72
+ pub certificate_json: String,
73
+ pub evaluation_json: String,
74
+ }
75
+
76
+ struct ObjectRow {
77
+ id: String,
78
+ text: String,
79
+ source: Option<String>,
80
+ metadata: Json,
81
+ }
82
+
83
+ struct Session {
84
+ image: Json,
85
+ graph: Graph,
86
+ prepared: PreparedImage,
87
+ indexes: Indexes,
88
+ objects: Vec<ObjectRow>,
89
+ semantic_root: String,
90
+ plan_input: Json,
91
+ }
92
+
93
+ pub fn evaluate_kar_v5(
94
+ image: &[u8],
95
+ sidecar_json: &str,
96
+ proposition: &str,
97
+ plan_json_text: &str,
98
+ ) -> Result<KarEvaluation, KarRunError> {
99
+ let session = open_session(image, sidecar_json, plan_json_text)?;
100
+ Ok(evaluation_from(&session, proposition))
101
+ }
102
+
103
+ pub fn verify_kar_v5(
104
+ image: &[u8],
105
+ sidecar_json: &str,
106
+ proposition: &str,
107
+ plan_json_text: &str,
108
+ claimed_json: &str,
109
+ ) -> KarVerification {
110
+ let session = match open_session(image, sidecar_json, plan_json_text) {
111
+ Ok(session) => session,
112
+ Err(error) => {
113
+ return KarVerification {
114
+ ok: false,
115
+ code: error.code,
116
+ reason: error.message,
117
+ status: None,
118
+ }
119
+ }
120
+ };
121
+ let actual = evaluation_from(&session, proposition);
122
+ compare_claim(&actual, claimed_json, proposition)
123
+ }
124
+
125
+ pub fn create_kar_dossier_v5(
126
+ image: &[u8],
127
+ sidecar_json: &str,
128
+ proposition: &str,
129
+ plan_json_text: &str,
130
+ ) -> Result<KarDossier, KarRunError> {
131
+ let session = open_session(image, sidecar_json, plan_json_text)?;
132
+ Ok(dossier_from(&session, proposition))
133
+ }
134
+
135
+ pub fn render_kar_dossier(dossier: &KarDossier) -> String {
136
+ dossier.markdown.clone()
137
+ }
138
+
139
+ pub fn verify_kar_dossier_v5(
140
+ image: &[u8],
141
+ sidecar_json: &str,
142
+ proposition: &str,
143
+ plan_json_text: &str,
144
+ manifest_json: &str,
145
+ certificate_json_text: &str,
146
+ evaluation_json: &str,
147
+ evidence_markdown: Option<&str>,
148
+ ) -> KarVerification {
149
+ let verdict = verify_kar_v5(
150
+ image,
151
+ sidecar_json,
152
+ proposition,
153
+ plan_json_text,
154
+ certificate_json_text,
155
+ );
156
+ if !verdict.ok {
157
+ return verdict;
158
+ }
159
+ let actual = match create_kar_dossier_v5(image, sidecar_json, proposition, plan_json_text) {
160
+ Ok(dossier) => dossier,
161
+ Err(error) => {
162
+ return KarVerification {
163
+ ok: false,
164
+ code: error.code,
165
+ reason: error.message,
166
+ status: None,
167
+ }
168
+ }
169
+ };
170
+ let claimed_manifest = match parse_json(manifest_json) {
171
+ Ok(value) => value,
172
+ Err(error) => {
173
+ return KarVerification {
174
+ ok: false,
175
+ code: "KAR_RESULT_MISMATCH".into(),
176
+ reason: error,
177
+ status: Some(actual.status_statement.clone()),
178
+ }
179
+ }
180
+ };
181
+ let actual_manifest = parse_json(&actual.manifest_json).expect("dossier manifest is JSON");
182
+ if canonicalize(&claimed_manifest) != canonicalize(&actual_manifest) {
183
+ return KarVerification {
184
+ ok: false,
185
+ code: "KAR_RESULT_MISMATCH".into(),
186
+ reason: "dossier manifest mismatch".into(),
187
+ status: Some(status_of(&actual)),
188
+ };
189
+ }
190
+ let claimed_certificate = match parse_json(certificate_json_text) {
191
+ Ok(value) => value,
192
+ Err(error) => {
193
+ return KarVerification {
194
+ ok: false,
195
+ code: "KAR_RESULT_MISMATCH".into(),
196
+ reason: error,
197
+ status: Some(status_of(&actual)),
198
+ }
199
+ }
200
+ };
201
+ let actual_certificate =
202
+ parse_json(&actual.certificate_json).expect("dossier certificate is JSON");
203
+ if canonicalize(&claimed_certificate) != canonicalize(&actual_certificate) {
204
+ return KarVerification {
205
+ ok: false,
206
+ code: "KAR_RESULT_MISMATCH".into(),
207
+ reason: "dossier certificate mismatch".into(),
208
+ status: Some(status_of(&actual)),
209
+ };
210
+ }
211
+ let claimed_evaluation = match parse_json(evaluation_json) {
212
+ Ok(value) => value,
213
+ Err(error) => {
214
+ return KarVerification {
215
+ ok: false,
216
+ code: "KAR_RESULT_MISMATCH".into(),
217
+ reason: error,
218
+ status: Some(status_of(&actual)),
219
+ }
220
+ }
221
+ };
222
+ let actual_evaluation =
223
+ parse_json(&actual.evaluation_json).expect("dossier evaluation is JSON");
224
+ if canonicalize(&claimed_evaluation) != canonicalize(&actual_evaluation) {
225
+ return KarVerification {
226
+ ok: false,
227
+ code: "KAR_RESULT_MISMATCH".into(),
228
+ reason: "dossier result mismatch".into(),
229
+ status: Some(status_of(&actual)),
230
+ };
231
+ }
232
+ if let Some(markdown) = evidence_markdown {
233
+ if markdown != actual.markdown {
234
+ return KarVerification {
235
+ ok: false,
236
+ code: "KAR_RESULT_MISMATCH".into(),
237
+ reason: "dossier rendering mismatch".into(),
238
+ status: Some(status_of(&actual)),
239
+ };
240
+ }
241
+ }
242
+ KarVerification {
243
+ ok: true,
244
+ code: "VERIFIED".into(),
245
+ reason: String::new(),
246
+ status: Some(status_of(&actual)),
247
+ }
248
+ }
249
+
250
+ fn status_of(dossier: &KarDossier) -> String {
251
+ parse_json(&dossier.evaluation_json)
252
+ .ok()
253
+ .and_then(|value| {
254
+ value
255
+ .get("status")
256
+ .and_then(Json::as_str)
257
+ .map(str::to_string)
258
+ })
259
+ .unwrap_or_default()
260
+ }
261
+
262
+ fn open_session(
263
+ image: &[u8],
264
+ sidecar_json: &str,
265
+ plan_json_text: &str,
266
+ ) -> Result<Session, KarRunError> {
267
+ let mounted = mount_knowledge_image(image).map_err(|error| KarRunError {
268
+ code: "KAR_IMAGE_REJECTED".into(),
269
+ message: error.to_string(),
270
+ })?;
271
+ let sidecar = parse_json(sidecar_json).map_err(|error| KarRunError {
272
+ code: "KAR_SIDECAR_INVALID".into(),
273
+ message: error,
274
+ })?;
275
+ let plan_input = parse_json(plan_json_text).map_err(|error| KarRunError {
276
+ code: "KAR_PLAN_INVALID".into(),
277
+ message: error,
278
+ })?;
279
+ let sidecar_value = validate_sidecar(&sidecar)?;
280
+ let mut rows = Vec::new();
281
+ for object in &mounted.objects {
282
+ let text = decode_object_text(&object.bytes);
283
+ let meta = cbor_to_json(&object.meta).map_err(|error| KarRunError {
284
+ code: "KAR_IMAGE_REJECTED".into(),
285
+ message: error,
286
+ })?;
287
+ let source = object_source(&object.kind, &meta);
288
+ rows.push(ObjectRow {
289
+ id: object.id.clone(),
290
+ text,
291
+ source,
292
+ metadata: Json::object(vec![
293
+ ("kind", Json::String(object.kind.clone())),
294
+ ("meta", meta),
295
+ ]),
296
+ });
297
+ }
298
+ rows.sort_by(|left, right| crate::kar::json::utf16_cmp(&left.id, &right.id));
299
+ let prepared = prepare_image(
300
+ rows.iter()
301
+ .map(|row| (row.id.clone(), row.text.clone()))
302
+ .collect(),
303
+ );
304
+ let binding = binding_error(
305
+ &sidecar_value,
306
+ &prepared,
307
+ &mounted.state_root,
308
+ &mounted.commit.object_root,
309
+ &mounted.commit_digest,
310
+ );
311
+ if let Some(error) = binding {
312
+ return Err(error);
313
+ }
314
+ let indexes = build_indexes(&sidecar_value.graph);
315
+ let semantic_root = semantic_root_of(&sidecar_value.graph);
316
+ let image_identity = Json::object(vec![
317
+ ("commitDigest", Json::String(mounted.commit_digest.clone())),
318
+ (
319
+ "knowledgeRoot",
320
+ Json::String(prepared.knowledge_root.clone()),
321
+ ),
322
+ (
323
+ "objectRoot",
324
+ Json::String(mounted.commit.object_root.clone()),
325
+ ),
326
+ ("stateRoot", Json::String(mounted.state_root.clone())),
327
+ ]);
328
+ Ok(Session {
329
+ image: image_identity,
330
+ graph: sidecar_value.graph,
331
+ prepared,
332
+ indexes,
333
+ objects: rows,
334
+ semantic_root,
335
+ plan_input,
336
+ })
337
+ }
338
+
339
+ struct Sidecar {
340
+ state_root: String,
341
+ object_root: String,
342
+ commit_digest: String,
343
+ knowledge_root: String,
344
+ graph: Graph,
345
+ }
346
+
347
+ fn validate_sidecar(input: &Json) -> Result<Sidecar, KarRunError> {
348
+ let Some(raw) = input.as_object() else {
349
+ return Err(KarRunError {
350
+ code: "KAR_SIDECAR_INVALID".into(),
351
+ message: "A KAR sidecar must be an object.".into(),
352
+ });
353
+ };
354
+ if !only_keys(
355
+ raw,
356
+ &[
357
+ "version",
358
+ "stateRoot",
359
+ "objectRoot",
360
+ "commitDigest",
361
+ "knowledgeRoot",
362
+ "graph",
363
+ ],
364
+ ) || input.get("version").and_then(Json::as_int) != Some(1)
365
+ {
366
+ return Err(KarRunError {
367
+ code: "KAR_SIDECAR_INVALID".into(),
368
+ message: "Sidecar version must be 1.".into(),
369
+ });
370
+ }
371
+ for field in ["stateRoot", "objectRoot", "commitDigest", "knowledgeRoot"] {
372
+ let Some(value) = input.get(field).and_then(Json::as_str) else {
373
+ return Err(KarRunError {
374
+ code: "KAR_SIDECAR_INVALID".into(),
375
+ message: format!("{field} must be a sha256 digest."),
376
+ });
377
+ };
378
+ if !is_root(value) {
379
+ return Err(KarRunError {
380
+ code: "KAR_SIDECAR_INVALID".into(),
381
+ message: format!("{field} must be a sha256 digest."),
382
+ });
383
+ }
384
+ }
385
+ let Some(graph_value) = input.get("graph") else {
386
+ return Err(KarRunError {
387
+ code: "KAR_GRAPH_INVALID".into(),
388
+ message: "The committed evidence graph is not valid.".into(),
389
+ });
390
+ };
391
+ let Some(graph) = prepare_graph(graph_value) else {
392
+ return Err(KarRunError {
393
+ code: "KAR_GRAPH_INVALID".into(),
394
+ message: "The committed evidence graph is not valid.".into(),
395
+ });
396
+ };
397
+ let knowledge_root = input
398
+ .get("knowledgeRoot")
399
+ .and_then(Json::as_str)
400
+ .unwrap_or("")
401
+ .to_string();
402
+ if graph.knowledge_root != knowledge_root {
403
+ return Err(KarRunError {
404
+ code: "KAR_GRAPH_NOT_BOUND".into(),
405
+ message:
406
+ "Sidecar knowledgeRoot does not equal the committed evidence graph knowledgeRoot."
407
+ .into(),
408
+ });
409
+ }
410
+ Ok(Sidecar {
411
+ state_root: input
412
+ .get("stateRoot")
413
+ .and_then(Json::as_str)
414
+ .unwrap_or("")
415
+ .to_string(),
416
+ object_root: input
417
+ .get("objectRoot")
418
+ .and_then(Json::as_str)
419
+ .unwrap_or("")
420
+ .to_string(),
421
+ commit_digest: input
422
+ .get("commitDigest")
423
+ .and_then(Json::as_str)
424
+ .unwrap_or("")
425
+ .to_string(),
426
+ knowledge_root,
427
+ graph,
428
+ })
429
+ }
430
+
431
+ fn binding_error(
432
+ sidecar: &Sidecar,
433
+ prepared: &PreparedImage,
434
+ state_root: &str,
435
+ object_root: &str,
436
+ commit_digest: &str,
437
+ ) -> Option<KarRunError> {
438
+ if sidecar.state_root != state_root {
439
+ return Some(KarRunError {
440
+ code: "KAR_GRAPH_NOT_BOUND".into(),
441
+ message: "Sidecar stateRoot does not match the mounted V5 image.".into(),
442
+ });
443
+ }
444
+ if sidecar.object_root != object_root {
445
+ return Some(KarRunError {
446
+ code: "KAR_GRAPH_NOT_BOUND".into(),
447
+ message: "Sidecar objectRoot does not match the mounted V5 image.".into(),
448
+ });
449
+ }
450
+ if sidecar.commit_digest != commit_digest {
451
+ return Some(KarRunError {
452
+ code: "KAR_GRAPH_NOT_BOUND".into(),
453
+ message: "Sidecar commitDigest does not match the mounted V5 image.".into(),
454
+ });
455
+ }
456
+ if sidecar.knowledge_root != prepared.knowledge_root {
457
+ return Some(KarRunError { code: "KAR_GRAPH_NOT_BOUND".into(), message: format!("Sidecar knowledgeRoot does not match the frozen KAR projection of this image. image {} sidecar {}", prepared.knowledge_root, sidecar.knowledge_root) });
458
+ }
459
+ None
460
+ }
461
+
462
+ fn evaluation_from(session: &Session, proposition: &str) -> KarEvaluation {
463
+ let certificate = evaluate_bound(&BoundInputs {
464
+ prepared: Some(&session.prepared),
465
+ graph: Some(&session.graph),
466
+ indexes: Some(&session.indexes),
467
+ image_input: &Json::Null,
468
+ graph_input: &graph_json(&session.graph),
469
+ query: proposition,
470
+ plan_input: &session.plan_input,
471
+ knowledge_root: Some(&session.prepared.knowledge_root),
472
+ semantic_root: Some(&session.semantic_root),
473
+ });
474
+ let plan = validate_plan(&session.plan_input);
475
+ let json = evaluation_json(session, proposition, plan.as_ref(), &certificate);
476
+ KarEvaluation {
477
+ status: certificate.status.clone(),
478
+ code: status_code(&certificate.status),
479
+ proposition: proposition.to_string(),
480
+ anchor_mode: plan.as_ref().map(anchor_mode),
481
+ frontiers: certificate.frontiers.clone(),
482
+ selected_len: certificate.evidence_ids.len(),
483
+ knowledge_root: certificate.roots.knowledge_root.clone(),
484
+ semantic_root: certificate.roots.semantic_root.clone(),
485
+ kar_root: certificate.roots.kar_root.clone(),
486
+ json: stringify(&json),
487
+ }
488
+ }
489
+
490
+ fn anchor_mode(plan: &Plan) -> String {
491
+ match plan.anchor {
492
+ crate::kar::model::AnchorSpec::Recompute { .. } => "recompute".into(),
493
+ crate::kar::model::AnchorSpec::Supplied { .. } => "supplied".into(),
494
+ }
495
+ }
496
+
497
+ fn evaluation_json(
498
+ session: &Session,
499
+ proposition: &str,
500
+ plan: Option<&Plan>,
501
+ certificate: &KarResult,
502
+ ) -> Json {
503
+ let choices = certificate
504
+ .choices
505
+ .iter()
506
+ .map(|choice| (choice.evidence_id.clone(), choice.frontiers.clone()))
507
+ .collect::<Vec<_>>();
508
+ let selected = certificate
509
+ .evidence_ids
510
+ .iter()
511
+ .map(|id| {
512
+ let object = session.objects.iter().find(|row| &row.id == id);
513
+ let frontiers = choices
514
+ .iter()
515
+ .find(|(evidence_id, _)| evidence_id == id)
516
+ .map(|(_, frontiers)| frontiers.clone())
517
+ .unwrap_or_default();
518
+ let mut pairs = vec![
519
+ (
520
+ "frontiers".to_string(),
521
+ Json::Array(frontiers.into_iter().map(Json::String).collect()),
522
+ ),
523
+ ("id".to_string(), Json::String(id.clone())),
524
+ ];
525
+ if let Some(object) = object {
526
+ pairs.push(("metadata".to_string(), object.metadata.clone()));
527
+ if let Some(source) = &object.source {
528
+ pairs.push(("source".to_string(), Json::String(source.clone())));
529
+ }
530
+ pairs.push(("text".to_string(), Json::String(object.text.clone())));
531
+ }
532
+ Json::Object(pairs)
533
+ })
534
+ .collect();
535
+ Json::object(vec![
536
+ ("api", Json::String(KAR_API.into())),
537
+ ("certificate", certificate_json(certificate)),
538
+ ("code", certificate_code(certificate)),
539
+ ("experimental", Json::Bool(true)),
540
+ (
541
+ "frontiers",
542
+ crate::kar::model::frontiers_json(&certificate.frontiers),
543
+ ),
544
+ ("image", session.image.clone()),
545
+ ("plan", plan.map(plan_json).unwrap_or(Json::Null)),
546
+ ("proposition", Json::String(proposition.to_string())),
547
+ ("selectedEvidence", Json::Array(selected)),
548
+ ("status", Json::String(certificate.status.clone())),
549
+ ("version", Json::String(KAR_VERSION.into())),
550
+ (
551
+ "witnesses",
552
+ Json::Array(certificate.witnesses.iter().map(witness_json).collect()),
553
+ ),
554
+ ])
555
+ }
556
+
557
+ fn certificate_code(certificate: &KarResult) -> Json {
558
+ match status_code(&certificate.status) {
559
+ Some(code) => Json::String(code),
560
+ None => Json::Null,
561
+ }
562
+ }
563
+
564
+ fn compare_claim(actual: &KarEvaluation, claimed_json: &str, proposition: &str) -> KarVerification {
565
+ let claimed_value = match parse_json(claimed_json) {
566
+ Ok(value) => value,
567
+ Err(_) => {
568
+ return KarVerification {
569
+ ok: false,
570
+ code: "KAR_RESULT_INVALID".into(),
571
+ reason: "The claimed result is not a KAR certificate.".into(),
572
+ status: Some(actual.status.clone()),
573
+ }
574
+ }
575
+ };
576
+ let Some(claimed) = certificate_of(&claimed_value) else {
577
+ return KarVerification {
578
+ ok: false,
579
+ code: "KAR_RESULT_INVALID".into(),
580
+ reason: "The claimed result is not a KAR certificate.".into(),
581
+ status: Some(actual.status.clone()),
582
+ };
583
+ };
584
+ let actual_certificate = parse_json(&actual.json)
585
+ .ok()
586
+ .and_then(|value| value.get("certificate").cloned())
587
+ .unwrap_or(Json::Null);
588
+ if canonicalize(&actual_certificate) != canonicalize(claimed) {
589
+ return KarVerification {
590
+ ok: false,
591
+ code: "KAR_RESULT_MISMATCH".into(),
592
+ reason: mismatch_reason(&actual_certificate, claimed),
593
+ status: Some(actual.status.clone()),
594
+ };
595
+ }
596
+ let Some(envelope) = claimed_json_object(&claimed_value) else {
597
+ return KarVerification {
598
+ ok: true,
599
+ code: "VERIFIED".into(),
600
+ reason: String::new(),
601
+ status: Some(actual.status.clone()),
602
+ };
603
+ };
604
+ if envelope.get("certificate").is_none() {
605
+ return KarVerification {
606
+ ok: true,
607
+ code: "VERIFIED".into(),
608
+ reason: String::new(),
609
+ status: Some(actual.status.clone()),
610
+ };
611
+ }
612
+ let actual_value = parse_json(&actual.json).expect("evaluation JSON");
613
+ if let Some(flag) = envelope.get("experimental") {
614
+ if flag != &Json::Bool(true) {
615
+ return mismatch(actual, "experimental flag mismatch");
616
+ }
617
+ }
618
+ if let Some(Json::String(value)) = envelope.get("proposition") {
619
+ if value != proposition {
620
+ return mismatch(actual, "proposition mismatch");
621
+ }
622
+ }
623
+ for (field, reason) in [
624
+ ("plan", "plan mismatch"),
625
+ ("image", "image identity mismatch"),
626
+ ("selectedEvidence", "selected evidence mismatch"),
627
+ ("frontiers", "frontier mismatch"),
628
+ ("witnesses", "witness mismatch"),
629
+ ] {
630
+ if let Some(value) = envelope.get(field) {
631
+ if canonicalize(value) != canonicalize(actual_value.get(field).unwrap_or(&Json::Null)) {
632
+ return mismatch(actual, reason);
633
+ }
634
+ }
635
+ }
636
+ if let Some(code) = envelope.get("code") {
637
+ if code != actual_value.get("code").unwrap_or(&Json::Null) {
638
+ return mismatch(actual, "status code mismatch");
639
+ }
640
+ }
641
+ if let Some(status) = envelope.get("status") {
642
+ if status != actual_value.get("status").unwrap_or(&Json::Null) {
643
+ return mismatch(actual, "status mismatch");
644
+ }
645
+ }
646
+ KarVerification {
647
+ ok: true,
648
+ code: "VERIFIED".into(),
649
+ reason: String::new(),
650
+ status: Some(actual.status.clone()),
651
+ }
652
+ }
653
+
654
+ fn mismatch(actual: &KarEvaluation, reason: &str) -> KarVerification {
655
+ KarVerification {
656
+ ok: false,
657
+ code: "KAR_RESULT_MISMATCH".into(),
658
+ reason: reason.into(),
659
+ status: Some(actual.status.clone()),
660
+ }
661
+ }
662
+
663
+ fn claimed_json_object(value: &Json) -> Option<&Json> {
664
+ value.as_object().map(|_| value)
665
+ }
666
+
667
+ fn certificate_of(input: &Json) -> Option<&Json> {
668
+ if input.get("certificate").and_then(Json::as_object).is_some() {
669
+ return certificate_of(input.get("certificate")?);
670
+ }
671
+ if input.get("version").and_then(Json::as_str) != Some(KAR_VERSION) {
672
+ return None;
673
+ }
674
+ let status = input.get("status").and_then(Json::as_str)?;
675
+ if !STATUSES.contains(&status) {
676
+ return None;
677
+ }
678
+ string_array(input.get("evidenceIds")?)?;
679
+ if !matches!(input.get("choices")?, Json::Array(_))
680
+ || !matches!(input.get("witnesses")?, Json::Array(_))
681
+ {
682
+ return None;
683
+ }
684
+ let frontiers = input.get("frontiers")?;
685
+ let decision = input.get("decision")?;
686
+ let roots = input.get("roots")?;
687
+ for frontier in FRONTIERS {
688
+ string_array(frontiers.get(frontier)?)?;
689
+ }
690
+ if decision.get("status").and_then(Json::as_str) != Some(status) {
691
+ return None;
692
+ }
693
+ for name in ROOT_FIELDS {
694
+ let root = roots.get(name).and_then(Json::as_str)?;
695
+ if !is_root(root) {
696
+ return None;
697
+ }
698
+ }
699
+ Some(input)
700
+ }
701
+
702
+ fn string_array(value: &Json) -> Option<()> {
703
+ let Json::Array(items) = value else {
704
+ return None;
705
+ };
706
+ if items.iter().all(|item| matches!(item, Json::String(_))) {
707
+ Some(())
708
+ } else {
709
+ None
710
+ }
711
+ }
712
+
713
+ fn mismatch_reason(actual: &Json, claimed: &Json) -> String {
714
+ for field in [
715
+ "version",
716
+ "status",
717
+ "evidenceIds",
718
+ "choices",
719
+ "frontiers",
720
+ "witnesses",
721
+ "decision",
722
+ "roots",
723
+ ] {
724
+ if canonicalize(actual.get(field).unwrap_or(&Json::Null))
725
+ == canonicalize(claimed.get(field).unwrap_or(&Json::Null))
726
+ {
727
+ continue;
728
+ }
729
+ if field == "roots" {
730
+ for root in ROOT_FIELDS {
731
+ let left = actual
732
+ .get("roots")
733
+ .and_then(|roots| roots.get(root))
734
+ .and_then(Json::as_str)
735
+ .unwrap_or("");
736
+ let right = claimed
737
+ .get("roots")
738
+ .and_then(|roots| roots.get(root))
739
+ .and_then(Json::as_str)
740
+ .unwrap_or("");
741
+ if left != right {
742
+ return format!("root mismatch {root}");
743
+ }
744
+ }
745
+ }
746
+ if field == "status" {
747
+ let left = actual.get("status").and_then(Json::as_str).unwrap_or("");
748
+ let right = claimed.get("status").and_then(Json::as_str).unwrap_or("");
749
+ return format!("status {left} did not match {right}");
750
+ }
751
+ return format!("field mismatch {field}");
752
+ }
753
+ "certificate mismatch".into()
754
+ }
755
+
756
+ fn dossier_from(session: &Session, proposition: &str) -> KarDossier {
757
+ let evaluation = evaluation_from(session, proposition);
758
+ let evaluation_value = parse_json(&evaluation.json).expect("evaluation JSON");
759
+ let certificate = evaluation_value
760
+ .get("certificate")
761
+ .cloned()
762
+ .unwrap_or(Json::Null);
763
+ let manifest = Json::object(vec![
764
+ ("format", Json::String(DOSSIER_FORMAT.into())),
765
+ ("image", session.image.clone()),
766
+ ("karRoot", Json::String(evaluation.kar_root.clone())),
767
+ (
768
+ "planRoot",
769
+ certificate
770
+ .get("roots")
771
+ .and_then(|roots| roots.get("planRoot"))
772
+ .cloned()
773
+ .unwrap_or(Json::Null),
774
+ ),
775
+ (
776
+ "queryDigest",
777
+ digest_json(&Json::String(proposition.to_string())).into_json_string(),
778
+ ),
779
+ ("resultDigest", digest_json(&certificate).into_json_string()),
780
+ (
781
+ "semanticRoot",
782
+ Json::String(evaluation.semantic_root.clone()),
783
+ ),
784
+ ]);
785
+ let markdown = render_markdown(session, proposition, &evaluation, &evaluation_value);
786
+ KarDossier {
787
+ status_statement: status_statement(&evaluation.status),
788
+ markdown,
789
+ manifest_json: stringify(&manifest),
790
+ certificate_json: stringify(&certificate),
791
+ evaluation_json: evaluation.json,
792
+ }
793
+ }
794
+
795
+ fn status_statement(status: &str) -> String {
796
+ if status == "SATISFIED" {
797
+ "Evidence requirements satisfied".into()
798
+ } else if status == "UNSATISFIED_EVIDENCE_REQUIREMENTS" {
799
+ "Evidence requirements not satisfied".into()
800
+ } else {
801
+ format!("Evaluation did not decide evidence requirements: {status}")
802
+ }
803
+ }
804
+
805
+ trait JsonString {
806
+ fn into_json_string(self) -> Json;
807
+ }
808
+
809
+ impl JsonString for String {
810
+ fn into_json_string(self) -> Json {
811
+ Json::String(self)
812
+ }
813
+ }
814
+
815
+ fn render_markdown(
816
+ session: &Session,
817
+ proposition: &str,
818
+ evaluation: &KarEvaluation,
819
+ value: &Json,
820
+ ) -> String {
821
+ let selected = value
822
+ .get("selectedEvidence")
823
+ .and_then(|item| match item {
824
+ Json::Array(rows) => Some(rows),
825
+ _ => None,
826
+ })
827
+ .cloned()
828
+ .unwrap_or_default();
829
+ let mut lines = Vec::new();
830
+ lines.push("PROPOSITION".into());
831
+ lines.push(proposition.to_string());
832
+ lines.push(String::new());
833
+ lines.push("STATUS".into());
834
+ lines.push(evaluation.status.clone());
835
+ lines.push(status_statement(&evaluation.status));
836
+ lines.push(String::new());
837
+ lines.push("SELECTED EVIDENCE".into());
838
+ push_evidence(&mut lines, &selected);
839
+ lines.push(String::new());
840
+ for (title, frontier) in [
841
+ ("SUPPORT", "F_S"),
842
+ ("OPPOSITION", "F_O"),
843
+ ("QUALIFICATIONS", "F_Q"),
844
+ ("TEMPORAL/APPLICABILITY", "F_T"),
845
+ ("AUTHORITY", "F_A"),
846
+ ] {
847
+ lines.push(title.into());
848
+ let ids = evaluation
849
+ .frontiers
850
+ .iter()
851
+ .find(|(name, _)| name == frontier)
852
+ .map(|(_, ids)| ids.clone())
853
+ .unwrap_or_default();
854
+ let rows = ids
855
+ .iter()
856
+ .map(|id| {
857
+ selected
858
+ .iter()
859
+ .find(|row| row.get("id").and_then(Json::as_str) == Some(id.as_str()))
860
+ .cloned()
861
+ .unwrap_or_else(|| evidence_row(session, id, frontier))
862
+ })
863
+ .collect::<Vec<_>>();
864
+ push_evidence(&mut lines, &rows);
865
+ lines.push(String::new());
866
+ }
867
+ lines.push("WHY THESE ITEMS WERE SELECTED".into());
868
+ let reasons = why_selected(session, proposition, value);
869
+ if reasons.is_empty() {
870
+ lines.push("(none)".into());
871
+ }
872
+ for reason in reasons {
873
+ lines.push(reason);
874
+ }
875
+ lines.push(String::new());
876
+ lines.push("CERTIFICATE ROOTS".into());
877
+ let roots = value
878
+ .get("certificate")
879
+ .and_then(|certificate| certificate.get("roots"));
880
+ for (label, field) in [
881
+ ("KnowledgeRoot", "knowledgeRoot"),
882
+ ("SemanticRoot", "semanticRoot"),
883
+ ("PlanRoot", "planRoot"),
884
+ ("AnchorRoot", "anchorRoot"),
885
+ ("FrontierRoot", "frontierRoot"),
886
+ ("EvidenceSetRoot", "evidenceSetRoot"),
887
+ ("DecisionRoot", "decisionRoot"),
888
+ ("KARRoot", "karRoot"),
889
+ ("QueryRoot", "queryRoot"),
890
+ ("FrontierWitnessRoot", "frontierWitnessRoot"),
891
+ ] {
892
+ let root = roots
893
+ .and_then(|item| item.get(field))
894
+ .and_then(Json::as_str)
895
+ .unwrap_or("");
896
+ lines.push(format!("{label} {root}"));
897
+ }
898
+ if let Some(state_root) = session.image.get("stateRoot").and_then(Json::as_str) {
899
+ lines.push(format!("V5StateRoot {state_root}"));
900
+ }
901
+ lines.push(String::new());
902
+ lines.push("HOW TO VERIFY".into());
903
+ lines.push("Recompute the frozen KAR certificate from the Knowledge Image, the Committed Evidence Graph, the plan, and the proposition.".into());
904
+ lines.push("knolo kar dossier verify <dossier-directory>".into());
905
+ lines.push(
906
+ "A change to the certificate, manifest, selected evidence, or this rendering fails closed."
907
+ .into(),
908
+ );
909
+ lines.push(String::new());
910
+ lines.join("\n")
911
+ }
912
+
913
+ fn push_evidence(lines: &mut Vec<String>, rows: &[Json]) {
914
+ if rows.is_empty() {
915
+ lines.push("(none)".into());
916
+ return;
917
+ }
918
+ for row in rows {
919
+ let id = row.get("id").and_then(Json::as_str).unwrap_or("");
920
+ let source = row
921
+ .get("source")
922
+ .and_then(Json::as_str)
923
+ .filter(|source| !source.is_empty())
924
+ .map(|source| format!(" source {source}"))
925
+ .unwrap_or_default();
926
+ let frontiers = match row.get("frontiers") {
927
+ Some(Json::Array(items)) => items
928
+ .iter()
929
+ .filter_map(Json::as_str)
930
+ .collect::<Vec<_>>()
931
+ .join(","),
932
+ _ => String::new(),
933
+ };
934
+ let frontier_text = if frontiers.is_empty() {
935
+ String::new()
936
+ } else {
937
+ format!(" frontiers {frontiers}")
938
+ };
939
+ lines.push(format!("{id}{source}{frontier_text}"));
940
+ if let Some(text) = row.get("text").and_then(Json::as_str) {
941
+ lines.push(text.to_string());
942
+ }
943
+ }
944
+ }
945
+
946
+ fn evidence_row(session: &Session, id: &str, frontier: &str) -> Json {
947
+ let object = session.objects.iter().find(|row| row.id == id);
948
+ let mut pairs = vec![
949
+ (
950
+ "frontiers".to_string(),
951
+ Json::Array(vec![Json::String(frontier.to_string())]),
952
+ ),
953
+ ("id".to_string(), Json::String(id.to_string())),
954
+ ];
955
+ if let Some(object) = object {
956
+ pairs.push(("metadata".to_string(), object.metadata.clone()));
957
+ if let Some(source) = &object.source {
958
+ pairs.push(("source".to_string(), Json::String(source.clone())));
959
+ }
960
+ pairs.push(("text".to_string(), Json::String(object.text.clone())));
961
+ }
962
+ Json::Object(pairs)
963
+ }
964
+
965
+ fn why_selected(session: &Session, proposition: &str, evaluation: &Json) -> Vec<String> {
966
+ let Some(plan) = evaluation
967
+ .get("plan")
968
+ .and_then(|value| validate_plan(value))
969
+ else {
970
+ return Vec::new();
971
+ };
972
+ let Some(certificate) = evaluation.get("certificate") else {
973
+ return Vec::new();
974
+ };
975
+ let status = certificate
976
+ .get("status")
977
+ .and_then(Json::as_str)
978
+ .unwrap_or("");
979
+ let evidence_ids = match certificate.get("evidenceIds") {
980
+ Some(Json::Array(items)) => items
981
+ .iter()
982
+ .filter_map(Json::as_str)
983
+ .map(str::to_string)
984
+ .collect::<Vec<_>>(),
985
+ _ => Vec::new(),
986
+ };
987
+ let node_ids = session.indexes.node_ids.clone();
988
+ let (commitment, rejected) =
989
+ crate::kar::model::resolve_anchor(proposition, &node_ids, &plan.anchor);
990
+ if rejected {
991
+ return Vec::new();
992
+ }
993
+ let Ok(closed) = close_frontiers(
994
+ &session.graph,
995
+ &plan,
996
+ &commitment.nodes,
997
+ Some(&session.indexes),
998
+ ) else {
999
+ return Vec::new();
1000
+ };
1001
+ let witnesses_match = canonicalize(&Json::Array(
1002
+ closed.witnesses.iter().map(witness_json).collect(),
1003
+ )) == canonicalize(certificate.get("witnesses").unwrap_or(&Json::Null));
1004
+ let mut lines = Vec::new();
1005
+ let mut seen = Vec::new();
1006
+ for witness in &closed.witnesses {
1007
+ let key = format!("{}\0{}", witness.frontier, witness.evidence_id);
1008
+ if seen.iter().any(|existing| existing == &key) {
1009
+ continue;
1010
+ }
1011
+ seen.push(key);
1012
+ if !evidence_ids.iter().any(|id| id == &witness.evidence_id) {
1013
+ continue;
1014
+ }
1015
+ let requirements = if witnesses_match {
1016
+ let mut found = Vec::new();
1017
+ for admission in &closed.admissions {
1018
+ if admission.evidence_id == witness.evidence_id
1019
+ && admission.frontier == witness.frontier
1020
+ && admission.applicable
1021
+ {
1022
+ for requirement in &admission.requirements {
1023
+ if !found.iter().any(|item| item == requirement) {
1024
+ found.push(requirement.clone());
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+ found.sort_by(|left, right| crate::kar::json::utf16_cmp(left, right));
1030
+ found
1031
+ } else {
1032
+ Vec::new()
1033
+ };
1034
+ let necessary = if witnesses_match && status == "SATISFIED" {
1035
+ let remaining = evidence_ids
1036
+ .iter()
1037
+ .filter(|id| *id != &witness.evidence_id)
1038
+ .cloned()
1039
+ .collect::<Vec<_>>();
1040
+ Some(!covers_plan(
1041
+ &plan,
1042
+ &closed.admissions,
1043
+ &session.prepared.texts,
1044
+ &remaining,
1045
+ ))
1046
+ } else {
1047
+ None
1048
+ };
1049
+ let reason = evidence_reason(status, necessary, &requirements);
1050
+ lines.push(format!(
1051
+ "{} {} {reason}",
1052
+ witness.evidence_id, witness.frontier
1053
+ ));
1054
+ }
1055
+ lines
1056
+ }
1057
+
1058
+ fn evidence_reason(status: &str, necessary: Option<bool>, requirements: &[String]) -> String {
1059
+ if status != "SATISFIED" {
1060
+ return "listed on a frontier while the decision did not select a set".into();
1061
+ }
1062
+ match necessary {
1063
+ None => "selected; necessity was not recomputed".into(),
1064
+ Some(true) if !requirements.is_empty() => format!(
1065
+ "required for this set; contributes {}",
1066
+ requirements.join(",")
1067
+ ),
1068
+ Some(true) => "required for this set".into(),
1069
+ Some(false) => "kept by the profile order; the floors still pass without it".into(),
1070
+ }
1071
+ }
1072
+
1073
+ fn decode_object_text(bytes: &[u8]) -> String {
1074
+ // `TextDecoder` is non-fatal. Invalid UTF-8 becomes U+FFFD.
1075
+ String::from_utf8_lossy(bytes).into_owned()
1076
+ }
1077
+
1078
+ fn object_source(kind: &str, meta: &Json) -> Option<String> {
1079
+ if let Some(source) = meta.get("source").and_then(Json::as_str) {
1080
+ if !source.is_empty() {
1081
+ return Some(source.to_string());
1082
+ }
1083
+ }
1084
+ if kind.is_empty() {
1085
+ None
1086
+ } else {
1087
+ Some(kind.to_string())
1088
+ }
1089
+ }
1090
+
1091
+ fn cbor_to_json(value: &CborValue) -> Result<Json, String> {
1092
+ match value {
1093
+ CborValue::Null => Ok(Json::Null),
1094
+ CborValue::Bool(item) => Ok(Json::Bool(*item)),
1095
+ CborValue::UInt(item) => i64::try_from(*item)
1096
+ .map(Json::Int)
1097
+ .map_err(|_| "KAR metadata integer is out of range".to_string()),
1098
+ CborValue::NInt(item) => Ok(Json::Int(*item)),
1099
+ CborValue::Text(item) => Ok(Json::String(item.clone())),
1100
+ // A byte string is a Uint8Array in the TypeScript projection.
1101
+ // JSON.stringify and canonicalize both turn that into decimal index keys.
1102
+ CborValue::Bytes(bytes) => Ok(Json::Object(
1103
+ bytes
1104
+ .iter()
1105
+ .enumerate()
1106
+ .map(|(index, byte)| (index.to_string(), Json::Int(i64::from(*byte))))
1107
+ .collect(),
1108
+ )),
1109
+ CborValue::Array(items) => items
1110
+ .iter()
1111
+ .map(cbor_to_json)
1112
+ .collect::<Result<Vec<_>, _>>()
1113
+ .map(Json::Array),
1114
+ CborValue::Map(entries) => {
1115
+ let mut pairs = Vec::new();
1116
+ for (key, child) in entries {
1117
+ pairs.push((key.clone(), cbor_to_json(child)?));
1118
+ }
1119
+ Ok(Json::Object(pairs))
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+ #[cfg(test)]
1125
+ mod tests {
1126
+ use super::*;
1127
+
1128
+ #[test]
1129
+ fn object_text_replaces_invalid_utf8() {
1130
+ assert_eq!(decode_object_text(&[0xe2, 0x82, 0xac]), "€");
1131
+ assert_eq!(decode_object_text(&[0xc2, 0x20]), "\u{FFFD} ");
1132
+ assert_eq!(
1133
+ decode_object_text(&[0xed, 0xa0, 0x80]),
1134
+ "\u{FFFD}\u{FFFD}\u{FFFD}"
1135
+ );
1136
+ }
1137
+
1138
+ #[test]
1139
+ fn metadata_bytes_project_like_uint8array() {
1140
+ let mut bytes = vec![0; 11];
1141
+ bytes[10] = 7;
1142
+ let value = cbor_to_json(&CborValue::Bytes(bytes)).expect("bytes");
1143
+ assert_eq!(
1144
+ canonicalize(&value),
1145
+ r#"{"0":0,"1":0,"10":7,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0}"#
1146
+ );
1147
+ assert_eq!(
1148
+ stringify(&value),
1149
+ r#"{"0":0,"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":7}"#
1150
+ );
1151
+ assert_eq!(
1152
+ stringify(&cbor_to_json(&CborValue::Bytes(Vec::new())).expect("empty")),
1153
+ "{}"
1154
+ );
1155
+ }
1156
+ }