@githolon/dsl 0.95.2 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 0.61.0
2
+
3
+ - **ERA-0 cloud-bound harness** — `NomosTestHarness.cloudBound` now runs the full WARRANTED onboarding
4
+ ceremony against the test cloud: the platform is BORN warranted+attested at cloud boot
5
+ (`NomosTestCloud.start(platform:, deployJsonPath:)`), the owner's device enrols through the REAL
6
+ first-device lane (`/dev/mint-token` → `enrollFirstDevice`), and `birthHome` is device-signed +
7
+ attested with `ownerKeyHash` (the born home is SIGNER-READY — it births estates/catalogues offline
8
+ from intent 0). Six named phases, all deadlined.
9
+ - **`NomosTestCloud.start(platform:, deployJsonPath:)`** — the dev cloud stands the platform up at boot
10
+ (era-0: the host does not create workspaces; the harness's old POST-create refuses 409 by design).
11
+ `/health` gates on `platformReady: true` so clients never race the ceremony.
12
+ - **`acquireSession(…, authorSecret:)`** — sign a locally-born (warranted) child session's writes with
13
+ its owner's device key; `deviceSecret(workspace)`/`devicePublicKey(workspace)` expose the harness
14
+ device (the harness IS the device) for `.births()` payload `ownerKeyHash` derivation.
15
+ - Requires `githolon`/`@githolon/client` ≥ the era-0 release (the client auto-signs self-serve birth
16
+ certs on the kernel's typed `birth-unattestable` refusal — apps never hand-roll certs).
17
+
1
18
  ## 0.56.0
2
19
 
3
20
  - **NomosTestCloud (co2 #4)** — a headless, cloud-BOUND real-sync test harness: start/stop a local
@@ -93,6 +93,16 @@ class NomosTestCloud {
93
93
  bool ephemeral = true,
94
94
  String? dataDir,
95
95
  String node = 'node',
96
+
97
+ /// ERA-0: a workspace is BORN, never host-created — so a platform the harness will offer births to must
98
+ /// be stood up BY THE CLOUD ITSELF at boot (the dev server's in-process operator lane, `--platform`).
99
+ /// A [NomosTestHarness.cloudBound] against this cloud REQUIRES this to name its `platformWorkspace`
100
+ /// (the old lane — the harness POST-creating the platform — refuses 409 `unborn` by design).
101
+ String? platform,
102
+
103
+ /// Optional platform law to deploy at boot (`--deploy build/<name>.deploy.json`) — the dev server
104
+ /// installs it on [platform] before serving. The harness may also deploy law itself post-boot.
105
+ String? deployJsonPath,
96
106
  Duration readyTimeout = const Duration(seconds: 30),
97
107
  void Function(String line)? onDiagnostic,
98
108
  }) async {
@@ -102,6 +112,8 @@ class NomosTestCloud {
102
112
  '--port', '$chosenPort',
103
113
  if (ephemeral) '--ephemeral',
104
114
  if (dataDir != null) ...['--data', dataDir],
115
+ if (platform != null) ...['--platform', platform],
116
+ if (deployJsonPath != null) ...['--deploy', deployJsonPath],
105
117
  ];
106
118
  void log(String m) => onDiagnostic?.call('[nomos-test-cloud] $m');
107
119
  log('spawning: $node ${args.join(' ')}');
@@ -111,23 +123,30 @@ class NomosTestCloud {
111
123
 
112
124
  final upstream = Uri.parse('http://127.0.0.1:$chosenPort');
113
125
  final client = HttpClient();
114
- // Wait for /health.
126
+ // Wait for /health — and, when a platform was requested, for `platformReady: true` (the boot-time
127
+ // platform ceremony: genesis → law deploy → K_root ceremony → warrant). Racing it would hit the
128
+ // half-born platform's typed refusals.
115
129
  final deadline = DateTime.now().add(readyTimeout);
116
130
  var ready = false;
117
131
  while (DateTime.now().isBefore(deadline)) {
118
132
  try {
119
133
  final req = await client.getUrl(upstream.replace(path: '/health'));
120
134
  final resp = await req.close();
121
- if (resp.statusCode == 200) { ready = true; await resp.drain<void>(); break; }
122
- await resp.drain<void>();
135
+ final body = await resp.transform(utf8.decoder).join();
136
+ if (resp.statusCode == 200) {
137
+ final health = jsonDecode(body) as Map<String, dynamic>;
138
+ final platformReady = health['platformReady'];
139
+ if (platform == null || platformReady == true) { ready = true; break; }
140
+ }
123
141
  } catch (_) {/* not up yet */}
124
142
  await Future<void>.delayed(const Duration(milliseconds: 200));
125
143
  }
126
144
  if (!ready) {
127
145
  proc.kill(ProcessSignal.sigkill);
128
- throw NomosTestCloudException('the dev cloud did not answer /health within ${readyTimeout.inSeconds}s (serverEntry: $serverEntry)');
146
+ throw NomosTestCloudException(
147
+ 'the dev cloud did not answer /health${platform != null ? ' with platformReady:true' : ''} within ${readyTimeout.inSeconds}s (serverEntry: $serverEntry)');
129
148
  }
130
- log('dev cloud healthy at $upstream');
149
+ log('dev cloud healthy at $upstream${platform != null ? ' (platform ceremony complete)' : ''}');
131
150
 
132
151
  // The partition proxy — forwards to the dev cloud when ONLINE, fails fast when OFFLINE.
133
152
  final proxy = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
@@ -129,6 +129,10 @@ class NomosTestHarness {
129
129
  /// workspace → the base64 device PUBLIC key it was opened with (only for [cluster]'s `deviceKeyed` set).
130
130
  final Map<String, String> _devicePublics;
131
131
 
132
+ /// workspace → the base64 device SECRET it was opened with (TEST HARNESS ONLY — the harness models the
133
+ /// device, so the test app may sign with the owner's key, e.g. to acquire a locally-born child session).
134
+ final Map<String, String> _deviceSecrets;
135
+
132
136
  /// The nomos framework controller's content hash from the offline install — the `frameworkHash` a
133
137
  /// `.births()` genesis installs (not derivable client-side). Same for every workspace on the plane.
134
138
  final String? frameworkHash;
@@ -172,6 +176,7 @@ class NomosTestHarness {
172
176
  NomosTestHarness._(this.bridge, this.workspace, this._transport, this._workdir,
173
177
  this._onDiagnostic, List<String> sessionNames,
174
178
  {Map<String, String> devicePublics = const {},
179
+ Map<String, String> deviceSecrets = const {},
175
180
  this.frameworkHash,
176
181
  Map<String, List<String>> lawHashes = const {},
177
182
  Map<String, String> snapshots = const {},
@@ -181,6 +186,7 @@ class NomosTestHarness {
181
186
  this.homeWorkspace})
182
187
  : _sessionNames = List.of(sessionNames), // GROWABLE — restoreWorkspace may add a fresh session
183
188
  _devicePublics = devicePublics,
189
+ _deviceSecrets = deviceSecrets,
184
190
  _lawHashes = Map.of(lawHashes),
185
191
  _snapshots = Map.of(snapshots),
186
192
  _installLane = installLane,
@@ -226,6 +232,11 @@ class NomosTestHarness {
226
232
  /// `sha256(base64decode(...))` is the attestor keyHash a reader enrols to trust this source's signed reads.
227
233
  String? devicePublicKey(String workspace) => _devicePublics[workspace];
228
234
 
235
+ /// The base64 device SECRET a session was opened with (TEST HARNESS ONLY — the harness IS the device;
236
+ /// a real app keeps this in secure storage). Sign writes to locally-born children with it, e.g.
237
+ /// `bridge.acquireSession(childWs, authorSecret: harness.deviceSecret(home))`.
238
+ String? deviceSecret(String workspace) => _deviceSecrets[workspace];
239
+
229
240
  /// The workspace names on this device's local plane (one for [ephemeral], N for [cluster]).
230
241
  List<String> get sessions => List.unmodifiable(_sessionNames);
231
242
 
@@ -363,6 +374,10 @@ class NomosTestHarness {
363
374
  workspace: workspace,
364
375
  clientId: clientId,
365
376
  allowUnborn: true,
377
+ // THE HARNESS DEVICE (era-0): the install lane mints one device key per plane — sessions
378
+ // sign with it, and the client completes the self-serve birth ceremony (enrol + cert)
379
+ // with the SAME key, so offline `.births()` offers Just Work.
380
+ authorSecret: info['deviceSecret']?.toString(),
366
381
  ));
367
382
  phase('ready — session "$workspace" connected (law ${(info['lawHashes'] as List?)?.isNotEmpty == true ? (info['lawHashes'] as List).first.toString().substring(0, 12) : "installed"})');
368
383
  // THE DECOY SESSION (multi-workspace by default in tests): sole-session inference is how
@@ -390,6 +405,12 @@ class NomosTestHarness {
390
405
  }
391
406
  final harness = NomosTestHarness._(
392
407
  plane.session(workspace), workspace, transport, workdir, onDiagnostic, [workspace],
408
+ devicePublics: {
409
+ if (info['devicePublic'] != null) workspace: info['devicePublic'].toString(),
410
+ },
411
+ deviceSecrets: {
412
+ if (info['deviceSecret'] != null) workspace: info['deviceSecret'].toString(),
413
+ },
393
414
  frameworkHash: info['frameworkHash']?.toString(),
394
415
  lawHashes: {
395
416
  workspace: ((info['lawHashes'] as List?) ?? const [])
@@ -476,6 +497,8 @@ class NomosTestHarness {
476
497
  // 1. install each workspace's law offline → one snapshot per workspace (colocated, still separate law).
477
498
  final snapshots = <String, String>{};
478
499
  final lawHashesByWs = <String, List<String>>{};
500
+ final installDevicePublics = <String, String>{};
501
+ final installDeviceSecrets = <String, String>{};
479
502
  String? frameworkHash;
480
503
  var _i = 0;
481
504
  for (final e in workspaces.entries) {
@@ -505,6 +528,10 @@ class NomosTestHarness {
505
528
  .map((x) => x.toString())
506
529
  .toList();
507
530
  frameworkHash ??= info['frameworkHash']?.toString();
531
+ // THE HARNESS DEVICE (era-0): the install lane mints one device key per workspace — sessions sign
532
+ // with it and the client completes the self-serve birth ceremony (enrol + cert) with the SAME key.
533
+ if (info['devicePublic'] != null) installDevicePublics[e.key] = info['devicePublic'].toString();
534
+ if (info['deviceSecret'] != null) installDeviceSecrets[e.key] = info['deviceSecret'].toString();
508
535
  }
509
536
 
510
537
  // 2. ONE runner / ONE bridge for the whole plane — no env restore default; each connect brings its own.
@@ -558,6 +585,8 @@ class NomosTestHarness {
558
585
  clientId: '$clientId-${e.key}',
559
586
  restoreFrom: snapshotB64[e.key],
560
587
  allowUnborn: true,
588
+ // signed with the install-lane harness device (era-0 self-serve birth ceremony key)
589
+ authorSecret: installDeviceSecrets[e.key],
561
590
  ));
562
591
  }
563
592
  phase('cluster: ${workspaces.length} session(s) connected');
@@ -567,6 +596,7 @@ class NomosTestHarness {
567
596
  // The re-open is the runner's ATOMIC session replace (dispose-then-connect under the hood); each
568
597
  // leg runs as a named, deadlined phase so a wedge is a typed HarnessPhaseTimeout, never a hang.
569
598
  final devicePublics = <String, String>{};
599
+ final deviceSecretsKeyed = <String, String>{};
570
600
  for (final ws in deviceKeyed) {
571
601
  if (!workspaces.containsKey(ws)) {
572
602
  throw NomosTestHarnessException(
@@ -581,6 +611,7 @@ class NomosTestHarness {
581
611
  onDiagnostic: onDiagnostic,
582
612
  run: () => bridge.session(ws).authorKeygen());
583
613
  devicePublics[ws] = (kp['public'] ?? '').toString();
614
+ deviceSecretsKeyed[ws] = (kp['secret'] ?? '').toString();
584
615
  await _phased(
585
616
  phase: "3b: keyed reconnect of '$ws'",
586
617
  workspace: ws,
@@ -608,7 +639,9 @@ class NomosTestHarness {
608
639
  final names = workspaces.keys.toList();
609
640
  final harness = NomosTestHarness._(
610
641
  bridge, names.first, transport, workdir, onDiagnostic, names,
611
- devicePublics: devicePublics,
642
+ // install-lane harness devices under; explicit deviceKeyed re-opens override for their ws
643
+ devicePublics: {...installDevicePublics, ...devicePublics},
644
+ deviceSecrets: {...installDeviceSecrets, ...deviceSecretsKeyed},
612
645
  frameworkHash: frameworkHash,
613
646
  lawHashes: lawHashesByWs,
614
647
  snapshots: snapshotB64,
@@ -704,18 +737,33 @@ class NomosTestHarness {
704
737
  final workdir = await Directory.systemTemp.createTemp('nomos-harness-');
705
738
  NodeRunnerTransport? transport;
706
739
  try {
707
- // 1/5 — the remote control plane: create the platform workspace + deploy the law under the test
708
- // cloud's own trust universe (its K_root; nothing here touches production).
740
+ // 1/6 — the remote control plane: BOOT the BORN platform + deploy the law under the test cloud's own
741
+ // trust universe. ERA-0: the host does not create workspaces — the platform must have been stood
742
+ // up BY THE CLOUD at boot (`NomosTestCloud.start(platform: '$platformWorkspace')`, the dev
743
+ // server's in-process operator lane). Booting a born workspace is idempotent; an unborn one
744
+ // refuses 409, surfaced here with the fix.
709
745
  String? frameworkHash;
710
746
  final lawHashes = <String>[];
711
747
  await _phased(
712
- phase: '1/5: create platform + deploy law on the test cloud',
748
+ phase: '1/6: boot the born platform + deploy law on the test cloud',
713
749
  workspace: platformWorkspace,
714
750
  awaiting: 'POST /v2/workspaces + /domains',
715
751
  timeout: phaseTimeout ?? _kInstallDeadline,
716
752
  onDiagnostic: onDiagnostic,
717
753
  run: () async {
718
- final created = await post('/v2/workspaces/$platformWorkspace', null);
754
+ Map<String, Object?> created;
755
+ try {
756
+ created = await post('/v2/workspaces/$platformWorkspace', null);
757
+ } on NomosTestHarnessException catch (e) {
758
+ if (e.message.contains('unborn')) {
759
+ throw NomosTestHarnessException(
760
+ "cloudBound: platform '$platformWorkspace' is UNBORN on the test cloud — era-0 has no "
761
+ "host-side create. Start the cloud with it: "
762
+ "NomosTestCloud.start(serverEntry: …, platform: '$platformWorkspace') "
763
+ '(the dev server births it at boot via its operator lane).');
764
+ }
765
+ rethrow;
766
+ }
719
767
  frameworkHash =
720
768
  ((created['controller'] as Map?)?['domainHash'])?.toString();
721
769
  for (final p in deployJsonPaths) {
@@ -734,46 +782,111 @@ class NomosTestHarness {
734
782
  throw NomosTestHarnessException(
735
783
  'cloudBound: no deploy.json carried a summary.domainHash — cannot run the birth ceremony (lawHash unknown)');
736
784
  }
737
- // 2/5 — THE HOME CEREMONY, remotely: the platform's own gateway offer births the home (the ONE
738
- // control-plane contact; the app lane below never touches the platform again).
785
+ // 2/6 — the LOCAL engine plane, CLOUD-BOUND, spawned BEFORE the birth: the runner's local kernel is
786
+ // what mints the device key + signs the birth cert (era-0: a birth is ATTESTED or not at all).
787
+ transport = await _phased(
788
+ phase: '2/6: spawn the node runner (cloud-bound)',
789
+ awaiting: 'Process.start($node)',
790
+ timeout: phaseTimeout ?? _kSpawnDeadline,
791
+ onDiagnostic: onDiagnostic,
792
+ run: () => NodeRunnerTransport.start(
793
+ entry: entry, resolveFrom: resolveFrom, node: node, onDiagnostic: onDiagnostic));
794
+ final plane = NomosBridge(transport!);
795
+ await _phased(
796
+ phase: '3/6: runner ready',
797
+ awaiting: "event:'ready'",
798
+ timeout: phaseTimeout ?? readyTimeout,
799
+ onDiagnostic: onDiagnostic,
800
+ run: () => plane.ready);
801
+ // 4/6 — THE ERA-0 DEVICE CEREMONY (the WARRANTED onboarding lane, exactly production's shape): mint the
802
+ // owner's device key in the LOCAL kernel, get an IdP token from the dev cloud's test IdP
803
+ // (device-bound: nonce == the device keyHash), and enrol the FIRST DEVICE against the platform's
804
+ // on-chain AuthProvider (enrollFirstDevice — no admin in the loop). The platform is WARRANTED, so
805
+ // plain unsigned enrolment refuses by design; this is the one first-device door.
806
+ final device = await _phased(
807
+ phase: '4/6: device ceremony (keygen → dev-IdP token → enrollFirstDevice)',
808
+ workspace: platformWorkspace,
809
+ awaiting: 'authorKeygen + /dev/mint-token + enrollFirstDevice',
810
+ timeout: phaseTimeout ?? _kInstallDeadline,
811
+ onDiagnostic: onDiagnostic,
812
+ run: () async {
813
+ // The runner's crypto ops (authorKeygen/signBirthCert) run on a CONNECTED holon — open an
814
+ // onboarding session against the PLATFORM first (reads are open; this is the enrol surface).
815
+ await plane.connect(
816
+ cloud: cloud.origin,
817
+ workspace: platformWorkspace,
818
+ clientId: '$clientId-onboard',
819
+ authToken: authToken,
820
+ );
821
+ final kp = await plane.authorKeygen();
822
+ final publicB64 = (kp['public'] ?? '').toString();
823
+ final secret = (kp['secret'] ?? '').toString();
824
+ if (publicB64.isEmpty || secret.isEmpty) {
825
+ throw NomosTestHarnessException('cloudBound: authorKeygen returned no device key');
826
+ }
827
+ final keyHash = sha256.convert(base64Decode(publicB64)).toString();
828
+ final minted = await post('/dev/mint-token', {'sub': bareUid, 'nonce': keyHash});
829
+ final idpToken = (minted['token'] ?? '').toString();
830
+ final issuer = (minted['issuer'] ?? '').toString();
831
+ if (idpToken.isEmpty || issuer.isEmpty) {
832
+ throw NomosTestHarnessException('cloudBound: /dev/mint-token returned no token — is the test cloud started with platform:/deployJsonPath: and a law composing setAuthProvider?');
833
+ }
834
+ await post('/v2/workspaces/$platformWorkspace/enrollFirstDevice', {
835
+ 'keyHash': keyHash,
836
+ 'verifiedVia': issuer,
837
+ 'validFromEpoch': 0,
838
+ 'grantedAt': DateTime.now().toUtc().toIso8601String(),
839
+ 'idpToken': idpToken,
840
+ 'actor': subject,
841
+ 'authorSecret': secret,
842
+ }, headers: {'x-nomos-auth': idpToken});
843
+ return (publicB64: publicB64, secret: secret, keyHash: keyHash, idpToken: idpToken);
844
+ });
845
+ // 5/6 — THE HOME CEREMONY, remotely: the platform's own gateway offer births the home (the ONE
846
+ // control-plane contact; the app lane below never touches the platform again). The offer carries
847
+ // the device-signed homeCert + the owner's device keyHash (the home's genesis ENROLS it, so the
848
+ // born home is SIGNER-READY — it can birth its own estates/catalogues from intent 0).
739
849
  String? home;
740
850
  await _phased(
741
- phase: '2/5: remote home-birth ceremony',
851
+ phase: '5/6: remote home-birth ceremony (attested)',
742
852
  workspace: platformWorkspace,
743
853
  awaiting: 'POST /v2/workspaces/$platformWorkspace/birthHome',
744
854
  timeout: phaseTimeout ?? _kInstallDeadline,
745
855
  onDiagnostic: onDiagnostic,
746
856
  run: () async {
857
+ final homeName = _homeNameOf(subject);
858
+ final homeCert = await plane.signBirthCert({
859
+ 'child': homeName,
860
+ 'founder': subject,
861
+ // The cert's `verified` must MATCH the gate verdict (CAS-as-law): the offer carries the
862
+ // dev-IdP token, so the gate resolves a VERIFIED founder.
863
+ 'verified': true,
864
+ 'parentKey': device.publicB64,
865
+ 'parentId': platformWorkspace,
866
+ 'epoch': 0,
867
+ }, device.secret);
747
868
  final out = await post('/v2/workspaces/$platformWorkspace/birthHome', {
748
869
  'owner': subject,
870
+ 'ownerKeyHash': device.keyHash,
749
871
  'frameworkHash': frameworkHash,
750
872
  'lawHash': lawHashes.first,
751
873
  'bornAt': DateTime.now().toUtc().toIso8601String(),
752
- });
874
+ 'homeCert': jsonEncode(homeCert),
875
+ 'actor': subject,
876
+ 'authorSecret': device.secret,
877
+ }, headers: {'x-nomos-auth': device.idpToken});
753
878
  final born = (out['born'] as List?) ?? const [];
754
879
  final bornNames = born.map((b) => (b as Map)['workspace']?.toString()).whereType<String>().where((w) => w.isNotEmpty).toList();
755
880
  home = bornNames.isEmpty ? null : bornNames.first;
756
- home ??= _homeNameOf(subject); // deterministic fallback (home-<sha256(subject)[..40]>)
881
+ if (home == null) {
882
+ final outcomes = jsonEncode(out['birthOutcomes'] ?? const []);
883
+ throw NomosTestHarnessException(
884
+ 'cloudBound: birthHome admitted but spawned no home (birthOutcomes=$outcomes)');
885
+ }
757
886
  });
758
- // 3/5 — the LOCAL engine plane, CLOUD-BOUND (no offline env: the runner pulls the runtime + the
759
- // home's custody from the test cloud like a real device).
760
- transport = await _phased(
761
- phase: '3/5: spawn the node runner (cloud-bound)',
762
- awaiting: 'Process.start($node)',
763
- timeout: phaseTimeout ?? _kSpawnDeadline,
764
- onDiagnostic: onDiagnostic,
765
- run: () => NodeRunnerTransport.start(
766
- entry: entry, resolveFrom: resolveFrom, node: node, onDiagnostic: onDiagnostic));
767
- final plane = NomosBridge(transport!);
768
- await _phased(
769
- phase: '4/5: runner ready',
770
- awaiting: "event:'ready'",
771
- timeout: phaseTimeout ?? readyTimeout,
772
- onDiagnostic: onDiagnostic,
773
- run: () => plane.ready);
774
- // 5/5 — mount the born home as a normal cloud-bound session (clone from the test cloud).
887
+ // 6/6 — mount the born home as a normal cloud-bound session (clone from the test cloud).
775
888
  await _phased(
776
- phase: '5/5: mount the born home',
889
+ phase: '6/6: mount the born home',
777
890
  workspace: home,
778
891
  awaiting: 'connect(cloud:, workspace: $home)',
779
892
  timeout: phaseTimeout ?? connectTimeout,
@@ -783,6 +896,10 @@ class NomosTestHarness {
783
896
  workspace: home!,
784
897
  clientId: clientId,
785
898
  authToken: authToken,
899
+ // The home was born WARRANTED with THIS device enrolled in its genesis (ownerKeyHash) —
900
+ // connect with the same key so every write is signed and the home's own `.births()` offers
901
+ // (estates/catalogues) are device-attested (the client signs the birth cert automatically).
902
+ authorSecret: device.secret,
786
903
  ));
787
904
  // NO DECOY on a cloudBound harness: the app is genuinely multi-workspace (the home plus every
788
905
  // locally-born child), so the plane is never sole-session — the decoy the offline harness
@@ -795,6 +912,11 @@ class NomosTestHarness {
795
912
  workdir,
796
913
  onDiagnostic,
797
914
  [home!],
915
+ // The owner's device (enrolled in the home's genesis via ownerKeyHash) — the app derives the
916
+ // keyHash for its own `.births()` payloads from [devicePublicKey] and signs locally-born-child
917
+ // sessions with [deviceSecret].
918
+ devicePublics: {home!: device.publicB64},
919
+ deviceSecrets: {home!: device.secret},
798
920
  frameworkHash: frameworkHash,
799
921
  lawHashes: {home!: lawHashes},
800
922
  phaseTimeout: phaseTimeout,
@@ -48,7 +48,7 @@ const int kWebviewBridgeVersion = 1;
48
48
  /// pre-stamp bundle, which reports nothing) is a stale baked asset and fails loudly at boot.
49
49
  /// MUST move with the paired @githolon/client release — a release-hygiene test pairs it with the
50
50
  /// runner stamp (the pubspec-pins pattern).
51
- const String kExpectedRunnerBundleVersion = '0.94.0';
51
+ const String kExpectedRunnerBundleVersion = '0.96.0';
52
52
 
53
53
  /// A STALE BAKED RUNNER BUNDLE: the app's built assets carry an older nomos-runner.js than this
54
54
  /// nomos_client release ships — Flutter's build cache kept it across a pub upgrade. Thrown from
@@ -1028,6 +1028,18 @@ class NomosBridge {
1028
1028
  if (m['ok'] == true) {
1029
1029
  completer.complete(m['result']);
1030
1030
  } else {
1031
+ // TYPED BIRTH FAILURE: the JS holon fails FAST on a birth whose child did not come up, and the
1032
+ // runner forwards the per-child outcome entries on errorInfo.failedBirths — rehydrate the typed
1033
+ // [BirthIncomplete] here (apps switch on the type + entries, never scrape the prose).
1034
+ final failedBirths = ((m['errorInfo'] as Map?)?['failedBirths'] as List?)
1035
+ ?.whereType<Map>()
1036
+ .map((e) => e.cast<String, Object?>())
1037
+ .toList();
1038
+ if (failedBirths != null && failedBirths.isNotEmpty) {
1039
+ completer.completeError(BirthIncomplete(failedBirths,
1040
+ head: ((m['errorInfo'] as Map?)?['head'] ?? '').toString()));
1041
+ return;
1042
+ }
1031
1043
  final info = NomosErrorInfo.fromBridgeMessage(m,
1032
1044
  fallbackOperation: op,
1033
1045
  fallbackTrace: trace,
@@ -1474,8 +1486,10 @@ class NomosBridge {
1474
1486
  /// `final estate = await engine.follow(outcome.bornRefs.single); final app = await EstateClient.bind(estate);`
1475
1487
  /// No loose `{workspaceId, aggregateId}` string pairs in app code. [cloud] binds the mount to a
1476
1488
  /// cloud (omit for the local plane — a locally-born child mounts in place, no re-clone).
1489
+ /// [authorSecret] — the device signing key for the followed session's writes (era-0: an attested-born
1490
+ /// child is WARRANTED; write access needs its owner's enrolled device key).
1477
1491
  Future<NomosSession> follow(NomosRef ref,
1478
- {String? cloud, String? clientId, String? authToken, bool allowUnborn = false}) async {
1492
+ {String? cloud, String? clientId, String? authToken, bool allowUnborn = false, String? authorSecret}) async {
1479
1493
  final ws = ref.workspace;
1480
1494
  if (ws.isEmpty) {
1481
1495
  throw ArgumentError.value(ref, 'ref',
@@ -1488,7 +1502,8 @@ class NomosBridge {
1488
1502
  session: ws,
1489
1503
  clientId: clientId,
1490
1504
  authToken: authToken,
1491
- allowUnborn: allowUnborn);
1505
+ allowUnborn: allowUnborn,
1506
+ authorSecret: authorSecret);
1492
1507
  }
1493
1508
  return session(ws);
1494
1509
  }
@@ -1503,8 +1518,13 @@ class NomosBridge {
1503
1518
  /// [ifAbsentCredentials] invoked — exactly once — and the connect made with what it returns.
1504
1519
  /// Omitting [ifAbsentCredentials] makes a genuinely-absent workspace a named [StateError] instead of a
1505
1520
  /// silent cloudless mount.
1521
+ /// [authorSecret] — the device signing key for THIS session's writes (era-0: a WARRANTED workspace —
1522
+ /// which every attested-born child is — refuses unsigned writes, so a locally-born child the app will
1523
+ /// WRITE to must be acquired with its owner's device key). Applies to the resident (local) open;
1524
+ /// the credentials path takes its own `NomosSessionCredentials.authorSecret` (falling back to this).
1506
1525
  Future<NomosSession> acquireSession(String workspace,
1507
- {Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) {
1526
+ {Future<NomosSessionCredentials> Function()? ifAbsentCredentials,
1527
+ String? authorSecret}) {
1508
1528
  // Fast path: a live session needs no runner round-trip (and never asks for credentials).
1509
1529
  final plane = _eventPlane;
1510
1530
  if (plane._sessionNames.contains(workspace)) {
@@ -1524,7 +1544,7 @@ class NomosBridge {
1524
1544
  // a pre-connect plane has no registry yet — fall through to the credentials decision below.
1525
1545
  }
1526
1546
  if (resident) {
1527
- await connect(workspace: workspace, session: workspace, allowUnborn: true);
1547
+ await connect(workspace: workspace, session: workspace, allowUnborn: true, authorSecret: authorSecret);
1528
1548
  return session(workspace);
1529
1549
  }
1530
1550
  if (ifAbsentCredentials == null) {
@@ -1539,7 +1559,7 @@ class NomosBridge {
1539
1559
  session: workspace,
1540
1560
  clientId: c.clientId,
1541
1561
  authToken: c.authToken,
1542
- authorSecret: c.authorSecret,
1562
+ authorSecret: c.authorSecret ?? authorSecret,
1543
1563
  allowUnborn: c.allowUnborn,
1544
1564
  );
1545
1565
  return session(workspace);
@@ -2495,15 +2515,16 @@ class NomosSession extends NomosBridge {
2495
2515
  /// single-flight registry; a session never owns mounts).
2496
2516
  @override
2497
2517
  Future<NomosSession> acquireSession(String workspace,
2498
- {Future<NomosSessionCredentials> Function()? ifAbsentCredentials}) =>
2499
- parent.acquireSession(workspace, ifAbsentCredentials: ifAbsentCredentials);
2518
+ {Future<NomosSessionCredentials> Function()? ifAbsentCredentials,
2519
+ String? authorSecret}) =>
2520
+ parent.acquireSession(workspace, ifAbsentCredentials: ifAbsentCredentials, authorSecret: authorSecret);
2500
2521
 
2501
2522
  /// A followed ref mounts on the SHARED plane — delegate to the parent (a session never owns mounts).
2502
2523
  @override
2503
2524
  Future<NomosSession> follow(NomosRef ref,
2504
- {String? cloud, String? clientId, String? authToken, bool allowUnborn = false}) =>
2525
+ {String? cloud, String? clientId, String? authToken, bool allowUnborn = false, String? authorSecret}) =>
2505
2526
  parent.follow(ref,
2506
- cloud: cloud, clientId: clientId, authToken: authToken, allowUnborn: allowUnborn);
2527
+ cloud: cloud, clientId: clientId, authToken: authToken, allowUnborn: allowUnborn, authorSecret: authorSecret);
2507
2528
 
2508
2529
  @override
2509
2530
  Future<Object?> _call(String op, [Map<String, Object?> args = const {}]) =>
package/dart/pubspec.yaml CHANGED
@@ -11,7 +11,7 @@ description: >-
11
11
  Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
12
12
  code-generated per-domain aggregate + directive-payload types. (De)serializes
13
13
  the EXACT serde JSON the Rust kernel + projection produce/consume.
14
- version: 0.60.0
14
+ version: 0.61.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -17,6 +17,7 @@
17
17
  import 'dart:convert';
18
18
  import 'dart:io';
19
19
 
20
+ import 'package:crypto/crypto.dart' show sha256;
20
21
  import 'package:nomos_client/nomos_headless.dart';
21
22
  import 'package:test/test.dart';
22
23
 
@@ -47,7 +48,7 @@ void main() {
47
48
  final diag = Platform.environment['NOMOS_HARNESS_DIAG'] == '1'
48
49
  ? (String l) => stderr.writeln(l)
49
50
  : null;
50
- final cloud = await NomosTestCloud.start(serverEntry: _devServer, onDiagnostic: diag);
51
+ final cloud = await NomosTestCloud.start(serverEntry: _devServer, platform: 'topology-platform', deployJsonPath: _deploy, onDiagnostic: diag);
51
52
  NomosTestHarness? harness;
52
53
  try {
53
54
  // 1+2 — the remote ceremony + the local mount, in one constructor (the app's boot path).
@@ -70,6 +71,11 @@ void main() {
70
71
  cloud.setOnline(false);
71
72
 
72
73
  // 4 — the home births the estate LOCALLY (the app lane never needs the network).
74
+ // ownerKeyHash: the owner's device (enrolled in the home's genesis) — the estate enrols the SAME
75
+ // key at birth so it accepts the owner's signed writes from intent 0 (era-0 signer-ready births).
76
+ final ownerKeyHash = sha256
77
+ .convert(base64Decode(harness.devicePublicKey(home.workspace)!))
78
+ .toString();
73
79
  final outcome = await harness.bridge.session(home.workspace).offerCreates(
74
80
  domain: 'home',
75
81
  directiveId: 'birthEstateWorkspace',
@@ -78,6 +84,7 @@ void main() {
78
84
  'frameworkHash': harness.frameworkHash,
79
85
  'lawHash': harness.lawHashes(home.workspace).first,
80
86
  'bornAt': '2026-07-14T00:00:00Z',
87
+ 'ownerKeyHash': ownerKeyHash,
81
88
  },
82
89
  actor: _subject,
83
90
  );
@@ -86,7 +93,7 @@ void main() {
86
93
  expect(estateWs, startsWith('estate-'));
87
94
 
88
95
  // 5 — the estate opens as a sibling session and accepts a LOCAL WRITE while offline.
89
- final estate = await harness.bridge.acquireSession(estateWs);
96
+ final estate = await harness.bridge.acquireSession(estateWs, authorSecret: harness.deviceSecret(home.workspace));
90
97
  await estate.offerCreates(
91
98
  domain: 'estate',
92
99
  directiveId: 'writeEstateNote',
@@ -49,13 +49,15 @@ const _owner = 'sample-owner';
49
49
  /// Set up a single-session plane over the estate-birth law + OFFER `birthEstateWorkspace` on it. Returns the
50
50
  /// harness (for teardown) + the offer outcome (born / birthOutcomes surfaced). [frameworkHash] overrides the
51
51
  /// real framework hash (pass a bogus one to force a genesis failure).
52
- Future<(NomosTestHarness, NomosOfferOutcome)> _birth({String? frameworkHash}) async {
52
+ Future<(NomosTestHarness, NomosOfferOutcome)> _birth(
53
+ {String? frameworkHash, void Function(NomosTestHarness)? onHarness}) async {
53
54
  final harness = await NomosTestHarness.ephemeral(
54
55
  deployJsonPaths: [_birthDeploy],
55
56
  nodeEntry: _repoEntry,
56
57
  runtimeDir: _runtimeDir,
57
58
  workspace: 'estate-birth',
58
59
  );
60
+ onHarness?.call(harness);
59
61
  // The genesis recipe installs the framework + child law BY HASH — the harness surfaces both (frameworkHash
60
62
  // is not derivable client-side; lawHash is this package's own domainHash).
61
63
  final fw = frameworkHash ?? harness.frameworkHash;
@@ -171,18 +173,23 @@ void main() {
171
173
  }
172
174
  // A well-formed but WRONG framework hash the parent does not fold → the child's step-0 install can't be
173
175
  // satisfied → the genesis fold refuses. The offer still admits on the parent (the birth ORDER is valid);
174
- // the child's fate comes back as a TYPED failure outcome, not a thrown error or a silent drop.
176
+ // the child's fate FAILS FAST as the TYPED [BirthIncomplete] (both the JS holon and the bridge throw it
177
+ // by design — a silent partial birth would strand the app polling head:null), carrying the per-child
178
+ // typed outcome entries (workspace, status, code, reason) — never a stringly error or a silent drop.
175
179
  final badHash = 'deadbeef' * 8; // 64 hex chars, not a package the parent holds
176
- final (harness, outcome) = await _birth(frameworkHash: badHash);
180
+ NomosTestHarness? harness;
177
181
  try {
178
- expect(outcome.birthOutcomes, isNotEmpty,
179
- reason: 'a failed birth still reports its typed verdict to Dart');
180
- expect(outcome.birthOutcomes.first['status'], isNot('born'),
182
+ await _birth(frameworkHash: badHash, onHarness: (h) => harness = h);
183
+ fail('an unresolvable framework hash must fail the birth (BirthIncomplete expected)');
184
+ } on BirthIncomplete catch (e) {
185
+ expect(e.failedBirths, isNotEmpty,
186
+ reason: 'a failed birth still reports its typed per-child verdicts');
187
+ expect(e.failedBirths.first['status'], isNot('born'),
181
188
  reason: 'an unresolvable framework hash cannot yield a born child');
182
- expect((outcome.birthOutcomes.first['reason'] ?? '').toString(), isNotEmpty,
189
+ expect((e.failedBirths.first['reason'] ?? '').toString(), isNotEmpty,
183
190
  reason: 'the failure carries a human-legible reason (never a silent drop)');
184
191
  } finally {
185
- await harness.dispose();
192
+ await harness?.dispose();
186
193
  }
187
194
  }, timeout: const Timeout(Duration(minutes: 3)));
188
195
  }
@@ -14,8 +14,11 @@
14
14
  @TestOn('vm')
15
15
  library;
16
16
 
17
+ import 'dart:convert';
17
18
  import 'dart:io';
18
19
 
20
+ import 'package:crypto/crypto.dart' show sha256;
21
+
19
22
  import 'package:nomos_client/nomos_headless.dart';
20
23
  import 'package:test/test.dart';
21
24
 
@@ -139,6 +142,8 @@ void main() {
139
142
  frameworkHash: harness.frameworkHash!,
140
143
  lawHash: harness.lawHashes(home.workspace).first,
141
144
  bornAt: DateTime.now().toUtc().toIso8601String(),
145
+ // era-0: the estate enrols the owner's device at birth (signer-ready) — the harness device.
146
+ ownerKeyHash: sha256.convert(base64Decode(harness.devicePublicKey(home.workspace)!)).toString(),
142
147
  ),
143
148
  );
144
149
  expect(birth.workspace, home.workspace,
@@ -146,7 +151,7 @@ void main() {
146
151
  expect(birth.born, hasLength(1));
147
152
  expect(birth.birthOutcomes.first['status'], 'born');
148
153
 
149
- final estateSession = await harness.bridge.follow(birth.bornRefs.single);
154
+ final estateSession = await harness.bridge.follow(birth.bornRefs.single, authorSecret: harness.deviceSecret(home.workspace));
150
155
  expect(estateSession.workspace, birth.born.single);
151
156
 
152
157
  final estateClient = await EstateClient.bind(estateSession, actor: _owner);