@githolon/dsl 0.101.5 → 0.102.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dart/CHANGELOG.md +13 -0
  2. package/dart/lib/nomos_client.dart +3 -52
  3. package/dart/lib/src/dev_cloud.dart +5 -0
  4. package/dart/lib/src/home.dart +10 -15
  5. package/dart/lib/src/nms_conformance_runtime.dart +12 -0
  6. package/dart/lib/src/nms_generated_runtime.dart +29 -0
  7. package/dart/lib/src/nms_test_environment.dart +8 -0
  8. package/dart/lib/src/node_runner_transport.dart +56 -18
  9. package/dart/lib/src/provider.dart +5 -1
  10. package/dart/lib/src/record_origin.dart +55 -0
  11. package/dart/lib/src/runner/node-entry.mjs +9105 -0
  12. package/dart/lib/src/runner/offline-install.mjs +4729 -0
  13. package/dart/lib/src/session_bound.dart +23 -10
  14. package/dart/lib/src/source_trust.dart +51 -0
  15. package/dart/lib/src/test_harness.dart +490 -58
  16. package/dart/lib/src/webview_bridge.dart +167 -58
  17. package/dart/lib/src/workspace_name.dart +7 -1
  18. package/dart/pubspec.lock +3 -3
  19. package/dart/pubspec.yaml +4 -2
  20. package/dart/test/acquire_session_credentials_test.dart +34 -17
  21. package/dart/test/acquire_session_test.dart +109 -24
  22. package/dart/test/adv_realm_interference_test.dart +227 -99
  23. package/dart/test/bundle_version_pairing_test.dart +1 -1
  24. package/dart/test/cloud_bound_topology_test.dart +1 -1
  25. package/dart/test/cluster_attested_test.dart +52 -35
  26. package/dart/test/cluster_births_test.dart +5 -2
  27. package/dart/test/cluster_device_keyed_acceptance_test.dart +5 -2
  28. package/dart/test/cluster_local_autosync_test.dart +5 -2
  29. package/dart/test/cluster_scale_test.dart +5 -2
  30. package/dart/test/compat_test.dart +1 -1
  31. package/dart/test/dlq_session_surface_test.dart +2 -1
  32. package/dart/test/errors_test.dart +1 -1
  33. package/dart/test/first_app_gauntlet_test.dart +78 -51
  34. package/dart/test/four_layer_nested_topology_test.dart +1 -1
  35. package/dart/test/harness_phase_timeout_test.dart +1 -1
  36. package/dart/test/home_parity_test.dart +9 -5
  37. package/dart/test/local_sync_inert_test.dart +22 -14
  38. package/dart/test/node_runner_transport_test.dart +9 -5
  39. package/dart/test/null_slot_test.dart +1 -1
  40. package/dart/test/parent_signer_recovery_test.dart +5 -2
  41. package/dart/test/schema_validation_test.dart +1 -1
  42. package/dart/test/sealing_client_test.dart +2 -1
  43. package/dart/test/session_bound_test.dart +92 -119
  44. package/dart/test/stale_bundle_test.dart +1 -1
  45. package/dart/test/test_harness_test.dart +77 -22
  46. package/dart/test/vo_read_typing_test.dart +32 -14
  47. package/dart/test/watch_after_offer_stall_test.dart +2 -1
  48. package/dart/test/webview_bridge_test.dart +17 -4
  49. package/dart/test/wire_test.dart +1 -1
  50. package/dart_types/CHANGELOG.md +8 -0
  51. package/dart_types/lib/nomos_types.dart +7 -6
  52. package/dart_types/lib/src/activity.dart +23 -13
  53. package/dart_types/lib/src/foreign_ref.dart +61 -112
  54. package/dart_types/lib/src/invitation.dart +96 -0
  55. package/dart_types/lib/src/nomos_ref.dart +97 -59
  56. package/dart_types/pubspec.yaml +1 -1
  57. package/dart_types/test/surfaced_test.dart +125 -19
  58. package/package.json +7 -3
  59. package/runtime/generated-application-host.js +8851 -0
  60. package/src/activity.ts +2 -2
  61. package/src/aggregate.ts +24 -1
  62. package/src/application_composition.ts +282 -0
  63. package/src/application_topology.ts +151 -0
  64. package/src/attested_core.ts +2 -2
  65. package/src/authoring_context.ts +9 -0
  66. package/src/birth_meter_ensure.ts +1 -1
  67. package/src/births.ts +53 -5
  68. package/src/build_package.ts +79 -71
  69. package/src/business_birth.ts +93 -10
  70. package/src/clear_directive.ts +3 -1
  71. package/src/codegen_dart.ts +369 -420
  72. package/src/codegen_dart_application.ts +1508 -0
  73. package/src/codegen_dart_client.ts +156 -95
  74. package/src/codegen_dart_scenario.ts +299 -0
  75. package/src/codegen_openapi.ts +8 -3
  76. package/src/codegen_proof.ts +1113 -1137
  77. package/src/codegen_provider_dart.ts +12 -1
  78. package/src/codegen_ts.ts +1176 -496
  79. package/src/compat_package_main.ts +22 -10
  80. package/src/compile_package_main.ts +1536 -337
  81. package/src/contract.ts +10 -4
  82. package/src/dart_barrel_guard.ts +5 -1
  83. package/src/describe_model.ts +13 -5
  84. package/src/directive.ts +244 -5
  85. package/src/engine_entry.ts +56 -13
  86. package/src/engine_javascript.ts +31 -0
  87. package/src/evolve_preview.ts +3 -12
  88. package/src/framework/application_home.ts +24 -10
  89. package/src/framework/capability.ts +15 -1
  90. package/src/framework/domain_lifecycle.ts +8 -0
  91. package/src/framework/framework_aggregates.ts +2 -2
  92. package/src/framework/genesis.ts +11 -1
  93. package/src/framework/home.ts +178 -75
  94. package/src/framework/impure_capability.ts +24 -7
  95. package/src/framework/relations.ts +13 -5
  96. package/src/framework/workspaces.ts +13 -12
  97. package/src/generated_client_names.ts +42 -3
  98. package/src/idof_lint.ts +65 -13
  99. package/src/index.ts +46 -12
  100. package/src/intent.ts +587 -0
  101. package/src/law_check.ts +64 -16
  102. package/src/precondition.ts +16 -4
  103. package/src/read_projection_namespace.ts +2 -7
  104. package/src/recipient.ts +33 -0
  105. package/src/relation_schema.ts +93 -0
  106. package/src/secondary_ensure.ts +11 -2
  107. package/src/shape_compat.ts +11 -1
  108. package/src/usd.ts +30 -8
  109. package/src/usd_layers.ts +447 -246
  110. package/src/usd_state.ts +1 -1
  111. package/src/value_object.ts +49 -13
  112. package/src/wire_encode.ts +124 -26
  113. package/src/workspace_sharing.ts +269 -0
  114. package/src/zod_introspection.ts +84 -0
  115. package/dart/example/nomos_secure_key_store.dart +0 -82
  116. package/dart/example/nomos_webview_transport.dart +0 -63
  117. package/dart/lib/nomos_headless.dart +0 -50
  118. package/dart/lib/testing.dart +0 -14
  119. package/dart/test/co2_product_gauntlet_test.dart +0 -244
  120. package/src/framework/identity.ts +0 -537
@@ -1,6 +1,7 @@
1
1
  // NOMOS TEST HARNESS — an EPHEMERAL, FULLY-OFFLINE real-law fixture for headless app tests. One call
2
- // installs a tenant's compiled law into a FRESH LOCAL workspace (no cloud contact, ever), boots the REAL
3
- // githolon (the byte-identical wasm, the real ledger, the real gate) over [NodeRunnerTransport], and hands
2
+ // installs a tenant's compiled law into a FRESH LOCAL application parent (no cloud contact, ever), performs
3
+ // the same receiving-group birth a production application performs, boots the REAL githolon (the
4
+ // byte-identical wasm, the real ledger, the real gate) over [NodeRunnerTransport], and hands
4
5
  // out a [NomosBridge] the generated Dart clients drive — the SAME surface the app UI uses — under
5
6
  // `flutter test` / `dart test` / CI.
6
7
  //
@@ -16,29 +17,69 @@
16
17
  // ```
17
18
  //
18
19
  // HOW IT STAYS OFFLINE. Two node children, both talking only to the local disk:
19
- // 1. `offline-install.ts` runs the PROVEN offline install lane (`githolon proof`'s adapter engine) —
20
- // genesis → framework controller → each tenant package's `installDomain`, folded through the ONE gate —
21
- // and serializes the workspace tree to a snapshot in a fresh temp dir.
20
+ // 1. `offline-install.ts` runs the PROVEN offline install lane (`githolon proof`'s adapter engine) on a
21
+ // throwaway application parent — genesis → framework controller → each tenant package's
22
+ // `installDomain`, folded through the ONE gate — and serializes its tree to a fresh temp dir. Generated
23
+ // scenarios then birth their deterministic receiving group locally through the real declared birth.
22
24
  // 2. the node runner (`node-entry.ts`) boots in `NOMOS_OFFLINE=1` mode: it serves the runtime wasm from the
23
25
  // local cache (`~/.holon/runtime`, filled by any warm `githolon` run) and boots the holon from that
24
26
  // snapshot (`restoreFrom`). Every other URL is REFUSED — a stray fetch fails loudly, never reaches the net.
25
27
  //
26
- // PREREQUISITES (documented so a failure is legible): `node` on PATH; a resolvable
27
- // `@githolon/client/runner/node-entry.ts`; and a warm `~/.holon/runtime` cache (holon-testmint.wasm + packages.json —
28
- // one `githolon` run fills it). All local; no network at test time.
28
+ // PREREQUISITES (documented so a failure is legible): `node` on PATH and the release-pinned runtime cache.
29
+ // The self-contained runner + installer ship inside nomos_client; the kernel executable does not. A cold
30
+ // runtime is acquired and content-addressed by the release tooling, then tests remain fully offline.
29
31
  //
30
32
  // PACKAGING NOTE: `offline-install.ts` imports the adapter engine, which today lives beside the in-repo
31
33
  // runner. For a PUBLISHED harness this install belongs behind a `githolon` subcommand (the CLI already
32
34
  // bundles the engine + this exact sequence in `proof_offline`); [installHelper] is the one seam to repoint.
35
+ @internal
36
+ library;
37
+
38
+ import 'package:meta/meta.dart' show internal;
39
+
33
40
  import 'dart:convert';
34
41
  import 'dart:io';
35
42
 
36
43
  import 'package:crypto/crypto.dart' show sha256;
44
+ import 'package:package_config/package_config.dart' show loadPackageConfigUri;
37
45
 
38
46
  import 'dev_cloud.dart';
39
47
  import 'node_runner_transport.dart';
48
+ import 'session_bound.dart' show DomainNotInstalledForSession;
40
49
  import 'webview_bridge.dart' show NomosBridge, NomosSession;
41
50
 
51
+ /// Framework-internal classifier for a sync result that can never become converged by retrying. Generated
52
+ /// scenarios use it through [NomosTestHarness.converge]; it is public only inside `package:nomos_client/src`
53
+ /// so the false-green contract has a cheap regression test without booting a cloud.
54
+ String? nmsPermanentConvergenceFailure(Map<String, Object?> result) {
55
+ final admission = result['admission'];
56
+ final sessions = admission is Map && admission['sessions'] is List
57
+ ? admission['sessions'] as List
58
+ : const [];
59
+ final rejected = sessions
60
+ .whereType<Map>()
61
+ .expand((session) =>
62
+ session['rejected'] is List ? session['rejected'] as List : const [])
63
+ .toList();
64
+ final deadLetters = admission is Map && admission['deadLetters'] is List
65
+ ? admission['deadLetters'] as List
66
+ : const [];
67
+ final converged = result['converged'];
68
+ final replayDeadLetters =
69
+ converged is Map && converged['deadLettered'] is List
70
+ ? converged['deadLettered'] as List
71
+ : const [];
72
+ final pushError = result['pushError'];
73
+ final refused = pushError is Map && pushError['refused'] == true;
74
+ if (rejected.isEmpty &&
75
+ deadLetters.isEmpty &&
76
+ replayDeadLetters.isEmpty &&
77
+ !refused) {
78
+ return null;
79
+ }
80
+ return jsonEncode(result);
81
+ }
82
+
42
83
  /// The offline-install lane configuration a harness keeps so the CUSTODY DIAGNOSTIC helpers
43
84
  /// (ask 9 of architecture/session_bound_clients.md) can re-run installs after construction:
44
85
  /// [seedWorkspaceFromDeployJson] / [resetWorkspaceCustody].
@@ -184,17 +225,16 @@ class NomosTestHarness {
184
225
  /// with [NomosTestCloud.setOnline]; assert remote custody with [NomosTestCloud.remoteHead].
185
226
  final NomosTestCloud? testCloud;
186
227
 
187
- /// The HOME workspace a [cloudBound] harness mounted (born remotely by the platform ceremony). Null for
188
- /// the offline constructors.
228
+ /// The primary application workspace. For [cloudBound] this is the identity home; for [ephemeral] it is
229
+ /// the one local application workspace. Internal restart logic uses it to reopen custody first.
189
230
  final String? homeWorkspace;
190
231
 
191
- /// The mounted home as a [NomosSession] (the [cloudBound] app-shaped entry point — bind the generated
192
- /// `HomeClient` to it). Throws for an offline harness (no home ceremony ran).
232
+ /// The primary mounted application session.
193
233
  NomosSession get homeSession {
194
234
  final ws = homeWorkspace;
195
235
  if (ws == null) {
196
236
  throw NomosTestHarnessException(
197
- 'homeSession is only available on a cloudBound harness (this one was built by ephemeral/cluster)');
237
+ 'this harness has no primary application session');
198
238
  }
199
239
  return session(ws);
200
240
  }
@@ -266,6 +306,64 @@ class NomosTestHarness {
266
306
  /// `bridge.acquireSession(childWs, authorSecret: harness.deviceSecret(home))`.
267
307
  String? deviceSecret(String workspace) => _deviceSecrets[workspace];
268
308
 
309
+ /// Framework-private scenario setup for a business intent guarded by a delegated role.
310
+ /// Generated `scenario.given(person).mayX()` methods are the only caller: they name the
311
+ /// business permission while this harness mints and admits the real parent-rooted delegation.
312
+ /// Tenant tests never handle relation names, certificate bytes, signer secrets, or raw offers.
313
+ Future<void> nomosInternalAllowDelegatedScenarioAction({
314
+ required String domain,
315
+ required String acceptanceIntent,
316
+ required String relation,
317
+ required String subject,
318
+ required String subjectField,
319
+ required String certificateField,
320
+ Map<String, Object?> facts = const {},
321
+ }) async {
322
+ final secret = _deviceSecrets[workspace];
323
+ if (secret == null || secret.isEmpty) {
324
+ throw NomosTestHarnessException(
325
+ 'the generated scenario cannot establish its business permission because the test device secret is unavailable');
326
+ }
327
+ final rows = await session(workspace)
328
+ .queryById('birth-attestation:self', null, subject);
329
+ if (rows.length != 1) {
330
+ throw NomosTestHarnessException(
331
+ 'the generated scenario expected one verified birth attestation before establishing business permission; found ${rows.length}');
332
+ }
333
+ final row = rows.single;
334
+ final data = row['data'] is Map
335
+ ? (row['data'] as Map).cast<String, Object?>()
336
+ : row.cast<String, Object?>();
337
+ final rawChain = data['certChain'];
338
+ final decodedChain = rawChain is String ? jsonDecode(rawChain) : rawChain;
339
+ final links = decodedChain is Map && decodedChain['links'] is List
340
+ ? List<Object?>.from(decodedChain['links'] as List)
341
+ : const <Object?>[];
342
+ final issued = await session(workspace).issueDelegationCert(
343
+ signerSecret: secret,
344
+ grantorId: (data['parentId'] ?? workspace).toString(),
345
+ relation: relation,
346
+ subject: subject,
347
+ certChain: links,
348
+ );
349
+ final certificate = issued['certString']?.toString();
350
+ if (certificate == null || certificate.isEmpty) {
351
+ throw NomosTestHarnessException(
352
+ 'the generated scenario could not mint the parent-rooted business delegation');
353
+ }
354
+ await session(workspace).offerOutcome(
355
+ domain: domain,
356
+ directiveId: acceptanceIntent,
357
+ actor: subject,
358
+ authorSecret: secret,
359
+ payload: <String, Object?>{
360
+ ...facts,
361
+ subjectField: subject,
362
+ certificateField: certificate,
363
+ },
364
+ );
365
+ }
366
+
269
367
  /// The workspace names on this device's local plane (one for [ephemeral], N for [cluster]).
270
368
  List<String> get sessions => List.unmodifiable(_sessionNames);
271
369
 
@@ -299,6 +397,7 @@ class NomosTestHarness {
299
397
  /// [autoTearDown] — when true (default) and [registerTearDown] is given, registers [dispose] as a teardown.
300
398
  static Future<NomosTestHarness> ephemeral({
301
399
  required List<String> deployJsonPaths,
400
+ String? ownerSubject,
302
401
  String workspace = 'test-ws',
303
402
  String clientId = 'test-runner',
304
403
  String? runtimeDir,
@@ -320,6 +419,9 @@ class NomosTestHarness {
320
419
  }
321
420
  final entry = nodeEntry ??
322
421
  await _resolveEntry(node, resolveFrom ?? Directory.current.path);
422
+ final effectiveRuntimeDir = runtimeDir ??
423
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
424
+ Platform.environment['NOMOS_OFFLINE_RUNTIME'];
323
425
  // THE INSTALL LANE. [installHelper] is the one seam (PACKAGING NOTE above): either the in-repo
324
426
  // `offline-install.mjs` node script, OR the published `githolon harness-install` subcommand (the CLI
325
427
  // bundles the engine + this exact install sequence, so a published tenant needs nothing but `githolon`).
@@ -337,6 +439,18 @@ class NomosTestHarness {
337
439
  File(resolvedHelper).existsSync();
338
440
  final githolonCmd = installHelper == null ? 'npx githolon' : resolvedHelper;
339
441
 
442
+ final owner = ownerSubject?.trim();
443
+ final birthsApplicationHome = owner != null &&
444
+ owner.isNotEmpty &&
445
+ workspace == _receivingGroupNameOf(owner);
446
+ // A receiving-group address is not an arbitrary law-install target. It is valid only when a parent
447
+ // births it with the attestation, signer seating and warrant ordered into genesis. Generated scenarios
448
+ // therefore install their package on a private throwaway application parent and exercise the same local
449
+ // birth machinery production uses. Raw/law-focused harness callers keep their explicitly named workspace.
450
+ final installWorkspace = birthsApplicationHome
451
+ ? 'nms-app-${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}'
452
+ : workspace;
453
+
340
454
  // A FRESH temp dir per harness — the snapshot + any transient runner state live here and nowhere else,
341
455
  // so dispose() leaves nothing behind.
342
456
  final workdir = await Directory.systemTemp.createTemp('nomos-harness-');
@@ -350,7 +464,7 @@ class NomosTestHarness {
350
464
  final info = await _phased(
351
465
  phase:
352
466
  '1/4: offline law install (${deployJsonPaths.length} package(s))',
353
- workspace: workspace,
467
+ workspace: installWorkspace,
354
468
  awaiting: useInRepoScript
355
469
  ? 'offline-install.ts'
356
470
  : 'githolon harness-install',
@@ -366,11 +480,22 @@ class NomosTestHarness {
366
480
  inRepoScript: resolvedHelper,
367
481
  githolonCmd: githolonCmd,
368
482
  deployJsonPaths: deployJsonPaths,
369
- workspace: workspace,
483
+ workspace: installWorkspace,
370
484
  snapshot: snapshot,
371
- runtimeDir: runtimeDir,
485
+ runtimeDir: effectiveRuntimeDir,
372
486
  onDiagnostic: onDiagnostic));
373
487
 
488
+ final runnerEnvironment = <String, String>{
489
+ 'NOMOS_OFFLINE': '1',
490
+ 'NOMOS_OFFLINE_RESTORE': snapshot,
491
+ 'NOMOS_OFFLINE_WASM': 'holon-testmint.wasm',
492
+ if (info['devicePublic'] != null)
493
+ 'NOMOS_TEST_K_ROOT_PUBLIC': info['devicePublic'].toString(),
494
+ if (info['deviceSecret'] != null)
495
+ 'NOMOS_TEST_ROOT_SECRET': info['deviceSecret'].toString(),
496
+ if (effectiveRuntimeDir != null)
497
+ 'NOMOS_OFFLINE_RUNTIME': effectiveRuntimeDir,
498
+ };
374
499
  transport = await _phased(
375
500
  phase: '2/4: spawn the node runner',
376
501
  awaiting: 'Process.start($node)',
@@ -380,16 +505,7 @@ class NomosTestHarness {
380
505
  entry: entry,
381
506
  resolveFrom: resolveFrom,
382
507
  node: node,
383
- environment: {
384
- 'NOMOS_OFFLINE': '1',
385
- 'NOMOS_OFFLINE_RESTORE': snapshot,
386
- 'NOMOS_OFFLINE_WASM': 'holon-testmint.wasm',
387
- if (info['devicePublic'] != null)
388
- 'NOMOS_TEST_K_ROOT_PUBLIC': info['devicePublic'].toString(),
389
- if (info['deviceSecret'] != null)
390
- 'NOMOS_TEST_ROOT_SECRET': info['deviceSecret'].toString(),
391
- if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
392
- },
508
+ environment: runnerEnvironment,
393
509
  onDiagnostic: onDiagnostic,
394
510
  ));
395
511
  final plane = NomosBridge(transport!);
@@ -405,7 +521,7 @@ class NomosTestHarness {
405
521
  run: () => plane.ready);
406
522
  await _phased(
407
523
  phase: '4/4: connect session',
408
- workspace: workspace,
524
+ workspace: installWorkspace,
409
525
  awaiting: 'connect',
410
526
  timeout: phaseTimeout ?? connectTimeout,
411
527
  onDiagnostic: onDiagnostic,
@@ -416,7 +532,8 @@ class NomosTestHarness {
416
532
  run: () => plane.connect(
417
533
  cloud:
418
534
  'https://offline.invalid', // ignored: the offline runner serves everything locally
419
- workspace: workspace,
535
+ workspace: installWorkspace,
536
+ session: installWorkspace,
420
537
  clientId: clientId,
421
538
  allowUnborn: true,
422
539
  // THE HARNESS DEVICE (era-0): the install lane mints one device key per plane — sessions
@@ -424,6 +541,197 @@ class NomosTestHarness {
424
541
  // with the SAME key, so offline `.births()` offers Just Work.
425
542
  authorSecret: info['deviceSecret']?.toString(),
426
543
  ));
544
+ if (birthsApplicationHome) {
545
+ final devicePublic = info['devicePublic']?.toString();
546
+ final deviceSecret = info['deviceSecret']?.toString();
547
+ final framework = info['frameworkHash']?.toString();
548
+ final laws = ((info['lawHashes'] as List?) ?? const [])
549
+ .map((value) => value.toString())
550
+ .where((value) => value.isNotEmpty)
551
+ .toList();
552
+ if (devicePublic == null ||
553
+ devicePublic.isEmpty ||
554
+ deviceSecret == null ||
555
+ deviceSecret.isEmpty ||
556
+ framework == null ||
557
+ framework.isEmpty ||
558
+ laws.isEmpty) {
559
+ throw NomosTestHarnessException(
560
+ 'the offline application parent did not return the framework law, application law and test device needed to birth the signed-in application');
561
+ }
562
+ final keyHash = sha256.convert(base64Decode(devicePublic)).toString();
563
+ final outcome = await _phased(
564
+ phase: 'application: birth the signed-in application',
565
+ workspace: workspace,
566
+ awaiting: 'attested receiving-group birth',
567
+ timeout: phaseTimeout ?? _kOtherDeadline,
568
+ onDiagnostic: onDiagnostic,
569
+ run: () => plane.session(installWorkspace).offerOutcome(
570
+ domain: '__nomos_application_home_v1',
571
+ directiveId: 'nomosInternalBirthReceivingGroup7c1f5e',
572
+ actor: owner,
573
+ authorSecret: deviceSecret,
574
+ payload: <String, Object?>{
575
+ 'owner': owner,
576
+ 'ownerKeyHash': keyHash,
577
+ 'frameworkHash': framework,
578
+ 'lawHash': laws.first,
579
+ 'bornAt': DateTime.now().toUtc().toIso8601String(),
580
+ },
581
+ ),
582
+ );
583
+ String? bornStatus;
584
+ for (final entry in outcome.birthOutcomes) {
585
+ if (entry['workspace']?.toString() == workspace) {
586
+ bornStatus = entry['status']?.toString();
587
+ break;
588
+ }
589
+ }
590
+ if (outcome.born.length != 1 ||
591
+ outcome.born.single != workspace ||
592
+ bornStatus != 'born') {
593
+ throw NomosTestHarnessException(
594
+ 'the offline application parent did not birth "$workspace" cleanly '
595
+ '(born=${outcome.born}, birthOutcomes=${outcome.birthOutcomes})');
596
+ }
597
+ await _phased(
598
+ phase: 'application: mount the signed-in application',
599
+ workspace: workspace,
600
+ awaiting: 'connect born receiving group',
601
+ timeout: phaseTimeout ?? connectTimeout,
602
+ onDiagnostic: onDiagnostic,
603
+ run: () => plane.connect(
604
+ cloud: 'https://offline.invalid',
605
+ workspace: workspace,
606
+ session: workspace,
607
+ clientId: '$clientId-home',
608
+ authorSecret: deviceSecret,
609
+ ),
610
+ );
611
+ } else if (owner != null && owner.isNotEmpty) {
612
+ final devicePublic = info['devicePublic']?.toString();
613
+ final deviceSecret = info['deviceSecret']?.toString();
614
+ if (devicePublic == null ||
615
+ devicePublic.isEmpty ||
616
+ deviceSecret == null ||
617
+ deviceSecret.isEmpty) {
618
+ throw NomosTestHarnessException(
619
+ 'the offline install did not return the test device key needed to make "$owner" an application-ready signer');
620
+ }
621
+ try {
622
+ final keyHash = sha256.convert(base64Decode(devicePublic)).toString();
623
+ await _phased(
624
+ phase: 'owner: seat the test device signer',
625
+ workspace: workspace,
626
+ awaiting: 'verified test-root signer enrollment',
627
+ timeout: phaseTimeout ?? _kOtherDeadline,
628
+ onDiagnostic: onDiagnostic,
629
+ // A generated TestPerson stands in for a real signed-in person. Seat the throwaway
630
+ // harness device through the installed framework law with the same verified provenance
631
+ // that the private test root later attests. Production clients never manufacture this
632
+ // marker; it exists only inside the offline test-mint ceremony.
633
+ run: () => plane.session(workspace).offerOutcome(
634
+ domain: '__nomos_application_home_v1',
635
+ directiveId: 'enrollSigner',
636
+ actor: owner,
637
+ authorSecret: deviceSecret,
638
+ payload: <String, Object?>{
639
+ 'keyHash': keyHash,
640
+ 'grantedBy': owner,
641
+ 'grantedAt': 1,
642
+ 'verifiedVia': 'nomos:test-mint',
643
+ },
644
+ ),
645
+ );
646
+ } catch (error) {
647
+ // Pre-application-home packages neither expose nor enforce this
648
+ // signer route. Preserve their old harness behaviour, while keeping
649
+ // every refusal from a current package fatal.
650
+ if (!error.toString().contains(
651
+ "no active installed law declares domain '__nomos_application_home_v1'")) {
652
+ rethrow;
653
+ }
654
+ }
655
+ try {
656
+ final bornAt = DateTime.now().toUtc().toIso8601String();
657
+ await _phased(
658
+ phase: 'owner: make the test application ready',
659
+ workspace: workspace,
660
+ awaiting: 'framework owner birth',
661
+ timeout: phaseTimeout ?? _kOtherDeadline,
662
+ onDiagnostic: onDiagnostic,
663
+ run: () async {
664
+ await plane.session(workspace).offerOutcome(
665
+ domain: '__nomos_application_home_v1',
666
+ directiveId: 'nomosInternalSeedReceivingGroupPolicy7c1f5e',
667
+ actor: 'host-birth',
668
+ payload: <String, Object?>{'setAt': bornAt},
669
+ );
670
+ await plane.session(workspace).offerOutcome(
671
+ domain: '__nomos_application_home_v1',
672
+ directiveId: 'nomosInternalSeedReceivingGroup7c1f5e',
673
+ actor: 'host-birth',
674
+ payload: <String, Object?>{
675
+ 'principal': owner,
676
+ 'workspaceName': _receivingGroupNameOf(owner),
677
+ 'bornAt': bornAt,
678
+ },
679
+ );
680
+ },
681
+ );
682
+ final parentCert = await plane.signBirthCert(<String, Object?>{
683
+ 'child': workspace,
684
+ 'founder': owner,
685
+ 'verified': true,
686
+ 'parentKey': devicePublic,
687
+ 'parentId': 'root',
688
+ 'epoch': 0,
689
+ }, deviceSecret);
690
+ await _phased(
691
+ phase: 'owner: establish the verified test application root',
692
+ workspace: workspace,
693
+ awaiting: 'birth attestation then genesis warrant',
694
+ timeout: phaseTimeout ?? _kOtherDeadline,
695
+ onDiagnostic: onDiagnostic,
696
+ run: () async {
697
+ await plane.session(workspace).offerOutcome(
698
+ domain: '__nomos_application_home_v1',
699
+ directiveId: 'seedBirthAttestation',
700
+ actor: owner,
701
+ authorSecret: deviceSecret,
702
+ payload: <String, Object?>{
703
+ 'child': workspace,
704
+ 'founder': owner,
705
+ 'verified': 'true',
706
+ 'parentId': 'root',
707
+ 'parentKey': devicePublic,
708
+ 'epoch': 0,
709
+ 'certChain': jsonEncode(<String, Object?>{
710
+ 'links': <Object?>[parentCert],
711
+ }),
712
+ },
713
+ );
714
+ await plane.session(workspace).offerOutcome(
715
+ domain: '__nomos_application_home_v1',
716
+ directiveId: 'seedGenesis',
717
+ actor: owner,
718
+ authorSecret: deviceSecret,
719
+ payload: <String, Object?>{
720
+ 'principal': owner,
721
+ 'workspaceId': workspace,
722
+ 'bornAt': '2000-01-01T00:00:00.000Z',
723
+ },
724
+ );
725
+ },
726
+ );
727
+ } on DomainNotInstalledForSession {
728
+ // Applications that do not declare the compiler-owned home domain do
729
+ // not have an owner seat to prepare. Only that precise absence is
730
+ // optional: a refusal from an installed home law must still fail the
731
+ // scenario, because hiding it would make an invalid fixture appear
732
+ // application-ready.
733
+ }
734
+ }
427
735
  phase(
428
736
  'ready — session "$workspace" connected (law ${(info['lawHashes'] as List?)?.isNotEmpty == true ? (info['lawHashes'] as List).first.toString().substring(0, 12) : "installed"})');
429
737
  // THE DECOY SESSION (multi-workspace by default in tests): sole-session inference is how
@@ -433,7 +741,7 @@ class NomosTestHarness {
433
741
  // dev's first test. [bridge] is a SESSION HANDLE pinned to [workspace], so tenant code written
434
742
  // against the harness keeps working unchanged; only code that grabs the raw plane and assumes
435
743
  // single-session trips. Opt out with `decoySession: false` (please don't).
436
- if (decoySession) {
744
+ if (decoySession && !birthsApplicationHome) {
437
745
  await _phased(
438
746
  phase: 'decoy: connect the decoy session',
439
747
  workspace: _decoyWorkspace,
@@ -473,9 +781,20 @@ class NomosTestHarness {
473
781
  useInRepoScript: useInRepoScript,
474
782
  inRepoScript: resolvedHelper,
475
783
  githolonCmd: githolonCmd,
476
- runtimeDir: runtimeDir,
784
+ runtimeDir: effectiveRuntimeDir,
477
785
  clientId: clientId),
478
- phaseTimeout: phaseTimeout);
786
+ phaseTimeout: phaseTimeout,
787
+ cloudRestartLane: _CloudRestartLane(
788
+ entry: entry,
789
+ node: node,
790
+ cloud: 'https://offline.invalid',
791
+ authToken: '',
792
+ authorSecret: info['deviceSecret']?.toString() ?? '',
793
+ clientId: clientId,
794
+ resolveFrom: resolveFrom,
795
+ environment: runnerEnvironment,
796
+ ),
797
+ homeWorkspace: workspace);
479
798
  if (autoTearDown && registerTearDown != null) {
480
799
  registerTearDown(harness.dispose);
481
800
  }
@@ -535,6 +854,9 @@ class NomosTestHarness {
535
854
  }
536
855
  final entry = nodeEntry ??
537
856
  await _resolveEntry(node, resolveFrom ?? Directory.current.path);
857
+ final effectiveRuntimeDir = runtimeDir ??
858
+ Platform.environment['NOMOS_DART_RUNTIME'] ??
859
+ Platform.environment['NOMOS_OFFLINE_RUNTIME'];
538
860
  final adjacentInstaller = '${File(entry).parent.path}/offline-install.mjs';
539
861
  final resolvedHelper = installHelper ??
540
862
  (File(adjacentInstaller).existsSync()
@@ -579,7 +901,7 @@ class NomosTestHarness {
579
901
  deployJsonPaths: e.value,
580
902
  workspace: e.key,
581
903
  snapshot: snap,
582
- runtimeDir: runtimeDir,
904
+ runtimeDir: effectiveRuntimeDir,
583
905
  onDiagnostic: onDiagnostic));
584
906
  snapshots[e.key] = snap;
585
907
  lawHashesByWs[e.key] = ((info['lawHashes'] as List?) ?? const [])
@@ -616,7 +938,8 @@ class NomosTestHarness {
616
938
  'NOMOS_TEST_K_ROOT_PUBLIC': planeTestRootPublic,
617
939
  if (planeTestRootSecret != null)
618
940
  'NOMOS_TEST_ROOT_SECRET': planeTestRootSecret,
619
- if (runtimeDir != null) 'NOMOS_OFFLINE_RUNTIME': runtimeDir,
941
+ if (effectiveRuntimeDir != null)
942
+ 'NOMOS_OFFLINE_RUNTIME': effectiveRuntimeDir,
620
943
  },
621
944
  onDiagnostic: onDiagnostic,
622
945
  ));
@@ -721,7 +1044,7 @@ class NomosTestHarness {
721
1044
  useInRepoScript: useInRepoScript,
722
1045
  inRepoScript: resolvedHelper,
723
1046
  githolonCmd: githolonCmd,
724
- runtimeDir: runtimeDir,
1047
+ runtimeDir: effectiveRuntimeDir,
725
1048
  clientId: clientId),
726
1049
  phaseTimeout: phaseTimeout);
727
1050
  if (autoTearDown && registerTearDown != null) {
@@ -881,15 +1204,16 @@ class NomosTestHarness {
881
1204
  'cloudBound: no deploy.json carried a summary.domainHash — cannot run the birth ceremony (lawHash unknown)');
882
1205
  }
883
1206
  final birthRoutes = frameworkPostRoutes
884
- .where((route) => route.directive == 'birthHome')
1207
+ .where((route) =>
1208
+ route.directive == 'nomosInternalBirthReceivingGroup7c1f5e')
885
1209
  .toSet()
886
1210
  .toList();
887
1211
  if (birthRoutes.length != 1) {
888
1212
  throw NomosTestHarnessException(
889
- 'cloudBound: the deployed package must expose exactly one semantic birthHome route; found ${birthRoutes.length}');
1213
+ 'cloudBound: the deployed package must expose exactly one framework receiving-group route; found ${birthRoutes.length}');
890
1214
  }
891
1215
  final applicationHomeDomain = birthRoutes.single.domain;
892
- final birthHomePath = birthRoutes.single.path;
1216
+ final birthReceivingGroupPath = birthRoutes.single.path;
893
1217
  final enrollmentRoutes = frameworkPostRoutes
894
1218
  .where((route) =>
895
1219
  route.directive == 'enrollFirstDevice' &&
@@ -1008,7 +1332,7 @@ class NomosTestHarness {
1008
1332
  final delegationCert = await plane.mintKeylessDelegation({
1009
1333
  'user': subject,
1010
1334
  'deviceKey': publicB64,
1011
- 'childName': _homeNameOf(subject),
1335
+ 'childName': _receivingGroupNameOf(subject),
1012
1336
  'parentId': platformWorkspace,
1013
1337
  'verifiedVia': issuer,
1014
1338
  'idpToken': idpToken,
@@ -1044,11 +1368,12 @@ class NomosTestHarness {
1044
1368
  await _phased(
1045
1369
  phase: '5/6: remote home-birth ceremony (attested)',
1046
1370
  workspace: platformWorkspace,
1047
- awaiting: 'POST /v2/workspaces/$platformWorkspace$birthHomePath',
1371
+ awaiting:
1372
+ 'POST /v2/workspaces/$platformWorkspace$birthReceivingGroupPath',
1048
1373
  timeout: phaseTimeout ?? _kInstallDeadline,
1049
1374
  onDiagnostic: onDiagnostic,
1050
1375
  run: () async {
1051
- final homeName = _homeNameOf(subject);
1376
+ final homeName = _receivingGroupNameOf(subject);
1052
1377
  final homeCert = await plane.signBirthCert({
1053
1378
  'child': homeName,
1054
1379
  'founder': subject,
@@ -1059,8 +1384,8 @@ class NomosTestHarness {
1059
1384
  'parentId': platformWorkspace,
1060
1385
  'epoch': 0,
1061
1386
  }, device.secret);
1062
- final out =
1063
- await post('/v2/workspaces/$platformWorkspace$birthHomePath', {
1387
+ final out = await post(
1388
+ '/v2/workspaces/$platformWorkspace$birthReceivingGroupPath', {
1064
1389
  'owner': subject,
1065
1390
  'ownerKeyHash': device.keyHash,
1066
1391
  'frameworkHash': frameworkHash,
@@ -1069,9 +1394,10 @@ class NomosTestHarness {
1069
1394
  'homeCert': jsonEncode(homeCert),
1070
1395
  'actor': subject,
1071
1396
  'authorSecret': device.secret,
1072
- }, headers: {
1073
- 'x-nomos-auth': device.idpToken
1074
- });
1397
+ },
1398
+ headers: {
1399
+ 'x-nomos-auth': device.idpToken
1400
+ });
1075
1401
  final born = (out['born'] as List?) ?? const [];
1076
1402
  final bornNames = born
1077
1403
  .map((b) => (b as Map)['workspace']?.toString())
@@ -1154,10 +1480,9 @@ class NomosTestHarness {
1154
1480
  }
1155
1481
  }
1156
1482
 
1157
- /// The framework home-slot derivation (`home-<sha256(subject)[0..40]>`) — the fallback when the birth
1158
- /// offer response carries no `born` surface (an older dev cloud).
1159
- static String _homeNameOf(String subject) =>
1160
- 'home-${sha256.convert(utf8.encode(subject)).toString().substring(0, 40)}';
1483
+ /// Compiler/runtime-only receiving-group address. The law independently re-derives the same value.
1484
+ static String _receivingGroupNameOf(String principal) =>
1485
+ 'nomos-rg-${sha256.convert(utf8.encode(principal)).toString().substring(0, 40)}';
1161
1486
 
1162
1487
  /// CONVERGE local custody with the test cloud (co2 topology step 8): sync every connected session —
1163
1488
  /// PARENT-FIRST by connect order, so the home's chain lands remotely and its birth offer-effects create
@@ -1184,8 +1509,18 @@ class NomosTestHarness {
1184
1509
  final r = (await bridge.session(ws).sync(admit: true))
1185
1510
  .cast<String, Object?>();
1186
1511
  last[ws] = r;
1187
- if (r['pushFailed'] != true && r['stillAhead'] != true)
1188
- pending.remove(ws);
1512
+ final permanentFailure = nmsPermanentConvergenceFailure(r);
1513
+ if (permanentFailure != null) {
1514
+ throw NomosTestHarnessException(
1515
+ 'converge: $ws rejected or dead-lettered business work — $permanentFailure',
1516
+ );
1517
+ }
1518
+ if (r['pushFailed'] != true &&
1519
+ r['stillAhead'] != true &&
1520
+ r['offline'] != true &&
1521
+ r['deferred'] != true) pending.remove(ws);
1522
+ } on NomosTestHarnessException {
1523
+ rethrow;
1189
1524
  } catch (e) {
1190
1525
  last[ws] = {'error': e.toString()};
1191
1526
  }
@@ -1403,7 +1738,7 @@ class NomosTestHarness {
1403
1738
  final home = homeWorkspace;
1404
1739
  if (lane == null || home == null) {
1405
1740
  throw NomosTestHarnessException(
1406
- 'restart() is available on a cloudBound harness');
1741
+ 'restart() requires a harness with one primary application session');
1407
1742
  }
1408
1743
 
1409
1744
  final oldPlane = _plane;
@@ -1490,9 +1825,63 @@ class NomosTestHarness {
1490
1825
  }
1491
1826
  }
1492
1827
 
1493
- /// Deterministic, idempotent, total-ordered teardown: stop the runner (SIGTERM → 5s deadline → SIGKILL,
1494
- /// via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to call twice; safe to call after a
1495
- /// failed setup. Nothing left running, nothing left on disk.
1828
+ /// Simulate reinstalling the application with no local ledger custody. Every application workspace must
1829
+ /// reopen from remote custody; a successful business read afterward is therefore a replay proof, not a
1830
+ /// read from the old process snapshot.
1831
+ Future<void> reinstall() async {
1832
+ if (_disposed) {
1833
+ throw NomosTestHarnessException(
1834
+ 'reinstall(): this test device has already been disposed');
1835
+ }
1836
+ final lane = _cloudRestartLane;
1837
+ final home = homeWorkspace;
1838
+ if (lane == null || home == null || testCloud == null) {
1839
+ throw NomosTestHarnessException(
1840
+ 'reinstall() requires a connected scenario with remote custody');
1841
+ }
1842
+
1843
+ final oldPlane = _plane;
1844
+ final targets = oldPlane.connectedSessions
1845
+ .where(
1846
+ (ws) => ws != _decoyWorkspace && !ws.endsWith('-onboard-scratch'))
1847
+ .toList();
1848
+ if (targets.remove(home)) targets.insert(0, home);
1849
+ for (final ws in targets) {
1850
+ await oldPlane.session(ws).stopAutoSync();
1851
+ }
1852
+ await _transport.dispose();
1853
+
1854
+ _transport = await NodeRunnerTransport.start(
1855
+ entry: lane.entry,
1856
+ resolveFrom: lane.resolveFrom,
1857
+ node: lane.node,
1858
+ environment: lane.environment,
1859
+ onDiagnostic: _onDiagnostic,
1860
+ );
1861
+ final reinstalled = NomosBridge(_transport);
1862
+ await reinstalled.ready;
1863
+ for (final ws in targets) {
1864
+ await reinstalled.connect(
1865
+ cloud: lane.cloud,
1866
+ workspace: ws,
1867
+ session: ws,
1868
+ clientId: '${lane.clientId}-reinstall-$ws',
1869
+ authToken: lane.authToken,
1870
+ authorSecret: lane.authorSecret,
1871
+ );
1872
+ }
1873
+ bridge = reinstalled.session(home);
1874
+ _sessionNames
1875
+ ..clear()
1876
+ ..addAll(targets);
1877
+ for (final ws in targets) {
1878
+ await reinstalled.session(ws).autoSync(fallbackMs: 60000);
1879
+ }
1880
+ }
1881
+
1882
+ /// Deterministic, idempotent, total-ordered teardown: close the bridge and all live watches, stop the runner
1883
+ /// (SIGTERM → 5s deadline → SIGKILL, via [NodeRunnerTransport.dispose]), then delete the temp dir. Safe to
1884
+ /// call twice; safe to call after a failed setup. Nothing left running, nothing left on disk.
1496
1885
  /// Disposal must ALWAYS complete (never leave a hung teardown holding the test runner): the transport
1497
1886
  /// dispose is deadlined too, and a breach is LOGGED (not thrown — throwing from teardown would mask the
1498
1887
  /// test's own outcome) before the temp dir is still deleted.
@@ -1502,6 +1891,20 @@ class NomosTestHarness {
1502
1891
  final f = _onDiagnostic;
1503
1892
  if (f != null)
1504
1893
  f('[nomos-test-harness] phase dispose (≤${(_phaseTimeout ?? _kOtherDeadline).inSeconds}s) …');
1894
+ try {
1895
+ // The bridge owns the transport's one message subscription and every watch controller. Closing the
1896
+ // transport first makes its incoming stream wait on consumers that can no longer receive their
1897
+ // unwatch/done messages—a deterministic teardown deadlock in reactive Flutter tests.
1898
+ await _plane.dispose().timeout(_phaseTimeout ?? _kOtherDeadline,
1899
+ onTimeout: () => throw HarnessPhaseTimeout(
1900
+ phase: 'dispose: close bridge watches',
1901
+ workspace: workspace,
1902
+ elapsed: _phaseTimeout ?? _kOtherDeadline,
1903
+ awaiting: 'NomosBridge.dispose'));
1904
+ } catch (e) {
1905
+ _diag(
1906
+ '[nomos-test-harness] bridge dispose error (continuing to runner cleanup): $e');
1907
+ }
1505
1908
  try {
1506
1909
  await _transport.dispose().timeout(_phaseTimeout ?? _kOtherDeadline,
1507
1910
  onTimeout: () => throw HarnessPhaseTimeout(
@@ -1583,7 +1986,7 @@ class NomosTestHarness {
1583
1986
  }
1584
1987
  if (r.exitCode != 0) {
1585
1988
  throw NomosTestHarnessException(
1586
- 'offline law install failed (exit ${r.exitCode}): ${err.isEmpty ? "(no stderr)" : err}');
1989
+ 'offline law install for "$workspace" failed (exit ${r.exitCode}): ${err.isEmpty ? "(no stderr)" : err}');
1587
1990
  }
1588
1991
  // The helper prints one JSON line on success; verify it before spawning the runner.
1589
1992
  final out = (r.stdout as String).trim();
@@ -1603,6 +2006,34 @@ class NomosTestHarness {
1603
2006
  }
1604
2007
 
1605
2008
  static Future<String> _resolveEntry(String node, String from) async {
2009
+ String? packageConfigPath = Platform.packageConfig;
2010
+ if (packageConfigPath == null) {
2011
+ var directory = Directory(from).absolute;
2012
+ while (true) {
2013
+ final candidate = File(
2014
+ '${directory.path}${Platform.pathSeparator}.dart_tool${Platform.pathSeparator}package_config.json',
2015
+ );
2016
+ if (candidate.existsSync()) {
2017
+ packageConfigPath = candidate.uri.toString();
2018
+ break;
2019
+ }
2020
+ final parent = directory.parent;
2021
+ if (parent.path == directory.path) break;
2022
+ directory = parent;
2023
+ }
2024
+ }
2025
+ if (packageConfigPath != null) {
2026
+ final packages = await loadPackageConfigUri(
2027
+ Uri.base.resolve(packageConfigPath),
2028
+ );
2029
+ final bundled = packages.resolve(
2030
+ Uri.parse('package:nomos_client/src/runner/node-entry.mjs'),
2031
+ );
2032
+ if (bundled != null && bundled.scheme == 'file') {
2033
+ final path = bundled.toFilePath();
2034
+ if (File(path).existsSync()) return path;
2035
+ }
2036
+ }
1606
2037
  // PREFER the BUILT .mjs (the published-consumer entry): node refuses type-stripping under node_modules
1607
2038
  // (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING — co2 DEFECT P3-002), so the raw .ts entry only boots
1608
2039
  // in-repo. The .mjs ships from @githolon/client ≥0.97.2; the .ts fallback keeps in-repo dev working.
@@ -1619,8 +2050,9 @@ class NomosTestHarness {
1619
2050
  if (r.exitCode == 0 && out.isNotEmpty) return out;
1620
2051
  }
1621
2052
  throw NomosTestHarnessException(
1622
- 'cannot resolve @githolon/client/runner/node-entry.{mjs,ts} from $from — '
1623
- 'is @githolon/client installed (npm install)? Pass nodeEntry: to override.');
2053
+ 'the Nomos release contains no bundled node runner and '
2054
+ '@githolon/client/runner/node-entry.{mjs,ts} cannot be resolved from $from. '
2055
+ 'Reinstall nomos_client, or pass nodeEntry: only when developing Nomos itself.');
1624
2056
  }
1625
2057
  }
1626
2058