@githolon/dsl 0.24.1 → 0.26.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 +22 -0
- package/dart/analysis_options.yaml +5 -0
- package/dart/lib/src/webview_bridge.dart +353 -17
- package/dart/pubspec.yaml +1 -1
- package/dart/test/webview_bridge_test.dart +77 -5
- package/package.json +1 -1
- package/src/births.ts +18 -2
- package/src/build_package.ts +16 -4
- package/src/codegen_dart.ts +3 -1
- package/src/codegen_ts.ts +1 -1
- package/src/directive.ts +53 -0
- package/src/framework/home.ts +15 -6
- package/src/framework/signing_identity.ts +58 -0
- package/src/framework/workspaces.ts +27 -6
- package/src/index.ts +1 -0
- package/src/usd.ts +66 -4
- package/src/usd_layers.ts +36 -4
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
## 0.22.0
|
|
2
|
+
|
|
3
|
+
- `NomosBridge.offerIntent` (canonical write verb; `offerDirective` kept as alias) + one-call `NomosBridge.createWorkspace` (sign + relay a control-plane offer).
|
|
4
|
+
|
|
5
|
+
## 0.21.0
|
|
6
|
+
- `NomosBridge.noteLocalSpan(...)` — inject a locally-measured span (e.g. the Dart-side durable save, the
|
|
7
|
+
"saved to disk" number) into the span stream so it appears on the flame chart AND the per-phase stats
|
|
8
|
+
exactly like a kernel span. Additive; used by `nomos_flutter`'s durable-save-on-write.
|
|
9
|
+
|
|
10
|
+
## 0.20.2
|
|
11
|
+
- `NomosBridge.connect` accepts the internal `allowUnborn` opt-in used by `NomosScope.home`, so the
|
|
12
|
+
home-birth ceremony can boot a local bridge before the home workspace has a remote main. The default
|
|
13
|
+
remains strict for ordinary workspaces.
|
|
14
|
+
|
|
15
|
+
## 0.20.1
|
|
16
|
+
- `NomosBridgeError` now carries structured `NomosErrorInfo` alongside the existing human-readable
|
|
17
|
+
`message`. The envelope includes stable code/component/operation fields, workspace/domain/directive
|
|
18
|
+
context, trace/span ids, arbitrary attributes, and `toTelemetryAttributes()` for future OpenTelemetry
|
|
19
|
+
export without taking an SDK dependency.
|
|
20
|
+
- Every bridge request now carries an OpenTelemetry-shaped trace context. String-only legacy runner errors
|
|
21
|
+
still work and are upgraded locally with operation/trace metadata.
|
|
22
|
+
|
|
1
23
|
## 0.20.0
|
|
2
24
|
- Authority-aware: `NomosBridge.offerDirective(domain, directiveId, payload, {actor, domainHash})` — generated
|
|
3
25
|
clients route every offer through THE ONE encoder (`encodeIntentOfferBytes` in @githolon/client); no
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
library;
|
|
16
16
|
|
|
17
17
|
import 'dart:async';
|
|
18
|
+
import 'dart:math';
|
|
18
19
|
|
|
19
20
|
/// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
|
|
20
21
|
const int kWebviewBridgeVersion = 1;
|
|
@@ -27,11 +28,176 @@ abstract interface class BridgeTransport {
|
|
|
27
28
|
Stream<Map<String, Object?>> get messages;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
/// Trace/span context carried over the bridge as plain JSON. It is intentionally OpenTelemetry-shaped without
|
|
32
|
+
/// importing an OTel SDK: apps can map it to spans later, while Nomos stays dependency-light at the runtime edge.
|
|
33
|
+
class NomosTraceContext {
|
|
34
|
+
final String? traceId;
|
|
35
|
+
final String? spanId;
|
|
36
|
+
final String? parentSpanId;
|
|
37
|
+
final bool? sampled;
|
|
38
|
+
|
|
39
|
+
const NomosTraceContext({
|
|
40
|
+
this.traceId,
|
|
41
|
+
this.spanId,
|
|
42
|
+
this.parentSpanId,
|
|
43
|
+
this.sampled,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
factory NomosTraceContext.fromJson(Object? value) {
|
|
47
|
+
if (value is! Map) return const NomosTraceContext();
|
|
48
|
+
final m = value.cast<String, Object?>();
|
|
49
|
+
return NomosTraceContext(
|
|
50
|
+
traceId: m['traceId']?.toString(),
|
|
51
|
+
spanId: m['spanId']?.toString(),
|
|
52
|
+
parentSpanId: m['parentSpanId']?.toString(),
|
|
53
|
+
sampled: m['sampled'] is bool ? m['sampled'] as bool : null,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
Map<String, Object?> toJson() => {
|
|
58
|
+
if (traceId != null) 'traceId': traceId,
|
|
59
|
+
if (spanId != null) 'spanId': spanId,
|
|
60
|
+
if (parentSpanId != null) 'parentSpanId': parentSpanId,
|
|
61
|
+
if (sampled != null) 'sampled': sampled,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
bool get isEmpty =>
|
|
65
|
+
traceId == null &&
|
|
66
|
+
spanId == null &&
|
|
67
|
+
parentSpanId == null &&
|
|
68
|
+
sampled == null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// Structured runtime error detail for the bridge boundary. Keep this data-only: it is safe to serialize, log,
|
|
72
|
+
/// persist, or later attach to an OpenTelemetry span without importing tracing into the core client package.
|
|
73
|
+
class NomosErrorInfo {
|
|
74
|
+
final String code;
|
|
75
|
+
final String message;
|
|
76
|
+
final String component;
|
|
77
|
+
final String? operation;
|
|
78
|
+
final String? workspace;
|
|
79
|
+
final String? domain;
|
|
80
|
+
final String? directiveId;
|
|
81
|
+
final NomosTraceContext trace;
|
|
82
|
+
final Map<String, Object?> attributes;
|
|
83
|
+
final NomosErrorInfo? cause;
|
|
84
|
+
|
|
85
|
+
const NomosErrorInfo({
|
|
86
|
+
required this.code,
|
|
87
|
+
required this.message,
|
|
88
|
+
this.component = 'nomos.client',
|
|
89
|
+
this.operation,
|
|
90
|
+
this.workspace,
|
|
91
|
+
this.domain,
|
|
92
|
+
this.directiveId,
|
|
93
|
+
this.trace = const NomosTraceContext(),
|
|
94
|
+
this.attributes = const {},
|
|
95
|
+
this.cause,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
factory NomosErrorInfo.legacy(
|
|
99
|
+
Object? message, {
|
|
100
|
+
String code = 'nomos.bridge.error',
|
|
101
|
+
String component = 'nomos.client',
|
|
102
|
+
String? operation,
|
|
103
|
+
NomosTraceContext trace = const NomosTraceContext(),
|
|
104
|
+
}) {
|
|
105
|
+
return NomosErrorInfo(
|
|
106
|
+
code: code,
|
|
107
|
+
message: (message ?? 'the githolon refused the request').toString(),
|
|
108
|
+
component: component,
|
|
109
|
+
operation: operation,
|
|
110
|
+
trace: trace,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
factory NomosErrorInfo.fromJson(
|
|
115
|
+
Object? value, {
|
|
116
|
+
Object? fallbackMessage,
|
|
117
|
+
String? fallbackOperation,
|
|
118
|
+
NomosTraceContext fallbackTrace = const NomosTraceContext(),
|
|
119
|
+
}) {
|
|
120
|
+
if (value is! Map) {
|
|
121
|
+
return NomosErrorInfo.legacy(
|
|
122
|
+
fallbackMessage,
|
|
123
|
+
operation: fallbackOperation,
|
|
124
|
+
trace: fallbackTrace,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
final m = value.cast<String, Object?>();
|
|
128
|
+
final trace = NomosTraceContext.fromJson(m['trace']);
|
|
129
|
+
return NomosErrorInfo(
|
|
130
|
+
code: (m['code'] ?? 'nomos.bridge.error').toString(),
|
|
131
|
+
message: (m['message'] ??
|
|
132
|
+
fallbackMessage ??
|
|
133
|
+
'the githolon refused the request')
|
|
134
|
+
.toString(),
|
|
135
|
+
component: (m['component'] ?? 'nomos.client').toString(),
|
|
136
|
+
operation: m['operation']?.toString() ?? fallbackOperation,
|
|
137
|
+
workspace: m['workspace']?.toString(),
|
|
138
|
+
domain: m['domain']?.toString(),
|
|
139
|
+
directiveId: m['directiveId']?.toString(),
|
|
140
|
+
trace: trace.isEmpty ? fallbackTrace : trace,
|
|
141
|
+
attributes:
|
|
142
|
+
((m['attributes'] as Map?)?.cast<String, Object?>()) ?? const {},
|
|
143
|
+
cause: m['cause'] == null
|
|
144
|
+
? null
|
|
145
|
+
: NomosErrorInfo.fromJson(m['cause'],
|
|
146
|
+
fallbackOperation: fallbackOperation),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
factory NomosErrorInfo.fromBridgeMessage(
|
|
151
|
+
Map<String, Object?> message, {
|
|
152
|
+
String? fallbackOperation,
|
|
153
|
+
NomosTraceContext fallbackTrace = const NomosTraceContext(),
|
|
154
|
+
Object? fallbackMessage,
|
|
155
|
+
}) {
|
|
156
|
+
return NomosErrorInfo.fromJson(
|
|
157
|
+
message['errorInfo'],
|
|
158
|
+
fallbackMessage: message['error'] ?? fallbackMessage,
|
|
159
|
+
fallbackOperation: fallbackOperation,
|
|
160
|
+
fallbackTrace: fallbackTrace,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
Map<String, Object?> toJson() => {
|
|
165
|
+
'code': code,
|
|
166
|
+
'message': message,
|
|
167
|
+
'component': component,
|
|
168
|
+
if (operation != null) 'operation': operation,
|
|
169
|
+
if (workspace != null) 'workspace': workspace,
|
|
170
|
+
if (domain != null) 'domain': domain,
|
|
171
|
+
if (directiveId != null) 'directiveId': directiveId,
|
|
172
|
+
if (!trace.isEmpty) 'trace': trace.toJson(),
|
|
173
|
+
if (attributes.isNotEmpty) 'attributes': attributes,
|
|
174
|
+
if (cause != null) 'cause': cause!.toJson(),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/// Attribute map suitable for attaching to a future OTel span/event. The keys are stable and low-cardinality
|
|
178
|
+
/// where possible; high-cardinality values stay explicit (`workspace`, `domain`, directive) so apps can redact.
|
|
179
|
+
Map<String, Object?> toTelemetryAttributes() => {
|
|
180
|
+
'exception.type': code,
|
|
181
|
+
'exception.message': message,
|
|
182
|
+
'nomos.error.code': code,
|
|
183
|
+
'nomos.component': component,
|
|
184
|
+
if (operation != null) 'nomos.operation': operation,
|
|
185
|
+
if (workspace != null) 'nomos.workspace': workspace,
|
|
186
|
+
if (domain != null) 'nomos.domain': domain,
|
|
187
|
+
if (directiveId != null) 'nomos.directive_id': directiveId,
|
|
188
|
+
if (trace.traceId != null) 'trace_id': trace.traceId,
|
|
189
|
+
if (trace.spanId != null) 'span_id': trace.spanId,
|
|
190
|
+
...attributes,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
30
194
|
/// A typed refusal surfaced from the githolon's gate (a law refusal, a drift, a malformed op). Carries the
|
|
31
|
-
/// human-first message verbatim
|
|
195
|
+
/// human-first message verbatim plus structured detail for logging, UI mapping, and future OTel export.
|
|
32
196
|
class NomosBridgeError implements Exception {
|
|
33
197
|
final String message;
|
|
34
|
-
|
|
198
|
+
final NomosErrorInfo info;
|
|
199
|
+
NomosBridgeError(this.message, {NomosErrorInfo? info})
|
|
200
|
+
: info = info ?? NomosErrorInfo.legacy(message);
|
|
35
201
|
@override
|
|
36
202
|
String toString() => 'NomosBridgeError: $message';
|
|
37
203
|
}
|
|
@@ -40,6 +206,8 @@ class NomosBridgeError implements Exception {
|
|
|
40
206
|
class NomosBridge {
|
|
41
207
|
final BridgeTransport _transport;
|
|
42
208
|
final Map<int, Completer<Object?>> _pending = {};
|
|
209
|
+
final Map<int, String> _pendingOps = {};
|
|
210
|
+
final Map<int, NomosTraceContext> _pendingTraces = {};
|
|
43
211
|
final Map<String, StreamController<List<dynamic>>> _watches = {};
|
|
44
212
|
// SHAPE COMPATIBILITY (#72): compat bindings emit a verdict MAP, not a row list.
|
|
45
213
|
final Map<String, StreamController<Map<String, Object?>>> _compatWatches = {};
|
|
@@ -55,12 +223,31 @@ class NomosBridge {
|
|
|
55
223
|
[]; // recent span events — so BOOT spans (emitted during connect, before the UI subscribes) survive
|
|
56
224
|
final Completer<void> _ready = Completer<void>();
|
|
57
225
|
int _seq = 0;
|
|
226
|
+
final String _traceId = _newTraceId();
|
|
58
227
|
late final StreamSubscription<Map<String, Object?>> _sub;
|
|
59
228
|
|
|
60
229
|
NomosBridge(this._transport) {
|
|
61
230
|
_sub = _transport.messages.listen(_onMessage);
|
|
62
231
|
}
|
|
63
232
|
|
|
233
|
+
static Random _traceRandom() {
|
|
234
|
+
try {
|
|
235
|
+
return Random.secure();
|
|
236
|
+
} catch (_) {
|
|
237
|
+
return Random(DateTime.now().microsecondsSinceEpoch);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
static String _newTraceId() {
|
|
242
|
+
final rng = _traceRandom();
|
|
243
|
+
String hex64() =>
|
|
244
|
+
rng.nextInt(0x100000000).toRadixString(16).padLeft(8, '0') +
|
|
245
|
+
rng.nextInt(0x100000000).toRadixString(16).padLeft(8, '0');
|
|
246
|
+
return '${hex64()}${hex64()}';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
static String _spanIdFor(int id) => id.toRadixString(16).padLeft(16, '0');
|
|
250
|
+
|
|
64
251
|
/// Completes once the runner has posted `{ event: "ready" }` (the wasm + client are booted).
|
|
65
252
|
Future<void> get ready => _ready.future;
|
|
66
253
|
|
|
@@ -76,8 +263,10 @@ class NomosBridge {
|
|
|
76
263
|
final ctrl = _watches[m['subId']];
|
|
77
264
|
if (ctrl != null && !ctrl.isClosed) {
|
|
78
265
|
if (m['ok'] == false) {
|
|
79
|
-
|
|
80
|
-
|
|
266
|
+
final info = NomosErrorInfo.fromBridgeMessage(m,
|
|
267
|
+
fallbackMessage: 'the watch read failed',
|
|
268
|
+
fallbackOperation: 'watch');
|
|
269
|
+
ctrl.addError(NomosBridgeError(info.message, info: info));
|
|
81
270
|
} else {
|
|
82
271
|
ctrl.add((m['rows'] as List?) ?? const []);
|
|
83
272
|
}
|
|
@@ -87,8 +276,10 @@ class NomosBridge {
|
|
|
87
276
|
final cctrl = _compatWatches[m['subId']];
|
|
88
277
|
if (cctrl != null && !cctrl.isClosed) {
|
|
89
278
|
if (m['ok'] == false) {
|
|
90
|
-
|
|
91
|
-
|
|
279
|
+
final info = NomosErrorInfo.fromBridgeMessage(m,
|
|
280
|
+
fallbackMessage: 'the compat watch failed',
|
|
281
|
+
fallbackOperation: 'watchCompatibility');
|
|
282
|
+
cctrl.addError(NomosBridgeError(info.message, info: info));
|
|
92
283
|
} else {
|
|
93
284
|
cctrl.add(((m['rows'] as Map?)?.cast<String, Object?>()) ?? const {});
|
|
94
285
|
}
|
|
@@ -121,12 +312,17 @@ class NomosBridge {
|
|
|
121
312
|
final id = m['id'];
|
|
122
313
|
if (id is! int) return;
|
|
123
314
|
final completer = _pending.remove(id);
|
|
315
|
+
final op = _pendingOps.remove(id);
|
|
316
|
+
final trace = _pendingTraces.remove(id) ?? const NomosTraceContext();
|
|
124
317
|
if (completer == null) return;
|
|
125
318
|
if (m['ok'] == true) {
|
|
126
319
|
completer.complete(m['result']);
|
|
127
320
|
} else {
|
|
128
|
-
|
|
129
|
-
|
|
321
|
+
final info = NomosErrorInfo.fromBridgeMessage(m,
|
|
322
|
+
fallbackOperation: op,
|
|
323
|
+
fallbackTrace: trace,
|
|
324
|
+
fallbackMessage: 'the githolon refused the request');
|
|
325
|
+
completer.completeError(NomosBridgeError(info.message, info: info));
|
|
130
326
|
}
|
|
131
327
|
}
|
|
132
328
|
|
|
@@ -136,8 +332,20 @@ class NomosBridge {
|
|
|
136
332
|
final id = ++_seq;
|
|
137
333
|
final completer = Completer<Object?>();
|
|
138
334
|
_pending[id] = completer;
|
|
139
|
-
|
|
140
|
-
|
|
335
|
+
_pendingOps[id] = op;
|
|
336
|
+
final trace = NomosTraceContext(
|
|
337
|
+
traceId: _traceId,
|
|
338
|
+
spanId: _spanIdFor(id),
|
|
339
|
+
sampled: true,
|
|
340
|
+
);
|
|
341
|
+
_pendingTraces[id] = trace;
|
|
342
|
+
_transport.send({
|
|
343
|
+
'v': kWebviewBridgeVersion,
|
|
344
|
+
'id': id,
|
|
345
|
+
'op': op,
|
|
346
|
+
'args': args,
|
|
347
|
+
'trace': trace.toJson(),
|
|
348
|
+
});
|
|
141
349
|
return completer.future;
|
|
142
350
|
}
|
|
143
351
|
|
|
@@ -157,7 +365,8 @@ class NomosBridge {
|
|
|
157
365
|
String? clientId,
|
|
158
366
|
String? authToken,
|
|
159
367
|
String? authorSecret,
|
|
160
|
-
String? restoreFrom
|
|
368
|
+
String? restoreFrom,
|
|
369
|
+
bool allowUnborn = false}) async {
|
|
161
370
|
await _call('connect', {
|
|
162
371
|
'cloud': cloud,
|
|
163
372
|
'workspace': workspace,
|
|
@@ -166,7 +375,8 @@ class NomosBridge {
|
|
|
166
375
|
// The device signing secret (base64, from [authorKeygen]) — the warrant default for every offer.
|
|
167
376
|
// Stays client-side; the kernel mints the signed-author attestation so authorship is replay-provable.
|
|
168
377
|
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
169
|
-
if (restoreFrom != null) 'restoreFrom': restoreFrom
|
|
378
|
+
if (restoreFrom != null) 'restoreFrom': restoreFrom,
|
|
379
|
+
if (allowUnborn) 'allowUnborn': true,
|
|
170
380
|
});
|
|
171
381
|
}
|
|
172
382
|
|
|
@@ -198,6 +408,94 @@ class NomosBridge {
|
|
|
198
408
|
return r['head'].toString();
|
|
199
409
|
}
|
|
200
410
|
|
|
411
|
+
/// UNDO the most-recent action via the kernel STRIKE primitive (the fold drops the struck intent by parity).
|
|
412
|
+
/// [scope] `'all'` undoes any author's most-recent action; `'user'` only THIS replica's own. [actions] is the
|
|
413
|
+
/// app's set of undoable directive ids (so undo never strikes a framework/control intent). [domain] +
|
|
414
|
+
/// [directiveId] name the law's strike directive (default `todo`/`undo`). Returns `{ok, undone, head}` (ok
|
|
415
|
+
/// false + reason when there's nothing to undo). REDO is [redoMostRecent] — it strikes the strike.
|
|
416
|
+
/// OFFER AN INTENT — the write verb. Plan a directive (domain/directiveId/payload) as the verified [actor];
|
|
417
|
+
/// the holon encodes the v2 offer envelope via the single codec and admits it. Returns the new local head.
|
|
418
|
+
/// (This is the canonical name; [offerDirective] is the legacy alias the generated clients still call.)
|
|
419
|
+
Future<String> offerIntent({
|
|
420
|
+
required String domain,
|
|
421
|
+
required String directiveId,
|
|
422
|
+
required Object? payload,
|
|
423
|
+
String? actor,
|
|
424
|
+
String? domainHash,
|
|
425
|
+
}) =>
|
|
426
|
+
offerDirective(
|
|
427
|
+
domain: domain,
|
|
428
|
+
directiveId: directiveId,
|
|
429
|
+
payload: payload,
|
|
430
|
+
actor: actor,
|
|
431
|
+
domainHash: domainHash);
|
|
432
|
+
|
|
433
|
+
/// MAKE A WORKSPACE — the one-call tenant path. A child workspace under a platform/[parent] is a SIGNED
|
|
434
|
+
/// control-plane offer; the holon seals `createWorkspace` (or [directiveId] `birthChild`) with the
|
|
435
|
+
/// requester's device key ([authorSecret], as [actor], e.g. `user:<uid>`) and relays it to the parent's
|
|
436
|
+
/// gateway. Returns the worker verdict (`{ok, status, body}`). The tenant move is one line:
|
|
437
|
+
/// `await bridge.createWorkspace(name: 'foo', parent: 'my-platform', actor: 'user:$uid', authorSecret: key)`.
|
|
438
|
+
Future<Map<String, Object?>> createWorkspace({
|
|
439
|
+
required String parent,
|
|
440
|
+
required String actor,
|
|
441
|
+
required String authorSecret,
|
|
442
|
+
String? name,
|
|
443
|
+
Object? payload,
|
|
444
|
+
String directiveId = 'createWorkspace',
|
|
445
|
+
String? domainHash,
|
|
446
|
+
String? authToken,
|
|
447
|
+
}) async {
|
|
448
|
+
final r = _asMap(await _call('createWorkspace', {
|
|
449
|
+
'parent': parent,
|
|
450
|
+
'actor': actor,
|
|
451
|
+
'authorSecret': authorSecret,
|
|
452
|
+
if (name != null) 'name': name,
|
|
453
|
+
if (payload != null) 'payload': payload,
|
|
454
|
+
'directiveId': directiveId,
|
|
455
|
+
if (domainHash != null) 'domainHash': domainHash,
|
|
456
|
+
if (authToken != null) 'authToken': authToken,
|
|
457
|
+
}));
|
|
458
|
+
return (r['verdict'] as Map?)?.cast<String, Object?>() ?? r;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
Future<Map<String, Object?>> undoMostRecent({
|
|
462
|
+
String scope = 'all',
|
|
463
|
+
List<String>? actions,
|
|
464
|
+
String domain = '',
|
|
465
|
+
String directiveId = 'undo',
|
|
466
|
+
}) async =>
|
|
467
|
+
_asMap(await _call('undoMostRecent', {
|
|
468
|
+
'scope': scope,
|
|
469
|
+
if (actions != null) 'actions': actions,
|
|
470
|
+
'domain': domain,
|
|
471
|
+
'directiveId': directiveId,
|
|
472
|
+
}));
|
|
473
|
+
|
|
474
|
+
/// REDO the most-recent undo (strike the strike → its target re-applies). Same [scope]/[domain]/[directiveId]
|
|
475
|
+
/// as [undoMostRecent]. Returns `{ok, redone, head}`.
|
|
476
|
+
Future<Map<String, Object?>> redoMostRecent({
|
|
477
|
+
String scope = 'all',
|
|
478
|
+
String domain = '',
|
|
479
|
+
String directiveId = 'undo',
|
|
480
|
+
}) async =>
|
|
481
|
+
_asMap(await _call('redoMostRecent', {
|
|
482
|
+
'scope': scope,
|
|
483
|
+
'domain': domain,
|
|
484
|
+
'directiveId': directiveId,
|
|
485
|
+
}));
|
|
486
|
+
|
|
487
|
+
/// HISTORY of one aggregate (a todo) — the full action timeline from the ledger: every intent that touched
|
|
488
|
+
/// it + the strikes that retract them (undo/restore/redo), oldest→newest. Each entry:
|
|
489
|
+
/// `{id, who, mine, at(ms), action, fields[], done?, text?, removed?, marker, isStrike, targetAction, struck}`.
|
|
490
|
+
/// The caller formats the labels (created / renamed / done / deleted / restored / undone). No extra read —
|
|
491
|
+
/// who/when ride inside the intent id.
|
|
492
|
+
Future<List<Map<String, Object?>>> historyOf(String aggregateId) async {
|
|
493
|
+
final r = _asMap(await _call('historyOf', {'aggregateId': aggregateId}));
|
|
494
|
+
return ((r['entries'] as List?) ?? const [])
|
|
495
|
+
.map((e) => (e as Map).cast<String, Object?>())
|
|
496
|
+
.toList();
|
|
497
|
+
}
|
|
498
|
+
|
|
201
499
|
/// Mint an ed25519 DEVICE KEY in-kernel → `{secret, public}` (base64). The SECRET stays on this device
|
|
202
500
|
/// (persist it in secure storage); pass it to [connect] as `authorSecret` (or per-offer `signWith`) so the
|
|
203
501
|
/// holon SIGNS each intent. The PUBLIC half is what gets enrolled so the gate re-verifies the signature on
|
|
@@ -218,8 +516,10 @@ class NomosBridge {
|
|
|
218
516
|
/// LOCAL kernel only (it never reaches a server). Returns the snake_case cert JSON `verify_to_root` parses.
|
|
219
517
|
Future<Map<String, Object?>> signBirthCert(
|
|
220
518
|
Map<String, Object?> fields, String deviceSecret) async =>
|
|
221
|
-
_asMap(await _call(
|
|
222
|
-
|
|
519
|
+
_asMap(await _call('signBirthCert', {
|
|
520
|
+
'fields': fields,
|
|
521
|
+
'deviceSecret': deviceSecret
|
|
522
|
+
}))['cert'] as Map<String, Object?>? ??
|
|
223
523
|
const {};
|
|
224
524
|
|
|
225
525
|
/// ENROLL THIS DEVICE — the LIVE gate: bind a device key's PUBLIC half to `principal` on-chain as a
|
|
@@ -259,7 +559,8 @@ class NomosBridge {
|
|
|
259
559
|
/// attestedAt}` → `{publicKey, signature}`. This is what drives `recordShare` into an invitee's home
|
|
260
560
|
/// (the free global invitation system): the granting workspace signs that it shares `object#relation`
|
|
261
561
|
/// with `subject`, and the invitee's home gate verifies the signature before recording it.
|
|
262
|
-
Future<Map<String, Object?>> attestationSign(
|
|
562
|
+
Future<Map<String, Object?>> attestationSign(
|
|
563
|
+
Map<String, Object?> args) async =>
|
|
263
564
|
_asMap(await _call('attestationSign', {'args': args}));
|
|
264
565
|
|
|
265
566
|
/// A declared query's rows from the local projection.
|
|
@@ -330,6 +631,31 @@ class NomosBridge {
|
|
|
330
631
|
/// (fetch wasm / clone ledger / instantiate) that streamed during connect(), before the UI subscribed.
|
|
331
632
|
List<Map<String, Object?>> get recentSpans => List.unmodifiable(_spanBuffer);
|
|
332
633
|
|
|
634
|
+
int _localSpanSeq = 0;
|
|
635
|
+
|
|
636
|
+
/// Inject a LOCALLY-MEASURED span (e.g. the Dart-side DURABLE SAVE — the "saved to disk" number) into the
|
|
637
|
+
/// span stream so it appears on the flame chart AND the app's per-phase stats EXACTLY like a kernel span.
|
|
638
|
+
/// Emits a begin then an end with the same id, [t0]..[t1] in ms-epoch. Purely additive — engines that emit
|
|
639
|
+
/// their own spans are unaffected; this is for measurements the kernel can't see because they happen in Dart.
|
|
640
|
+
void noteLocalSpan({
|
|
641
|
+
required String track,
|
|
642
|
+
required String name,
|
|
643
|
+
required String kind,
|
|
644
|
+
required int t0,
|
|
645
|
+
required int t1,
|
|
646
|
+
String? id,
|
|
647
|
+
}) {
|
|
648
|
+
final sid = id ?? 'local-${_localSpanSeq++}';
|
|
649
|
+
for (final m in <Map<String, Object?>>[
|
|
650
|
+
{'event': 'span', 'span': {'phase': 'begin', 'id': sid, 'track': track, 'name': name, 'kind': kind, 't0': t0}},
|
|
651
|
+
{'event': 'span', 'span': {'phase': 'end', 'id': sid, 't1': t1}},
|
|
652
|
+
]) {
|
|
653
|
+
_spanBuffer.add(m);
|
|
654
|
+
if (_spanBuffer.length > 400) _spanBuffer.removeRange(0, _spanBuffer.length - 400);
|
|
655
|
+
if (!_spanEvents.isClosed) _spanEvents.add(m);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
333
659
|
/// Ledger size (commits) + branch-vs-main (ahead) + projection bytes — the dashboard's slow state.
|
|
334
660
|
Future<Map<String, Object?>> metrics() async =>
|
|
335
661
|
_asMap(await _call('metrics'));
|
|
@@ -411,10 +737,20 @@ class NomosBridge {
|
|
|
411
737
|
/// Tear down: stop the transport subscription + fail any in-flight calls.
|
|
412
738
|
Future<void> dispose() async {
|
|
413
739
|
await _sub.cancel();
|
|
414
|
-
for (final
|
|
415
|
-
|
|
740
|
+
for (final entry in _pending.entries) {
|
|
741
|
+
final c = entry.value;
|
|
742
|
+
if (!c.isCompleted) {
|
|
743
|
+
final trace = _pendingTraces[entry.key] ?? const NomosTraceContext();
|
|
744
|
+
final info = NomosErrorInfo.legacy('bridge disposed',
|
|
745
|
+
code: 'nomos.bridge.disposed',
|
|
746
|
+
operation: _pendingOps[entry.key],
|
|
747
|
+
trace: trace);
|
|
748
|
+
c.completeError(NomosBridgeError(info.message, info: info));
|
|
749
|
+
}
|
|
416
750
|
}
|
|
417
751
|
_pending.clear();
|
|
752
|
+
_pendingOps.clear();
|
|
753
|
+
_pendingTraces.clear();
|
|
418
754
|
for (final w in _watches.values) {
|
|
419
755
|
if (!w.isClosed) await w.close();
|
|
420
756
|
}
|
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.22.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
|
@@ -49,7 +49,17 @@ class FakeRunner implements BridgeTransport {
|
|
|
49
49
|
'v': kWebviewBridgeVersion,
|
|
50
50
|
'id': id,
|
|
51
51
|
'ok': false,
|
|
52
|
-
'error': 'the law refused this intent'
|
|
52
|
+
'error': 'the law refused this intent',
|
|
53
|
+
'errorInfo': {
|
|
54
|
+
'code': 'nomos.law.refused',
|
|
55
|
+
'message': 'the law refused this intent',
|
|
56
|
+
'component': 'test.runner',
|
|
57
|
+
'operation': 'offer',
|
|
58
|
+
'domain': 'x',
|
|
59
|
+
'directiveId': 'refuseMe',
|
|
60
|
+
'trace': message['trace'],
|
|
61
|
+
'attributes': {'test.case': 'refusal'}
|
|
62
|
+
}
|
|
53
63
|
});
|
|
54
64
|
} else {
|
|
55
65
|
_incoming.add(reply({'head': 'HEAD-offer'}));
|
|
@@ -79,7 +89,16 @@ class FakeRunner implements BridgeTransport {
|
|
|
79
89
|
_incoming.add(reply({'count': 7}));
|
|
80
90
|
break;
|
|
81
91
|
case 'sync':
|
|
82
|
-
|
|
92
|
+
if (args['admit'] == false) {
|
|
93
|
+
_incoming.add({
|
|
94
|
+
'v': kWebviewBridgeVersion,
|
|
95
|
+
'id': id,
|
|
96
|
+
'ok': false,
|
|
97
|
+
'error': 'legacy sync failed'
|
|
98
|
+
});
|
|
99
|
+
} else {
|
|
100
|
+
_incoming.add(reply({'pushed': true}));
|
|
101
|
+
}
|
|
83
102
|
break;
|
|
84
103
|
case 'autoSync':
|
|
85
104
|
_incoming.add(reply({'started': true}));
|
|
@@ -198,8 +217,6 @@ Future<Object?> _invoke(
|
|
|
198
217
|
/// What the corpus says the typed (unwrapped) return value should be, given the reply's `result`.
|
|
199
218
|
Object? _expectedTyped(String op, Map<String, Object?> result) {
|
|
200
219
|
switch (op) {
|
|
201
|
-
case 'offer':
|
|
202
|
-
return result['head'];
|
|
203
220
|
case 'offer':
|
|
204
221
|
return result['head'];
|
|
205
222
|
case 'query':
|
|
@@ -277,6 +294,33 @@ void main() {
|
|
|
277
294
|
expect((req['args'] as Map)['intentBytes'], intentBytes);
|
|
278
295
|
});
|
|
279
296
|
|
|
297
|
+
test('requests carry OpenTelemetry-shaped trace context', () async {
|
|
298
|
+
await bridge.ready;
|
|
299
|
+
await bridge.connect(cloud: 'https://c', workspace: 'ws', clientId: 'c1');
|
|
300
|
+
final req = runner.sent.firstWhere((m) => m['op'] == 'connect');
|
|
301
|
+
final trace = (req['trace'] as Map).cast<String, Object?>();
|
|
302
|
+
expect(trace['traceId'], isA<String>());
|
|
303
|
+
expect((trace['traceId'] as String).length, 32);
|
|
304
|
+
expect(trace['spanId'], isA<String>());
|
|
305
|
+
expect((trace['spanId'] as String).length, 16);
|
|
306
|
+
expect(trace['sampled'], isTrue);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test('connect only sends allowUnborn when explicitly opted in', () async {
|
|
310
|
+
await bridge.ready;
|
|
311
|
+
await bridge.connect(cloud: 'https://c', workspace: 'strict');
|
|
312
|
+
var req = runner.sent.lastWhere((m) => m['op'] == 'connect');
|
|
313
|
+
expect((req['args'] as Map).containsKey('allowUnborn'), isFalse);
|
|
314
|
+
|
|
315
|
+
await bridge.connect(
|
|
316
|
+
cloud: 'https://c',
|
|
317
|
+
workspace: 'home-unborn',
|
|
318
|
+
allowUnborn: true,
|
|
319
|
+
);
|
|
320
|
+
req = runner.sent.lastWhere((m) => m['op'] == 'connect');
|
|
321
|
+
expect((req['args'] as Map)['allowUnborn'], isTrue);
|
|
322
|
+
});
|
|
323
|
+
|
|
280
324
|
test(
|
|
281
325
|
'connect / query / queryById / count / sync / lawStatus all wrap the runner',
|
|
282
326
|
() async {
|
|
@@ -363,6 +407,34 @@ void main() {
|
|
|
363
407
|
err = e;
|
|
364
408
|
}
|
|
365
409
|
expect(err, isA<NomosBridgeError>());
|
|
366
|
-
|
|
410
|
+
final e = err as NomosBridgeError;
|
|
411
|
+
expect(e.message, contains('law refused'));
|
|
412
|
+
expect(e.info.code, 'nomos.law.refused');
|
|
413
|
+
expect(e.info.component, 'test.runner');
|
|
414
|
+
expect(e.info.operation, 'offer');
|
|
415
|
+
expect(e.info.domain, 'x');
|
|
416
|
+
expect(e.info.directiveId, 'refuseMe');
|
|
417
|
+
expect(e.info.trace.traceId, isNotEmpty);
|
|
418
|
+
expect(e.info.toTelemetryAttributes()['nomos.error.code'],
|
|
419
|
+
'nomos.law.refused');
|
|
420
|
+
expect(e.info.toTelemetryAttributes()['nomos.operation'], 'offer');
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test(
|
|
424
|
+
'legacy string-only runner errors still get operation and trace metadata',
|
|
425
|
+
() async {
|
|
426
|
+
await bridge.ready;
|
|
427
|
+
Object? err;
|
|
428
|
+
try {
|
|
429
|
+
await bridge.sync(admit: false);
|
|
430
|
+
} on NomosBridgeError catch (e) {
|
|
431
|
+
err = e;
|
|
432
|
+
}
|
|
433
|
+
expect(err, isA<NomosBridgeError>());
|
|
434
|
+
final e = err as NomosBridgeError;
|
|
435
|
+
expect(e.message, 'legacy sync failed');
|
|
436
|
+
expect(e.info.code, 'nomos.bridge.error');
|
|
437
|
+
expect(e.info.operation, 'sync');
|
|
438
|
+
expect(e.info.trace.traceId, isNotEmpty);
|
|
367
439
|
});
|
|
368
440
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.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/births.ts
CHANGED
|
@@ -92,15 +92,31 @@ export function birth(recipe: BirthRecipe): void {
|
|
|
92
92
|
*/
|
|
93
93
|
export interface ParentAttestedBirthMarker {
|
|
94
94
|
readonly __isParentAttestedBirth: true;
|
|
95
|
+
/** The singleton aggregate INSTANCE id holding the home owner (the subject the cert binds to). Lowered
|
|
96
|
+
* into the `nomosParentAttestedBirth` era key so the KERNEL reads it, never hardcodes it. Optional — a
|
|
97
|
+
* bare marker omits it and the kernel falls back to the framework `home` default (the era rule). */
|
|
98
|
+
readonly ownerObject?: string;
|
|
99
|
+
/** The field on that singleton holding the owner principal. */
|
|
100
|
+
readonly ownerField?: string;
|
|
101
|
+
/** The singleton aggregate INSTANCE id holding the travelling `BirthCertChain`. */
|
|
102
|
+
readonly certObject?: string;
|
|
103
|
+
/** The field on that singleton holding the JSON cert chain. */
|
|
104
|
+
readonly certField?: string;
|
|
95
105
|
}
|
|
96
106
|
export const requireParentAttestedBirth: ParentAttestedBirthMarker = { __isParentAttestedBirth: true };
|
|
97
107
|
|
|
98
108
|
/** Shape-discover the {@link requireParentAttestedBirth} sentinel in a module's exports (the composer). */
|
|
99
109
|
export function moduleRequiresParentAttestedBirth(mod: Record<string, unknown>): boolean {
|
|
110
|
+
return parentAttestedBirthMarker(mod) !== null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Shape-discover the {@link requireParentAttestedBirth} sentinel AND return it (with any declared owner/cert
|
|
114
|
+
* locators) — the composer lowers the locators into the era key so the kernel reads them. `null` ⇒ absent. */
|
|
115
|
+
export function parentAttestedBirthMarker(mod: Record<string, unknown>): ParentAttestedBirthMarker | null {
|
|
100
116
|
for (const v of Object.values(mod)) {
|
|
101
117
|
if (v && typeof v === "object" && (v as ParentAttestedBirthMarker).__isParentAttestedBirth === true) {
|
|
102
|
-
return
|
|
118
|
+
return v as ParentAttestedBirthMarker;
|
|
103
119
|
}
|
|
104
120
|
}
|
|
105
|
-
return
|
|
121
|
+
return null;
|
|
106
122
|
}
|
package/src/build_package.ts
CHANGED
|
@@ -58,7 +58,7 @@ import { domainHash, emitManifestBytes } from "./manifest.js";
|
|
|
58
58
|
import { emitUsd } from "./usd.js";
|
|
59
59
|
import { expandCapabilityExports } from "./capability_exports.js";
|
|
60
60
|
import { RelationTuple } from "./framework/relations.js";
|
|
61
|
-
import {
|
|
61
|
+
import { parentAttestedBirthMarker } from "./births.js";
|
|
62
62
|
|
|
63
63
|
// ─────────────────────────────────────────────────────────────────────────────────
|
|
64
64
|
// composeDomainModule — the named-keys `module(...)`
|
|
@@ -187,8 +187,20 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
187
187
|
// it requires a parent-attested-birth cert chain. Discovered by shape across the module list (the SAME
|
|
188
188
|
// duck-typing aggregates/directives use). Omit-when-false (era rule): a law that never declares it is
|
|
189
189
|
// byte-identical in the canonical manifest/USD to before the key existed.
|
|
190
|
-
|
|
191
|
-
|
|
190
|
+
// Discover the sentinel AND its declared owner/cert locators (the kernel reads them off the era key, names
|
|
191
|
+
// neither). A bare marker omits the locators ⇒ the kernel's framework default applies (the era rule).
|
|
192
|
+
const pabMarker =
|
|
193
|
+
parentAttestedBirthMarker(merged) ??
|
|
194
|
+
spec.modules.map((m) => parentAttestedBirthMarker(m)).find((x) => x !== null) ??
|
|
195
|
+
null;
|
|
196
|
+
const parentAttestedBirth = pabMarker
|
|
197
|
+
? {
|
|
198
|
+
...(pabMarker.ownerObject !== undefined ? { ownerObject: pabMarker.ownerObject } : {}),
|
|
199
|
+
...(pabMarker.ownerField !== undefined ? { ownerField: pabMarker.ownerField } : {}),
|
|
200
|
+
...(pabMarker.certObject !== undefined ? { certObject: pabMarker.certObject } : {}),
|
|
201
|
+
...(pabMarker.certField !== undefined ? { certField: pabMarker.certField } : {}),
|
|
202
|
+
}
|
|
203
|
+
: null;
|
|
192
204
|
|
|
193
205
|
return {
|
|
194
206
|
name: spec.name,
|
|
@@ -217,7 +229,7 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
217
229
|
...(relationTypes.length > 0 ? { relationTypes } : {}),
|
|
218
230
|
// THE PEER-SOVEREIGN ROOT-OF-TRUST: omit-when-false — a law that doesn't require parent-attested
|
|
219
231
|
// birth is byte-identical in the canonical manifest/USD (the nomosParentAttestedBirth era rule).
|
|
220
|
-
...(parentAttestedBirth ? { parentAttestedBirth
|
|
232
|
+
...(parentAttestedBirth ? { parentAttestedBirth } : {}),
|
|
221
233
|
...(dartImports.length > 0 ? { dartImports } : {}),
|
|
222
234
|
...(spec.dartRefImports !== undefined && spec.dartRefImports.length > 0
|
|
223
235
|
? { dartRefImports: [...spec.dartRefImports] }
|
package/src/codegen_dart.ts
CHANGED
|
@@ -2463,7 +2463,9 @@ export interface DomainModule {
|
|
|
2463
2463
|
* cross-ws read). ADDITIVE + OPTIONAL: a law that declares none omits the key and is byte-identical
|
|
2464
2464
|
* to before it existed (the era rule); pre-feature + legacy/non-warranted workspaces replay green.
|
|
2465
2465
|
*/
|
|
2466
|
-
parentAttestedBirth?:
|
|
2466
|
+
parentAttestedBirth?:
|
|
2467
|
+
| boolean
|
|
2468
|
+
| { ownerObject?: string; ownerField?: string; certObject?: string; certField?: string };
|
|
2467
2469
|
/**
|
|
2468
2470
|
* The domain's NATIVE ZANZIBAR RELATION SCHEMA (`relation_schema.ts` — the userset-rewrite law,
|
|
2469
2471
|
* P1). Pure-data per-type relation rewrites lowered to the `nomosRelations` USD era key (NO JS on
|
package/src/codegen_ts.ts
CHANGED
|
@@ -432,7 +432,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
432
432
|
|
|
433
433
|
// ── DERIVED BIRTH LANES (sharding §10 RATIFIED, slice 1) ──
|
|
434
434
|
// A `workspaceType(...).hasMany(...)` taxonomy DERIVES typed birth helpers over
|
|
435
|
-
// the EXISTING birth machinery (the boot lane + the parent's
|
|
435
|
+
// the EXISTING birth machinery (the boot lane + the parent's birthChild/createWorkspace
|
|
436
436
|
// governance offers, `POST /v2/workspaces/...` — derived, never forked). Fully typesafe
|
|
437
437
|
// per Jack's ruling: the caller passes LOGICAL names
|
|
438
438
|
// (parent param named after the parent TYPE); the `p--ws` composition is internal
|
package/src/directive.ts
CHANGED
|
@@ -92,6 +92,26 @@ export interface Directive<P = unknown> {
|
|
|
92
92
|
readonly signature: string;
|
|
93
93
|
};
|
|
94
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* THE KEYLESS VERIFIED-FIRST-DEVICE GATE (architecture/keyless_verified_onboard.md). When set, this
|
|
97
|
+
* directive bootstraps a principal's FIRST device-signer from an ON-CHAIN, device-bound IdP attestation:
|
|
98
|
+
* its authorization is the carried `idpToken` (re-verified against the workspace AuthProvider IDENTICALLY
|
|
99
|
+
* on author / receive / verify_chain), NOT a pre-enrolled signer — so a brand-new IdP-verified user can
|
|
100
|
+
* seat its first key self-serve + peer-to-peer. Lowered into the `nomosVerifiedFirstEnroll` era key as a
|
|
101
|
+
* per-directive FIELD MAPPING (the payload fields the gate reads the token / seated keyHash / claimed
|
|
102
|
+
* issuer from), so the KERNEL hardcodes NEITHER the directive id NOR the field names — it reads both from
|
|
103
|
+
* THIS law declaration (the same way it reads `.attestationAuthorized()`'s gate). Defaulted to the
|
|
104
|
+
* framework `enrollFirstDevice` convention by `.verifiedFirstDevice()`; a tenant can author its OWN
|
|
105
|
+
* first-enroll directive over the same machinery. OMITTED for law with no such directive (the era rule).
|
|
106
|
+
*/
|
|
107
|
+
readonly firstDeviceEnrollGate?: {
|
|
108
|
+
/** The payload field holding the IdP token (the captured premise the gate re-verifies). */
|
|
109
|
+
readonly tokenField: string;
|
|
110
|
+
/** The payload field holding sha256(device pubkey) being seated (the gate ties it to the signer). */
|
|
111
|
+
readonly keyHashField: string;
|
|
112
|
+
/** The payload field holding the claimed issuer (the gate asserts it == the prefix AuthProvider.issuer). */
|
|
113
|
+
readonly verifiedViaField: string;
|
|
114
|
+
};
|
|
95
115
|
/**
|
|
96
116
|
* The directive's DECLARED read boundary: the ref types its `plan` may READ (the
|
|
97
117
|
* "IR for law boundaries" decision — the boundary is part of the domain identity).
|
|
@@ -194,6 +214,20 @@ export interface RequirableDirective<P> extends Directive<P> {
|
|
|
194
214
|
* host-free. Framework-only (the home law's `recordShare`). Returns a NEW requirable directive.
|
|
195
215
|
*/
|
|
196
216
|
attestationAuthorized(owner: { ownerObject: string; ownerField: string }): RequirableDirective<P>;
|
|
217
|
+
/**
|
|
218
|
+
* Declare this directive a KEYLESS VERIFIED-FIRST-DEVICE bootstrap (keyless_verified_onboard.md): its
|
|
219
|
+
* authorization is a carried, device-bound IdP `idpToken` re-verified against the workspace AuthProvider —
|
|
220
|
+
* not a pre-enrolled signer — so a brand-new principal can seat its FIRST device key self-serve +
|
|
221
|
+
* peer-to-peer. Lowered into the `nomosVerifiedFirstEnroll` era key as a per-directive payload FIELD
|
|
222
|
+
* MAPPING, so the kernel reads the directive id + field names from the law (hardcoding neither). The field
|
|
223
|
+
* names DEFAULT to the framework `enrollFirstDevice` convention; override only to author a custom shape.
|
|
224
|
+
* Returns a NEW requirable directive.
|
|
225
|
+
*/
|
|
226
|
+
verifiedFirstDevice(opts?: {
|
|
227
|
+
tokenField?: string;
|
|
228
|
+
keyHashField?: string;
|
|
229
|
+
verifiedViaField?: string;
|
|
230
|
+
}): RequirableDirective<P>;
|
|
197
231
|
/**
|
|
198
232
|
* Declare the directive's READ boundary. TWO argument kinds, one declaration site:
|
|
199
233
|
* * a STRING declares a ref TYPE its `plan` may read (the original boundary —
|
|
@@ -392,6 +426,25 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
|
|
|
392
426
|
},
|
|
393
427
|
});
|
|
394
428
|
},
|
|
429
|
+
verifiedFirstDevice(opts?: {
|
|
430
|
+
tokenField?: string;
|
|
431
|
+
keyHashField?: string;
|
|
432
|
+
verifiedViaField?: string;
|
|
433
|
+
}): RequirableDirective<P> {
|
|
434
|
+
// KEYLESS VERIFIED-FIRST-DEVICE (keyless_verified_onboard.md): the carried device-bound `idpToken` IS
|
|
435
|
+
// the authorization — lowered into the `nomosVerifiedFirstEnroll` era key as the payload FIELD MAPPING
|
|
436
|
+
// the one gate reads, so the kernel hardcodes NEITHER the directive id NOR the field names (it reads
|
|
437
|
+
// both from this declaration). Field names default HERE (in the DSL) to the framework convention; a
|
|
438
|
+
// tenant may override to author its own first-enroll directive over the same kernel machinery.
|
|
439
|
+
return makeRequirable({
|
|
440
|
+
...base,
|
|
441
|
+
firstDeviceEnrollGate: {
|
|
442
|
+
tokenField: opts?.tokenField ?? "idpToken",
|
|
443
|
+
keyHashField: opts?.keyHashField ?? "keyHash",
|
|
444
|
+
verifiedViaField: opts?.verifiedViaField ?? "verifiedVia",
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
},
|
|
395
448
|
reads(...reads: (string | QueryDecl)[]): RequirableDirective<P> {
|
|
396
449
|
// Split by argument kind: strings are the ref-type boundary; QueryDecls are the
|
|
397
450
|
// captured-read lane (#58). Both accumulate, dedup (ref types by value, queries
|
package/src/framework/home.ts
CHANGED
|
@@ -35,7 +35,7 @@ import { query } from "../query.js";
|
|
|
35
35
|
import { birth } from "../births.js";
|
|
36
36
|
import { workspaceInvariant, refAs } from "./workspace_invariant.js";
|
|
37
37
|
import { identityRelations } from "./signing_identity.js";
|
|
38
|
-
import { requireParentAttestedBirth } from "../births.js";
|
|
38
|
+
import { requireParentAttestedBirth, type ParentAttestedBirthMarker } from "../births.js";
|
|
39
39
|
import { GenesisWarrant, seedGenesis } from "./genesis.js";
|
|
40
40
|
|
|
41
41
|
// `nomos.sha256` — the TRUSTLESS, pure hash computed IN the githolon (Rust sha2 bound in the sealed engine;
|
|
@@ -84,9 +84,9 @@ export function writeHomeOwner(p: { principal: string; homeName: string; bornAt:
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
* `seedHomeOwner` — the
|
|
88
|
-
* the fixed singleton id.
|
|
89
|
-
* lane
|
|
87
|
+
* `seedHomeOwner` — the genesis directive that stamps the home's owner + its self-committed `homeName` at
|
|
88
|
+
* the fixed singleton id. It is authored by the birth/genesis chain and structurally refused on the open
|
|
89
|
+
* session lane. `.ensures` (a deterministic-keyed singleton). The `homeName`
|
|
90
90
|
* is caller-supplied but the {@link homeNameBindsOwner} invariant proves it `== home-<sha256(principal)>`,
|
|
91
91
|
* so a squatter cannot claim a name slot that doesn't hash to ITS own owner.
|
|
92
92
|
*/
|
|
@@ -150,7 +150,7 @@ export function homeNameOf(subject: string): string {
|
|
|
150
150
|
* so HomeOwner is seeded and `homeNameBindsOwner` passes (a birth without it would be an invalid, un-owned
|
|
151
151
|
* home).
|
|
152
152
|
*/
|
|
153
|
-
const HOME_LAW_DOMAIN = "home";
|
|
153
|
+
export const HOME_LAW_DOMAIN = "home";
|
|
154
154
|
export const birthHome = directive("birthHome")
|
|
155
155
|
.creates(HomeOwner) // the birth's law-declared touched type — the child's HomeOwner is what this offer seeds
|
|
156
156
|
.payload(
|
|
@@ -359,4 +359,13 @@ export const seedBirthAttestation = directive("seedBirthAttestation")
|
|
|
359
359
|
* `NotRootedAtRoot`). Era-ruled: a home law that does NOT export this carries no key and replays byte-
|
|
360
360
|
* identical (the squat-only `homeNameBindsOwner` check still runs; the cert check is simply inert).
|
|
361
361
|
*/
|
|
362
|
-
export const parentAttestedBirth =
|
|
362
|
+
export const parentAttestedBirth: ParentAttestedBirthMarker = {
|
|
363
|
+
...requireParentAttestedBirth,
|
|
364
|
+
// DECLARE the owner + cert locators so the KERNEL reads them (it names neither): the home owner lives at
|
|
365
|
+
// the `HomeOwner` singleton's `principal`, the travelling cert at the `BirthAttestation` singleton's
|
|
366
|
+
// `certChain`. Mirrors how `.attestationAuthorized()` declares its owner-source — the kernel hardcodes none.
|
|
367
|
+
ownerObject: HOME_OWNER_ID,
|
|
368
|
+
ownerField: "principal",
|
|
369
|
+
certObject: BIRTH_ATTESTATION_ID,
|
|
370
|
+
certField: "certChain",
|
|
371
|
+
};
|
|
@@ -269,6 +269,64 @@ export const enrollSignerSelfRequiresVerified = workspaceInvariant("EnrollSigner
|
|
|
269
269
|
: { reject: "enroll-signer-self-requires-verified-principal" },
|
|
270
270
|
);
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* `enrollFirstDevice` (architecture/keyless_verified_onboard.md) — THE KEYLESS VERIFIED FIRST-DEVICE
|
|
274
|
+
* self-onboard. A brand-new principal (an IdP-verified user with NO enrolled signer key) bootstraps its
|
|
275
|
+
* FIRST device-signer on a WARRANTED parent, self-serve + peer-to-peer, no admin/server in the loop.
|
|
276
|
+
*
|
|
277
|
+
* SELF-BIND (identical to {@link enrollSignerSelf}): the object is the AUTHORING principal's identity
|
|
278
|
+
* (`ctx.principal()`), NEVER a payload field — a device can only ever seat a key for ITS OWN identity.
|
|
279
|
+
*
|
|
280
|
+
* The PAYLOAD carries the IdP's signed identity assertion as an ON-CHAIN CAPTURED PREMISE — `idpToken`
|
|
281
|
+
* (the JWT). The kernel's `nomosVerifiedFirstEnroll` arm re-verifies `idpToken` IDENTICALLY on author /
|
|
282
|
+
* receive / verify_chain against the workspace's law-declared AuthProvider keys (which are themselves
|
|
283
|
+
* on-chain law-state, so every replaying peer has them — a JWT is a signed PUBLIC assertion, not a secret;
|
|
284
|
+
* the IdP is the identity root-of-trust, parallel to K_root for lineage). Era-inert unless the law composes
|
|
285
|
+
* this directive. (Replay across IdP key rotation needs the signing key RETAINED in AuthProvider.keys — an
|
|
286
|
+
* append-only `rotateKeys` is the follow-up; until then the tenant keeps the active keys in the set.)
|
|
287
|
+
*
|
|
288
|
+
* SAFETY (kernel-enforced, NOT here): the token MUST be device-bound (`token[nonce|cnf] == sha256(the
|
|
289
|
+
* signing device pubkey)`) under `AuthProvider.requireDeviceBinding` (DEFAULT true) so an on-chain token is
|
|
290
|
+
* useless to a thief; proof-of-possession ties the signing key to the seated key; `verifiedVia` must equal
|
|
291
|
+
* the prefix AuthProvider issuer. The plan only seats the leaf; the kernel owns the verification.
|
|
292
|
+
*/
|
|
293
|
+
export const enrollFirstDevice = directive("enrollFirstDevice")
|
|
294
|
+
.ensures(RelationTuple)
|
|
295
|
+
.payload(
|
|
296
|
+
z.object({
|
|
297
|
+
keyHash: sha256Hex, // sha256hex of the ed25519 device PUBLIC key being bootstrapped (== the signer)
|
|
298
|
+
/** The IdP/issuer that vouched (must equal the prefix AuthProvider.issuer — kernel-asserted). */
|
|
299
|
+
verifiedVia: z.string().min(1),
|
|
300
|
+
/** The SignerEpoch this binding is valid from (caller reads the current epoch and stamps it). */
|
|
301
|
+
validFromEpoch: z.number().int().nonnegative(),
|
|
302
|
+
grantedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
|
|
303
|
+
/** THE CAPTURED PREMISE: the IdP's signed identity assertion (JWT), re-verified on every lane against
|
|
304
|
+
* the on-chain AuthProvider keys. */
|
|
305
|
+
idpToken: z.string().min(1),
|
|
306
|
+
}),
|
|
307
|
+
)
|
|
308
|
+
.plan((p, ctx) => {
|
|
309
|
+
// SELF-BIND: the object is the AUTHORING principal's identity (read the raw principal from the endorsed
|
|
310
|
+
// evidence — the framework may; an author cannot). The kernel arm proves the principal is the IdP subject.
|
|
311
|
+
const me = ctx.principal().asAuthoritySubject<string>("bootstrap my first device signer key from a verified IdP token");
|
|
312
|
+
const rawPrincipal = endorsementOf(me)?.principal ?? "";
|
|
313
|
+
return writeTuple({
|
|
314
|
+
object: signerObject(rawPrincipal),
|
|
315
|
+
relation: SIGNER_RELATION,
|
|
316
|
+
subject: "key:" + p.keyHash,
|
|
317
|
+
grantedBy: ctx.principal().asAttribution(),
|
|
318
|
+
grantedAt: p.grantedAt,
|
|
319
|
+
verifiedVia: p.verifiedVia,
|
|
320
|
+
validFromEpoch: p.validFromEpoch,
|
|
321
|
+
});
|
|
322
|
+
})
|
|
323
|
+
// THE KEYLESS GATE DECLARATION: the carried device-bound `idpToken` IS this directive's authorization
|
|
324
|
+
// (re-verified against the workspace AuthProvider on every lane), not a pre-enrolled signer. This lowers
|
|
325
|
+
// the per-directive payload FIELD MAPPING into the `nomosVerifiedFirstEnroll` era key, so the KERNEL reads
|
|
326
|
+
// the directive id + field names from the law — naming neither. Defaults are this directive's own payload
|
|
327
|
+
// shape (`idpToken`/`keyHash`/`verifiedVia`).
|
|
328
|
+
.verifiedFirstDevice();
|
|
329
|
+
|
|
272
330
|
/**
|
|
273
331
|
* `revokeSigner` — REMOVE-WINS revoke of a signer key for the AUTHORING identity (self-bind: a device
|
|
274
332
|
* revokes only keys of its OWN principal). Flips the signer leaf to `status:revoked` (reuse the existing
|
|
@@ -334,10 +334,9 @@ export const QuotaGrant = aggregate("QuotaGrant", {
|
|
|
334
334
|
});
|
|
335
335
|
|
|
336
336
|
/**
|
|
337
|
-
* PlatformGrant —
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
* grants — authority is a law-state fact, never a worker config.
|
|
337
|
+
* PlatformGrant — legacy audit surface for platform invites. Platform creation now rides
|
|
338
|
+
* {@link birthChild} with genesis data and is gated by `#creator`; any remaining PlatformGrant rows are
|
|
339
|
+
* inert compatibility/audit data, never a worker config.
|
|
341
340
|
*/
|
|
342
341
|
export const PlatformGrant = aggregate("PlatformGrant", {
|
|
343
342
|
/** The auth-provider principal uid invited to create platforms. */
|
|
@@ -952,6 +951,12 @@ export const setPlatformPool = directive("setPlatformPool")
|
|
|
952
951
|
* principals only WITHIN that platform's own tree. The cloud's own provider
|
|
953
952
|
* (captain app) is the depth-0 case, configured by the host.
|
|
954
953
|
*/
|
|
954
|
+
/** THE AUTHPROVIDER SINGLETON INSTANCE ID — the ONE place this string lives DSL-side (the kernel owns the
|
|
955
|
+
* matching `nomos_identity::auth_provider::AUTH_PROVIDER_ID`; a drift-guard test keeps them identical). The
|
|
956
|
+
* kernel verifies presented tokens against the AuthProvider at this id, so its SHAPE is kernel-owned
|
|
957
|
+
* first-class machinery; `setAuthProvider`/`rotateKeys` compile to it. */
|
|
958
|
+
export const AUTH_PROVIDER_ID = "auth-provider:platform";
|
|
959
|
+
|
|
955
960
|
export const AuthProvider = aggregate("AuthProvider", {
|
|
956
961
|
/** The singleton key — always `"platform"`. */
|
|
957
962
|
scope: t.string().merge(Lww),
|
|
@@ -982,6 +987,16 @@ export const AuthProvider = aggregate("AuthProvider", {
|
|
|
982
987
|
* CA vouches) and each key matches its leaf cert — no host trust, no admin. Pinned once via the
|
|
983
988
|
* admin-gated {@link setAuthProvider}. Absent ⇒ no trustless rotation (rotate via setAuthProvider). */
|
|
984
989
|
rootCert: t.string().merge(Lww).optional(),
|
|
990
|
+
/** OPTIONAL — the JWT claim carrying the DEVICE BINDING for the keyless first-device enroll
|
|
991
|
+
* (the `enrollFirstDevice` lane, keyless_verified_onboard.md): the kernel asserts
|
|
992
|
+
* `token[deviceBindingClaim] == sha256(the signing device pubkey)`, so an on-chain token is useless to a
|
|
993
|
+
* thief (no device secret). Standard values: `"nonce"` (OIDC) or `"cnf"`. Absent ⇒ none configured. */
|
|
994
|
+
deviceBindingClaim: t.string().merge(Lww).optional(),
|
|
995
|
+
/** OPTIONAL — `"false"` to OPT OUT of the device-binding requirement (accept the bounded ~token-lifetime
|
|
996
|
+
* replay window — for IdPs that can't carry a nonce, eyes-open). The kernel applies STRICT (require the
|
|
997
|
+
* binding) when this is absent or `"true"` — strict-by-default, so a naked token never lands unless the
|
|
998
|
+
* tenant explicitly relaxes it. */
|
|
999
|
+
requireDeviceBinding: t.string().merge(Lww).optional(),
|
|
985
1000
|
/** Human label for audit ("acme supabase", ...). */
|
|
986
1001
|
label: t.string().merge(Lww),
|
|
987
1002
|
/** Who set it (attribution). */
|
|
@@ -1010,13 +1025,17 @@ export const setAuthProvider = directive("setAuthProvider")
|
|
|
1010
1025
|
principalClaim: z.string().min(1).optional(),
|
|
1011
1026
|
/** OPTIONAL — the pinned CA root (base64 DER) enabling trustless {@link rotateKeys}. */
|
|
1012
1027
|
rootCert: z.string().min(1).optional(),
|
|
1028
|
+
/** OPTIONAL — the device-binding claim for keyless first-device enroll ("nonce" | "cnf"). */
|
|
1029
|
+
deviceBindingClaim: z.string().min(1).optional(),
|
|
1030
|
+
/** OPTIONAL — "false" to accept the bounded replay window (strict device-binding is the default). */
|
|
1031
|
+
requireDeviceBinding: z.enum(["true", "false"]).optional(),
|
|
1013
1032
|
label: z.string().min(1),
|
|
1014
1033
|
setBy: z.string().min(1),
|
|
1015
1034
|
setAt: epochMs,
|
|
1016
1035
|
}),
|
|
1017
1036
|
)
|
|
1018
1037
|
.plan((p) => {
|
|
1019
|
-
const ap = instance(AuthProvider,
|
|
1038
|
+
const ap = instance(AuthProvider, AUTH_PROVIDER_ID);
|
|
1020
1039
|
return [
|
|
1021
1040
|
withMarker(set(ap, "scope", "platform"), "ensures"),
|
|
1022
1041
|
set(ap, "issuer", p.issuer),
|
|
@@ -1025,6 +1044,8 @@ export const setAuthProvider = directive("setAuthProvider")
|
|
|
1025
1044
|
...(p.keys ? [set(ap, "keys", p.keys)] : []),
|
|
1026
1045
|
...(p.principalClaim ? [set(ap, "principalClaim", p.principalClaim)] : []),
|
|
1027
1046
|
...(p.rootCert ? [set(ap, "rootCert", p.rootCert)] : []),
|
|
1047
|
+
...(p.deviceBindingClaim ? [set(ap, "deviceBindingClaim", p.deviceBindingClaim)] : []),
|
|
1048
|
+
...(p.requireDeviceBinding ? [set(ap, "requireDeviceBinding", p.requireDeviceBinding)] : []),
|
|
1028
1049
|
set(ap, "label", p.label),
|
|
1029
1050
|
set(ap, "setBy", p.setBy),
|
|
1030
1051
|
set(ap, "setAt", p.setAt),
|
|
@@ -1050,7 +1071,7 @@ export const rotateKeys = directive("rotateKeys")
|
|
|
1050
1071
|
}),
|
|
1051
1072
|
)
|
|
1052
1073
|
.plan((p) => {
|
|
1053
|
-
const ap = instance(AuthProvider,
|
|
1074
|
+
const ap = instance(AuthProvider, AUTH_PROVIDER_ID);
|
|
1054
1075
|
// The kernel's x5c admission gate authorizes this (validate_rotate_keys); the plan just stores the
|
|
1055
1076
|
// CA-vouched JWKS. The `ensures` marker upserts onto the existing AuthProvider singleton.
|
|
1056
1077
|
return [withMarker(set(ap, "keys", p.keys), "ensures"), set(ap, "setAt", p.rotatedAt)];
|
package/src/index.ts
CHANGED
package/src/usd.ts
CHANGED
|
@@ -285,6 +285,28 @@ export interface UsdLayer {
|
|
|
285
285
|
}
|
|
286
286
|
>;
|
|
287
287
|
};
|
|
288
|
+
/**
|
|
289
|
+
* THE KEYLESS VERIFIED-FIRST-DEVICE GATE (architecture/keyless_verified_onboard.md). Present IFF ≥1
|
|
290
|
+
* directive declares `.verifiedFirstDevice()`: a per-directive payload FIELD MAPPING. A principal's FIRST
|
|
291
|
+
* device-signer may then bootstrap on a WARRANTED parent from an ON-CHAIN, device-bound IdP attestation:
|
|
292
|
+
* the kernel's `nomosVerifiedFirstEnroll` arm reads THIS declaration — the directive id + the payload
|
|
293
|
+
* fields the token / seated keyHash / claimed issuer ride in — and re-verifies the carried `idpToken`
|
|
294
|
+
* against the workspace's AuthProvider IDENTICALLY on author/receive/verify_chain (self-serve +
|
|
295
|
+
* peer-to-peer, no admin/server). The KERNEL hardcodes NEITHER the directive id NOR the field names — it
|
|
296
|
+
* reads both here (the same way it reads `nomosAttestationGate`). OMITTED for law with no such directive
|
|
297
|
+
* (the era rule — byte-identical hash; the arm inert).
|
|
298
|
+
*/
|
|
299
|
+
readonly nomosVerifiedFirstEnroll?: {
|
|
300
|
+
readonly v: 1;
|
|
301
|
+
readonly directives: Record<
|
|
302
|
+
string,
|
|
303
|
+
{
|
|
304
|
+
readonly tokenField: string;
|
|
305
|
+
readonly keyHashField: string;
|
|
306
|
+
readonly verifiedViaField: string;
|
|
307
|
+
}
|
|
308
|
+
>;
|
|
309
|
+
};
|
|
288
310
|
/**
|
|
289
311
|
* THE BIRTH GATE DECLARATION (G3 — birth is a declared kernel primitive, the unix model). Present IFF ≥1
|
|
290
312
|
* directive declares `.births()`: the SORTED set of their ids. A directive named here BIRTHS a child
|
|
@@ -311,6 +333,12 @@ export interface UsdLayer {
|
|
|
311
333
|
*/
|
|
312
334
|
readonly nomosParentAttestedBirth?: {
|
|
313
335
|
readonly v: 1;
|
|
336
|
+
/** The LAW-DECLARED owner + cert locators (the kernel reads them; names neither). The framework `home`
|
|
337
|
+
* convention; a pre-declaration `{v:1}` key falls back to the kernel default (the era rule). */
|
|
338
|
+
readonly ownerObject: string;
|
|
339
|
+
readonly ownerField: string;
|
|
340
|
+
readonly certObject: string;
|
|
341
|
+
readonly certField: string;
|
|
314
342
|
};
|
|
315
343
|
/**
|
|
316
344
|
* STABLE IDENTIFIERS (#58 — names are labels, identity is minted). The layer's
|
|
@@ -759,10 +787,43 @@ export function emitUsd(
|
|
|
759
787
|
const nomosBirthGate =
|
|
760
788
|
birthDirectives.length > 0 ? { v: 1 as const, directives: birthDirectives } : undefined;
|
|
761
789
|
// THE PEER-SOVEREIGN ROOT-OF-TRUST: the home/child law declared it requires a parent-attested-birth
|
|
762
|
-
// cert chain (the `requireParentAttestedBirth` sentinel
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
790
|
+
// cert chain (the `requireParentAttestedBirth` sentinel). Emit the era key with the LAW-DECLARED owner +
|
|
791
|
+
// cert locators (so the kernel reads them, names neither); a bare marker (no locators) falls back to the
|
|
792
|
+
// framework `home` convention. Omit when absent (a law that doesn't require it is byte-identical).
|
|
793
|
+
const pabSrc = (
|
|
794
|
+
l.module as {
|
|
795
|
+
parentAttestedBirth?: boolean | { ownerObject?: string; ownerField?: string; certObject?: string; certField?: string };
|
|
796
|
+
}
|
|
797
|
+
).parentAttestedBirth;
|
|
798
|
+
const nomosParentAttestedBirth = pabSrc
|
|
799
|
+
? {
|
|
800
|
+
v: 1 as const,
|
|
801
|
+
ownerObject: (typeof pabSrc === "object" && pabSrc.ownerObject) || "home-owner:self",
|
|
802
|
+
ownerField: (typeof pabSrc === "object" && pabSrc.ownerField) || "principal",
|
|
803
|
+
certObject: (typeof pabSrc === "object" && pabSrc.certObject) || "birth-attestation:self",
|
|
804
|
+
certField: (typeof pabSrc === "object" && pabSrc.certField) || "certChain",
|
|
805
|
+
}
|
|
806
|
+
: undefined;
|
|
807
|
+
// THE KEYLESS VERIFIED-FIRST-DEVICE GATE (keyless_verified_onboard.md): collect each directive declaring
|
|
808
|
+
// `.verifiedFirstDevice()` → its payload FIELD MAPPING (where the token / seated keyHash / claimed issuer
|
|
809
|
+
// ride). The kernel's nomosVerifiedFirstEnroll arm reads the directive id + field names from THIS
|
|
810
|
+
// declaration (hardcoding neither) and bootstraps a first device-signer from the carried device-bound
|
|
811
|
+
// IdP attestation. Omit-when-empty (the era rule — byte-identical hash; the arm inert).
|
|
812
|
+
const firstEnrollReq: Record<
|
|
813
|
+
string,
|
|
814
|
+
{ tokenField: string; keyHashField: string; verifiedViaField: string }
|
|
815
|
+
> = {};
|
|
816
|
+
for (const d of [...l.module.directives].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))) {
|
|
817
|
+
if (d.firstDeviceEnrollGate !== undefined) {
|
|
818
|
+
firstEnrollReq[d.id] = {
|
|
819
|
+
tokenField: d.firstDeviceEnrollGate.tokenField,
|
|
820
|
+
keyHashField: d.firstDeviceEnrollGate.keyHashField,
|
|
821
|
+
verifiedViaField: d.firstDeviceEnrollGate.verifiedViaField,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const nomosVerifiedFirstEnroll =
|
|
826
|
+
Object.keys(firstEnrollReq).length > 0 ? { v: 1 as const, directives: firstEnrollReq } : undefined;
|
|
766
827
|
// THE CAPTURED-READ GATE DECLARATION (#58): collected from the canonical
|
|
767
828
|
// manifest's per-directive `capturedReads` (omit-when-empty — read-free law's
|
|
768
829
|
// USD is byte-identical apart from the always-on stableIds key).
|
|
@@ -894,6 +955,7 @@ export function emitUsd(
|
|
|
894
955
|
...(nomosAttestationGate !== undefined ? { nomosAttestationGate } : {}),
|
|
895
956
|
...(nomosBirthGate !== undefined ? { nomosBirthGate } : {}),
|
|
896
957
|
...(nomosParentAttestedBirth !== undefined ? { nomosParentAttestedBirth } : {}),
|
|
958
|
+
...(nomosVerifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll } : {}),
|
|
897
959
|
...(Object.keys(visAggregates).length > 0
|
|
898
960
|
? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
|
|
899
961
|
: {}),
|
package/src/usd_layers.ts
CHANGED
|
@@ -183,6 +183,9 @@ function domainLayerAttrLines(domain: string, layer: UsdLayer, indent: string):
|
|
|
183
183
|
...(layer.nomosParentAttestedBirth !== undefined
|
|
184
184
|
? [`${indent}custom string nomos:parentAttestedBirthHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosParentAttestedBirth)))}`]
|
|
185
185
|
: []),
|
|
186
|
+
...(layer.nomosVerifiedFirstEnroll !== undefined
|
|
187
|
+
? [`${indent}custom string nomos:verifiedFirstEnrollHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosVerifiedFirstEnroll)))}`]
|
|
188
|
+
: []),
|
|
186
189
|
];
|
|
187
190
|
}
|
|
188
191
|
|
|
@@ -782,11 +785,31 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
|
782
785
|
}
|
|
783
786
|
if (b.size > 0) birthGate = { v: 1, directives: [...b].sort() };
|
|
784
787
|
}
|
|
785
|
-
// THE PEER-SOVEREIGN ROOT-OF-TRUST: the era key is present iff ANY layer
|
|
786
|
-
//
|
|
788
|
+
// THE PEER-SOVEREIGN ROOT-OF-TRUST: the era key is present iff ANY layer declares it (once required,
|
|
789
|
+
// required). Carry the FIRST layer's declared owner/cert locators verbatim (preserve them — do NOT
|
|
790
|
+
// reconstruct {v:1}, which would drop the locators the kernel reads). Omit-when-absent (era rule).
|
|
787
791
|
let parentAttestedBirth: UsdLayer["nomosParentAttestedBirth"];
|
|
788
|
-
|
|
789
|
-
|
|
792
|
+
for (const l of stack) {
|
|
793
|
+
if (l.nomosParentAttestedBirth !== undefined) {
|
|
794
|
+
parentAttestedBirth = l.nomosParentAttestedBirth;
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// THE KEYLESS VERIFIED-FIRST-DEVICE GATE: directiveId → payload field mapping, merged later-wins across
|
|
799
|
+
// layers, SORTED keys (mirrors the attestation gate). Omit-when-empty (era rule).
|
|
800
|
+
let verifiedFirstEnroll: UsdLayer["nomosVerifiedFirstEnroll"];
|
|
801
|
+
{
|
|
802
|
+
type VfeSrc = NonNullable<UsdLayer["nomosVerifiedFirstEnroll"]>["directives"][string];
|
|
803
|
+
const v = new Map<string, VfeSrc>();
|
|
804
|
+
for (const l of stack) {
|
|
805
|
+
if (l.nomosVerifiedFirstEnroll === undefined) continue;
|
|
806
|
+
for (const [id, src] of Object.entries(l.nomosVerifiedFirstEnroll.directives)) v.set(id, src);
|
|
807
|
+
}
|
|
808
|
+
if (v.size > 0) {
|
|
809
|
+
const rec: Record<string, VfeSrc> = {};
|
|
810
|
+
for (const k of [...v.keys()].sort()) rec[k] = v.get(k)!;
|
|
811
|
+
verifiedFirstEnroll = { v: 1, directives: rec };
|
|
812
|
+
}
|
|
790
813
|
}
|
|
791
814
|
return {
|
|
792
815
|
path,
|
|
@@ -806,6 +829,7 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
|
|
|
806
829
|
...(attestationGate !== undefined ? { nomosAttestationGate: attestationGate } : {}),
|
|
807
830
|
...(birthGate !== undefined ? { nomosBirthGate: birthGate } : {}),
|
|
808
831
|
...(parentAttestedBirth !== undefined ? { nomosParentAttestedBirth: parentAttestedBirth } : {}),
|
|
832
|
+
...(verifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll: verifiedFirstEnroll } : {}),
|
|
809
833
|
};
|
|
810
834
|
});
|
|
811
835
|
|
|
@@ -935,6 +959,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
935
959
|
const attestationGateByDomain = new Map<string, UsdLayer["nomosAttestationGate"]>();
|
|
936
960
|
const birthGateByDomain = new Map<string, UsdLayer["nomosBirthGate"]>();
|
|
937
961
|
const parentAttestedBirthByDomain = new Map<string, UsdLayer["nomosParentAttestedBirth"]>();
|
|
962
|
+
const verifiedFirstEnrollByDomain = new Map<string, UsdLayer["nomosVerifiedFirstEnroll"]>();
|
|
938
963
|
for (const p of prims) {
|
|
939
964
|
if (p.attrs.get("nomos:kind") === "domain") {
|
|
940
965
|
const domain = attrString(p, "nomos:domain");
|
|
@@ -996,6 +1021,11 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
996
1021
|
if (typeof pabHex === "string") {
|
|
997
1022
|
parentAttestedBirthByDomain.set(domain, JSON.parse(unhexUtf8(pabHex)) as UsdLayer["nomosParentAttestedBirth"]);
|
|
998
1023
|
}
|
|
1024
|
+
// THE KEYLESS VERIFIED-FIRST-DEVICE GATE: the hex-carried era marker.
|
|
1025
|
+
const vfeHex = p.attrs.get("nomos:verifiedFirstEnrollHex");
|
|
1026
|
+
if (typeof vfeHex === "string") {
|
|
1027
|
+
verifiedFirstEnrollByDomain.set(domain, JSON.parse(unhexUtf8(vfeHex)) as UsdLayer["nomosVerifiedFirstEnroll"]);
|
|
1028
|
+
}
|
|
999
1029
|
}
|
|
1000
1030
|
}
|
|
1001
1031
|
|
|
@@ -1095,6 +1125,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1095
1125
|
const attGate = attestationGateByDomain.get(domain);
|
|
1096
1126
|
const birthGate = birthGateByDomain.get(domain);
|
|
1097
1127
|
const parentAttestedBirth = parentAttestedBirthByDomain.get(domain);
|
|
1128
|
+
const verifiedFirstEnroll = verifiedFirstEnrollByDomain.get(domain);
|
|
1098
1129
|
return {
|
|
1099
1130
|
path: `/Nomos/${domain}`,
|
|
1100
1131
|
prims: [...b.prims].sort(byPath),
|
|
@@ -1113,6 +1144,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
|
|
|
1113
1144
|
...(attGate !== undefined ? { nomosAttestationGate: attGate } : {}),
|
|
1114
1145
|
...(birthGate !== undefined ? { nomosBirthGate: birthGate } : {}),
|
|
1115
1146
|
...(parentAttestedBirth !== undefined ? { nomosParentAttestedBirth: parentAttestedBirth } : {}),
|
|
1147
|
+
...(verifiedFirstEnroll !== undefined ? { nomosVerifiedFirstEnroll: verifiedFirstEnroll } : {}),
|
|
1116
1148
|
};
|
|
1117
1149
|
})
|
|
1118
1150
|
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|