@githolon/dsl 0.9.0 → 0.11.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.
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// COPY THIS INTO YOUR FLUTTER APP. It is an EXAMPLE, not part of the `nomos_dsl` library: it depends on
|
|
2
|
+
// `webview_flutter`, which the pure-Dart `nomos_dsl` package deliberately does NOT depend on (so the bridge
|
|
3
|
+
// protocol logic stays Flutter-free and unit-testable). This ~50-line transport is the only app-specific glue.
|
|
4
|
+
//
|
|
5
|
+
// It implements `BridgeTransport` (from `package:nomos_dsl/nomos_dsl.dart`) over a `webview_flutter`
|
|
6
|
+
// WebViewController loading the published `nomos-runner` bundle:
|
|
7
|
+
// • host → runner: `controller.runJavaScript("window.__nomosSend(<json>)")`
|
|
8
|
+
// • runner → host: a `JavaScriptChannel("NomosHost")` whose onMessage feeds the bridge's message stream.
|
|
9
|
+
//
|
|
10
|
+
// WIRING (in your widget's initState):
|
|
11
|
+
// final controller = WebViewController()
|
|
12
|
+
// ..setJavaScriptMode(JavaScriptMode.unrestricted);
|
|
13
|
+
// final transport = WebViewTransport(controller); // registers the NomosHost channel
|
|
14
|
+
// await controller.loadFlutterAsset('assets/nomos/nomos-runner.html');
|
|
15
|
+
// final bridge = NomosBridge(transport);
|
|
16
|
+
// await bridge.ready; // resolves on the runner's {event:ready}
|
|
17
|
+
// await bridge.connect(cloud: 'https://nomos.captainapp.co.uk', workspace: myWs, clientId: deviceId);
|
|
18
|
+
// final head = await bridge.dispatch(domain: 'guestbook', directiveId: 'signGuestbook',
|
|
19
|
+
// payload: {'author': 'ada', 'message': 'hi', 'mood': 'delighted'}, domainHash: GUESTBOOK_DOMAIN_HASH);
|
|
20
|
+
// final rows = await bridge.query('entriesByAuthor', {'author': 'ada'});
|
|
21
|
+
//
|
|
22
|
+
// (Put the runner bundle at `assets/nomos/nomos-runner.{html,js}` and declare it under `flutter: assets:`.)
|
|
23
|
+
|
|
24
|
+
import 'dart:async';
|
|
25
|
+
import 'dart:convert';
|
|
26
|
+
|
|
27
|
+
import 'package:nomos_dsl/nomos_dsl.dart';
|
|
28
|
+
import 'package:webview_flutter/webview_flutter.dart';
|
|
29
|
+
|
|
30
|
+
/// A [BridgeTransport] over a `webview_flutter` controller hosting the `nomos-runner` bundle.
|
|
31
|
+
class WebViewTransport implements BridgeTransport {
|
|
32
|
+
final WebViewController controller;
|
|
33
|
+
final _incoming = StreamController<Map<String, Object?>>.broadcast();
|
|
34
|
+
|
|
35
|
+
WebViewTransport(this.controller) {
|
|
36
|
+
// runner → host: the bundle posts JSON strings on the "NomosHost" channel.
|
|
37
|
+
controller.addJavaScriptChannel(
|
|
38
|
+
'NomosHost',
|
|
39
|
+
onMessageReceived: (JavaScriptMessage m) {
|
|
40
|
+
try {
|
|
41
|
+
final decoded = jsonDecode(m.message);
|
|
42
|
+
if (decoded is Map) _incoming.add(decoded.cast<String, Object?>());
|
|
43
|
+
} catch (_) {/* a non-JSON console line — ignore */}
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
void send(Map<String, Object?> message) {
|
|
53
|
+
// host → runner: hand the JSON to the bundle. Double-encode so it survives as a JS string literal.
|
|
54
|
+
final json = jsonEncode(message);
|
|
55
|
+
controller.runJavaScript('window.__nomosSend(${jsonEncode(json)})');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Call once, right after the page finishes loading (e.g. from `NavigationDelegate.onPageFinished`), so the
|
|
59
|
+
/// runner flushes any message (the initial `{event:"ready"}`) it emitted before this channel was wired.
|
|
60
|
+
void flushReady() => controller.runJavaScript('window.__nomosReady && window.__nomosReady()');
|
|
61
|
+
|
|
62
|
+
void dispose() => _incoming.close();
|
|
63
|
+
}
|
package/dart/lib/nomos_dsl.dart
CHANGED
|
@@ -28,6 +28,9 @@ export 'src/driver.dart';
|
|
|
28
28
|
export 'src/dispatch.dart';
|
|
29
29
|
export 'src/subscriptions.dart';
|
|
30
30
|
export 'src/schema_validation.dart';
|
|
31
|
+
// THE WEBVIEW BRIDGE (app-binding boundary) — the async Dart runtime that drives the REAL githolon running
|
|
32
|
+
// in a WebView, so the generated typed client never needs a `MemoryPersistence` fake.
|
|
33
|
+
export 'src/webview_bridge.dart';
|
|
31
34
|
// The PROVIDER boundary (server-side capability-provider SDK runtime: TaskOutcome /
|
|
32
35
|
// TaskSuccess / TaskFailure / TaskResult / TaskProvider / HostClock). The symmetric twin
|
|
33
36
|
// of `dispatch.dart`; the generated per-task provider classes build on it.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/// THE WEBVIEW BRIDGE — the Dart half of the app-binding boundary. A Flutter app talks to the REAL githolon
|
|
7
|
+
/// (the `@githolon/client` runner running in a WebView on mobile / the same page on Flutter web) over ONE
|
|
8
|
+
/// stable message protocol, so the generated typed Dart client drives the real kernel — never a
|
|
9
|
+
/// `MemoryPersistence` pretending to be a GitHolon.
|
|
10
|
+
///
|
|
11
|
+
/// A WebView round-trip is ASYNC, so the bridge is async (unlike the sync FRB `Authorer` in `dispatch.dart`).
|
|
12
|
+
/// The generated per-domain Dart client wraps a [NomosBridge]; the bridge owns request/reply correlation +
|
|
13
|
+
/// reactive `watch` streams. The platform TRANSPORT ([BridgeTransport]) is injected — `dart:js_util` on web,
|
|
14
|
+
/// a WebView JS-channel on mobile — so this protocol logic is pure Dart + fully unit-testable.
|
|
15
|
+
library;
|
|
16
|
+
|
|
17
|
+
import 'dart:async';
|
|
18
|
+
|
|
19
|
+
/// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
|
|
20
|
+
const int kWebviewBridgeVersion = 1;
|
|
21
|
+
|
|
22
|
+
/// The platform channel to the WebView runner: send a JSON-shaped message; receive the runner's messages.
|
|
23
|
+
/// Web impl jsifies/dartifies over `dart:js_util`; mobile impl JSON-encodes over a WebView JS-channel. The
|
|
24
|
+
/// bridge treats messages as plain `Map<String, Object?>` — the transport owns the channel serialization.
|
|
25
|
+
abstract interface class BridgeTransport {
|
|
26
|
+
void send(Map<String, Object?> message);
|
|
27
|
+
Stream<Map<String, Object?>> get messages;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// A typed refusal surfaced from the githolon's gate (a law refusal, a drift, a malformed op). Carries the
|
|
31
|
+
/// human-first message verbatim — the generated client lets it throw, loudly, exactly like the TS client.
|
|
32
|
+
class NomosBridgeError implements Exception {
|
|
33
|
+
final String message;
|
|
34
|
+
NomosBridgeError(this.message);
|
|
35
|
+
@override
|
|
36
|
+
String toString() => 'NomosBridgeError: $message';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
|
|
40
|
+
class NomosBridge {
|
|
41
|
+
final BridgeTransport _transport;
|
|
42
|
+
final Map<int, Completer<Object?>> _pending = {};
|
|
43
|
+
final Map<String, StreamController<List<dynamic>>> _watches = {};
|
|
44
|
+
final Completer<void> _ready = Completer<void>();
|
|
45
|
+
int _seq = 0;
|
|
46
|
+
late final StreamSubscription<Map<String, Object?>> _sub;
|
|
47
|
+
|
|
48
|
+
NomosBridge(this._transport) {
|
|
49
|
+
_sub = _transport.messages.listen(_onMessage);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Completes once the runner has posted `{ event: "ready" }` (the wasm + client are booted).
|
|
53
|
+
Future<void> get ready => _ready.future;
|
|
54
|
+
|
|
55
|
+
void _onMessage(Map<String, Object?> m) {
|
|
56
|
+
if (m['v'] != kWebviewBridgeVersion) return; // a foreign-version message is ignored (forward-compatible)
|
|
57
|
+
final event = m['event'];
|
|
58
|
+
if (event == 'ready') {
|
|
59
|
+
if (!_ready.isCompleted) _ready.complete();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (event == 'watch') {
|
|
63
|
+
final ctrl = _watches[m['subId']];
|
|
64
|
+
if (ctrl != null && !ctrl.isClosed) ctrl.add((m['rows'] as List?) ?? const []);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
final id = m['id'];
|
|
68
|
+
if (id is! int) return;
|
|
69
|
+
final completer = _pending.remove(id);
|
|
70
|
+
if (completer == null) return;
|
|
71
|
+
if (m['ok'] == true) {
|
|
72
|
+
completer.complete(m['result']);
|
|
73
|
+
} else {
|
|
74
|
+
completer.completeError(NomosBridgeError((m['error'] ?? 'the githolon refused the request').toString()));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Send one request and await the typed reply (the runner answers ok/error). The ONLY low-level call —
|
|
79
|
+
/// every typed method below is a thin wrapper, so the request/reply contract lives in exactly one place.
|
|
80
|
+
Future<Object?> _call(String op, [Map<String, Object?> args = const {}]) {
|
|
81
|
+
final id = ++_seq;
|
|
82
|
+
final completer = Completer<Object?>();
|
|
83
|
+
_pending[id] = completer;
|
|
84
|
+
_transport.send({'v': kWebviewBridgeVersion, 'id': id, 'op': op, 'args': args});
|
|
85
|
+
return completer.future;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
Map<String, Object?> _asMap(Object? v) => (v as Map?)?.cast<String, Object?>() ?? const {};
|
|
89
|
+
List<Map<String, dynamic>> _asRows(Object? v) =>
|
|
90
|
+
((_asMap(v)['rows'] as List?) ?? const []).map((e) => (e as Map).cast<String, dynamic>()).toList();
|
|
91
|
+
|
|
92
|
+
// ── the runtime-aware surface (the boundary the generated Dart client wraps) ──
|
|
93
|
+
|
|
94
|
+
/// 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});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// 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}));
|
|
102
|
+
return r['head'].toString();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// §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}));
|
|
108
|
+
return r['head'].toString();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// A declared query's rows from the local projection.
|
|
112
|
+
Future<List<Map<String, dynamic>>> query(String queryId, [Map<String, Object?> params = const {}]) async =>
|
|
113
|
+
_asRows(await _call('query', {'queryId': queryId, 'params': params}));
|
|
114
|
+
|
|
115
|
+
/// 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}));
|
|
117
|
+
|
|
118
|
+
/// 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();
|
|
121
|
+
|
|
122
|
+
/// 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}));
|
|
124
|
+
|
|
125
|
+
/// Diagnostics — is the expected law installed/active/drift-free here? (The generated client surfaces it
|
|
126
|
+
/// 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}));
|
|
128
|
+
|
|
129
|
+
/// Reactive local read: a stream of row-sets that re-emits on every local change. Cancelling the
|
|
130
|
+
/// subscription tells the runner to drop the watch.
|
|
131
|
+
Stream<List<dynamic>> watch(String queryId, [Map<String, Object?> params = const {}]) =>
|
|
132
|
+
_watchStream(kind: 'query', queryId: queryId, params: params);
|
|
133
|
+
Stream<List<dynamic>> watchById(String id) => _watchStream(kind: 'byId', id: id);
|
|
134
|
+
|
|
135
|
+
Stream<List<dynamic>> _watchStream({required String kind, String? queryId, Map<String, Object?>? params, String? id}) {
|
|
136
|
+
final subId = 'w${++_seq}';
|
|
137
|
+
late final StreamController<List<dynamic>> ctrl;
|
|
138
|
+
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}),
|
|
140
|
+
onCancel: () async {
|
|
141
|
+
_watches.remove(subId);
|
|
142
|
+
try { await _call('unwatch', {'subId': subId}); } catch (_) {}
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
_watches[subId] = ctrl;
|
|
146
|
+
return ctrl.stream;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// Tear down: stop the transport subscription + fail any in-flight calls.
|
|
150
|
+
Future<void> dispose() async {
|
|
151
|
+
await _sub.cancel();
|
|
152
|
+
for (final c in _pending.values) {
|
|
153
|
+
if (!c.isCompleted) c.completeError(NomosBridgeError('bridge disposed'));
|
|
154
|
+
}
|
|
155
|
+
_pending.clear();
|
|
156
|
+
for (final w in _watches.values) {
|
|
157
|
+
if (!w.isClosed) await w.close();
|
|
158
|
+
}
|
|
159
|
+
_watches.clear();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// NOMOS — the WEBVIEW BRIDGE protocol test (the Dart half of the app-binding boundary). A FAKE transport
|
|
2
|
+
// stands in for the WebView runner (`webview-runner.mjs`): it auto-answers each request from a stub holon,
|
|
3
|
+
// exactly as the real runner would, so the bridge's request/reply correlation, reactive `watch` streams, and
|
|
4
|
+
// error framing are proven in pure Dart — no browser, no MemoryPersistence. The bridge and the JS runner are
|
|
5
|
+
// the two ends of ONE protocol (`kWebviewBridgeVersion`).
|
|
6
|
+
import 'dart:async';
|
|
7
|
+
import 'dart:convert';
|
|
8
|
+
import 'dart:io';
|
|
9
|
+
|
|
10
|
+
import 'package:nomos_dsl/nomos_dsl.dart';
|
|
11
|
+
import 'package:test/test.dart';
|
|
12
|
+
|
|
13
|
+
/// A fake runner-over-a-transport: records sent requests, replies the way `webview-runner.mjs` does, and can
|
|
14
|
+
/// push reactive `watch` events. Lets us drive [NomosBridge] without a real WebView.
|
|
15
|
+
class FakeRunner implements BridgeTransport {
|
|
16
|
+
final _incoming = StreamController<Map<String, Object?>>.broadcast();
|
|
17
|
+
final List<Map<String, Object?>> sent = [];
|
|
18
|
+
String? lastWatchSubId;
|
|
19
|
+
|
|
20
|
+
FakeRunner() {
|
|
21
|
+
// the runner announces readiness at construction.
|
|
22
|
+
scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@override
|
|
26
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
27
|
+
|
|
28
|
+
@override
|
|
29
|
+
void send(Map<String, Object?> message) {
|
|
30
|
+
sent.add(message);
|
|
31
|
+
final id = message['id'] as int;
|
|
32
|
+
final op = message['op'] as String;
|
|
33
|
+
final args = (message['args'] as Map).cast<String, Object?>();
|
|
34
|
+
// 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
|
+
scheduleMicrotask(() {
|
|
37
|
+
switch (op) {
|
|
38
|
+
case 'connect': _incoming.add(reply({'workspace': args['workspace']})); break;
|
|
39
|
+
case 'dispatch':
|
|
40
|
+
// a directive the law refuses answers ok:false (the runner frames the gate's verdict).
|
|
41
|
+
if (args['directiveId'] == 'refuseMe') {
|
|
42
|
+
_incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': false, 'error': 'the law refused this intent'});
|
|
43
|
+
} else {
|
|
44
|
+
_incoming.add(reply({'head': 'HEAD-${args['directiveId']}'}));
|
|
45
|
+
}
|
|
46
|
+
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'"});
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// 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});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// A transport that answers EVERY request with one canned `result` (the golden corpus's reply) and records
|
|
66
|
+
/// the request the bridge sent — so we can assert both halves of the wire contract from the Dart side.
|
|
67
|
+
class ReplayTransport implements BridgeTransport {
|
|
68
|
+
final Map<String, Object?> result;
|
|
69
|
+
final _incoming = StreamController<Map<String, Object?>>.broadcast();
|
|
70
|
+
Map<String, Object?>? lastRequest;
|
|
71
|
+
ReplayTransport(this.result) {
|
|
72
|
+
scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'event': 'ready'}));
|
|
73
|
+
}
|
|
74
|
+
@override
|
|
75
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
76
|
+
@override
|
|
77
|
+
void send(Map<String, Object?> message) {
|
|
78
|
+
lastRequest = message;
|
|
79
|
+
final id = message['id'];
|
|
80
|
+
scheduleMicrotask(() => _incoming.add({'v': kWebviewBridgeVersion, 'id': id, 'ok': true, 'result': result}));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Load the shared golden corpus (the same file the JS runner test replays). Probed across the candidate
|
|
85
|
+
/// working dirs `dart test` may run from, so it doesn't depend on one invocation cwd.
|
|
86
|
+
Map<String, Object?> _loadCorpus() {
|
|
87
|
+
const candidates = [
|
|
88
|
+
'../../cloud/web-client/test/fixtures/bridge-protocol.golden.json', // cwd = dsl/dart (the usual)
|
|
89
|
+
'cloud/web-client/test/fixtures/bridge-protocol.golden.json', // cwd = repo root
|
|
90
|
+
'../../../cloud/web-client/test/fixtures/bridge-protocol.golden.json',
|
|
91
|
+
];
|
|
92
|
+
for (final p in candidates) {
|
|
93
|
+
final f = File(p);
|
|
94
|
+
if (f.existsSync()) return (jsonDecode(f.readAsStringSync()) as Map).cast<String, Object?>();
|
|
95
|
+
}
|
|
96
|
+
fail('golden corpus not found from ${Directory.current.path}; tried $candidates');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// Drive one corpus op through the typed bridge method and return its typed result (so we can compare it to
|
|
100
|
+
/// what the corpus says the unwrapped value should be).
|
|
101
|
+
Future<Object?> _invoke(NomosBridge b, String op, Map<String, Object?> a) async {
|
|
102
|
+
switch (op) {
|
|
103
|
+
case 'connect':
|
|
104
|
+
await b.connect(cloud: a['cloud'] as String, workspace: a['workspace'] as String, clientId: a['clientId'] as String?);
|
|
105
|
+
return null;
|
|
106
|
+
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);
|
|
108
|
+
case 'deliverShare':
|
|
109
|
+
return b.deliverShare(quote: (a['quote'] as Map).cast<String, Object?>(), domainHash: a['domainHash'] as String);
|
|
110
|
+
case 'query':
|
|
111
|
+
return b.query(a['queryId'] as String, (a['params'] as Map).cast<String, Object?>());
|
|
112
|
+
case 'queryById':
|
|
113
|
+
return b.queryById(a['id'] as String);
|
|
114
|
+
case 'count':
|
|
115
|
+
return b.count(a['countId'] as String, a['group'] as String);
|
|
116
|
+
case 'sum':
|
|
117
|
+
return b.sum(a['sumId'] as String, a['group'] as String);
|
|
118
|
+
case 'sync':
|
|
119
|
+
return b.sync(admit: a['admit'] as bool);
|
|
120
|
+
case 'lawStatus':
|
|
121
|
+
return b.lawStatus(a['hash'] as String);
|
|
122
|
+
default:
|
|
123
|
+
fail('corpus op not wired into the Dart bridge: $op');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/// What the corpus says the typed (unwrapped) return value should be, given the reply's `result`.
|
|
128
|
+
Object? _expectedTyped(String op, Map<String, Object?> result) {
|
|
129
|
+
switch (op) {
|
|
130
|
+
case 'dispatch':
|
|
131
|
+
case 'deliverShare':
|
|
132
|
+
return result['head'];
|
|
133
|
+
case 'query':
|
|
134
|
+
case 'queryById':
|
|
135
|
+
return result['rows'];
|
|
136
|
+
case 'count':
|
|
137
|
+
return result['count'];
|
|
138
|
+
case 'sum':
|
|
139
|
+
return result['sum'];
|
|
140
|
+
case 'sync':
|
|
141
|
+
case 'lawStatus':
|
|
142
|
+
return result;
|
|
143
|
+
default:
|
|
144
|
+
return null; // connect: void
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
void main() {
|
|
149
|
+
// ── THE GOLDEN CORPUS — the cross-language conformance the JS runner replays too ──
|
|
150
|
+
// For each case: call the typed bridge method with the corpus `args`, then assert (1) the request the bridge
|
|
151
|
+
// SENT carries exactly those args, and (2) the typed value the bridge parsed from the corpus `result` matches.
|
|
152
|
+
// The JS runner asserts the SAME corpus from the other side — so neither end can drift the wire alone.
|
|
153
|
+
group('golden corpus (shared with the JS runner)', () {
|
|
154
|
+
final corpus = _loadCorpus();
|
|
155
|
+
final cases = (corpus['cases'] as List).cast<Map<String, Object?>>();
|
|
156
|
+
test('version pins the bridge version', () => expect(corpus['version'], kWebviewBridgeVersion));
|
|
157
|
+
for (final c in cases) {
|
|
158
|
+
final op = c['op'] as String;
|
|
159
|
+
final args = (c['args'] as Map).cast<String, Object?>();
|
|
160
|
+
final result = (c['result'] as Map).cast<String, Object?>();
|
|
161
|
+
test("'$op' request args + parsed result match the corpus", () async {
|
|
162
|
+
final transport = ReplayTransport(result);
|
|
163
|
+
final bridge = NomosBridge(transport);
|
|
164
|
+
await bridge.ready;
|
|
165
|
+
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');
|
|
168
|
+
await bridge.dispose();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
late FakeRunner runner;
|
|
174
|
+
late NomosBridge bridge;
|
|
175
|
+
setUp(() { runner = FakeRunner(); bridge = NomosBridge(runner); });
|
|
176
|
+
tearDown(() => bridge.dispose());
|
|
177
|
+
|
|
178
|
+
test('ready completes once the runner boots', () async {
|
|
179
|
+
await bridge.ready.timeout(const Duration(seconds: 1));
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('dispatch round-trips a typed head; args reach the runner verbatim', () async {
|
|
183
|
+
await bridge.ready;
|
|
184
|
+
final head = await bridge.dispatch(domain: 'guestbook', directiveId: 'signGuestbook', payload: {'author': 'ada'}, domainHash: 'abc');
|
|
185
|
+
expect(head, 'HEAD-signGuestbook');
|
|
186
|
+
final req = runner.sent.firstWhere((m) => m['op'] == 'dispatch');
|
|
187
|
+
expect((req['args'] as Map)['payload'], {'author': 'ada'});
|
|
188
|
+
expect((req['args'] as Map)['domainHash'], 'abc');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('connect / deliverShare / query / queryById / count / sync / lawStatus all wrap the runner', () async {
|
|
192
|
+
await bridge.ready;
|
|
193
|
+
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'});
|
|
196
|
+
expect((await bridge.queryById('Entry_1')).first['id'], 'Entry_1');
|
|
197
|
+
expect(await bridge.count('entries'), 7);
|
|
198
|
+
expect(await bridge.sync(), {'pushed': true});
|
|
199
|
+
expect(await bridge.lawStatus('abc'), {'installed': true, 'drifted': false});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('watch is a reactive stream; a runner push reaches the listener; cancel unwatches', () async {
|
|
203
|
+
await bridge.ready;
|
|
204
|
+
final got = <List<dynamic>>[];
|
|
205
|
+
final sub = bridge.watch('byAuthor', {'author': 'ada'}).listen(got.add);
|
|
206
|
+
await Future<void>.delayed(Duration.zero); // let the watch register
|
|
207
|
+
runner.pushWatch(runner.lastWatchSubId!, [{'id': 'a', 'data': {'n': 2}}]);
|
|
208
|
+
await Future<void>.delayed(Duration.zero);
|
|
209
|
+
expect(got, [[{'id': 'a', 'data': {'n': 2}}]]);
|
|
210
|
+
await sub.cancel();
|
|
211
|
+
expect(runner.sent.any((m) => m['op'] == 'unwatch'), isTrue);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('a law refusal throws NomosBridgeError with the verbatim message', () async {
|
|
215
|
+
await bridge.ready;
|
|
216
|
+
Object? err;
|
|
217
|
+
try {
|
|
218
|
+
await bridge.dispatch(domain: 'x', directiveId: 'refuseMe', payload: {}, domainHash: 'h');
|
|
219
|
+
} on NomosBridgeError catch (e) {
|
|
220
|
+
err = e;
|
|
221
|
+
}
|
|
222
|
+
expect(err, isA<NomosBridgeError>());
|
|
223
|
+
expect((err as NomosBridgeError).message, contains('law refused'));
|
|
224
|
+
});
|
|
225
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
package/src/framework/home.ts
CHANGED
|
@@ -32,8 +32,14 @@ import { directive } from "../directive.js";
|
|
|
32
32
|
import { create } from "../authoring.js";
|
|
33
33
|
import { set, type PlannedOp } from "../ops.js";
|
|
34
34
|
import { query } from "../query.js";
|
|
35
|
+
import { workspaceInvariant, refAs } from "./workspace_invariant.js";
|
|
35
36
|
import { identityRelations } from "./signing_identity.js";
|
|
36
37
|
|
|
38
|
+
// `nomos.sha256` — the TRUSTLESS, pure hash computed IN the githolon (Rust sha2 bound in the sealed engine;
|
|
39
|
+
// see deterministic-rquickjs). Available as a global to plans + invariant predicates (like `nomos.mint`); it
|
|
40
|
+
// lets the home law re-derive `home-<hash(owner)>` itself, so the SQUAT invariant runs in-law on every peer.
|
|
41
|
+
declare const nomos: { sha256(input: string): string };
|
|
42
|
+
|
|
37
43
|
/** The KEY DIRECTORY is the `signer` relation — re-exported so a home holon's law composes it (the
|
|
38
44
|
* identity's public device keys, enrolled via `enrollSigner`, checked by the gate). */
|
|
39
45
|
export { identityRelations };
|
|
@@ -47,30 +53,58 @@ export { identityRelations };
|
|
|
47
53
|
* `seedHomeOwner`, kept off the open session lane), so a tenant cannot forge ownership.
|
|
48
54
|
*/
|
|
49
55
|
export const HOME_OWNER_ID = "home-owner:self";
|
|
56
|
+
/** A home holon's SELF-COMMITTED NAME prefix — `home-<sha256(subject)>` (the client's `homeNameOf` + the
|
|
57
|
+
* squat invariant both derive it). The home's chain commits to its own routing name, so the bailiff can
|
|
58
|
+
* bind the name to the CONTENT (custody) while the LAW guarantees the name hashes its owner. */
|
|
59
|
+
export const HOME_NAME_PREFIX = "home-";
|
|
60
|
+
export const HOME_NAME_HASH_LEN = 40;
|
|
50
61
|
export const HomeOwner = aggregate("HomeOwner", {
|
|
51
62
|
/** The identity this home belongs to — the canonical `user:<uid>` subject the §3 gate binds quotes to. */
|
|
52
63
|
principal: t.string().merge(Lww),
|
|
64
|
+
/** THE HOME'S SELF-COMMITTED NAME (`home-<sha256(principal)>`) — the squat invariant asserts it hashes the
|
|
65
|
+
* owner, so the chain's claimed name can ONLY be its rightful owner's slot (home_holons §5). */
|
|
66
|
+
homeName: t.string().merge(Lww),
|
|
53
67
|
/** ISO-8601, caller-stamped (determinism — no clock in the plan). */
|
|
54
68
|
bornAt: t.string().merge(Lww),
|
|
55
69
|
});
|
|
56
70
|
|
|
57
|
-
/** The framework PRIMITIVE plan: stamp the home's owner (singleton at the fixed id
|
|
58
|
-
export function writeHomeOwner(p: { principal: string; bornAt: string }): PlannedOp[] {
|
|
71
|
+
/** The framework PRIMITIVE plan: stamp the home's owner + its self-committed name (singleton at the fixed id). */
|
|
72
|
+
export function writeHomeOwner(p: { principal: string; homeName: string; bornAt: string }): PlannedOp[] {
|
|
59
73
|
const h = instance(HomeOwner, HOME_OWNER_ID);
|
|
60
|
-
return [set(h, "principal", p.principal), set(h, "bornAt", p.bornAt)];
|
|
74
|
+
return [set(h, "principal", p.principal), set(h, "homeName", p.homeName), set(h, "bornAt", p.bornAt)];
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
/**
|
|
64
|
-
* `seedHomeOwner` — the birth-door directive that stamps the home's owner
|
|
65
|
-
* CONTROL-PLANE write: the host authors it once at lazy birth
|
|
66
|
-
*
|
|
67
|
-
*
|
|
78
|
+
* `seedHomeOwner` — the birth-door directive that stamps the home's owner + its self-committed `homeName` at
|
|
79
|
+
* the fixed singleton id. A CONTROL-PLANE write: the host authors it once at lazy birth; the open session
|
|
80
|
+
* lane structurally REFUSES it (by directive id). `.ensures` (a deterministic-keyed singleton). The `homeName`
|
|
81
|
+
* is caller-supplied but the {@link homeNameBindsOwner} invariant proves it `== home-<sha256(principal)>`,
|
|
82
|
+
* so a squatter cannot claim a name slot that doesn't hash to ITS own owner.
|
|
68
83
|
*/
|
|
69
84
|
export const seedHomeOwner = directive("seedHomeOwner")
|
|
70
85
|
.ensures(HomeOwner)
|
|
71
|
-
.payload(z.object({ principal: z.string().min(1), bornAt: z.string().min(1) }))
|
|
86
|
+
.payload(z.object({ principal: z.string().min(1), homeName: z.string().min(1), bornAt: z.string().min(1) }))
|
|
72
87
|
.plan((p) => writeHomeOwner(p));
|
|
73
88
|
|
|
89
|
+
/**
|
|
90
|
+
* THE SQUAT INVARIANT (home_holons §5) — a home holon's self-committed `homeName` MUST be the hash of its
|
|
91
|
+
* owner: `homeName == home-<sha256(principal)>`. Declared by the home law, it EXECUTES at the one gate — and
|
|
92
|
+
* crucially at `verify_chain` (the bailiff's upload-birth): a squatter's chain (claiming `home-<hash(bob)>`
|
|
93
|
+
* but owned by `mallory`) FAILS the home law's OWN gate during adoption — refused IN THE GITHOLON, BY THE LAW,
|
|
94
|
+
* never by a host check. The host only routes the chain to the name it commits to (custody). `nomos.sha256`
|
|
95
|
+
* re-derives the hash in-law on every peer, so the verdict is identical everywhere.
|
|
96
|
+
*/
|
|
97
|
+
export const homeNameBindsOwner = workspaceInvariant("HomeNameBindsOwner")
|
|
98
|
+
.on("seedHomeOwner")
|
|
99
|
+
.reads(() => [refAs("owner", "HomeOwner", HOME_OWNER_ID)])
|
|
100
|
+
.assert(({ owner }) => {
|
|
101
|
+
const o = (owner ?? {}) as { principal?: string; homeName?: string };
|
|
102
|
+
const expected = HOME_NAME_PREFIX + nomos.sha256(String(o.principal ?? "")).slice(0, HOME_NAME_HASH_LEN);
|
|
103
|
+
return o.homeName === expected
|
|
104
|
+
? { accept: true }
|
|
105
|
+
: { reject: "a home's name must hash its owner (home-<sha256(principal)>) — refusing a squatted home" };
|
|
106
|
+
});
|
|
107
|
+
|
|
74
108
|
/**
|
|
75
109
|
* THE SHARED-WITH-ME INDEX (§5) — one record per cross-custody attestation delivered to this home holon:
|
|
76
110
|
* a signed edge `<asserterWorkspace> : <object> # <relation> @ this-identity`, indexed LOCALLY so the
|
package/src/index.ts
CHANGED
|
@@ -243,8 +243,11 @@ export {
|
|
|
243
243
|
sharedByWorkspace,
|
|
244
244
|
HomeOwner,
|
|
245
245
|
HOME_OWNER_ID,
|
|
246
|
+
HOME_NAME_PREFIX,
|
|
247
|
+
HOME_NAME_HASH_LEN,
|
|
246
248
|
seedHomeOwner,
|
|
247
249
|
writeHomeOwner,
|
|
250
|
+
homeNameBindsOwner,
|
|
248
251
|
} from "./framework/home.js";
|
|
249
252
|
// THE GENESIS WARRANT (identity_keys.md step 0) — the in-history birth fact that lets
|
|
250
253
|
// the gate's genesis clause admit a founding control-plane grant on a virgin chain.
|