@githolon/dsl 0.35.0 → 0.37.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 +5 -0
- package/dart/lib/nomos_headless.dart +19 -0
- package/dart/lib/src/node_runner_transport.dart +125 -0
- package/dart/lib/src/test_harness.dart +235 -0
- package/dart/pubspec.yaml +1 -1
- package/dart/test/node_runner_transport_test.dart +74 -0
- package/dart/test/test_harness_test.dart +70 -0
- package/package.json +1 -1
- package/src/attested.ts +2 -0
- package/src/attested_core.ts +21 -0
- package/src/framework/attested_receipts.ts +185 -0
- package/src/framework/source_roster.ts +96 -0
- package/src/index.ts +27 -0
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## 0.28.0
|
|
2
|
+
|
|
3
|
+
- `NomosTestHarness` (`nomos_headless.dart`): ephemeral real-law fixtures — one call births a throwaway cloud workspace, deploys compiled `*.deploy.json` law, and hands out independent client replicas (`connect(clientId:)`) over the node runner. Identity defaults to the `githolon login --agent` session (`~/.holon/credentials.json`). Live-proven: birth → deploy → write on one replica → converge + typed read on another, 14s end-to-end.
|
|
4
|
+
- HEADLESS TRANSPORT (`package:nomos_client/nomos_headless.dart`): `NodeRunnerTransport` — a `BridgeTransport` over the `@githolon/client` node runner (`runner/node-entry.mjs`) as a plain child process (NDJSON stdio). `flutter test` / `dart test` / CI drive the REAL githolon (byte-identical wasm, real ledger, real gate) through `NomosBridge` + the generated typed clients — no Flutter plugins, no WebView, no assets. Requires `node` on PATH + a resolvable `@githolon/client` (auto-resolved from the app's node_modules, or pass `entry:`). Proven end-to-end: connect → offerDirective → sync(admit) → typed query, and the same test green under both `dart test` and `flutter test`.
|
|
5
|
+
|
|
1
6
|
## 0.27.0
|
|
2
7
|
|
|
3
8
|
- TYPED SNAPSHOT RECOVERY (corrupt local custody): `NomosBridge.connect(recover: true)` recovers IN-PLACE from a corrupt restoreFrom snapshot — salvages local-only authored writes (via the fallible `intents_above`, which stops at a missing-ancestor gap) + the DLQ, refolds FRESH from cloud, re-offers the salvaged work (deduped), re-queues the DLQ. The report rides `bridge.lastRecovery` = `{salvagedWrites, requeuedDeadLetters, unsalvageable}` — a write the current law refuses lands in `unsalvageable` + the DLQ (surfaced, never dropped). Also `holon.salvage()` / `holon.absorbSalvage()`. Closes the restoreFrom-leaves-read-model-empty case.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/// The HEADLESS transport library — `dart:io` only, so it lives outside the main
|
|
2
|
+
/// `package:nomos_client/nomos_client.dart` barrel (which stays web-safe).
|
|
3
|
+
///
|
|
4
|
+
/// ```dart
|
|
5
|
+
/// import 'package:nomos_client/nomos_client.dart';
|
|
6
|
+
/// import 'package:nomos_client/nomos_headless.dart';
|
|
7
|
+
///
|
|
8
|
+
/// final transport = await NodeRunnerTransport.start(resolveFrom: 'path/to/app');
|
|
9
|
+
/// final bridge = NomosBridge(transport);
|
|
10
|
+
/// await bridge.ready;
|
|
11
|
+
/// await bridge.connect(cloud: ..., workspace: ..., clientId: 'test-runner');
|
|
12
|
+
/// ```
|
|
13
|
+
///
|
|
14
|
+
/// The runner child process is the SAME `@githolon/client` every peer runs; the
|
|
15
|
+
/// generated Dart clients drive it through [NomosBridge] exactly as in the app.
|
|
16
|
+
library;
|
|
17
|
+
|
|
18
|
+
export 'src/node_runner_transport.dart';
|
|
19
|
+
export 'src/test_harness.dart';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// THE HEADLESS (NODE) TRANSPORT — a [BridgeTransport] over the `@githolon/client` node runner
|
|
2
|
+
// (`runner/node-entry.mjs`), spawned as a plain child process speaking the ONE bridge protocol as
|
|
3
|
+
// newline-delimited JSON on stdio. This is what lets `dart test` / `flutter test` / CI drive the REAL
|
|
4
|
+
// githolon (the byte-identical wasm, the real ledger, the real gate) through [NomosBridge] and the
|
|
5
|
+
// generated typed clients — no Flutter plugins, no WebView, no assets. The only environment requirement
|
|
6
|
+
// is `node` on PATH plus a resolvable `@githolon/client` (any app that compiles domains already has both).
|
|
7
|
+
//
|
|
8
|
+
// This file is a PURE RELAY: no orchestration, no op knowledge — the protocol implementation lives in
|
|
9
|
+
// createWebviewRunner (shared with the WebView path) and the orchestration in index.mjs, same as every peer.
|
|
10
|
+
import 'dart:async';
|
|
11
|
+
import 'dart:convert';
|
|
12
|
+
import 'dart:io';
|
|
13
|
+
|
|
14
|
+
import 'webview_bridge.dart' show BridgeTransport;
|
|
15
|
+
|
|
16
|
+
class NodeRunnerTransport implements BridgeTransport {
|
|
17
|
+
final Process _proc;
|
|
18
|
+
final StreamController<Map<String, Object?>> _incoming =
|
|
19
|
+
StreamController<Map<String, Object?>>.broadcast();
|
|
20
|
+
final void Function(String line)? _onDiagnostic;
|
|
21
|
+
bool _disposed = false;
|
|
22
|
+
|
|
23
|
+
NodeRunnerTransport._(this._proc, this._onDiagnostic) {
|
|
24
|
+
_proc.stdout
|
|
25
|
+
.transform(utf8.decoder)
|
|
26
|
+
.transform(const LineSplitter())
|
|
27
|
+
.listen((line) {
|
|
28
|
+
final s = line.trim();
|
|
29
|
+
if (s.isEmpty) return;
|
|
30
|
+
Object? decoded;
|
|
31
|
+
try {
|
|
32
|
+
decoded = jsonDecode(s);
|
|
33
|
+
} catch (_) {
|
|
34
|
+
_diag('[nomos-node-runner stdout] $line');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (decoded is Map && !_incoming.isClosed) {
|
|
38
|
+
_incoming.add(decoded.cast<String, Object?>());
|
|
39
|
+
}
|
|
40
|
+
}, onDone: () {
|
|
41
|
+
if (!_disposed && !_incoming.isClosed) {
|
|
42
|
+
_incoming.addError(
|
|
43
|
+
StateError('the nomos node runner exited unexpectedly'));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
_proc.stderr
|
|
47
|
+
.transform(utf8.decoder)
|
|
48
|
+
.transform(const LineSplitter())
|
|
49
|
+
.listen((line) => _diag('[nomos-node-runner] $line'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
void _diag(String line) {
|
|
53
|
+
final f = _onDiagnostic;
|
|
54
|
+
if (f != null) {
|
|
55
|
+
f(line);
|
|
56
|
+
} else {
|
|
57
|
+
stderr.writeln(line);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Spawn the node runner and return the connected transport. Wrap it in a
|
|
62
|
+
/// [NomosBridge] and `await bridge.ready` (the runner posts `{event:'ready'}`
|
|
63
|
+
/// on boot), then `connect(...)` as any other transport.
|
|
64
|
+
///
|
|
65
|
+
/// [entry] — path to `node-entry.mjs`. When omitted it is resolved with
|
|
66
|
+
/// `require.resolve('@githolon/client/runner/node-entry.mjs')` from
|
|
67
|
+
/// [resolveFrom] (default: the current directory), i.e. the consuming app's
|
|
68
|
+
/// own `node_modules`.
|
|
69
|
+
static Future<NodeRunnerTransport> start({
|
|
70
|
+
String? entry,
|
|
71
|
+
String? resolveFrom,
|
|
72
|
+
String node = 'node',
|
|
73
|
+
Map<String, String>? environment,
|
|
74
|
+
void Function(String line)? onDiagnostic,
|
|
75
|
+
}) async {
|
|
76
|
+
final entryPath = entry ??
|
|
77
|
+
await _resolveEntry(node, resolveFrom ?? Directory.current.path);
|
|
78
|
+
final proc = await Process.start(
|
|
79
|
+
node,
|
|
80
|
+
[entryPath],
|
|
81
|
+
environment: environment,
|
|
82
|
+
workingDirectory: resolveFrom,
|
|
83
|
+
);
|
|
84
|
+
return NodeRunnerTransport._(proc, onDiagnostic);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static Future<String> _resolveEntry(String node, String from) async {
|
|
88
|
+
final r = await Process.run(
|
|
89
|
+
node,
|
|
90
|
+
['-p', "require.resolve('@githolon/client/runner/node-entry.mjs')"],
|
|
91
|
+
workingDirectory: from,
|
|
92
|
+
);
|
|
93
|
+
final out = (r.stdout as String).trim();
|
|
94
|
+
if (r.exitCode != 0 || out.isEmpty) {
|
|
95
|
+
throw StateError(
|
|
96
|
+
'cannot resolve @githolon/client/runner/node-entry.mjs from $from — '
|
|
97
|
+
'is @githolon/client installed (npm install)? stderr: ${r.stderr}');
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@override
|
|
103
|
+
Stream<Map<String, Object?>> get messages => _incoming.stream;
|
|
104
|
+
|
|
105
|
+
@override
|
|
106
|
+
void send(Map<String, Object?> message) {
|
|
107
|
+
if (_disposed) throw StateError('NodeRunnerTransport is disposed');
|
|
108
|
+
_proc.stdin.writeln(jsonEncode(message));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// Tear down: stop the runner process and close the message stream.
|
|
112
|
+
Future<void> dispose() async {
|
|
113
|
+
if (_disposed) return;
|
|
114
|
+
_disposed = true;
|
|
115
|
+
try {
|
|
116
|
+
await _proc.stdin.close();
|
|
117
|
+
} catch (_) {}
|
|
118
|
+
_proc.kill(ProcessSignal.sigterm);
|
|
119
|
+
await _proc.exitCode.timeout(const Duration(seconds: 5), onTimeout: () {
|
|
120
|
+
_proc.kill(ProcessSignal.sigkill);
|
|
121
|
+
return -9;
|
|
122
|
+
});
|
|
123
|
+
await _incoming.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// NOMOS TEST HARNESS — ephemeral real-law fixtures for headless app tests. One call births a throwaway
|
|
2
|
+
// workspace on the cloud, deploys the given compiled domain packages (`build/<name>.deploy.json`), and hands
|
|
3
|
+
// out [NomosBridge]s over [NodeRunnerTransport] — the REAL githolon (byte-identical wasm, real ledger, real
|
|
4
|
+
// gate) driven by the same generated Dart clients the app UI uses, under `flutter test` / `dart test` / CI.
|
|
5
|
+
//
|
|
6
|
+
// ```dart
|
|
7
|
+
// final harness = await NomosTestHarness.ephemeral(
|
|
8
|
+
// deployJsonPaths: ['build/guestbook.deploy.json'],
|
|
9
|
+
// );
|
|
10
|
+
// final bridge = await harness.connect(clientId: 'test-runner');
|
|
11
|
+
// final app = GuestbookClient(bridge); // the generated client — same one the app uses
|
|
12
|
+
// ...
|
|
13
|
+
// await harness.dispose();
|
|
14
|
+
// ```
|
|
15
|
+
//
|
|
16
|
+
// Identity: a birth needs a principal. Pass [principal]/[authToken] explicitly, or the harness falls back to
|
|
17
|
+
// the `githolon` CLI session (`~/.holon/credentials.json`, minted by `npx githolon login --agent`) — the same
|
|
18
|
+
// self-onboarded verified identity the compile/deploy tooling uses. The token rides `x-nomos-auth` as proof;
|
|
19
|
+
// its `sub` claim rides `x-nomos-principal` (the transitional bare claim the birth lane requires until kernel
|
|
20
|
+
// birth-verify lands).
|
|
21
|
+
//
|
|
22
|
+
// The harness is HOST-LANE plumbing only (create + deploy are the open, kernel-gated cloud endpoints — the
|
|
23
|
+
// same calls `githolon ws create` / `githolon deploy` make); everything after rides the one bridge protocol.
|
|
24
|
+
// Workspaces are throwaway: there is no open destroy lane (reclaim is law-gated), so `dispose()` tears down
|
|
25
|
+
// the local transports and leaves the ledger — name them uniquely (the default) and let them age out.
|
|
26
|
+
import 'dart:convert';
|
|
27
|
+
import 'dart:io';
|
|
28
|
+
import 'dart:math';
|
|
29
|
+
|
|
30
|
+
import 'node_runner_transport.dart';
|
|
31
|
+
import 'webview_bridge.dart' show NomosBridge;
|
|
32
|
+
|
|
33
|
+
class NomosTestHarnessException implements Exception {
|
|
34
|
+
final String message;
|
|
35
|
+
NomosTestHarnessException(this.message);
|
|
36
|
+
@override
|
|
37
|
+
String toString() => 'NomosTestHarnessException: $message';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class NomosTestHarness {
|
|
41
|
+
/// The cloud base URL the harness (and every bridge it connects) talks to.
|
|
42
|
+
final String cloud;
|
|
43
|
+
|
|
44
|
+
/// The ephemeral workspace name this harness birthed.
|
|
45
|
+
final String workspace;
|
|
46
|
+
|
|
47
|
+
final String? _principal;
|
|
48
|
+
final String? _authToken;
|
|
49
|
+
final String? _nodeEntry;
|
|
50
|
+
final String? _resolveFrom;
|
|
51
|
+
final void Function(String line)? _onDiagnostic;
|
|
52
|
+
final List<NodeRunnerTransport> _transports = [];
|
|
53
|
+
bool _disposed = false;
|
|
54
|
+
|
|
55
|
+
NomosTestHarness._(this.cloud, this.workspace, this._principal,
|
|
56
|
+
this._authToken, this._nodeEntry, this._resolveFrom, this._onDiagnostic);
|
|
57
|
+
|
|
58
|
+
/// Birth a throwaway workspace and deploy the given compiled domain packages into it.
|
|
59
|
+
///
|
|
60
|
+
/// [deployJsonPaths] — the `build/<name>.deploy.json` files `nomos-compile` emits (deployed in order;
|
|
61
|
+
/// platform/framework law first when one domain's law depends on another's).
|
|
62
|
+
/// [workspace] — override the minted `test-<hex>` name (e.g. to reuse a workspace across a suite).
|
|
63
|
+
/// [principal]/[authToken] — the identity the workspace is born for; defaults to the `githolon` CLI
|
|
64
|
+
/// session (`~/.holon/credentials.json`).
|
|
65
|
+
/// [nodeEntry]/[resolveFrom] — forwarded to [NodeRunnerTransport.start].
|
|
66
|
+
static Future<NomosTestHarness> ephemeral({
|
|
67
|
+
List<String> deployJsonPaths = const [],
|
|
68
|
+
String cloud = 'https://nomos.captainapp.co.uk',
|
|
69
|
+
String? workspace,
|
|
70
|
+
String? principal,
|
|
71
|
+
String? authToken,
|
|
72
|
+
String? nodeEntry,
|
|
73
|
+
String? resolveFrom,
|
|
74
|
+
void Function(String line)? onDiagnostic,
|
|
75
|
+
}) async {
|
|
76
|
+
final id = _identity(principal, authToken);
|
|
77
|
+
final ws = workspace ?? 'test-${_hex(12)}';
|
|
78
|
+
final harness = NomosTestHarness._(_stripSlash(cloud), ws, id.principal,
|
|
79
|
+
id.authToken, nodeEntry, resolveFrom, onDiagnostic);
|
|
80
|
+
await harness._create();
|
|
81
|
+
for (final p in deployJsonPaths) {
|
|
82
|
+
await harness.deployLaw(p);
|
|
83
|
+
}
|
|
84
|
+
return harness;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
static String _brief(String s) => s.length > 600 ? '${s.substring(0, 600)}…[+${s.length - 600} chars]' : s;
|
|
88
|
+
|
|
89
|
+
/// Deploy one more compiled package (`<name>.deploy.json`) into the workspace — e.g. a law evolve mid-test.
|
|
90
|
+
Future<void> deployLaw(String deployJsonPath) async {
|
|
91
|
+
final f = File(deployJsonPath);
|
|
92
|
+
if (!f.existsSync()) {
|
|
93
|
+
throw NomosTestHarnessException(
|
|
94
|
+
'deploy json not found: $deployJsonPath — run `npx githolon compile` first');
|
|
95
|
+
}
|
|
96
|
+
final r = await _post('/v2/workspaces/$workspace/domains',
|
|
97
|
+
body: await f.readAsString(), contentType: 'application/json');
|
|
98
|
+
if (r.$1 >= 300 || !(r.$2['ok'] == true)) {
|
|
99
|
+
throw NomosTestHarnessException(
|
|
100
|
+
'deploy of $deployJsonPath to $workspace refused (${r.$1}): ${_brief(r.$3)}');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Spawn a fresh runner + bridge connected to the harness workspace. Each call is an independent
|
|
105
|
+
/// client replica (own clientId → own session branch), so multi-client convergence is testable.
|
|
106
|
+
Future<NomosBridge> connect({
|
|
107
|
+
String clientId = 'test-runner',
|
|
108
|
+
String? authToken,
|
|
109
|
+
String? authorSecret,
|
|
110
|
+
bool allowUnborn = false,
|
|
111
|
+
}) async {
|
|
112
|
+
if (_disposed) throw StateError('harness is disposed');
|
|
113
|
+
final transport = await NodeRunnerTransport.start(
|
|
114
|
+
entry: _nodeEntry,
|
|
115
|
+
resolveFrom: _resolveFrom,
|
|
116
|
+
onDiagnostic: _onDiagnostic,
|
|
117
|
+
);
|
|
118
|
+
_transports.add(transport);
|
|
119
|
+
final bridge = NomosBridge(transport);
|
|
120
|
+
await bridge.ready.timeout(const Duration(seconds: 30));
|
|
121
|
+
await bridge.connect(
|
|
122
|
+
cloud: cloud,
|
|
123
|
+
workspace: workspace,
|
|
124
|
+
clientId: clientId,
|
|
125
|
+
authToken: authToken ?? _authToken,
|
|
126
|
+
authorSecret: authorSecret,
|
|
127
|
+
allowUnborn: allowUnborn,
|
|
128
|
+
);
|
|
129
|
+
return bridge;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Tear down every runner this harness spawned. The throwaway workspace's ledger stays on the cloud
|
|
133
|
+
/// (there is no open destroy lane — reclaim is law-gated); its unique name keeps it inert.
|
|
134
|
+
Future<void> dispose() async {
|
|
135
|
+
if (_disposed) return;
|
|
136
|
+
_disposed = true;
|
|
137
|
+
for (final t in _transports) {
|
|
138
|
+
await t.dispose();
|
|
139
|
+
}
|
|
140
|
+
_transports.clear();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── host-lane plumbing ──
|
|
144
|
+
|
|
145
|
+
Future<void> _create() async {
|
|
146
|
+
final r = await _post('/v2/workspaces/$workspace');
|
|
147
|
+
if (r.$1 >= 300 || !(r.$2['ok'] == true)) {
|
|
148
|
+
throw NomosTestHarnessException(
|
|
149
|
+
'workspace birth for $workspace refused (${r.$1}): ${_brief(r.$3)} — '
|
|
150
|
+
'a birth needs a principal (npx githolon login --agent, or pass principal:/authToken:)');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
Future<(int, Map<String, Object?>, String)> _post(String path,
|
|
155
|
+
{String? body, String? contentType}) async {
|
|
156
|
+
final client = HttpClient();
|
|
157
|
+
try {
|
|
158
|
+
final req = await client.postUrl(Uri.parse('$cloud$path'));
|
|
159
|
+
if (_authToken != null) req.headers.set('x-nomos-auth', _authToken);
|
|
160
|
+
if (_principal != null) req.headers.set('x-nomos-principal', _principal);
|
|
161
|
+
if (contentType != null) {
|
|
162
|
+
req.headers.contentType = ContentType.parse('$contentType; charset=utf-8');
|
|
163
|
+
}
|
|
164
|
+
if (body != null) req.add(utf8.encode(body));
|
|
165
|
+
final res = await req.close();
|
|
166
|
+
final text = await res.transform(utf8.decoder).join();
|
|
167
|
+
Map<String, Object?> decoded = const {};
|
|
168
|
+
try {
|
|
169
|
+
final d = jsonDecode(text);
|
|
170
|
+
if (d is Map) decoded = d.cast<String, Object?>();
|
|
171
|
+
} catch (_) {}
|
|
172
|
+
return (res.statusCode, decoded, text);
|
|
173
|
+
} finally {
|
|
174
|
+
client.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
static ({String? principal, String? authToken}) _identity(
|
|
179
|
+
String? principal, String? authToken) {
|
|
180
|
+
var p = principal;
|
|
181
|
+
var t = authToken;
|
|
182
|
+
if (p == null && t == null) {
|
|
183
|
+
final creds = _holonCreds();
|
|
184
|
+
p = creds.principal;
|
|
185
|
+
t = creds.authToken;
|
|
186
|
+
}
|
|
187
|
+
// the token's own `sub` claim is the transitional bare principal claim the birth lane requires.
|
|
188
|
+
p ??= t != null ? _jwtSub(t) : null;
|
|
189
|
+
if (p == null && t == null) {
|
|
190
|
+
throw NomosTestHarnessException(
|
|
191
|
+
'no identity for the workspace birth — run `npx githolon login --agent` '
|
|
192
|
+
'(the harness reads ~/.holon/credentials.json) or pass principal:/authToken:');
|
|
193
|
+
}
|
|
194
|
+
return (principal: p, authToken: t);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
static ({String? principal, String? authToken}) _holonCreds() {
|
|
198
|
+
try {
|
|
199
|
+
final home = Platform.environment['HOME'] ??
|
|
200
|
+
Platform.environment['USERPROFILE'] ??
|
|
201
|
+
'';
|
|
202
|
+
final f = File('$home/.holon/credentials.json');
|
|
203
|
+
if (!f.existsSync()) return (principal: null, authToken: null);
|
|
204
|
+
final d = jsonDecode(f.readAsStringSync()) as Map;
|
|
205
|
+
final session = (d['session'] as Map?)?.cast<String, Object?>();
|
|
206
|
+
return (
|
|
207
|
+
principal: session?['uid']?.toString() ?? d['principal']?.toString(),
|
|
208
|
+
authToken: session?['accessToken']?.toString(),
|
|
209
|
+
);
|
|
210
|
+
} catch (_) {
|
|
211
|
+
return (principal: null, authToken: null);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
static String? _jwtSub(String token) {
|
|
216
|
+
final parts = token.split('.');
|
|
217
|
+
if (parts.length != 3) return null;
|
|
218
|
+
try {
|
|
219
|
+
final d = jsonDecode(
|
|
220
|
+
utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))) as Map;
|
|
221
|
+
final sub = d['sub'];
|
|
222
|
+
return sub is String && sub.isNotEmpty ? sub : null;
|
|
223
|
+
} catch (_) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
static String _stripSlash(String s) =>
|
|
229
|
+
s.endsWith('/') ? s.substring(0, s.length - 1) : s;
|
|
230
|
+
|
|
231
|
+
static String _hex(int n) {
|
|
232
|
+
final rng = Random.secure();
|
|
233
|
+
return List.generate(n, (_) => rng.nextInt(16).toRadixString(16)).join();
|
|
234
|
+
}
|
|
235
|
+
}
|
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.
|
|
14
|
+
version: 0.28.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The headless transport against the REAL node runner (`cloud/web-client/runner/node-entry.mjs`):
|
|
2
|
+
// protocol round-trip through NomosBridge without touching the cloud — ready handshake, typed refusal
|
|
3
|
+
// on a pre-connect op, unknown-op error. The live connect path is covered by the env-gated smoke below.
|
|
4
|
+
@TestOn('vm')
|
|
5
|
+
library;
|
|
6
|
+
|
|
7
|
+
import 'dart:io';
|
|
8
|
+
|
|
9
|
+
import 'package:nomos_client/nomos_client.dart';
|
|
10
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
11
|
+
import 'package:test/test.dart';
|
|
12
|
+
|
|
13
|
+
String _repoEntry() {
|
|
14
|
+
// In-repo the runner lives beside us; consumers resolve it from node_modules instead.
|
|
15
|
+
final here = Directory.current.path;
|
|
16
|
+
final p = '$here/../../cloud/web-client/runner/node-entry.mjs';
|
|
17
|
+
if (!File(p).existsSync()) {
|
|
18
|
+
throw StateError('node-entry.mjs not found at $p');
|
|
19
|
+
}
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
void main() {
|
|
24
|
+
late NodeRunnerTransport transport;
|
|
25
|
+
late NomosBridge bridge;
|
|
26
|
+
|
|
27
|
+
setUp(() async {
|
|
28
|
+
transport = await NodeRunnerTransport.start(entry: _repoEntry());
|
|
29
|
+
bridge = NomosBridge(transport);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
tearDown(() async {
|
|
33
|
+
await transport.dispose();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('posts the ready handshake', () async {
|
|
37
|
+
await bridge.ready.timeout(const Duration(seconds: 15));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('a pre-connect op answers a typed bridge error', () async {
|
|
41
|
+
await bridge.ready.timeout(const Duration(seconds: 15));
|
|
42
|
+
await expectLater(
|
|
43
|
+
bridge.head(),
|
|
44
|
+
throwsA(isA<NomosBridgeError>().having((e) => e.message, 'message',
|
|
45
|
+
contains('not connected'))),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('requests are id-correlated (two in flight)', () async {
|
|
50
|
+
await bridge.ready.timeout(const Duration(seconds: 15));
|
|
51
|
+
final a = bridge.head().then((_) => 'ok', onError: (_) => 'err');
|
|
52
|
+
final b = bridge.metrics().then((_) => 'ok', onError: (_) => 'err');
|
|
53
|
+
expect(await a, 'err');
|
|
54
|
+
expect(await b, 'err');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test(
|
|
58
|
+
'LIVE smoke: connect + query against a real workspace',
|
|
59
|
+
skip: Platform.environment['NOMOS_HEADLESS_E2E'] == null
|
|
60
|
+
? 'set NOMOS_HEADLESS_E2E=<workspace> (and optionally NOMOS_CLOUD) to run'
|
|
61
|
+
: null,
|
|
62
|
+
() async {
|
|
63
|
+
final ws = Platform.environment['NOMOS_HEADLESS_E2E']!;
|
|
64
|
+
final cloud = Platform.environment['NOMOS_CLOUD'] ??
|
|
65
|
+
'https://nomos.captainapp.co.uk';
|
|
66
|
+
await bridge.ready.timeout(const Duration(seconds: 15));
|
|
67
|
+
await bridge.connect(
|
|
68
|
+
cloud: cloud, workspace: ws, clientId: 'headless-test');
|
|
69
|
+
final head = await bridge.head();
|
|
70
|
+
expect(head, isNotEmpty);
|
|
71
|
+
},
|
|
72
|
+
timeout: const Timeout(Duration(minutes: 3)),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// NomosTestHarness: unit coverage for the identity/jwt plumbing, plus the LIVE ephemeral fixture loop
|
|
2
|
+
// (birth a throwaway ws → deploy real law → two client replicas → write on one, converge on the other).
|
|
3
|
+
// The live test needs a `githolon login --agent` session (or NOMOS_PRINCIPAL) and is opt-in via NOMOS_LIVE=1.
|
|
4
|
+
@TestOn('vm')
|
|
5
|
+
library;
|
|
6
|
+
|
|
7
|
+
import 'dart:convert';
|
|
8
|
+
import 'dart:io';
|
|
9
|
+
|
|
10
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
11
|
+
import 'package:test/test.dart';
|
|
12
|
+
|
|
13
|
+
String _repoEntry() =>
|
|
14
|
+
'${Directory.current.path}/../../cloud/web-client/runner/node-entry.mjs';
|
|
15
|
+
|
|
16
|
+
String _guestbookDeployJson() =>
|
|
17
|
+
'${Directory.current.path}/../../examples/guestbook/build/guestbook.deploy.json';
|
|
18
|
+
|
|
19
|
+
void main() {
|
|
20
|
+
test('ephemeral without any identity refuses with a named remedy', () async {
|
|
21
|
+
// isolate from a real ~/.holon by pointing HOME at an empty dir
|
|
22
|
+
// (can't re-env the current isolate — so only run when no creds file exists).
|
|
23
|
+
final home = Platform.environment['HOME'] ?? '';
|
|
24
|
+
if (File('$home/.holon/credentials.json').existsSync()) {
|
|
25
|
+
markTestSkipped('a githolon session exists — the fallback would find it');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
await expectLater(
|
|
29
|
+
NomosTestHarness.ephemeral(),
|
|
30
|
+
throwsA(isA<NomosTestHarnessException>()),
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test(
|
|
35
|
+
'LIVE: ephemeral fixture — birth, deploy law, write on one replica, read it on another',
|
|
36
|
+
skip: Platform.environment['NOMOS_LIVE'] != '1'
|
|
37
|
+
? 'set NOMOS_LIVE=1 (needs a githolon login --agent session)'
|
|
38
|
+
: null,
|
|
39
|
+
() async {
|
|
40
|
+
final harness = await NomosTestHarness.ephemeral(
|
|
41
|
+
deployJsonPaths: [_guestbookDeployJson()],
|
|
42
|
+
nodeEntry: _repoEntry(),
|
|
43
|
+
);
|
|
44
|
+
try {
|
|
45
|
+
final writer = await harness.connect(clientId: 'writer');
|
|
46
|
+
final reader = await harness.connect(clientId: 'reader');
|
|
47
|
+
await writer.offerDirective(
|
|
48
|
+
domain: 'guestbook',
|
|
49
|
+
directiveId: 'signGuestbook',
|
|
50
|
+
payload: {
|
|
51
|
+
'author': 'harness',
|
|
52
|
+
'message': 'ephemeral fixture green',
|
|
53
|
+
'mood': 'delighted',
|
|
54
|
+
'signedAt': DateTime.now().toUtc().toIso8601String(),
|
|
55
|
+
});
|
|
56
|
+
await writer.sync(admit: true);
|
|
57
|
+
await reader.sync(admit: false);
|
|
58
|
+
final rows = await reader.query('entriesByAuthor', {'author': 'harness'});
|
|
59
|
+
expect(rows, hasLength(1));
|
|
60
|
+
expect(
|
|
61
|
+
(rows.first['data'] as Map)['message'], 'ephemeral fixture green');
|
|
62
|
+
stdout.writeln(
|
|
63
|
+
'live harness green: ws=${harness.workspace} rows=${jsonEncode(rows.first['data'])}');
|
|
64
|
+
} finally {
|
|
65
|
+
await harness.dispose();
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
timeout: const Timeout(Duration(minutes: 5)),
|
|
69
|
+
);
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.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/attested.ts
CHANGED
package/src/attested_core.ts
CHANGED
|
@@ -94,6 +94,27 @@ export function trustSource(workspace: string, opts: TrustSourceOptions): TrustS
|
|
|
94
94
|
return decl;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/** K2's genesis sentinel — `trustParent()` declares `genesis: "@parent"`; the kernel derives
|
|
98
|
+
* identity from the reader's OWN birth certificate (kernel mirror:
|
|
99
|
+
* `nomos_executor::PARENT_TRUST_SENTINEL`). */
|
|
100
|
+
export const PARENT_TRUST_SENTINEL = "@parent";
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* DECLARE trust in the reader's PARENT (K2, stage 4 — §1.3): the accepted attestor set is
|
|
104
|
+
* DERIVED from the reader's own birth certificate (`BirthAttestation.parentKey`, seeded at
|
|
105
|
+
* birth by the parent's offer-effect) plus any delegation cert the envelope carries that the
|
|
106
|
+
* parent key signed — zero hand-pinned keys, one-hop vouching by construction. `workspace`
|
|
107
|
+
* remains the ROUTABLE name (transport must know whom to ask); identity is the derived chain,
|
|
108
|
+
* never the name. Rotation = the source serves a fresh parent-signed delegation — no reader
|
|
109
|
+
* law change ever.
|
|
110
|
+
*/
|
|
111
|
+
export function trustParent(
|
|
112
|
+
workspace: string,
|
|
113
|
+
opts: Omit<TrustSourceOptions, "genesis" | "keyHashes" | "roster"> = {},
|
|
114
|
+
): TrustSourceDecl {
|
|
115
|
+
return trustSource(workspace, { ...opts, genesis: PARENT_TRUST_SENTINEL, roster: "derived" });
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
/** Shape predicate for compile discovery (the `isQueryDecl` pattern). */
|
|
98
119
|
export function isTrustSourceDecl(v: unknown): v is TrustSourceDecl {
|
|
99
120
|
return (
|
|
@@ -0,0 +1,185 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* framework/attested_receipts.ts — ATTESTATION RECEIPTS + EQUIVOCATION ACCOUNTABILITY
|
|
8
|
+
* (attested reads slice B, stage 3 — `architecture/attested_reads_design.md` §5).
|
|
9
|
+
*
|
|
10
|
+
* The SOURCE's ledger must REMEMBER what it vouched: every attested-read signature the
|
|
11
|
+
* read-attestor emits is leaf-accumulated (`sha256hex(attested_read_signed_bytes)` — derived,
|
|
12
|
+
* never carried) and SEALED into the source's own chain as one {@link NomosAttestationReceipt}
|
|
13
|
+
* per batch — one write covers up to 1024 reads. The seal is an ORDINARY OFFER
|
|
14
|
+
* ({@link recordAttestationBatch}, authored AS the enrolled `workspace:read-attestor` through
|
|
15
|
+
* the one gate); the kernel's `attestation_ledger_violation` arm DEMANDS the receipt's
|
|
16
|
+
* `batchRoot` be the Merkle root of its carried leaves on every door, so a chain-committed
|
|
17
|
+
* receipt is always internally consistent. The adapter only SCHEDULES the seal (the debounced-
|
|
18
|
+
* admission-alarm precedent: scheduling affects WHEN, never WHAT or WHETHER).
|
|
19
|
+
*
|
|
20
|
+
* The READER's half: {@link reportSourceEquivocation} folds a {@link NomosSourceDishonor} fact
|
|
21
|
+
* from two mutually-incriminating signed envelopes (same source key, same fold_root, same
|
|
22
|
+
* (queryId, args), DIVERGENT canonical results). The kernel verifies the evidence — pure
|
|
23
|
+
* carried bytes, the xwi posture — at author, edge admission, AND verify_chain; a forged or
|
|
24
|
+
* non-contradictory report is a typed refusal (`EquivocationEvidenceInvalid`). Once folded,
|
|
25
|
+
* a trust edge declaring `onDishonor: "refuse"` refuses every LATER attestation from that
|
|
26
|
+
* `(genesis, keyHash)` — position-aware like revocation, never retroactive.
|
|
27
|
+
*/
|
|
28
|
+
import { z } from "zod";
|
|
29
|
+
import { aggregate, instance } from "../aggregate.js";
|
|
30
|
+
import { t } from "../fields.js";
|
|
31
|
+
import { Lww } from "../drivers.js";
|
|
32
|
+
import { directive } from "../directive.js";
|
|
33
|
+
import { set } from "../ops.js";
|
|
34
|
+
import { query } from "../query.js";
|
|
35
|
+
|
|
36
|
+
/** The framework-reserved receipt instance-id prefix (kernel mirror:
|
|
37
|
+
* `nomos_executor::ATTESTATION_RECEIPT_PREFIX`). */
|
|
38
|
+
export const ATTESTATION_RECEIPT_PREFIX = "attest-receipt:";
|
|
39
|
+
|
|
40
|
+
/** The receipt instance id for a batch. */
|
|
41
|
+
export function attestationReceiptId(batchId: string): string {
|
|
42
|
+
return ATTESTATION_RECEIPT_PREFIX + batchId;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The framework-reserved dishonor instance-id prefix (kernel mirror:
|
|
46
|
+
* `nomos_executor::SOURCE_DISHONOR_PREFIX`). */
|
|
47
|
+
export const SOURCE_DISHONOR_PREFIX = "source-dishonor:";
|
|
48
|
+
|
|
49
|
+
/** The dishonor instance id for a proven `(sourceGenesis, keyHash)`. */
|
|
50
|
+
export function sourceDishonorId(sourceGenesis: string, keyHash: string): string {
|
|
51
|
+
return `${SOURCE_DISHONOR_PREFIX}${sourceGenesis}:${keyHash}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* NomosAttestationReceipt (§5.1) — ONE sealed batch of attested-read receipt leaves in the
|
|
56
|
+
* SOURCE's own chain. Each leaf = `sha256hex(attested_read_signed_bytes)` — it commits to the
|
|
57
|
+
* FULL attestation (query, args, canonical result, head, fold_root, epoch, time) with zero
|
|
58
|
+
* extra signed bytes and NO reader identity (least-disclosure). `receiptPath` (the kernel
|
|
59
|
+
* query op) serves Merkle inclusion `{batchRoot, index, path}` from the folded `leaves`.
|
|
60
|
+
*/
|
|
61
|
+
export const NomosAttestationReceipt = aggregate("NomosAttestationReceipt", {
|
|
62
|
+
/** `"<epoch>-<nSealedReceiptsInFold>-<keyHash8>"` — chain-derived mint (collision-free across remounts); promised on every envelope's `receipt_hint`. */
|
|
63
|
+
batchId: t.string().merge(Lww),
|
|
64
|
+
/** sha256 Merkle root over the batch's leaf hashes (kernel-verified == root(leaves) at the gate). */
|
|
65
|
+
batchRoot: t.string().merge(Lww),
|
|
66
|
+
/** The leaf count (kernel-verified == leaves.length at the gate). */
|
|
67
|
+
leafCount: t.int().merge(Lww),
|
|
68
|
+
/** The leaf hashes, in serve order (JSON array) — what `receiptPath` serves from. */
|
|
69
|
+
leaves: t.json().merge(Lww),
|
|
70
|
+
/** The `attested_at_ms` range the batch covers. */
|
|
71
|
+
firstAt: t.int().merge(Lww),
|
|
72
|
+
lastAt: t.int().merge(Lww),
|
|
73
|
+
/** The source chain position when the batch sealed. */
|
|
74
|
+
headAtSeal: t.string().merge(Lww),
|
|
75
|
+
foldRootAtSeal: t.string().merge(Lww),
|
|
76
|
+
/** Which read-attestor key signed every member of this batch (`sha256hex(pubkey)`). */
|
|
77
|
+
signerKeyHash: t.string().merge(Lww),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Audit read: the source's sealed receipt stream, by batch id. */
|
|
81
|
+
export const attestationReceiptByBatch = query("attestationReceiptByBatch")
|
|
82
|
+
.key("batchId")
|
|
83
|
+
.returns(NomosAttestationReceipt);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `recordAttestationBatch` (§5.2) — THE SEAL. One authored intent per batch, authored AS the
|
|
87
|
+
* enrolled `workspace:read-attestor` through the NORMAL offer gate (the key is an enrolled
|
|
88
|
+
* signer precisely so it can do this — §1.2's "one key, two duties"). The payload is the
|
|
89
|
+
* kernel's own `attestationOpenBatch` snapshot, carried verbatim by the adapter; the gate
|
|
90
|
+
* re-derives the Merkle root and refuses any drift (`AttestationReceiptInvalid`) — the
|
|
91
|
+
* adapter carries, never computes. Seal triggers (leafCount ≥ 1024 / age ≥ 60s / epoch change
|
|
92
|
+
* / unmount) are the kernel's verdict on the same snapshot; a failed seal retries, never
|
|
93
|
+
* drops silently.
|
|
94
|
+
*/
|
|
95
|
+
export const recordAttestationBatch = directive("recordAttestationBatch")
|
|
96
|
+
.ensures(NomosAttestationReceipt)
|
|
97
|
+
.payload(
|
|
98
|
+
z.object({
|
|
99
|
+
batchId: z.string().min(1),
|
|
100
|
+
batchRoot: z.string().min(1),
|
|
101
|
+
leafCount: z.number().int().positive(),
|
|
102
|
+
leaves: z.array(z.string().min(1)).min(1),
|
|
103
|
+
firstAt: z.number().int().nonnegative(),
|
|
104
|
+
lastAt: z.number().int().nonnegative(),
|
|
105
|
+
headAtSeal: z.string(),
|
|
106
|
+
foldRootAtSeal: z.string(),
|
|
107
|
+
signerKeyHash: z.string().min(1),
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
.plan((p) => {
|
|
111
|
+
const r = instance(NomosAttestationReceipt, attestationReceiptId(p.batchId));
|
|
112
|
+
return [
|
|
113
|
+
set(r, "batchId", p.batchId),
|
|
114
|
+
set(r, "batchRoot", p.batchRoot),
|
|
115
|
+
set(r, "leafCount", p.leafCount),
|
|
116
|
+
set(r, "leaves", JSON.stringify(p.leaves)),
|
|
117
|
+
set(r, "firstAt", p.firstAt),
|
|
118
|
+
set(r, "lastAt", p.lastAt),
|
|
119
|
+
set(r, "headAtSeal", p.headAtSeal),
|
|
120
|
+
set(r, "foldRootAtSeal", p.foldRootAtSeal),
|
|
121
|
+
set(r, "signerKeyHash", p.signerKeyHash),
|
|
122
|
+
];
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* NomosSourceDishonor (§5.3) — a PROVEN equivocation, folded in the READER's (or a
|
|
127
|
+
* governor's) chain at `source-dishonor:<sourceGenesis>:<keyHash>`. The full evidence (the
|
|
128
|
+
* two signed envelopes) rides the committed intent's payload — the fact aggregate carries
|
|
129
|
+
* the identity tuple + when it was proven. Once folded, `onDishonor: "refuse"` trust edges
|
|
130
|
+
* refuse every LATER attestation from this `(genesis, keyHash)` (position-aware).
|
|
131
|
+
*/
|
|
132
|
+
export const NomosSourceDishonor = aggregate("NomosSourceDishonor", {
|
|
133
|
+
/** The equivocating source chain's genesis oid (its true identity). */
|
|
134
|
+
sourceGenesis: t.string().merge(Lww),
|
|
135
|
+
/** The equivocating read-attestor key (`sha256hex(pubkey)`). */
|
|
136
|
+
keyHash: t.string().merge(Lww),
|
|
137
|
+
/** The fold-root BOTH envelopes pinned — the state the source spoke of with two tongues. */
|
|
138
|
+
foldRoot: t.string().merge(Lww),
|
|
139
|
+
/** When the reporter proved it (ms — the reporter's clock, informational). */
|
|
140
|
+
provenAtMs: t.int().merge(Lww),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/** Audit read: the folded dishonor facts, by source genesis. */
|
|
144
|
+
export const sourceDishonorByGenesis = query("sourceDishonorByGenesis")
|
|
145
|
+
.key("sourceGenesis")
|
|
146
|
+
.returns(NomosSourceDishonor);
|
|
147
|
+
|
|
148
|
+
/** One carried evidence envelope (an attested read exactly as captured — §5.3 walk 1). */
|
|
149
|
+
const evidenceEnvelope = z.object({
|
|
150
|
+
queryId: z.string().min(1),
|
|
151
|
+
args: z.record(z.string(), z.string()).default({}),
|
|
152
|
+
result: z.unknown(),
|
|
153
|
+
attestation: z.record(z.string(), z.unknown()),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `reportSourceEquivocation` (§5.3) — carry TWO signed attested-read envelopes; the KERNEL
|
|
158
|
+
* gate verifies (pure carried bytes): both ed25519 signatures, same source key, same
|
|
159
|
+
* `(source_genesis, fold_root, queryId, canonical args)`, DIVERGENT canonical results — and
|
|
160
|
+
* that the folded fact's id names exactly the proven `(genesis, keyHash)`. Forged or
|
|
161
|
+
* non-contradictory evidence is `EquivocationEvidenceInvalid` on every door. The plan trusts
|
|
162
|
+
* the payload's identity mirrors ONLY because the gate re-derives them from the evidence.
|
|
163
|
+
*/
|
|
164
|
+
export const reportSourceEquivocation = directive("reportSourceEquivocation")
|
|
165
|
+
.ensures(NomosSourceDishonor)
|
|
166
|
+
.payload(
|
|
167
|
+
z.object({
|
|
168
|
+
a: evidenceEnvelope,
|
|
169
|
+
b: evidenceEnvelope,
|
|
170
|
+
/** Mirror of the proven identity (kernel-checked against the verified evidence). */
|
|
171
|
+
sourceGenesis: z.string().min(1),
|
|
172
|
+
keyHash: z.string().min(1),
|
|
173
|
+
foldRoot: z.string().min(1),
|
|
174
|
+
provenAtMs: z.number().int().nonnegative(),
|
|
175
|
+
}),
|
|
176
|
+
)
|
|
177
|
+
.plan((p) => {
|
|
178
|
+
const d = instance(NomosSourceDishonor, sourceDishonorId(p.sourceGenesis, p.keyHash));
|
|
179
|
+
return [
|
|
180
|
+
set(d, "sourceGenesis", p.sourceGenesis),
|
|
181
|
+
set(d, "keyHash", p.keyHash),
|
|
182
|
+
set(d, "foldRoot", p.foldRoot),
|
|
183
|
+
set(d, "provenAtMs", p.provenAtMs),
|
|
184
|
+
];
|
|
185
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* framework/source_roster.ts — K3 TRACKED ROSTERS (attested reads slice B, stage 5 —
|
|
8
|
+
* `architecture/attested_reads_design.md` §1.5).
|
|
9
|
+
*
|
|
10
|
+
* The reader's chain maintains a {@link NomosSourceRoster} per trusted source: a reader-side
|
|
11
|
+
* mirror of the SOURCE's own read-attestor signer roster, delivered as ATTESTED READS of the
|
|
12
|
+
* source's signer chain itself (the kernel-native `attestorRoster` query — the roster rides
|
|
13
|
+
* the SAME attested-read machinery as any foreign premise). `roster: "tracked"` on the trust
|
|
14
|
+
* edge activates the folded roster inside `accepted_keys`; rotation then needs ZERO reader
|
|
15
|
+
* law deploys, and a source-side revocation propagates on the reader's next sync.
|
|
16
|
+
*
|
|
17
|
+
* THE GROUNDED RECURSION (§1.5.2): a roster read's envelope is verified against the accepted
|
|
18
|
+
* set AS ALREADY FOLDED in the reader's prefix — the FIRST sync therefore verifies against
|
|
19
|
+
* the bootstrap trust (the K1 pins or the K2 birth-cert-derived key), which exists by
|
|
20
|
+
* construction before any roster does; every later sync verifies against the roster its own
|
|
21
|
+
* prefix folded. Deterministic at replay: the roster is reader-chain state.
|
|
22
|
+
*/
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { aggregate, instance } from "../aggregate.js";
|
|
25
|
+
import { t } from "../fields.js";
|
|
26
|
+
import { Lww } from "../drivers.js";
|
|
27
|
+
import { directive, type Directive } from "../directive.js";
|
|
28
|
+
import { set } from "../ops.js";
|
|
29
|
+
import { read } from "../read.js";
|
|
30
|
+
import { foreignQuery } from "../attested_core.js";
|
|
31
|
+
|
|
32
|
+
/** The framework-reserved roster instance-id prefix (kernel mirror:
|
|
33
|
+
* `nomos_executor::SOURCE_ROSTER_PREFIX`). */
|
|
34
|
+
export const SOURCE_ROSTER_PREFIX = "source-roster:";
|
|
35
|
+
|
|
36
|
+
/** The roster instance id for a source genesis. */
|
|
37
|
+
export function sourceRosterId(sourceGenesis: string): string {
|
|
38
|
+
return SOURCE_ROSTER_PREFIX + sourceGenesis;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The kernel-native source-side roster query id (`nomos_executor::ATTESTOR_ROSTER_QUERY`). */
|
|
42
|
+
export const ATTESTOR_ROSTER_QUERY_ID = "attestorRoster";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* NomosSourceRoster (§1.5) — the reader's folded mirror of one source's read-attestor
|
|
46
|
+
* roster, keyed by the source's TRUE identity (`source-roster:<genesis>`). `rosterJson` is
|
|
47
|
+
* the `keyHash → leaf` map exactly as the source's signer chain folds it (a key is ACCEPTED
|
|
48
|
+
* iff its leaf's `status == "active"` — the position-aware revocation mirror); the kernel's
|
|
49
|
+
* `accepted_keys` reads THIS under `roster: "tracked"`.
|
|
50
|
+
*/
|
|
51
|
+
export const NomosSourceRoster = aggregate("NomosSourceRoster", {
|
|
52
|
+
/** The routable source name the edge was declared under (diagnostics). */
|
|
53
|
+
source: t.string().merge(Lww),
|
|
54
|
+
/** The `keyHash → enrollment leaf` map, JSON — as attested by the source's own chain. */
|
|
55
|
+
rosterJson: t.json().merge(Lww),
|
|
56
|
+
/** The source's `SignerEpoch` at the attested sync. */
|
|
57
|
+
currentEpoch: t.int().merge(Lww),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** One roster row as the source's `attestorRoster` query answers it. */
|
|
61
|
+
interface AttestorRosterRow {
|
|
62
|
+
id: string;
|
|
63
|
+
data: {
|
|
64
|
+
genesis: string;
|
|
65
|
+
currentEpoch: number;
|
|
66
|
+
keys: Record<string, unknown>;
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* BUILD the reader-side `syncSourceRoster` framework directive for ONE tracked source
|
|
72
|
+
* (§1.5.1) — export the result from the law that declares `trustSource(sourceWs, { roster:
|
|
73
|
+
* "tracked", … })`. Its plan performs `read(attestorRoster, {}, { from: sourceWs })` (an
|
|
74
|
+
* ordinary attested read — verified by the five-step arm against the accepted set the reader's
|
|
75
|
+
* PREFIX already folds: pins/birth-cert first, then the roster itself) and folds the answer
|
|
76
|
+
* into {@link NomosSourceRoster}. One directive per tracked source per law (the id embeds
|
|
77
|
+
* nothing — a law tracking several sources exports several via distinct ids).
|
|
78
|
+
*/
|
|
79
|
+
export function sourceRosterSync(sourceWs: string, directiveId = "syncSourceRoster"): Directive<Record<string, never>> {
|
|
80
|
+
const rosterQuery = foreignQuery(ATTESTOR_ROSTER_QUERY_ID);
|
|
81
|
+
return directive(directiveId)
|
|
82
|
+
.ensures(NomosSourceRoster)
|
|
83
|
+
.payload(z.object({}).strict())
|
|
84
|
+
.plan(() => {
|
|
85
|
+
const rows = read<AttestorRosterRow[]>(rosterQuery, {}, { from: sourceWs });
|
|
86
|
+
if (rows.length === 0) throw new Error("attestor-roster-empty");
|
|
87
|
+
const d = rows[0]!.data;
|
|
88
|
+
const r = instance(NomosSourceRoster, sourceRosterId(d.genesis));
|
|
89
|
+
return [
|
|
90
|
+
set(r, "source", sourceWs),
|
|
91
|
+
set(r, "rosterJson", JSON.stringify(d.keys ?? {})),
|
|
92
|
+
set(r, "currentEpoch", d.currentEpoch ?? 0),
|
|
93
|
+
];
|
|
94
|
+
})
|
|
95
|
+
.readsFrom(sourceWs, rosterQuery) as Directive<Record<string, never>>;
|
|
96
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -124,6 +124,8 @@ export { read } from "./read.js";
|
|
|
124
124
|
// surface (`trustSource`), the foreign query reference, and the reader-side SourceFrontier.
|
|
125
125
|
export {
|
|
126
126
|
trustSource,
|
|
127
|
+
trustParent,
|
|
128
|
+
PARENT_TRUST_SENTINEL,
|
|
127
129
|
foreignQuery,
|
|
128
130
|
NomosSourceFrontier,
|
|
129
131
|
sourceFrontierId,
|
|
@@ -133,6 +135,31 @@ export {
|
|
|
133
135
|
type TrustSourceOptions,
|
|
134
136
|
type ForeignQueryRef,
|
|
135
137
|
} from "./attested.js";
|
|
138
|
+
// ATTESTATION RECEIPTS + EQUIVOCATION ACCOUNTABILITY (slice B stage 3 — §5): the source-side
|
|
139
|
+
// sealed receipt batch + the reader-side proven-dishonor fact, both ordinary law the kernel's
|
|
140
|
+
// `attestation_ledger_violation` arm guards on every door.
|
|
141
|
+
export {
|
|
142
|
+
NomosAttestationReceipt,
|
|
143
|
+
recordAttestationBatch,
|
|
144
|
+
attestationReceiptByBatch,
|
|
145
|
+
attestationReceiptId,
|
|
146
|
+
ATTESTATION_RECEIPT_PREFIX,
|
|
147
|
+
NomosSourceDishonor,
|
|
148
|
+
reportSourceEquivocation,
|
|
149
|
+
sourceDishonorByGenesis,
|
|
150
|
+
sourceDishonorId,
|
|
151
|
+
SOURCE_DISHONOR_PREFIX,
|
|
152
|
+
} from "./framework/attested_receipts.js";
|
|
153
|
+
// K3 TRACKED ROSTERS (slice B stage 5 — §1.5): the reader-side folded source roster + the
|
|
154
|
+
// per-source sync directive factory (roster updates ride the attested-read machinery itself,
|
|
155
|
+
// grounded on the pins/birth-cert bootstrap).
|
|
156
|
+
export {
|
|
157
|
+
NomosSourceRoster,
|
|
158
|
+
sourceRosterSync,
|
|
159
|
+
sourceRosterId,
|
|
160
|
+
SOURCE_ROSTER_PREFIX,
|
|
161
|
+
ATTESTOR_ROSTER_QUERY_ID,
|
|
162
|
+
} from "./framework/source_roster.js";
|
|
136
163
|
// THE BIRTH EFFECT (G3 — birth is a declared kernel primitive): a `.births()` directive's plan records the
|
|
137
164
|
// child's genesis recipe via `birth(recipe)` (reuse `buildGenesisChain`); it rides the committed intent as a
|
|
138
165
|
// declared output, and the kernel — reading the `.births` declaration off the law manifest (the
|