@riddledc/riddle-proof 0.5.33 → 0.5.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/{chunk-A5AWVY5A.js → chunk-4YCWZVBN.js} +20 -2
  2. package/dist/{chunk-CHKYSZLU.js → chunk-7R6ZQE3X.js} +5 -5
  3. package/dist/{chunk-TMMKRKY5.js → chunk-J2MERROF.js} +1 -0
  4. package/dist/{chunk-F6VFKS7K.js → chunk-MQ2BHHLX.js} +2 -2
  5. package/dist/{chunk-7ZJAUEUN.js → chunk-OASB3CYU.js} +6 -1
  6. package/dist/chunk-ODORKNSO.js +121 -0
  7. package/dist/engine-harness.cjs +26 -2
  8. package/dist/engine-harness.js +4 -4
  9. package/dist/index.cjs +154 -4
  10. package/dist/index.d.cts +2 -1
  11. package/dist/index.d.ts +2 -1
  12. package/dist/index.js +24 -8
  13. package/dist/openclaw.cjs +5 -0
  14. package/dist/openclaw.js +2 -2
  15. package/dist/proof-run-core.cjs +20 -2
  16. package/dist/proof-run-core.d.cts +16 -0
  17. package/dist/proof-run-core.d.ts +16 -0
  18. package/dist/proof-run-core.js +1 -1
  19. package/dist/proof-run-engine.cjs +20 -2
  20. package/dist/proof-run-engine.d.cts +24 -0
  21. package/dist/proof-run-engine.d.ts +24 -0
  22. package/dist/proof-run-engine.js +1 -1
  23. package/dist/proof-session.cjs +151 -0
  24. package/dist/proof-session.d.cts +37 -0
  25. package/dist/proof-session.d.ts +37 -0
  26. package/dist/proof-session.js +18 -0
  27. package/dist/result.cjs +1 -0
  28. package/dist/result.js +1 -1
  29. package/dist/runner.cjs +6 -0
  30. package/dist/runner.js +3 -3
  31. package/dist/state.cjs +5 -0
  32. package/dist/state.js +2 -2
  33. package/dist/types.d.cts +67 -1
  34. package/dist/types.d.ts +67 -1
  35. package/package.json +7 -2
  36. package/runtime/lib/preflight.py +42 -2
  37. package/runtime/lib/util.py +237 -0
  38. package/runtime/lib/verify.py +55 -2
  39. package/runtime/tests/recon_verify_smoke.py +120 -1
@@ -20,6 +20,8 @@ CAPTURE_DIAGNOSTIC_VERSION = 'riddle-proof.capture-diagnostic.v1'
20
20
  DEBUG_STRING_LIMIT = 2000
21
21
  CAPTURE_HINT_CACHE_VERSION = 'riddle-proof.capture-hints.v1'
22
22
  CAPTURE_HINT_CACHE_LIMIT = 12
23
+ PROOF_SESSION_VERSION = 'riddle-proof.visual-session.v1'
24
+ PROOF_SESSION_FINGERPRINT_VERSION = 'riddle-proof.visual-session.fingerprint.v1'
23
25
  SENSITIVE_KEY_FRAGMENTS = (
24
26
  'authorization',
25
27
  'apikey',
@@ -306,6 +308,241 @@ def compact_debug_value(value, limit=DEBUG_STRING_LIMIT):
306
308
  return value
307
309
 
308
310
 
311
+ def stable_json(value):
312
+ return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
313
+
314
+
315
+ def sha256_text(value):
316
+ text = str(value or '').strip()
317
+ if not text:
318
+ return ''
319
+ return hashlib.sha256(text.encode('utf-8')).hexdigest()
320
+
321
+
322
+ def parse_optional_json(value, field_name):
323
+ if value in (None, ''):
324
+ return None
325
+ if isinstance(value, (dict, list)):
326
+ return value
327
+ if not isinstance(value, str):
328
+ raise SystemExit(field_name + ' must be JSON.')
329
+ text = value.strip()
330
+ if not text:
331
+ return None
332
+ try:
333
+ return json.loads(text)
334
+ except Exception as exc:
335
+ raise SystemExit(field_name + ' is not valid JSON: ' + str(exc))
336
+
337
+
338
+ def load_proof_session_source(value):
339
+ source = str(value or '').strip()
340
+ if not source:
341
+ return None
342
+ try:
343
+ if source.startswith('{'):
344
+ session = json.loads(source)
345
+ elif source.startswith(('http://', 'https://')):
346
+ with urlopen(source, timeout=20) as response:
347
+ session = json.loads(response.read(512 * 1024).decode('utf-8'))
348
+ else:
349
+ with open(os.path.abspath(os.path.expanduser(source))) as f:
350
+ session = json.load(f)
351
+ except Exception as exc:
352
+ raise SystemExit('resume_session could not be loaded: ' + str(exc))
353
+
354
+ if not isinstance(session, dict):
355
+ raise SystemExit('resume_session must be a JSON object.')
356
+ if session.get('version') != PROOF_SESSION_VERSION:
357
+ raise SystemExit('resume_session has unsupported version: ' + str(session.get('version') or ''))
358
+ if not session.get('session_id'):
359
+ raise SystemExit('resume_session is missing session_id.')
360
+ if not session.get('fingerprint'):
361
+ raise SystemExit('resume_session is missing fingerprint.')
362
+ return session
363
+
364
+
365
+ def apply_proof_session_defaults(state, session):
366
+ if not isinstance(session, dict):
367
+ return []
368
+ applied = []
369
+ route = session.get('route') if isinstance(session.get('route'), dict) else {}
370
+ capture = session.get('capture') if isinstance(session.get('capture'), dict) else {}
371
+ target_image = session.get('target_image') if isinstance(session.get('target_image'), dict) else {}
372
+
373
+ defaults = [
374
+ ('server_path', route.get('path'), 'proof_session'),
375
+ ('wait_for_selector', capture.get('wait_for_selector'), 'proof_session'),
376
+ ('proof_plan', capture.get('proof_plan'), 'proof_session'),
377
+ ('capture_script', capture.get('capture_script'), 'proof_session'),
378
+ ('reference', session.get('reference'), 'proof_session'),
379
+ ('verification_mode', session.get('verification_mode'), 'proof_session'),
380
+ ('target_image_url', target_image.get('url'), 'proof_session'),
381
+ ('target_image_hash', target_image.get('hash'), 'proof_session'),
382
+ ]
383
+ for key, value, source in defaults:
384
+ text = str(value or '').strip()
385
+ if text and not str(state.get(key) or '').strip():
386
+ state[key] = text
387
+ applied.append(key)
388
+ if key == 'server_path':
389
+ state['server_path_source'] = source
390
+
391
+ if state.get('viewport_matrix_json') in (None, '') and session.get('viewport_matrix') is not None:
392
+ state['viewport_matrix_json'] = json.dumps(session.get('viewport_matrix'))
393
+ applied.append('viewport_matrix_json')
394
+ if state.get('deterministic_setup_json') in (None, '') and session.get('deterministic_setup') is not None:
395
+ state['deterministic_setup_json'] = json.dumps(session.get('deterministic_setup'))
396
+ applied.append('deterministic_setup_json')
397
+ if state.get('assertions_json') in (None, '') and session.get('assertions') is not None:
398
+ state['assertions_json'] = json.dumps(session.get('assertions'))
399
+ applied.append('assertions_json')
400
+ return applied
401
+
402
+
403
+ def visual_session_fingerprint_basis(state, route=''):
404
+ route_value = str(route or state.get('server_path') or '').strip()
405
+ basis = {
406
+ 'version': PROOF_SESSION_FINGERPRINT_VERSION,
407
+ 'repo': str(state.get('repo') or '').strip() or None,
408
+ 'route': route_value or None,
409
+ 'wait_for_selector': str(state.get('wait_for_selector') or '').strip() or None,
410
+ 'reference': str(state.get('requested_reference') or state.get('reference') or '').strip() or None,
411
+ 'verification_mode': str(state.get('verification_mode') or '').strip().lower() or None,
412
+ 'target_image_url': str(state.get('target_image_url') or '').strip() or None,
413
+ 'target_image_hash': str(state.get('target_image_hash') or '').strip() or None,
414
+ 'viewport_matrix': state.get('viewport_matrix'),
415
+ 'deterministic_setup': state.get('deterministic_setup'),
416
+ 'assertions': state.get('parsed_assertions'),
417
+ 'capture_script_hash': sha256_text(state.get('capture_script')),
418
+ }
419
+ return {key: value for key, value in basis.items() if value is not None}
420
+
421
+
422
+ def visual_session_fingerprint_from_basis(basis):
423
+ return hashlib.sha256(stable_json(basis).encode('utf-8')).hexdigest()
424
+
425
+
426
+ def visual_session_fingerprint(state, route=''):
427
+ return visual_session_fingerprint_from_basis(visual_session_fingerprint_basis(state, route=route))
428
+
429
+
430
+ def proof_session_mismatches(state, session, route=''):
431
+ if not isinstance(session, dict):
432
+ return []
433
+ expected = session.get('fingerprint_basis') if isinstance(session.get('fingerprint_basis'), dict) else {}
434
+ actual = visual_session_fingerprint_basis(state, route=route)
435
+ keys = sorted(set(expected.keys()) | set(actual.keys()))
436
+ mismatches = []
437
+ for key in keys:
438
+ if stable_json(expected.get(key)) != stable_json(actual.get(key)):
439
+ mismatches.append({
440
+ 'key': key,
441
+ 'expected': expected.get(key),
442
+ 'actual': actual.get(key),
443
+ })
444
+ return mismatches
445
+
446
+
447
+ def validate_proof_session_resume(state, route=''):
448
+ session = state.get('parent_proof_session')
449
+ if not isinstance(session, dict):
450
+ return {'status': 'not_requested'}
451
+ mismatches = proof_session_mismatches(state, session, route=route)
452
+ if mismatches:
453
+ state['proof_session_resume'] = {
454
+ 'status': 'fingerprint_mismatch',
455
+ 'parent_session_id': session.get('session_id'),
456
+ 'parent_fingerprint': session.get('fingerprint'),
457
+ 'mismatches': mismatches,
458
+ }
459
+ raise SystemExit(
460
+ 'resume_session fingerprint mismatch: ' +
461
+ ', '.join(item['key'] for item in mismatches[:8])
462
+ )
463
+ state['proof_session_resume'] = {
464
+ 'status': 'accepted',
465
+ 'parent_session_id': session.get('session_id'),
466
+ 'parent_fingerprint': session.get('fingerprint'),
467
+ 'fingerprint': visual_session_fingerprint(state, route=route),
468
+ }
469
+ return state['proof_session_resume']
470
+
471
+
472
+ def capture_proof_session_seed(state, route=''):
473
+ parent = state.get('parent_proof_session') if isinstance(state.get('parent_proof_session'), dict) else {}
474
+ basis = visual_session_fingerprint_basis(state, route=route)
475
+ return {
476
+ 'version': PROOF_SESSION_VERSION,
477
+ 'session_kind': 'capture-seed',
478
+ 'run_id': str(state.get('run_id') or '').strip(),
479
+ 'parent_session_id': parent.get('session_id') or None,
480
+ 'parent_fingerprint': parent.get('fingerprint') or None,
481
+ 'fingerprint': visual_session_fingerprint_from_basis(basis),
482
+ 'fingerprint_basis': basis,
483
+ 'repo': str(state.get('repo') or '').strip(),
484
+ 'route': {'path': str(route or state.get('server_path') or '').strip()},
485
+ 'reference': str(state.get('requested_reference') or state.get('reference') or '').strip(),
486
+ 'verification_mode': str(state.get('verification_mode') or '').strip(),
487
+ 'target_image': {
488
+ 'url': str(state.get('target_image_url') or '').strip(),
489
+ 'hash': str(state.get('target_image_hash') or '').strip(),
490
+ },
491
+ 'viewport_matrix': state.get('viewport_matrix'),
492
+ 'deterministic_setup': state.get('deterministic_setup'),
493
+ 'capture': {
494
+ 'proof_plan': str(state.get('proof_plan') or '').strip(),
495
+ 'capture_script': str(state.get('capture_script') or '').strip(),
496
+ 'wait_for_selector': str(state.get('wait_for_selector') or '').strip(),
497
+ },
498
+ 'assertions': state.get('parsed_assertions'),
499
+ }
500
+
501
+
502
+ def proof_session_output_url(payload):
503
+ item = capture_output_item(payload, 'proof-session.json') or capture_output_item(payload, 'proof-session')
504
+ return (item or {}).get('url', '') if item else ''
505
+
506
+
507
+ def build_visual_proof_session(state, route='', observed_after_path='', artifacts=None, evidence=None, status=''):
508
+ parent = state.get('parent_proof_session') if isinstance(state.get('parent_proof_session'), dict) else {}
509
+ basis = visual_session_fingerprint_basis(state, route=route)
510
+ session_id = 'rps_' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()) + '_' + hashlib.sha1(os.urandom(16)).hexdigest()[:8]
511
+ return {
512
+ 'version': PROOF_SESSION_VERSION,
513
+ 'session_id': session_id,
514
+ 'run_id': str(state.get('run_id') or '').strip(),
515
+ 'parent_session_id': parent.get('session_id') or None,
516
+ 'parent_fingerprint': parent.get('fingerprint') or None,
517
+ 'created_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
518
+ 'fingerprint': visual_session_fingerprint_from_basis(basis),
519
+ 'fingerprint_basis': basis,
520
+ 'repo': str(state.get('repo') or '').strip(),
521
+ 'branch': str(state.get('branch') or state.get('target_branch') or '').strip(),
522
+ 'route': {
523
+ 'path': str(route or state.get('server_path') or '').strip(),
524
+ 'observed_after_path': str(observed_after_path or '').strip(),
525
+ },
526
+ 'reference': str(state.get('requested_reference') or state.get('reference') or '').strip(),
527
+ 'verification_mode': str(state.get('verification_mode') or '').strip(),
528
+ 'target_image': {
529
+ 'url': str(state.get('target_image_url') or '').strip(),
530
+ 'hash': str(state.get('target_image_hash') or '').strip(),
531
+ },
532
+ 'viewport_matrix': state.get('viewport_matrix'),
533
+ 'deterministic_setup': state.get('deterministic_setup'),
534
+ 'capture': {
535
+ 'proof_plan': str(state.get('proof_plan') or '').strip(),
536
+ 'capture_script': str(state.get('capture_script') or '').strip(),
537
+ 'wait_for_selector': str(state.get('wait_for_selector') or '').strip(),
538
+ },
539
+ 'assertions': state.get('parsed_assertions'),
540
+ 'artifacts': artifacts or {},
541
+ 'evidence': evidence or {},
542
+ 'status': status,
543
+ }
544
+
545
+
309
546
  def redact_for_diagnostics(value):
310
547
  if isinstance(value, dict):
311
548
  redacted = {}
@@ -13,6 +13,8 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
13
  from util import (
14
14
  append_capture_diagnostic,
15
15
  apply_auth_context,
16
+ build_visual_proof_session,
17
+ capture_proof_session_seed,
16
18
  capture_static_preview,
17
19
  enrich_capture_payload,
18
20
  has_auth_context,
@@ -24,6 +26,7 @@ from util import (
24
26
  run_project_build,
25
27
  save_state,
26
28
  should_use_static_preview,
29
+ proof_session_output_url,
27
30
  summarize_capture_artifacts,
28
31
  )
29
32
  import subprocess as sp
@@ -215,7 +218,7 @@ def abort_capture_failure(state, results, expected_path, message, raw_payload):
215
218
  raise SystemExit(summary)
216
219
 
217
220
 
218
- def build_probe_capture_script(base_script='', verification_mode='proof'):
221
+ def build_probe_capture_script(base_script='', verification_mode='proof', proof_session_seed=None):
219
222
  pieces = []
220
223
  script = (base_script or '').strip()
221
224
  pieces.append('let __riddleProofCaptureScriptError = null;')
@@ -280,6 +283,12 @@ def build_probe_capture_script(base_script='', verification_mode='proof'):
280
283
  ])
281
284
  if auto_screenshot_for_mode(verification_mode) and not capture_script_saves_screenshot(script):
282
285
  pieces.append("await saveScreenshot('after-proof');")
286
+ if isinstance(proof_session_seed, dict):
287
+ pieces.append(
288
+ 'try { if (typeof saveJson === "function") await saveJson("proof-session", ' +
289
+ json.dumps(proof_session_seed) +
290
+ '); } catch {}'
291
+ )
283
292
  pieces.append('if (__riddleProofCaptureScriptError) throw __riddleProofCaptureScriptError;')
284
293
  pieces.append('return { pageState, proofEvidence: __riddleProofEvidenceValue };')
285
294
  return ' '.join(pieces)
@@ -1056,6 +1065,36 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1056
1065
  semantic_context,
1057
1066
  [],
1058
1067
  )
1068
+ observed_after_path = ((semantic_context.get('route') or {}).get('after_observed_path')) or ''
1069
+ proof_session_artifact_url = proof_session_output_url(after_payload)
1070
+ outputs = []
1071
+ for item in after_payload.get('outputs') or []:
1072
+ if not isinstance(item, dict):
1073
+ continue
1074
+ outputs.append({
1075
+ 'name': str(item.get('name') or ''),
1076
+ 'url': str(item.get('url') or ''),
1077
+ 'type': str(item.get('type') or item.get('kind') or ''),
1078
+ })
1079
+ proof_session = build_visual_proof_session(
1080
+ state,
1081
+ route=expected_path,
1082
+ observed_after_path=observed_after_path,
1083
+ artifacts={
1084
+ 'before': state.get('before_cdn') or '',
1085
+ 'prod': state.get('prod_cdn') or '',
1086
+ 'after': state.get('after_cdn') or '',
1087
+ 'session': proof_session_artifact_url,
1088
+ 'outputs': outputs,
1089
+ },
1090
+ evidence={
1091
+ 'visual_delta': visual_delta,
1092
+ 'semantic_context': semantic_context,
1093
+ 'artifact_contract': artifact_contract,
1094
+ 'artifact_usage': artifact_usage,
1095
+ },
1096
+ status='evidence_captured' if after_observation.get('valid') else 'capture_incomplete',
1097
+ )
1059
1098
  return {
1060
1099
  'verification_mode': normalized_verification_mode(state.get('verification_mode')),
1061
1100
  'reference': state.get('requested_reference') or state.get('reference', 'both'),
@@ -1078,6 +1117,7 @@ def build_evidence_bundle(state, results, after_payload, after_observation, requ
1078
1117
  'proof_evidence_sample': compact_value(proof_evidence) if proof_evidence is not None else '',
1079
1118
  'success_criteria': (state.get('success_criteria') or '').strip(),
1080
1119
  'assertions': state.get('parsed_assertions') or None,
1120
+ 'proof_session': proof_session,
1081
1121
  }
1082
1122
 
1083
1123
 
@@ -1193,7 +1233,8 @@ expected_path = (
1193
1233
  or '/'
1194
1234
  )
1195
1235
  verification_mode = normalized_verification_mode(s.get('verification_mode'))
1196
- probe_capture_script = build_probe_capture_script(capture_script, verification_mode)
1236
+ proof_session_seed = capture_proof_session_seed(s, expected_path)
1237
+ probe_capture_script = build_probe_capture_script(capture_script, verification_mode, proof_session_seed)
1197
1238
  results = {
1198
1239
  'baseline': {
1199
1240
  'reference': reference,
@@ -1402,6 +1443,18 @@ if reference in ('prod', 'both') and prod_url:
1402
1443
 
1403
1444
  evidence_bundle = build_evidence_bundle(s, results, after_payload, after_observation, required_baseline_present, expected_path)
1404
1445
  s['evidence_bundle'] = evidence_bundle
1446
+ s['proof_session'] = evidence_bundle.get('proof_session') or {}
1447
+ s['proof_session_fingerprint'] = (s.get('proof_session') or {}).get('fingerprint') or ''
1448
+ s['proof_session_artifact_url'] = ((s.get('proof_session') or {}).get('artifacts') or {}).get('session') or ''
1449
+ if s.get('proof_session'):
1450
+ summary_lines.append(
1451
+ 'Proof session: ' +
1452
+ str((s.get('proof_session') or {}).get('session_id') or '') +
1453
+ ' ' +
1454
+ str((s.get('proof_session') or {}).get('fingerprint') or '')[:12]
1455
+ )
1456
+ if s.get('proof_session_artifact_url'):
1457
+ summary_lines.append('Proof session artifact: ' + s['proof_session_artifact_url'])
1405
1458
  visual_delta = ((evidence_bundle.get('after') or {}).get('visual_delta') or {})
1406
1459
  if visual_delta.get('status') != 'not_applicable':
1407
1460
  summary_lines.append('Visual delta gate: ' + compact_value(visual_delta, limit=700))
@@ -204,10 +204,13 @@ class FakeRiddle:
204
204
  ],
205
205
  }
206
206
  if 'after-proof' in script:
207
+ outputs = [{'name': 'after.png', 'url': 'https://cdn.example.com/after.png'}]
208
+ if 'proof-session' in script:
209
+ outputs.append({'name': 'proof-session.json', 'url': 'https://cdn.example.com/proof-session.json'})
207
210
  return {
208
211
  'ok': True,
209
212
  'screenshots': [{'url': 'https://cdn.example.com/after.png'}],
210
- 'outputs': [{'name': 'after.png', 'url': 'https://cdn.example.com/after.png'}],
213
+ 'outputs': outputs,
211
214
  'console': state_console({
212
215
  'bodyTextLength': 180,
213
216
  'visibleTextSample': 'Pricing CTA Buy Now',
@@ -637,6 +640,105 @@ def run_preflight_records_prod_reference_skip_reason():
637
640
  shutil.rmtree(tempdir, ignore_errors=True)
638
641
 
639
642
 
643
+ def run_preflight_resumes_visual_proof_session():
644
+ tempdir = Path(tempfile.mkdtemp(prefix='riddle-proof-preflight-session-'))
645
+ args_path = tempdir / 'args.json'
646
+ state_path = tempdir / 'state.json'
647
+ repo_dir = tempdir / 'repo'
648
+ try:
649
+ make_project(repo_dir, "export const routes = [{ path: '/pricing', element: <Pricing /> }];\n")
650
+ util = load_module('util_proof_session_builder', UTIL_PATH)
651
+ parent_session = util.build_visual_proof_session({
652
+ 'repo': 'example/repo',
653
+ 'server_path': '/pricing',
654
+ 'reference': 'before',
655
+ 'verification_mode': 'visual',
656
+ 'target_image_url': 'https://cdn.example.com/spec.png',
657
+ 'target_image_hash': 'sha256:spec',
658
+ 'viewport_matrix': [
659
+ {'name': 'mobile', 'width': 390, 'height': 844},
660
+ {'name': 'desktop', 'width': 1280, 'height': 900},
661
+ ],
662
+ 'deterministic_setup': {'seed': 'pricing-visual-v1'},
663
+ 'parsed_assertions': [{'kind': 'text', 'contains': 'Buy Now'}],
664
+ 'proof_plan': 'Capture pricing route and compare CTA visual state.',
665
+ 'capture_script': "await page.waitForSelector('[data-testid=pricing-cta]'); await saveScreenshot('after-proof');",
666
+ 'wait_for_selector': '[data-testid=pricing-cta]',
667
+ }, route='/pricing', observed_after_path='/pricing', status='evidence_captured')
668
+
669
+ args_path.write_text(json.dumps({
670
+ 'repo': 'example/repo',
671
+ 'repo_dir': str(repo_dir),
672
+ 'mode': 'static',
673
+ 'change_request': 'Continue the pricing visual iteration',
674
+ 'commit_message': 'Continue the pricing visual iteration',
675
+ 'build_command': BUILD_SCRIPT,
676
+ 'build_output': 'build',
677
+ 'allow_static_preview_fallback': True,
678
+ 'resume_session': json.dumps(parent_session),
679
+ }, indent=2))
680
+ with temporary_env(
681
+ RIDDLE_PROOF_ARGS_FILE=str(args_path),
682
+ RIDDLE_PROOF_STATE_FILE=str(state_path),
683
+ ):
684
+ sys.modules.pop('util', None)
685
+ load_module('preflight_resume_session', PREFLIGHT_PATH)
686
+ after_preflight = json.loads(state_path.read_text())
687
+ assert after_preflight['proof_session_resume']['status'] == 'accepted'
688
+ assert after_preflight['proof_session_resume']['applied_fields']
689
+ assert after_preflight['parent_proof_session']['session_id'] == parent_session['session_id']
690
+ assert after_preflight['server_path'] == '/pricing'
691
+ assert after_preflight['server_path_source'] == 'proof_session'
692
+ assert after_preflight['reference'] == 'before'
693
+ assert after_preflight['verification_mode'] == 'visual'
694
+ assert after_preflight['target_image_url'] == 'https://cdn.example.com/spec.png'
695
+ assert after_preflight['target_image_hash'] == 'sha256:spec'
696
+ assert after_preflight['viewport_matrix'][0]['name'] == 'mobile'
697
+ assert after_preflight['deterministic_setup']['seed'] == 'pricing-visual-v1'
698
+ assert after_preflight['parsed_assertions'][0]['contains'] == 'Buy Now'
699
+ assert after_preflight['proof_plan_status'] == 'ready'
700
+ assert after_preflight['author_status'] == 'ready'
701
+
702
+ mismatch_args_path = tempdir / 'mismatch-args.json'
703
+ mismatch_state_path = tempdir / 'mismatch-state.json'
704
+ mismatch_args_path.write_text(json.dumps({
705
+ 'repo': 'example/repo',
706
+ 'repo_dir': str(repo_dir),
707
+ 'mode': 'static',
708
+ 'reference': 'before',
709
+ 'verification_mode': 'visual',
710
+ 'change_request': 'Continue the pricing visual iteration',
711
+ 'commit_message': 'Continue the pricing visual iteration',
712
+ 'build_command': BUILD_SCRIPT,
713
+ 'build_output': 'build',
714
+ 'allow_static_preview_fallback': True,
715
+ 'server_path': '/wrong',
716
+ 'resume_session': json.dumps(parent_session),
717
+ }, indent=2))
718
+ with temporary_env(
719
+ RIDDLE_PROOF_ARGS_FILE=str(mismatch_args_path),
720
+ RIDDLE_PROOF_STATE_FILE=str(mismatch_state_path),
721
+ ):
722
+ try:
723
+ sys.modules.pop('util', None)
724
+ load_module('preflight_resume_session_mismatch', PREFLIGHT_PATH)
725
+ except SystemExit as exc:
726
+ assert 'fingerprint mismatch' in str(exc), exc
727
+ else:
728
+ raise AssertionError('route mismatch should reject resumed proof session')
729
+ mismatch_state = json.loads(mismatch_state_path.read_text())
730
+ assert mismatch_state['proof_session_resume']['status'] == 'fingerprint_mismatch'
731
+ assert any(item['key'] == 'route' for item in mismatch_state['proof_session_resume']['mismatches'])
732
+
733
+ return {
734
+ 'ok': True,
735
+ 'parent_session_id': parent_session['session_id'],
736
+ 'fingerprint': after_preflight['proof_session_resume']['fingerprint'],
737
+ }
738
+ finally:
739
+ shutil.rmtree(tempdir, ignore_errors=True)
740
+
741
+
640
742
  def base_state(tempdir: Path, *, reference='before', prod_url=''):
641
743
  before_dir = tempdir / 'before'
642
744
  after_dir = tempdir / 'after'
@@ -1157,6 +1259,7 @@ def run_verify_requests_supervisor_assessment():
1157
1259
  try:
1158
1260
  state = base_state(tempdir, reference='both', prod_url='https://prod.example.com/pricing')
1159
1261
  state.update({
1262
+ 'repo': 'example/repo',
1160
1263
  'recon_status': 'ready_for_proof_plan',
1161
1264
  'author_status': 'ready',
1162
1265
  'proof_plan_status': 'ready',
@@ -1223,6 +1326,21 @@ def run_verify_requests_supervisor_assessment():
1223
1326
  assert after_details['observed_path_raw'] == '/s/pv-after/pricing', after_details
1224
1327
  assert 'Buy Now' in after_details['visible_text_sample'], after_details
1225
1328
  assert after_details['buttons'] == ['Buy Now'], after_details
1329
+ proof_session = after_verify['proof_session']
1330
+ assert proof_session['version'] == 'riddle-proof.visual-session.v1'
1331
+ assert proof_session['repo'] == 'example/repo'
1332
+ assert proof_session['route']['path'] == '/pricing'
1333
+ assert proof_session['route']['observed_after_path'] == '/pricing'
1334
+ assert proof_session['artifacts']['before'] == 'https://cdn.example.com/before.png'
1335
+ assert proof_session['artifacts']['prod'] == 'https://cdn.example.com/prod.png'
1336
+ assert proof_session['artifacts']['after'] == 'https://cdn.example.com/after.png'
1337
+ assert proof_session['artifacts']['session'] == 'https://cdn.example.com/proof-session.json'
1338
+ assert proof_session['capture']['wait_for_selector'] == '[data-testid=pricing-cta]'
1339
+ assert proof_session['fingerprint_basis']['route'] == '/pricing'
1340
+ assert after_verify['proof_session_fingerprint'] == proof_session['fingerprint']
1341
+ assert after_verify['proof_session_artifact_url'] == 'https://cdn.example.com/proof-session.json'
1342
+ assert after_verify['evidence_bundle']['proof_session']['fingerprint'] == proof_session['fingerprint']
1343
+ assert after_verify['proof_assessment_request']['evidence_bundle']['proof_session']['fingerprint'] == proof_session['fingerprint']
1226
1344
  runtime_events = after_verify.get('runtime_events') or []
1227
1345
  assert any(event.get('kind') == 'workflow.phase.started' and event.get('step') == 'verify' and event.get('phase') == 'build' for event in runtime_events)
1228
1346
  assert any(event.get('kind') == 'workflow.phase.finished' and event.get('step') == 'verify' and event.get('phase') == 'build' for event in runtime_events)
@@ -1866,6 +1984,7 @@ def run_ship_resolves_real_pr_branch():
1866
1984
  if __name__ == '__main__':
1867
1985
  payload = {
1868
1986
  'preflight_reference_skip_reason': run_preflight_records_prod_reference_skip_reason(),
1987
+ 'preflight_resume_visual_proof_session': run_preflight_resumes_visual_proof_session(),
1869
1988
  'capture_artifact_enrichment': run_capture_artifact_enrichment(),
1870
1989
  'capture_diagnostics_redaction': run_capture_diagnostics_redact_sensitive_values(),
1871
1990
  'apply_auth_context': run_apply_auth_context_passes_supported_auth_payloads(),