@githolon/dsl 0.12.0 → 0.14.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.
@@ -41,6 +41,16 @@ class NomosBridge {
41
41
  final BridgeTransport _transport;
42
42
  final Map<int, Completer<Object?>> _pending = {};
43
43
  final Map<String, StreamController<List<dynamic>>> _watches = {};
44
+ final StreamController<Map<String, Object?>> _syncEvents =
45
+ StreamController<Map<String, Object?>>.broadcast();
46
+ final StreamController<Map<String, Object?>> _signalEvents =
47
+ StreamController<Map<String, Object?>>.broadcast();
48
+ final StreamController<Map<String, Object?>> _flameEvents =
49
+ StreamController<Map<String, Object?>>.broadcast();
50
+ final StreamController<Map<String, Object?>> _spanEvents =
51
+ StreamController<Map<String, Object?>>.broadcast();
52
+ final List<Map<String, Object?>> _spanBuffer =
53
+ []; // recent span events — so BOOT spans (emitted during connect, before the UI subscribes) survive
44
54
  final Completer<void> _ready = Completer<void>();
45
55
  int _seq = 0;
46
56
  late final StreamSubscription<Map<String, Object?>> _sub;
@@ -53,7 +63,8 @@ class NomosBridge {
53
63
  Future<void> get ready => _ready.future;
54
64
 
55
65
  void _onMessage(Map<String, Object?> m) {
56
- if (m['v'] != kWebviewBridgeVersion) return; // a foreign-version message is ignored (forward-compatible)
66
+ if (m['v'] != kWebviewBridgeVersion)
67
+ return; // a foreign-version message is ignored (forward-compatible)
57
68
  final event = m['event'];
58
69
  if (event == 'ready') {
59
70
  if (!_ready.isCompleted) _ready.complete();
@@ -61,7 +72,37 @@ class NomosBridge {
61
72
  }
62
73
  if (event == 'watch') {
63
74
  final ctrl = _watches[m['subId']];
64
- if (ctrl != null && !ctrl.isClosed) ctrl.add((m['rows'] as List?) ?? const []);
75
+ if (ctrl != null && !ctrl.isClosed) {
76
+ if (m['ok'] == false) {
77
+ ctrl.addError(NomosBridgeError(
78
+ (m['error'] ?? 'the watch read failed').toString()));
79
+ } else {
80
+ ctrl.add((m['rows'] as List?) ?? const []);
81
+ }
82
+ }
83
+ return;
84
+ }
85
+ if (event == 'sync' || event == 'link') {
86
+ // autoSync telemetry: each round { event:'sync', reason, result } and socket state { event:'link', status }.
87
+ if (!_syncEvents.isClosed) _syncEvents.add(m);
88
+ return;
89
+ }
90
+ if (event == 'trace') {
91
+ // the realtime cloud flamepath: { event:'trace', trace:{at, oid, edge, clients} } — one per admission.
92
+ if (!_flameEvents.isClosed) _flameEvents.add(m);
93
+ return;
94
+ }
95
+ if (event == 'signal') {
96
+ // signal holon projection: ephemeral coordination such as peer frontiers / lag.
97
+ if (!_signalEvents.isClosed) _signalEvents.add(m);
98
+ return;
99
+ }
100
+ if (event == 'span') {
101
+ // LIVE per-layer span: { event:'span', span:{ phase:'begin'|'end', id, track, name, kind, t0|t1 } }.
102
+ _spanBuffer.add(m);
103
+ if (_spanBuffer.length > 400)
104
+ _spanBuffer.removeRange(0, _spanBuffer.length - 400);
105
+ if (!_spanEvents.isClosed) _spanEvents.add(m);
65
106
  return;
66
107
  }
67
108
  final id = m['id'];
@@ -71,7 +112,8 @@ class NomosBridge {
71
112
  if (m['ok'] == true) {
72
113
  completer.complete(m['result']);
73
114
  } else {
74
- completer.completeError(NomosBridgeError((m['error'] ?? 'the githolon refused the request').toString()));
115
+ completer.completeError(NomosBridgeError(
116
+ (m['error'] ?? 'the githolon refused the request').toString()));
75
117
  }
76
118
  }
77
119
 
@@ -81,65 +123,164 @@ class NomosBridge {
81
123
  final id = ++_seq;
82
124
  final completer = Completer<Object?>();
83
125
  _pending[id] = completer;
84
- _transport.send({'v': kWebviewBridgeVersion, 'id': id, 'op': op, 'args': args});
126
+ _transport
127
+ .send({'v': kWebviewBridgeVersion, 'id': id, 'op': op, 'args': args});
85
128
  return completer.future;
86
129
  }
87
130
 
88
- Map<String, Object?> _asMap(Object? v) => (v as Map?)?.cast<String, Object?>() ?? const {};
131
+ Map<String, Object?> _asMap(Object? v) =>
132
+ (v as Map?)?.cast<String, Object?>() ?? const {};
89
133
  List<Map<String, dynamic>> _asRows(Object? v) =>
90
- ((_asMap(v)['rows'] as List?) ?? const []).map((e) => (e as Map).cast<String, dynamic>()).toList();
134
+ ((_asMap(v)['rows'] as List?) ?? const [])
135
+ .map((e) => (e as Map).cast<String, dynamic>())
136
+ .toList();
91
137
 
92
138
  // ── the runtime-aware surface (the boundary the generated Dart client wraps) ──
93
139
 
94
140
  /// Open the user/estate workspace: pull its ledger, boot the local holon under the installed law.
95
- Future<void> connect({required String cloud, required String workspace, String? clientId, String? authToken, String? restoreFrom}) async {
96
- await _call('connect', {'cloud': cloud, 'workspace': workspace, if (clientId != null) 'clientId': clientId, if (authToken != null) 'authToken': authToken, if (restoreFrom != null) 'restoreFrom': restoreFrom});
141
+ Future<void> connect(
142
+ {required String cloud,
143
+ required String workspace,
144
+ String? clientId,
145
+ String? authToken,
146
+ String? restoreFrom}) async {
147
+ await _call('connect', {
148
+ 'cloud': cloud,
149
+ 'workspace': workspace,
150
+ if (clientId != null) 'clientId': clientId,
151
+ if (authToken != null) 'authToken': authToken,
152
+ if (restoreFrom != null) 'restoreFrom': restoreFrom
153
+ });
97
154
  }
98
155
 
99
156
  /// LOCAL-FIRST write: author `directiveId` under `domainHash`. Returns the new local head; durable on [sync].
100
- Future<String> dispatch({required String domain, required String directiveId, required Map<String, Object?> payload, required String domainHash}) async {
101
- final r = _asMap(await _call('dispatch', {'domain': domain, 'directiveId': directiveId, 'payload': payload, 'domainHash': domainHash}));
157
+ Future<String> dispatch(
158
+ {required String domain,
159
+ required String directiveId,
160
+ required Map<String, Object?> payload,
161
+ required String domainHash}) async {
162
+ final r = _asMap(await _call('dispatch', {
163
+ 'domain': domain,
164
+ 'directiveId': directiveId,
165
+ 'payload': payload,
166
+ 'domainHash': domainHash
167
+ }));
102
168
  return r['head'].toString();
103
169
  }
104
170
 
105
171
  /// §5 cross-custody delivery: a `recordShare` carrying a pre-signed §3 quote into a home holon.
106
- Future<String> deliverShare({required Map<String, Object?> quote, required String domainHash}) async {
107
- final r = _asMap(await _call('deliverShare', {'quote': quote, 'domainHash': domainHash}));
172
+ Future<String> deliverShare(
173
+ {required Map<String, Object?> quote, required String domainHash}) async {
174
+ final r = _asMap(await _call(
175
+ 'deliverShare', {'quote': quote, 'domainHash': domainHash}));
108
176
  return r['head'].toString();
109
177
  }
110
178
 
111
179
  /// A declared query's rows from the local projection.
112
- Future<List<Map<String, dynamic>>> query(String queryId, [Map<String, Object?> params = const {}]) async =>
180
+ Future<List<Map<String, dynamic>>> query(String queryId,
181
+ [Map<String, Object?> params = const {}]) async =>
113
182
  _asRows(await _call('query', {'queryId': queryId, 'params': params}));
114
183
 
115
184
  /// A single aggregate by id (the by-id read).
116
- Future<List<Map<String, dynamic>>> queryById(String id) async => _asRows(await _call('queryById', {'id': id}));
185
+ Future<List<Map<String, dynamic>>> queryById(String id) async =>
186
+ _asRows(await _call('queryById', {'id': id}));
117
187
 
118
188
  /// A maintained O(1) count / sum.
119
- Future<int> count(String countId, [String group = '']) async => ((_asMap(await _call('count', {'countId': countId, 'group': group}))['count']) as num).toInt();
120
- Future<int> sum(String sumId, [String group = '']) async => ((_asMap(await _call('sum', {'sumId': sumId, 'group': group}))['sum']) as num).toInt();
189
+ Future<int> count(String countId, [String group = '']) async =>
190
+ ((_asMap(await _call('count', {'countId': countId, 'group': group}))[
191
+ 'count']) as num)
192
+ .toInt();
193
+ Future<int> sum(String sumId, [String group = '']) async =>
194
+ ((_asMap(await _call('sum', {'sumId': sumId, 'group': group}))['sum'])
195
+ as num)
196
+ .toInt();
121
197
 
122
198
  /// Push local writes → the session branch → cloud admission, and pull canonical main back.
123
- Future<Map<String, Object?>> sync({bool admit = true}) async => _asMap(await _call('sync', {'admit': admit}));
199
+ Future<Map<String, Object?>> sync({bool admit = true}) async =>
200
+ _asMap(await _call('sync', {'admit': admit}));
201
+
202
+ /// PERSIST: a base64 snapshot of the whole local holon (the bare nomos.git incl. un-synced writes + the
203
+ /// DLQ). Store it on disk and pass it back as `connect(restoreFrom:)` next launch — the app then boots from
204
+ /// LOCAL CUSTODY (no network clone), instant at any chain length. Returns the snapshot string.
205
+ Future<String> export() async =>
206
+ (_asMap(await _call('export'))['snapshot'] ?? '').toString();
207
+
208
+ /// The local ledger tip (an O(1) ref read). Cheap change signal — persistence skips re-exporting an
209
+ /// unchanged snapshot when [head] matches the last saved tip.
210
+ Future<String> head() async =>
211
+ (_asMap(await _call('head'))['head'] ?? '').toString();
212
+
213
+ /// AUTO-SYNC — event-driven background sync. The edge DOORBELL converges peers' writes in real time and a
214
+ /// local write flushes the up leg at once; `fallbackMs` is only a just-in-case safety pull (default 60s).
215
+ /// Each round arrives on [syncEvents] as `{ event:'sync', reason, result }`; socket state as `{event:'link'}`.
216
+ /// Call once after [connect] — then the app never polls.
217
+ Future<void> autoSync(
218
+ {int? debounceMs, int? fallbackMs, String? signalId}) async =>
219
+ await _call('autoSync', {
220
+ if (debounceMs != null) 'debounceMs': debounceMs,
221
+ if (fallbackMs != null) 'fallbackMs': fallbackMs,
222
+ if (signalId != null) 'signalId': signalId
223
+ });
224
+ Future<void> stopAutoSync() async {
225
+ await _call('stopAutoSync');
226
+ }
227
+
228
+ /// Background-sync telemetry: each autoSync round (`event:'sync'`) and doorbell socket state (`event:'link'`).
229
+ Stream<Map<String, Object?>> get syncEvents => _syncEvents.stream;
230
+
231
+ /// Ephemeral signal-holon projections (`event:'signal'`), currently peer frontiers / lag for the workspace.
232
+ Stream<Map<String, Object?>> get signalEvents => _signalEvents.stream;
233
+
234
+ /// THE REALTIME FLAMEPATH: one event per admission the edge broadcasts over the doorbell —
235
+ /// `{ at, oid, edge:{totalMs,scanMs,fetchMs,gateFoldMs,sealMs,intents}, clients:[{client,n}] }`.
236
+ /// Every subscriber sees EVERY admission (yours and your peers') — the cloud track of the flame chart.
237
+ Stream<Map<String, Object?>> get flameEvents => _flameEvents.stream;
238
+
239
+ /// LIVE per-layer spans of the local pipeline (begin/end as they happen): mutex wait, githolon author,
240
+ /// push, converge … The host renders in-progress bars so a hang is visible WHILE it blocks, not after.
241
+ Stream<Map<String, Object?>> get spanEvents => _spanEvents.stream;
242
+
243
+ /// The buffered recent span events — replay these on first build to recover the BOOT flamepath
244
+ /// (fetch wasm / clone ledger / instantiate) that streamed during connect(), before the UI subscribed.
245
+ List<Map<String, Object?>> get recentSpans => List.unmodifiable(_spanBuffer);
246
+
247
+ /// Ledger size (commits) + branch-vs-main (ahead) + projection bytes — the dashboard's slow state.
248
+ Future<Map<String, Object?>> metrics() async =>
249
+ _asMap(await _call('metrics'));
124
250
 
125
251
  /// Diagnostics — is the expected law installed/active/drift-free here? (The generated client surfaces it
126
252
  /// so the app can answer "can I author this directive, and if not what deploy fixes it".)
127
- Future<Map<String, Object?>> lawStatus(String hash) async => _asMap(await _call('lawStatus', {'hash': hash}));
253
+ Future<Map<String, Object?>> lawStatus(String hash) async =>
254
+ _asMap(await _call('lawStatus', {'hash': hash}));
128
255
 
129
256
  /// Reactive local read: a stream of row-sets that re-emits on every local change. Cancelling the
130
257
  /// subscription tells the runner to drop the watch.
131
- Stream<List<dynamic>> watch(String queryId, [Map<String, Object?> params = const {}]) =>
258
+ Stream<List<dynamic>> watch(String queryId,
259
+ [Map<String, Object?> params = const {}]) =>
132
260
  _watchStream(kind: 'query', queryId: queryId, params: params);
133
- Stream<List<dynamic>> watchById(String id) => _watchStream(kind: 'byId', id: id);
261
+ Stream<List<dynamic>> watchById(String id) =>
262
+ _watchStream(kind: 'byId', id: id);
134
263
 
135
- Stream<List<dynamic>> _watchStream({required String kind, String? queryId, Map<String, Object?>? params, String? id}) {
264
+ Stream<List<dynamic>> _watchStream(
265
+ {required String kind,
266
+ String? queryId,
267
+ Map<String, Object?>? params,
268
+ String? id}) {
136
269
  final subId = 'w${++_seq}';
137
270
  late final StreamController<List<dynamic>> ctrl;
138
271
  ctrl = StreamController<List<dynamic>>(
139
- onListen: () => _call('watch', {'subId': subId, 'kind': kind, if (queryId != null) 'queryId': queryId, if (params != null) 'params': params, if (id != null) 'id': id}),
272
+ onListen: () => _call('watch', {
273
+ 'subId': subId,
274
+ 'kind': kind,
275
+ if (queryId != null) 'queryId': queryId,
276
+ if (params != null) 'params': params,
277
+ if (id != null) 'id': id
278
+ }),
140
279
  onCancel: () async {
141
280
  _watches.remove(subId);
142
- try { await _call('unwatch', {'subId': subId}); } catch (_) {}
281
+ try {
282
+ await _call('unwatch', {'subId': subId});
283
+ } catch (_) {}
143
284
  },
144
285
  );
145
286
  _watches[subId] = ctrl;
@@ -157,5 +298,9 @@ class NomosBridge {
157
298
  if (!w.isClosed) await w.close();
158
299
  }
159
300
  _watches.clear();
301
+ if (!_syncEvents.isClosed) await _syncEvents.close();
302
+ if (!_signalEvents.isClosed) await _signalEvents.close();
303
+ if (!_flameEvents.isClosed) await _flameEvents.close();
304
+ if (!_spanEvents.isClosed) await _spanEvents.close();
160
305
  }
161
306
  }
@@ -19,7 +19,8 @@ class FakeRunner implements BridgeTransport {
19
19
 
20
20
  FakeRunner() {
21
21
  // the runner announces readiness at construction.
22
- scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
22
+ scheduleMicrotask(
23
+ () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
23
24
  }
24
25
 
25
26
  @override
@@ -32,34 +33,98 @@ class FakeRunner implements BridgeTransport {
32
33
  final op = message['op'] as String;
33
34
  final args = (message['args'] as Map).cast<String, Object?>();
34
35
  // reply exactly as the runner's ops do (the contract both ends share).
35
- Map<String, Object?> reply(Object? result) => {'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result};
36
+ Map<String, Object?> reply(Object? result) =>
37
+ {'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result};
36
38
  scheduleMicrotask(() {
37
39
  switch (op) {
38
- case 'connect': _incoming.add(reply({'workspace': args['workspace']})); break;
40
+ case 'connect':
41
+ _incoming.add(reply({'workspace': args['workspace']}));
42
+ break;
39
43
  case 'dispatch':
40
44
  // a directive the law refuses answers ok:false (the runner frames the gate's verdict).
41
45
  if (args['directiveId'] == 'refuseMe') {
42
- _incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': false, 'error': 'the law refused this intent'});
46
+ _incoming.add({
47
+ 'v': kWebviewBridgeVersion,
48
+ 'id': id,
49
+ 'ok': false,
50
+ 'error': 'the law refused this intent'
51
+ });
43
52
  } else {
44
53
  _incoming.add(reply({'head': 'HEAD-${args['directiveId']}'}));
45
54
  }
46
55
  break;
47
- case 'deliverShare': _incoming.add(reply({'head': 'HEAD-share'})); break;
48
- case 'query': _incoming.add(reply({'rows': [{'id': 'a', 'data': {'q': args['queryId']}}]})); break;
49
- case 'queryById': _incoming.add(reply({'rows': [{'id': args['id'], 'data': {'ok': true}}]})); break;
50
- case 'count': _incoming.add(reply({'count': 7})); break;
51
- case 'sync': _incoming.add(reply({'pushed': true})); break;
52
- case 'lawStatus': _incoming.add(reply({'installed': true, 'drifted': false})); break;
53
- case 'badop': _incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': false, 'error': 'the law refused this intent'}); break;
54
- case 'watch': lastWatchSubId = args['subId'] as String; _incoming.add(reply({'subId': args['subId']})); break;
55
- case 'unwatch': _incoming.add(reply({'subId': args['subId']})); break;
56
- default: _incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': false, 'error': "unknown op '$op'"});
56
+ case 'deliverShare':
57
+ _incoming.add(reply({'head': 'HEAD-share'}));
58
+ break;
59
+ case 'query':
60
+ _incoming.add(reply({
61
+ 'rows': [
62
+ {
63
+ 'id': 'a',
64
+ 'data': {'q': args['queryId']}
65
+ }
66
+ ]
67
+ }));
68
+ break;
69
+ case 'queryById':
70
+ _incoming.add(reply({
71
+ 'rows': [
72
+ {
73
+ 'id': args['id'],
74
+ 'data': {'ok': true}
75
+ }
76
+ ]
77
+ }));
78
+ break;
79
+ case 'count':
80
+ _incoming.add(reply({'count': 7}));
81
+ break;
82
+ case 'sync':
83
+ _incoming.add(reply({'pushed': true}));
84
+ break;
85
+ case 'autoSync':
86
+ _incoming.add(reply({'started': true}));
87
+ break;
88
+ case 'lawStatus':
89
+ _incoming.add(reply({'installed': true, 'drifted': false}));
90
+ break;
91
+ case 'badop':
92
+ _incoming.add({
93
+ 'v': kWebviewBridgeVersion,
94
+ 'id': id,
95
+ 'ok': false,
96
+ 'error': 'the law refused this intent'
97
+ });
98
+ break;
99
+ case 'watch':
100
+ lastWatchSubId = args['subId'] as String;
101
+ _incoming.add(reply({'subId': args['subId']}));
102
+ break;
103
+ case 'unwatch':
104
+ _incoming.add(reply({'subId': args['subId']}));
105
+ break;
106
+ default:
107
+ _incoming.add({
108
+ 'v': kWebviewBridgeVersion,
109
+ 'id': id,
110
+ 'ok': false,
111
+ 'error': "unknown op '$op'"
112
+ });
57
113
  }
58
114
  });
59
115
  }
60
116
 
61
117
  /// Simulate the holon firing a reactive change to an active watch.
62
- void pushWatch(String subId, List<dynamic> rows) => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'watch', 'subId': subId, 'rows': rows});
118
+ void pushWatch(String subId, List<dynamic> rows) => _incoming.add({
119
+ 'v': kWebviewBridgeVersion,
120
+ 'event': 'watch',
121
+ 'subId': subId,
122
+ 'rows': rows
123
+ });
124
+
125
+ /// Simulate the runner forwarding a signal holon projection.
126
+ void pushSignal(Map<String, Object?> signal) => _incoming
127
+ .add({'v': kWebviewBridgeVersion, 'event': 'signal', 'signal': signal});
63
128
  }
64
129
 
65
130
  /// A transport that answers EVERY request with one canned `result` (the golden corpus's reply) and records
@@ -69,7 +134,8 @@ class ReplayTransport implements BridgeTransport {
69
134
  final _incoming = StreamController<Map<String, Object?>>.broadcast();
70
135
  Map<String, Object?>? lastRequest;
71
136
  ReplayTransport(this.result) {
72
- scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
137
+ scheduleMicrotask(
138
+ () => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
73
139
  }
74
140
  @override
75
141
  Stream<Map<String, Object?>> get messages => _incoming.stream;
@@ -77,7 +143,8 @@ class ReplayTransport implements BridgeTransport {
77
143
  void send(Map<String, Object?> message) {
78
144
  lastRequest = message;
79
145
  final id = message['id'];
80
- scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result}));
146
+ scheduleMicrotask(() => _incoming.add(
147
+ {'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result}));
81
148
  }
82
149
  }
83
150
 
@@ -91,24 +158,37 @@ Map<String, Object?> _loadCorpus() {
91
158
  ];
92
159
  for (final p in candidates) {
93
160
  final f = File(p);
94
- if (f.existsSync()) return (jsonDecode(f.readAsStringSync()) as Map).cast<String, Object?>();
161
+ if (f.existsSync())
162
+ return (jsonDecode(f.readAsStringSync()) as Map).cast<String, Object?>();
95
163
  }
96
- fail('golden corpus not found from ${Directory.current.path}; tried $candidates');
164
+ fail(
165
+ 'golden corpus not found from ${Directory.current.path}; tried $candidates');
97
166
  }
98
167
 
99
168
  /// Drive one corpus op through the typed bridge method and return its typed result (so we can compare it to
100
169
  /// what the corpus says the unwrapped value should be).
101
- Future<Object?> _invoke(NomosBridge b, String op, Map<String, Object?> a) async {
170
+ Future<Object?> _invoke(
171
+ NomosBridge b, String op, Map<String, Object?> a) async {
102
172
  switch (op) {
103
173
  case 'connect':
104
- await b.connect(cloud: a['cloud'] as String, workspace: a['workspace'] as String, clientId: a['clientId'] as String?);
174
+ await b.connect(
175
+ cloud: a['cloud'] as String,
176
+ workspace: a['workspace'] as String,
177
+ clientId: a['clientId'] as String?);
105
178
  return null;
106
179
  case 'dispatch':
107
- return b.dispatch(domain: a['domain'] as String, directiveId: a['directiveId'] as String, payload: (a['payload'] as Map).cast<String, Object?>(), domainHash: a['domainHash'] as String);
180
+ return b.dispatch(
181
+ domain: a['domain'] as String,
182
+ directiveId: a['directiveId'] as String,
183
+ payload: (a['payload'] as Map).cast<String, Object?>(),
184
+ domainHash: a['domainHash'] as String);
108
185
  case 'deliverShare':
109
- return b.deliverShare(quote: (a['quote'] as Map).cast<String, Object?>(), domainHash: a['domainHash'] as String);
186
+ return b.deliverShare(
187
+ quote: (a['quote'] as Map).cast<String, Object?>(),
188
+ domainHash: a['domainHash'] as String);
110
189
  case 'query':
111
- return b.query(a['queryId'] as String, (a['params'] as Map).cast<String, Object?>());
190
+ return b.query(
191
+ a['queryId'] as String, (a['params'] as Map).cast<String, Object?>());
112
192
  case 'queryById':
113
193
  return b.queryById(a['id'] as String);
114
194
  case 'count':
@@ -153,7 +233,8 @@ void main() {
153
233
  group('golden corpus (shared with the JS runner)', () {
154
234
  final corpus = _loadCorpus();
155
235
  final cases = (corpus['cases'] as List).cast<Map<String, Object?>>();
156
- test('version pins the bridge version', () => expect(corpus['version'], kWebviewBridgeVersion));
236
+ test('version pins the bridge version',
237
+ () => expect(corpus['version'], kWebviewBridgeVersion));
157
238
  for (final c in cases) {
158
239
  final op = c['op'] as String;
159
240
  final args = (c['args'] as Map).cast<String, Object?>();
@@ -163,8 +244,12 @@ void main() {
163
244
  final bridge = NomosBridge(transport);
164
245
  await bridge.ready;
165
246
  final typed = await _invoke(bridge, op, args);
166
- expect((transport.lastRequest!['args'] as Map).cast<String, Object?>(), equals(args), reason: 'sent request args');
167
- if (op != 'connect') expect(typed, equals(_expectedTyped(op, result)), reason: 'parsed typed result');
247
+ expect((transport.lastRequest!['args'] as Map).cast<String, Object?>(),
248
+ equals(args),
249
+ reason: 'sent request args');
250
+ if (op != 'connect')
251
+ expect(typed, equals(_expectedTyped(op, result)),
252
+ reason: 'parsed typed result');
168
253
  await bridge.dispose();
169
254
  });
170
255
  }
@@ -172,50 +257,107 @@ void main() {
172
257
 
173
258
  late FakeRunner runner;
174
259
  late NomosBridge bridge;
175
- setUp(() { runner = FakeRunner(); bridge = NomosBridge(runner); });
260
+ setUp(() {
261
+ runner = FakeRunner();
262
+ bridge = NomosBridge(runner);
263
+ });
176
264
  tearDown(() => bridge.dispose());
177
265
 
178
266
  test('ready completes once the runner boots', () async {
179
267
  await bridge.ready.timeout(const Duration(seconds: 1));
180
268
  });
181
269
 
182
- test('dispatch round-trips a typed head; args reach the runner verbatim', () async {
270
+ test('dispatch round-trips a typed head; args reach the runner verbatim',
271
+ () async {
183
272
  await bridge.ready;
184
- final head = await bridge.dispatch(domain: 'guestbook', directiveId: 'signGuestbook', payload: {'author': 'ada'}, domainHash: 'abc');
273
+ final head = await bridge.dispatch(
274
+ domain: 'guestbook',
275
+ directiveId: 'signGuestbook',
276
+ payload: {'author': 'ada'},
277
+ domainHash: 'abc');
185
278
  expect(head, 'HEAD-signGuestbook');
186
279
  final req = runner.sent.firstWhere((m) => m['op'] == 'dispatch');
187
280
  expect((req['args'] as Map)['payload'], {'author': 'ada'});
188
281
  expect((req['args'] as Map)['domainHash'], 'abc');
189
282
  });
190
283
 
191
- test('connect / deliverShare / query / queryById / count / sync / lawStatus all wrap the runner', () async {
284
+ test(
285
+ 'connect / deliverShare / query / queryById / count / sync / lawStatus all wrap the runner',
286
+ () async {
192
287
  await bridge.ready;
193
288
  await bridge.connect(cloud: 'https://c', workspace: 'ws', clientId: 'c1');
194
- expect(await bridge.deliverShare(quote: {'publicKey': 'k'}, domainHash: 'h'), 'HEAD-share');
195
- expect((await bridge.query('byAuthor', {'author': 'ada'})).first['data'], {'q': 'byAuthor'});
289
+ expect(
290
+ await bridge.deliverShare(quote: {'publicKey': 'k'}, domainHash: 'h'),
291
+ 'HEAD-share');
292
+ expect((await bridge.query('byAuthor', {'author': 'ada'})).first['data'],
293
+ {'q': 'byAuthor'});
196
294
  expect((await bridge.queryById('Entry_1')).first['id'], 'Entry_1');
197
295
  expect(await bridge.count('entries'), 7);
198
296
  expect(await bridge.sync(), {'pushed': true});
199
- expect(await bridge.lawStatus('abc'), {'installed': true, 'drifted': false});
297
+ expect(
298
+ await bridge.lawStatus('abc'), {'installed': true, 'drifted': false});
200
299
  });
201
300
 
202
- test('watch is a reactive stream; a runner push reaches the listener; cancel unwatches', () async {
301
+ test(
302
+ 'watch is a reactive stream; a runner push reaches the listener; cancel unwatches',
303
+ () async {
203
304
  await bridge.ready;
204
305
  final got = <List<dynamic>>[];
205
306
  final sub = bridge.watch('byAuthor', {'author': 'ada'}).listen(got.add);
206
307
  await Future<void>.delayed(Duration.zero); // let the watch register
207
- runner.pushWatch(runner.lastWatchSubId!, [{'id': 'a', 'data': {'n': 2}}]);
308
+ runner.pushWatch(runner.lastWatchSubId!, [
309
+ {
310
+ 'id': 'a',
311
+ 'data': {'n': 2}
312
+ }
313
+ ]);
208
314
  await Future<void>.delayed(Duration.zero);
209
- expect(got, [[{'id': 'a', 'data': {'n': 2}}]]);
315
+ expect(got, [
316
+ [
317
+ {
318
+ 'id': 'a',
319
+ 'data': {'n': 2}
320
+ }
321
+ ]
322
+ ]);
210
323
  await sub.cancel();
211
324
  expect(runner.sent.any((m) => m['op'] == 'unwatch'), isTrue);
212
325
  });
213
326
 
214
- test('a law refusal throws NomosBridgeError with the verbatim message', () async {
327
+ test('signal events are forwarded on their own stream', () async {
328
+ await bridge.ready;
329
+ final got = <Map<String, Object?>>[];
330
+ final sub = bridge.signalEvents.listen(got.add);
331
+ runner.pushSignal({
332
+ 'type': 'signal.projection',
333
+ 'domain': 'presence',
334
+ 'projection': {
335
+ 'peers': [
336
+ {'clientId': 'prof-a', 'behindBest': 141},
337
+ ],
338
+ },
339
+ });
340
+ await Future<void>.delayed(Duration.zero);
341
+ expect(got.single['signal'], isA<Map>());
342
+ expect(((got.single['signal'] as Map)['projection'] as Map)['peers'],
343
+ isA<List>());
344
+ await sub.cancel();
345
+ });
346
+
347
+ test('autoSync forwards a scoped signal id', () async {
348
+ await bridge.ready;
349
+ await bridge.autoSync(signalId: 'signal:ws:list:main');
350
+ final req = runner.sent.firstWhere((m) => m['op'] == 'autoSync');
351
+ expect((req['args'] as Map)['signalId'], 'signal:ws:list:main');
352
+ });
353
+
354
+ test('a law refusal throws NomosBridgeError with the verbatim message',
355
+ () async {
215
356
  await bridge.ready;
216
357
  Object? err;
217
358
  try {
218
- await bridge.dispatch(domain: 'x', directiveId: 'refuseMe', payload: {}, domainHash: 'h');
359
+ await bridge.dispatch(
360
+ domain: 'x', directiveId: 'refuseMe', payload: {}, domainHash: 'h');
219
361
  } on NomosBridgeError catch (e) {
220
362
  err = e;
221
363
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -641,6 +641,11 @@ export const AuthProvider = aggregate("AuthProvider", {
641
641
  issuer: t.string().merge(Lww),
642
642
  /** The provider's public JWKS endpoint (asymmetric verification keys). */
643
643
  jwksUrl: t.string().merge(Lww),
644
+ /** OPTIONAL expected `aud` claim. When the platform sets it, a token's `aud` MUST carry this value
645
+ * (defense-in-depth beyond the issuer match — e.g. a Firebase project id, an Auth0 API audience).
646
+ * Absent ⇒ issuer match alone. This is LAW-STATE the PLATFORM owns; the host merely enforces it as a
647
+ * capability (it never chooses the audience). */
648
+ audience: t.string().merge(Lww),
644
649
  /** Human label for audit ("acme supabase", ...). */
645
650
  label: t.string().merge(Lww),
646
651
  /** Who set it (attribution). */
@@ -661,6 +666,7 @@ export const setAuthProvider = directive("setAuthProvider")
661
666
  // deterministic engine does not provide — a regex keeps the plan pure.
662
667
  issuer: z.string().regex(/^https?:\/\/\S+$/, "expected an http(s) URL"),
663
668
  jwksUrl: z.string().regex(/^https?:\/\/\S+$/, "expected an http(s) URL"),
669
+ audience: z.string().min(1).optional(),
664
670
  label: z.string().min(1),
665
671
  setBy: z.string().min(1),
666
672
  setAt: epochMs,
@@ -672,6 +678,7 @@ export const setAuthProvider = directive("setAuthProvider")
672
678
  withMarker(set(ap, "scope", "platform"), "ensures"),
673
679
  set(ap, "issuer", p.issuer),
674
680
  set(ap, "jwksUrl", p.jwksUrl),
681
+ ...(p.audience ? [set(ap, "audience", p.audience)] : []),
675
682
  set(ap, "label", p.label),
676
683
  set(ap, "setBy", p.setBy),
677
684
  set(ap, "setAt", p.setAt),