@githolon/dsl 0.105.5 → 0.105.7

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.
@@ -1254,6 +1254,14 @@ class NomosBridge {
1254
1254
  final Map<String, SyncStatus> _statusBySession = {};
1255
1255
  final Map<String, StreamController<SyncStatus>> _syncStatusBySession = {};
1256
1256
  Timer? _metricsTimer;
1257
+ // The zone this bridge was CONSTRUCTED in. The metrics poll belongs to the
1258
+ // bridge (a long-lived, often file-shared object in tests), yet it used to be
1259
+ // created in whichever zone the first status LISTENER attached from — in
1260
+ // widget tests that is a per-test FakeAsync zone, so a file-shared bridge's
1261
+ // periodic poll became a fake timer owned by ONE test and tripped
1262
+ // flutter_test's '!timersPending' invariant at that test's teardown. Timers
1263
+ // run in their owner's zone, not their caller's.
1264
+ final Zone _creationZone = Zone.current;
1257
1265
  Future<void>? _metricsPoll;
1258
1266
  bool _metricsPollAgain = false;
1259
1267
  final Completer<void> _ready = Completer<void>();
@@ -1602,13 +1610,27 @@ class NomosBridge {
1602
1610
  sampled: true,
1603
1611
  );
1604
1612
  _pendingTraces[id] = trace;
1605
- _transport.send({
1606
- 'v': kWebviewBridgeVersion,
1607
- 'id': id,
1608
- 'op': op,
1609
- 'args': args,
1610
- 'trace': trace.toJson(),
1611
- });
1613
+ try {
1614
+ _transport.send({
1615
+ 'v': kWebviewBridgeVersion,
1616
+ 'id': id,
1617
+ 'op': op,
1618
+ 'args': args,
1619
+ 'trace': trace.toJson(),
1620
+ });
1621
+ } catch (error, stackTrace) {
1622
+ // A transport that refuses (disposed at teardown, closed pipe) must fail
1623
+ // THE FUTURE, not throw synchronously: a sync throw bypasses every
1624
+ // `.catchError`/`onError` guard callers attach for exactly this case
1625
+ // (watch re-registration and unwatch during dispose), leaks the pending
1626
+ // completer, and lands as an unhandled error in whatever zone happened to
1627
+ // be current — the "thrown after the test had completed" noise that buried
1628
+ // ~100 real test results. Same loud error, delivered where errors go.
1629
+ _pending.remove(id);
1630
+ _pendingOps.remove(id);
1631
+ _pendingTraces.remove(id);
1632
+ completer.completeError(error, stackTrace);
1633
+ }
1612
1634
  return completer.future;
1613
1635
  }
1614
1636
 
@@ -1791,6 +1813,23 @@ class NomosBridge {
1791
1813
  }).toList();
1792
1814
  }
1793
1815
 
1816
+ /// Framework-private plane persistence primitive. It snapshots exact local custody even when [workspace]
1817
+ /// was born in the kernel but no Dart runner session has ever acquired it. This does not connect, mount a
1818
+ /// tenant session, fetch from cloud, or grant authority; the serving realm exports its resident plane view.
1819
+ Future<({String workspace, String head, String? snapshot, bool unchanged})>
1820
+ nomosInternalSnapshotWorkspace(String workspace, {String? ifHead}) async {
1821
+ final r = _asMap(await _call('snapshotWorkspace', {
1822
+ 'workspace': workspace,
1823
+ if (ifHead != null && ifHead.isNotEmpty) 'ifHead': ifHead,
1824
+ }));
1825
+ return (
1826
+ workspace: (r['workspace'] ?? workspace).toString(),
1827
+ head: (r['head'] ?? '').toString(),
1828
+ snapshot: r['snapshot']?.toString(),
1829
+ unchanged: r['unchanged'] == true,
1830
+ );
1831
+ }
1832
+
1794
1833
  /// Open a workspace on this plane: boot its LOCAL githolon under the installed law (from [restoreFrom]
1795
1834
  /// custody, or a cloud pull when [cloud] is given). This is the sugar the single-workspace path uses; a
1796
1835
  /// multi-workspace app names a [session] per workspace and holds their [session] handles.
@@ -2351,7 +2390,52 @@ class NomosBridge {
2351
2390
  }
2352
2391
  // SINGLE-FLIGHT: one in-flight open per workspace per plane; concurrent callers join it.
2353
2392
  return plane._acquiring.putIfAbsent(workspace, () async {
2354
- try {
2393
+ // RESIDENCY IS THE KERNEL'S TO DEMAND AND OURS TO SATISFY. Replaying a chain can reach a
2394
+ // colocated read whose source workspace is not mounted; the kernel refuses TYPED, naming it.
2395
+ // Mount the named sibling — which consults local custody first and only then clones — and
2396
+ // retry. Bounded, and each name is satisfied at most once, so a genuinely missing workspace
2397
+ // fails with the kernel's own refusal instead of spinning.
2398
+ final satisfied = <String>{};
2399
+ for (var round = 0;; round++) {
2400
+ try {
2401
+ return await _acquireSessionOnce(
2402
+ workspace,
2403
+ ready: ready,
2404
+ inheritPrincipal: inheritPrincipal,
2405
+ ifAbsentCredentials: ifAbsentCredentials,
2406
+ authorSecret: authorSecret,
2407
+ );
2408
+ } catch (error) {
2409
+ final required = nomosInternalResidencyRequiredWorkspace(error);
2410
+ if (required == null ||
2411
+ required == workspace ||
2412
+ !satisfied.add(required) ||
2413
+ round >= 8) {
2414
+ rethrow;
2415
+ }
2416
+ await acquireSession(
2417
+ required,
2418
+ ifAbsentCredentials: ifAbsentCredentials,
2419
+ authorSecret: authorSecret,
2420
+ inheritedPrincipal: sourcePrincipal,
2421
+ );
2422
+ }
2423
+ }
2424
+ }).whenComplete(() {
2425
+ plane._acquiring.remove(workspace);
2426
+ });
2427
+ }
2428
+
2429
+ Future<NomosSession> _acquireSessionOnce(
2430
+ String workspace, {
2431
+ required Future<NomosSession> Function(NomosSession) ready,
2432
+ required void Function() inheritPrincipal,
2433
+ Future<NomosSessionCredentials> Function()? ifAbsentCredentials,
2434
+ String? authorSecret,
2435
+ }) async {
2436
+ final plane = _eventPlane;
2437
+ {
2438
+ {
2355
2439
  if (plane._sessionNames.contains(workspace)) {
2356
2440
  return ready(session(workspace));
2357
2441
  }
@@ -2427,11 +2511,8 @@ class NomosBridge {
2427
2511
  allowUnborn: c.allowUnborn,
2428
2512
  );
2429
2513
  return ready(session(workspace));
2430
- } finally {
2431
- // completed (either way) — the next acquire re-evaluates fresh (a live session hits the fast path).
2432
- plane._acquiring.remove(workspace);
2433
2514
  }
2434
- });
2515
+ }
2435
2516
  }
2436
2517
 
2437
2518
  /// @internal Attach the generated application's opaque local-custody store
@@ -3506,8 +3587,12 @@ class NomosBridge {
3506
3587
  // metrics snapshot.
3507
3588
  if (!_syncStatusCtrl.isClosed) _syncStatusCtrl.add(_status);
3508
3589
  _pollMetricsOnce();
3509
- _metricsTimer ??=
3510
- Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
3590
+ _metricsTimer ??= _creationZone.run(
3591
+ () => Timer.periodic(
3592
+ const Duration(seconds: 4),
3593
+ (_) => _creationZone.run(_pollMetricsOnce),
3594
+ ),
3595
+ );
3511
3596
  }
3512
3597
 
3513
3598
  void _startSessionStatusPoll(String session) {
@@ -3516,8 +3601,12 @@ class NomosBridge {
3516
3601
  controller.add(_currentSessionStatus(session));
3517
3602
  }
3518
3603
  _pollMetricsOnce();
3519
- _metricsTimer ??=
3520
- Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
3604
+ _metricsTimer ??= _creationZone.run(
3605
+ () => Timer.periodic(
3606
+ const Duration(seconds: 4),
3607
+ (_) => _creationZone.run(_pollMetricsOnce),
3608
+ ),
3609
+ );
3521
3610
  }
3522
3611
 
3523
3612
  void _stopStatusPollIfUnused() {
@@ -3654,24 +3743,34 @@ class NomosBridge {
3654
3743
  required int t0,
3655
3744
  required int t1,
3656
3745
  String? id,
3746
+ String? session,
3747
+ String? head,
3657
3748
  }) {
3658
3749
  final plane = _eventPlane;
3659
3750
  final sid = id ?? 'local-${plane._localSpanSeq++}';
3660
3751
  for (final m in <Map<String, Object?>>[
3661
3752
  {
3662
3753
  'event': 'span',
3754
+ if (session != null && session.isNotEmpty) 'session': session,
3663
3755
  'span': {
3664
3756
  'phase': 'begin',
3665
3757
  'id': sid,
3666
3758
  'track': track,
3667
3759
  'name': name,
3668
3760
  'kind': kind,
3669
- 't0': t0
3761
+ 't0': t0,
3762
+ if (head != null && head.isNotEmpty) 'head': head,
3670
3763
  }
3671
3764
  },
3672
3765
  {
3673
3766
  'event': 'span',
3674
- 'span': {'phase': 'end', 'id': sid, 't1': t1}
3767
+ if (session != null && session.isNotEmpty) 'session': session,
3768
+ 'span': {
3769
+ 'phase': 'end',
3770
+ 'id': sid,
3771
+ 't1': t1,
3772
+ if (head != null && head.isNotEmpty) 'head': head,
3773
+ }
3675
3774
  },
3676
3775
  ]) {
3677
3776
  plane._spanBuffer.add(m);
@@ -4270,3 +4369,21 @@ class _BridgeForeignWorkspaceReader implements NomosForeignWorkspaceReader {
4270
4369
  return ctrl.stream;
4271
4370
  }
4272
4371
  }
4372
+
4373
+ /// THE KERNEL NAMES WHAT IT NEEDS. When a chain replay reaches a colocated foreign read whose
4374
+ /// source workspace is not mounted, the kernel refuses with a typed, machine-readable message
4375
+ /// naming that workspace. The cloud has satisfied this since the requirement was introduced; the
4376
+ /// client never learned to, so an estate whose history reads a catalogue simply could not open on a
4377
+ /// device — the same kernel, the same rule, only one implementer.
4378
+ ///
4379
+ /// Parses the workspace name out of the refusal, or null when this is some other failure. Matching
4380
+ /// on the message is deliberate and narrow: the refusal is kernel-authored and stable, and the
4381
+ /// alternative — treating every open failure as a residency miss — would retry real errors forever.
4382
+ String? nomosInternalResidencyRequiredWorkspace(Object error) {
4383
+ final text = error.toString();
4384
+ if (!text.contains('foreign-read-not-ready')) return null;
4385
+ final match = RegExp(r"source workspace '([^']+)' is not resident")
4386
+ .firstMatch(text);
4387
+ final workspace = match?.group(1)?.trim();
4388
+ return (workspace == null || workspace.isEmpty) ? null : workspace;
4389
+ }
package/dart/pubspec.lock CHANGED
@@ -191,7 +191,7 @@ packages:
191
191
  path: "../dart_types"
192
192
  relative: true
193
193
  source: path
194
- version: "0.8.5"
194
+ version: "0.8.7"
195
195
  package_config:
196
196
  dependency: "direct main"
197
197
  description:
package/dart/pubspec.yaml CHANGED
@@ -7,7 +7,7 @@ repository: https://github.com/Captain-App/nomos2
7
7
  description: >-
8
8
  Nomos 2 Dart client — generated domain values, intent payloads and application
9
9
  lifecycle types over the typed Nomos kernel protocol and USDA custody model.
10
- version: 0.64.20
10
+ version: 0.64.24
11
11
 
12
12
  environment:
13
13
  sdk: ^3.5.0
@@ -16,7 +16,7 @@ dependencies:
16
16
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
17
17
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
18
18
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
19
- nomos_types: ^0.8.6
19
+ nomos_types: ^0.8.7
20
20
  json_schema: ^5.2.2
21
21
  crypto: ^3.0.0
22
22
  package_config: ^2.1.0
@@ -70,6 +70,17 @@ class FakeRunner implements BridgeTransport {
70
70
  ]
71
71
  };
72
72
  break;
73
+ case 'snapshotWorkspace':
74
+ result = {
75
+ 'workspace': args['workspace'],
76
+ 'head': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
77
+ if (args['ifHead'] ==
78
+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa')
79
+ 'unchanged': true
80
+ else
81
+ 'snapshot': 'resident-snapshot-b64',
82
+ };
83
+ break;
73
84
  default:
74
85
  result = {};
75
86
  }
@@ -392,6 +403,32 @@ void main() {
392
403
  await bridge.dispose();
393
404
  });
394
405
 
406
+ test(
407
+ 'plane snapshot exports resident custody without manufacturing a Dart session',
408
+ () async {
409
+ final runner = FakeRunner(resident: ['born-child']);
410
+ final bridge = NomosBridge(runner);
411
+ await bridge.ready;
412
+
413
+ final saved =
414
+ await bridge.nomosInternalSnapshotWorkspace('born-child');
415
+ expect(saved.workspace, 'born-child');
416
+ expect(saved.head, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
417
+ expect(saved.snapshot, 'resident-snapshot-b64');
418
+ expect(saved.unchanged, isFalse);
419
+ expect(runner.connectCount, 0,
420
+ reason:
421
+ 'save-all must not acquire an unopened child or invent host authority');
422
+
423
+ final unchanged = await bridge.nomosInternalSnapshotWorkspace(
424
+ 'born-child',
425
+ ifHead: saved.head);
426
+ expect(unchanged.unchanged, isTrue);
427
+ expect(unchanged.snapshot, isNull);
428
+ expect(runner.connectCount, 0);
429
+ await bridge.dispose();
430
+ });
431
+
395
432
  test(
396
433
  'NomosSession.isConnected + assertConnected: fail at BIND, not on first query',
397
434
  () async {
@@ -0,0 +1,49 @@
1
+ // THE KERNEL NAMES WHAT IT NEEDS; EVERY HOST MUST SATISFY IT.
2
+ //
3
+ // The colocated-read residency rule is enforced by the KERNEL, so it fires identically in the cloud
4
+ // container and on a phone — the whole point of one artifact everywhere. It shipped with only the
5
+ // cloud taught to satisfy it, so an estate whose history reads a catalogue could not open on a
6
+ // device at all. These tests pin the client's half: the exact refusal the kernel emits must be
7
+ // recognised, and nothing else must be mistaken for it.
8
+ import 'package:test/test.dart';
9
+ import 'package:nomos_client/src/webview_bridge.dart'
10
+ show nomosInternalResidencyRequiredWorkspace;
11
+
12
+ void main() {
13
+ test('recognises the kernel refusal and names the workspace to mount', () {
14
+ // Verbatim from a device, 2026-08-07 (build 1182, opening an estate).
15
+ const refusal =
16
+ 'NomosBridgeError: NMS-KERNEL-CALL: restore_main: imported custody was refused: '
17
+ '{"atCommit":"2059537e7c7d1dcaa9b4f6f4ac2ded2ae59b53a5","atIndex":20,'
18
+ '"check":"colocated-read","error":"foreign-read-not-ready: source workspace '
19
+ "'catalogue-531e3764f974ff741be62b119a28f0a9dbf396d7' is not resident in this kernel\","
20
+ '"mode":"verify_chain","ok":true,"valid":false,"walk":"full"}';
21
+ expect(
22
+ nomosInternalResidencyRequiredWorkspace(refusal),
23
+ 'catalogue-531e3764f974ff741be62b119a28f0a9dbf396d7',
24
+ );
25
+ });
26
+
27
+ test('does not mistake other failures for a residency miss', () {
28
+ // If any of these were read as "mount something and retry", a real error would retry forever.
29
+ for (final other in <String>[
30
+ 'NomosBridgeError: NMS-KERNEL-CALL: restore_main: verification failed: bad signature',
31
+ 'NomosSeatingPendingFailure: signer enrollment is not yet provably durable',
32
+ 'StateError: the workspace is not on this device\'s plane',
33
+ '',
34
+ ]) {
35
+ expect(nomosInternalResidencyRequiredWorkspace(other), isNull,
36
+ reason: 'must not treat "$other" as a residency requirement');
37
+ }
38
+ });
39
+
40
+ test('the sibling refusal that names no workspace is not actionable', () {
41
+ // The kernel has a second foreign-read-not-ready shape for an unsafe path. There is nothing to
42
+ // mount, so it must fall through to the caller rather than being retried.
43
+ expect(
44
+ nomosInternalResidencyRequiredWorkspace(
45
+ "foreign-read-not-ready: source workspace '../escape' is not a safe internal custody path"),
46
+ isNull,
47
+ );
48
+ });
49
+ }
@@ -19,7 +19,7 @@ String get _root => '${Directory.current.path}/../..';
19
19
  String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.ts';
20
20
  String get _fixture => '$_root/cloud/web-client/test/fixtures/encrypted-notes';
21
21
  String get _deploy => '$_fixture/build/encrypted-notes.law.usdz';
22
- String get _lockfile => '$_fixture/nomos.stable-ids.json';
22
+ String get _lockfile => '$_fixture/nomos.identity.usda';
23
23
  String get _runtimeDir =>
24
24
  Platform.environment['NOMOS_DART_RUNTIME'] ??
25
25
  Platform.environment['NOMOS_OFFLINE_RUNTIME'] ??
@@ -618,6 +618,25 @@ void main() {
618
618
  await bridge.ready.timeout(const Duration(seconds: 1));
619
619
  });
620
620
 
621
+ test('a local durability span remains workspace/head specific', () async {
622
+ await bridge.ready;
623
+ bridge.noteLocalSpan(
624
+ track: 'submit',
625
+ name: 'durable save (disk)',
626
+ kind: 'persist',
627
+ t0: 100,
628
+ t1: 120,
629
+ session: 'born-child',
630
+ head: '2222222222222222222222222222222222222222',
631
+ );
632
+ final saved = bridge.recentSpans
633
+ .where((event) => event['session'] == 'born-child')
634
+ .toList();
635
+ expect(saved, hasLength(2));
636
+ expect((saved.last['span'] as Map)['head'],
637
+ '2222222222222222222222222222222222222222');
638
+ });
639
+
621
640
  test('dispose closes active watches without mutating its iteration',
622
641
  () async {
623
642
  await bridge.ready;
@@ -1,3 +1,8 @@
1
+ ## 0.8.7
2
+
3
+ - Decode only the kernel's stamped structural map-entry envelope as an envelope. Ordinary domain
4
+ objects that happen to contain a `value` field now retain all of their fields.
5
+
1
6
  ## 0.8.6
2
7
 
3
8
  - NEW: `NomosLocalWorkRejectedEvent` / `NomosRejectedIntent` — the typed "the cloud refused work your
@@ -524,13 +524,17 @@ Map<String, Object?> readJsonMapOrEmpty(
524
524
  /// container (the flat projection re-parsed it) is returned as-is. A bare scalar (int
525
525
  /// from a spike path) is returned unchanged.
526
526
  Object? _decodeJsonMapValue(Object? val) {
527
+ // ONLY the kernel's structural entry shape `{value: {Str|Int: ...}, stamp: ...}`
528
+ // may be unwrapped. Keying the check on `value` alone ate any legitimate
529
+ // domain object that happens to carry a `value` field (a custom-field entry
530
+ // `{fieldId, status, updatedAt, value}` lost everything but its value).
527
531
  if (val is Map && val.containsKey('value')) {
528
532
  final value = val['value'];
529
533
  if (value is Map && value.containsKey('Str')) {
530
534
  return _maybeJsonDecode(value['Str']);
531
535
  }
532
536
  if (value is Map && value.containsKey('Int')) return value['Int'];
533
- return value;
537
+ if (val.containsKey('stamp')) return value;
534
538
  }
535
539
  return _maybeJsonDecode(val);
536
540
  }
@@ -9,7 +9,7 @@ description: >-
9
9
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
10
10
  no runtime. Typed protobuf and USDA boundary values plus the offer codec,
11
11
  reactive read engine, schema validation, and data-only error/trace types.
12
- version: 0.8.6
12
+ version: 0.8.7
13
13
 
14
14
  environment:
15
15
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.105.5",
3
+ "version": "0.105.7",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",