@githolon/dsl 0.24.0 → 0.25.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 CHANGED
@@ -1,3 +1,16 @@
1
+ ## 0.20.2
2
+ - `NomosBridge.connect` accepts the internal `allowUnborn` opt-in used by `NomosScope.home`, so the
3
+ home-birth ceremony can boot a local bridge before the home workspace has a remote main. The default
4
+ remains strict for ordinary workspaces.
5
+
6
+ ## 0.20.1
7
+ - `NomosBridgeError` now carries structured `NomosErrorInfo` alongside the existing human-readable
8
+ `message`. The envelope includes stable code/component/operation fields, workspace/domain/directive
9
+ context, trace/span ids, arbitrary attributes, and `toTelemetryAttributes()` for future OpenTelemetry
10
+ export without taking an SDK dependency.
11
+ - Every bridge request now carries an OpenTelemetry-shaped trace context. String-only legacy runner errors
12
+ still work and are upgraded locally with operation/trace metadata.
13
+
1
14
  ## 0.20.0
2
15
  - Authority-aware: `NomosBridge.offerDirective(domain, directiveId, payload, {actor, domainHash})` — generated
3
16
  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 — the generated client lets it throw, loudly, exactly like the TS client.
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
- NomosBridgeError(this.message);
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
- ctrl.addError(NomosBridgeError(
80
- (m['error'] ?? 'the watch read failed').toString()));
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
- cctrl.addError(NomosBridgeError(
91
- (m['error'] ?? 'the compat watch failed').toString()));
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
- completer.completeError(NomosBridgeError(
129
- (m['error'] ?? 'the githolon refused the request').toString()));
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
- _transport
140
- .send({'v': kWebviewBridgeVersion, 'id': id, 'op': op, 'args': args});
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}) async {
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
 
@@ -218,8 +428,10 @@ class NomosBridge {
218
428
  /// LOCAL kernel only (it never reaches a server). Returns the snake_case cert JSON `verify_to_root` parses.
219
429
  Future<Map<String, Object?>> signBirthCert(
220
430
  Map<String, Object?> fields, String deviceSecret) async =>
221
- _asMap(await _call(
222
- 'signBirthCert', {'fields': fields, 'deviceSecret': deviceSecret}))['cert'] as Map<String, Object?>? ??
431
+ _asMap(await _call('signBirthCert', {
432
+ 'fields': fields,
433
+ 'deviceSecret': deviceSecret
434
+ }))['cert'] as Map<String, Object?>? ??
223
435
  const {};
224
436
 
225
437
  /// ENROLL THIS DEVICE — the LIVE gate: bind a device key's PUBLIC half to `principal` on-chain as a
@@ -259,7 +471,8 @@ class NomosBridge {
259
471
  /// attestedAt}` → `{publicKey, signature}`. This is what drives `recordShare` into an invitee's home
260
472
  /// (the free global invitation system): the granting workspace signs that it shares `object#relation`
261
473
  /// with `subject`, and the invitee's home gate verifies the signature before recording it.
262
- Future<Map<String, Object?>> attestationSign(Map<String, Object?> args) async =>
474
+ Future<Map<String, Object?>> attestationSign(
475
+ Map<String, Object?> args) async =>
263
476
  _asMap(await _call('attestationSign', {'args': args}));
264
477
 
265
478
  /// A declared query's rows from the local projection.
@@ -330,6 +543,31 @@ class NomosBridge {
330
543
  /// (fetch wasm / clone ledger / instantiate) that streamed during connect(), before the UI subscribed.
331
544
  List<Map<String, Object?>> get recentSpans => List.unmodifiable(_spanBuffer);
332
545
 
546
+ int _localSpanSeq = 0;
547
+
548
+ /// Inject a LOCALLY-MEASURED span (e.g. the Dart-side DURABLE SAVE — the "saved to disk" number) into the
549
+ /// span stream so it appears on the flame chart AND the app's per-phase stats EXACTLY like a kernel span.
550
+ /// Emits a begin then an end with the same id, [t0]..[t1] in ms-epoch. Purely additive — engines that emit
551
+ /// their own spans are unaffected; this is for measurements the kernel can't see because they happen in Dart.
552
+ void noteLocalSpan({
553
+ required String track,
554
+ required String name,
555
+ required String kind,
556
+ required int t0,
557
+ required int t1,
558
+ String? id,
559
+ }) {
560
+ final sid = id ?? 'local-${_localSpanSeq++}';
561
+ for (final m in <Map<String, Object?>>[
562
+ {'event': 'span', 'span': {'phase': 'begin', 'id': sid, 'track': track, 'name': name, 'kind': kind, 't0': t0}},
563
+ {'event': 'span', 'span': {'phase': 'end', 'id': sid, 't1': t1}},
564
+ ]) {
565
+ _spanBuffer.add(m);
566
+ if (_spanBuffer.length > 400) _spanBuffer.removeRange(0, _spanBuffer.length - 400);
567
+ if (!_spanEvents.isClosed) _spanEvents.add(m);
568
+ }
569
+ }
570
+
333
571
  /// Ledger size (commits) + branch-vs-main (ahead) + projection bytes — the dashboard's slow state.
334
572
  Future<Map<String, Object?>> metrics() async =>
335
573
  _asMap(await _call('metrics'));
@@ -411,10 +649,20 @@ class NomosBridge {
411
649
  /// Tear down: stop the transport subscription + fail any in-flight calls.
412
650
  Future<void> dispose() async {
413
651
  await _sub.cancel();
414
- for (final c in _pending.values) {
415
- if (!c.isCompleted) c.completeError(NomosBridgeError('bridge disposed'));
652
+ for (final entry in _pending.entries) {
653
+ final c = entry.value;
654
+ if (!c.isCompleted) {
655
+ final trace = _pendingTraces[entry.key] ?? const NomosTraceContext();
656
+ final info = NomosErrorInfo.legacy('bridge disposed',
657
+ code: 'nomos.bridge.disposed',
658
+ operation: _pendingOps[entry.key],
659
+ trace: trace);
660
+ c.completeError(NomosBridgeError(info.message, info: info));
661
+ }
416
662
  }
417
663
  _pending.clear();
664
+ _pendingOps.clear();
665
+ _pendingTraces.clear();
418
666
  for (final w in _watches.values) {
419
667
  if (!w.isClosed) await w.close();
420
668
  }
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.20.0
14
+ version: 0.21.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
- _incoming.add(reply({'pushed': true}));
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
- expect((err as NomosBridgeError).message, contains('law refused'));
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.24.0",
3
+ "version": "0.25.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 true;
118
+ return v as ParentAttestedBirthMarker;
103
119
  }
104
120
  }
105
- return false;
121
+ return null;
106
122
  }
@@ -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 { moduleRequiresParentAttestedBirth } from "./births.js";
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
- const parentAttestedBirth =
191
- moduleRequiresParentAttestedBirth(merged) || spec.modules.some((m) => moduleRequiresParentAttestedBirth(m));
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: true } : {}),
232
+ ...(parentAttestedBirth ? { parentAttestedBirth } : {}),
221
233
  ...(dartImports.length > 0 ? { dartImports } : {}),
222
234
  ...(spec.dartRefImports !== undefined && spec.dartRefImports.length > 0
223
235
  ? { dartRefImports: [...spec.dartRefImports] }
@@ -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?: boolean;
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
@@ -361,6 +361,12 @@ export interface ChildBirthLeg {
361
361
  readonly birthPayload: Record<string, unknown>;
362
362
  /** Required `…At` fields the runtime stamps with ISO now() at dispatch (mirrors the create leg). */
363
363
  readonly birthAutoStamped: readonly string[];
364
+ /** The payload field whose value is the authoring actor for this birth offer, when discoverable from the
365
+ * genesis recipe's step actors. Needed for gated births (`.births().requires(...)`) so proof drives the
366
+ * same actor the generated client would seal as `security.actor`. */
367
+ readonly birthActorField?: string;
368
+ /** The relation the birth offer requires (`.births().requires(...)`), when present. */
369
+ readonly birthRequiresRelation?: string;
364
370
  /** The payload field that carries the bootstrap framework-controller hash (runtime: the local wasm's own
365
371
  * controller hash) — discovered from the recipe's `bootstrap/installDomain` step's payload, by matching
366
372
  * the field whose synthesized value the recipe routed there. `undefined` ⇒ the recipe hard-codes it. */
@@ -379,6 +385,21 @@ export interface ChildBirthLeg {
379
385
  readonly childSeedInstanceId?: string;
380
386
  /** A child declared query that reads the seed back (returns the seed aggregate). `undefined` ⇒ by-id only. */
381
387
  readonly childQuery?: { readonly id: string; readonly key: readonly string[] };
388
+ /** Optional parent setup: when this birth is gated by authority that is established by first birthing the
389
+ * parent workspace (home → estate is the canonical case), the proof runner drives this setup birth first
390
+ * and then authors the child birth inside the born parent. */
391
+ readonly parentSetup?: ChildBirthParentSetup;
392
+ }
393
+
394
+ export interface ChildBirthParentSetup {
395
+ readonly parentDomain: string;
396
+ readonly birthDirectiveId: string;
397
+ readonly birthPayload: Record<string, unknown>;
398
+ readonly birthAutoStamped: readonly string[];
399
+ readonly birthActorField?: string;
400
+ readonly frameworkHashField?: string;
401
+ readonly lawHashField?: string;
402
+ readonly childDomain: string;
382
403
  }
383
404
 
384
405
  /** A stub `nomos` global (mint + sha256) so a `.births()` plan that mints / hashes a child name runs to
@@ -489,6 +510,19 @@ export function discoverChildBirthLeg(
489
510
  const nomosStep = installSteps.find((s) => s.domain === "nomos");
490
511
  const frameworkHashField = bootstrapStep ? fieldForValue(bootstrapStep.payload?.domainHash) : undefined;
491
512
  const lawHashField = nomosStep ? fieldForValue(nomosStep.payload?.domainHash) : undefined;
513
+ const actorField = (() => {
514
+ for (const step of chain as Array<DiscoveredGenesisStep & { actor?: unknown }>) {
515
+ const actor = step?.actor;
516
+ const exact = fieldForValue(actor);
517
+ if (exact !== undefined) return exact;
518
+ if (typeof actor !== "string") continue;
519
+ for (const [field, value] of Object.entries(synth.value)) {
520
+ if (typeof value !== "string" || value.length === 0) continue;
521
+ if (actor === `user:${value}` || actor === `key:${value}`) return field;
522
+ }
523
+ }
524
+ return undefined;
525
+ })();
492
526
 
493
527
  // The parent payload the leg dispatches: the synthesized values MINUS the framework/law-hash placeholders
494
528
  // (the runtime injects the REAL hashes onto those fields by name — a `sample-*` hash would fail the child
@@ -502,6 +536,8 @@ export function discoverChildBirthLeg(
502
536
  birthDirectiveId: birthDir.id,
503
537
  birthPayload,
504
538
  birthAutoStamped: synth.autoStamped,
539
+ ...(actorField !== undefined ? { birthActorField: actorField } : {}),
540
+ ...(birthDir.requiresRelationGate !== undefined ? { birthRequiresRelation: birthDir.requiresRelationGate.relation } : {}),
505
541
  ...(frameworkHashField !== undefined ? { frameworkHashField } : {}),
506
542
  ...(lawHashField !== undefined ? { lawHashField } : {}),
507
543
  childDomain,
@@ -512,6 +548,38 @@ export function discoverChildBirthLeg(
512
548
  };
513
549
  }
514
550
 
551
+ function childBirthsForModule(mod: DomainModule, modules: readonly DomainModule[]): ChildBirthLeg[] {
552
+ const domain = mod.domain ?? mod.name;
553
+ const out: ChildBirthLeg[] = [];
554
+ for (const d of mod.directives as Directive<unknown>[]) {
555
+ if (d?.birthsChild !== true || typeof d.payloadSchema !== "object") continue;
556
+ const leg = discoverChildBirthLeg(d, domain, modules);
557
+ if (leg !== undefined) out.push(leg);
558
+ }
559
+ return out;
560
+ }
561
+
562
+ function asParentSetup(leg: ChildBirthLeg): ChildBirthParentSetup {
563
+ return {
564
+ parentDomain: leg.parentDomain,
565
+ birthDirectiveId: leg.birthDirectiveId,
566
+ birthPayload: leg.birthPayload,
567
+ birthAutoStamped: leg.birthAutoStamped,
568
+ ...(leg.birthActorField !== undefined ? { birthActorField: leg.birthActorField } : {}),
569
+ ...(leg.frameworkHashField !== undefined ? { frameworkHashField: leg.frameworkHashField } : {}),
570
+ ...(leg.lawHashField !== undefined ? { lawHashField: leg.lawHashField } : {}),
571
+ childDomain: leg.childDomain,
572
+ };
573
+ }
574
+
575
+ function attachParentSetup(leg: ChildBirthLeg, allBirths: readonly ChildBirthLeg[]): ChildBirthLeg {
576
+ if (leg.birthRequiresRelation === undefined) return leg;
577
+ const setup = allBirths.find((candidate) =>
578
+ candidate.childDomain === leg.parentDomain &&
579
+ !(candidate.parentDomain === leg.parentDomain && candidate.birthDirectiveId === leg.birthDirectiveId));
580
+ return setup === undefined ? leg : { ...leg, parentSetup: asParentSetup(setup) };
581
+ }
582
+
515
583
  /** Discover the SINGLETON instance id a seed directive writes (a fixed `<Agg>:self`-shaped id), by running
516
584
  * its plan with a probe payload + birth-probe globals and reading the first written op's instance id when
517
585
  * it is a constant (the same id across two distinct probe payloads). `undefined` ⇒ not a fixed singleton. */
@@ -644,6 +712,7 @@ function birthDirectiveOf(mod: DomainModule): Directive<unknown> | undefined {
644
712
  * domain is at least `compile-valid` (gaps 1+3 — every domain is proof-addressable). */
645
713
  export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[]): ProofLegs {
646
714
  const domain = mod.domain ?? mod.name;
715
+ const allBirths = modules.flatMap((m) => childBirthsForModule(m, modules));
647
716
  // The standalone-create leg (when the domain has a non-birth `.creates`). FAIL SOFT: an exotic payload
648
717
  // shape the synthesizer can't sample just drops the standalone leg (the domain stays compile-valid /
649
718
  // child-birth-provable), never loses the index.
@@ -655,11 +724,12 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
655
724
  }
656
725
  // The child-birth leg (when the domain has a `.births()` directive) — discovered by running its plan.
657
726
  const birthDir = birthDirectiveOf(mod);
658
- const childBirth = birthDir !== undefined ? discoverChildBirthLeg(birthDir, domain, modules) : undefined;
727
+ const childBirth = birthDir !== undefined ? allBirths.find((b) => b.parentDomain === domain && b.birthDirectiveId === birthDir.id) : undefined;
728
+ const childBirthWithSetup = childBirth !== undefined ? attachParentSetup(childBirth, allBirths) : undefined;
659
729
 
660
730
  const legs: ProofKind[] = ["compile-valid"];
661
731
  if (standalone !== null) legs.push("standalone-provable");
662
- if (childBirth !== undefined) legs.push("child-birth-provable");
732
+ if (childBirthWithSetup !== undefined) legs.push("child-birth-provable");
663
733
 
664
734
  return {
665
735
  domain,
@@ -675,7 +745,7 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
675
745
  ...(standalone.count !== undefined ? { count: standalone.count } : {}),
676
746
  }
677
747
  : {}),
678
- ...(childBirth !== undefined ? { childBirth } : {}),
748
+ ...(childBirthWithSetup !== undefined ? { childBirth: childBirthWithSetup } : {}),
679
749
  };
680
750
  }
681
751
 
@@ -798,26 +868,65 @@ function liveChildBirthLegLines(cb: ChildBirthLeg, opts: TsProofOptions, n: () =
798
868
  // The birth payload literal: the synthesized values + the REAL hashes onto the discovered fields + the
799
869
  // auto-stamped `…At` fields stamped now.
800
870
  const lines: string[] = [];
871
+ const setup = cb.parentSetup;
872
+ const actorExpr = (field: string | undefined, payloadVar: string): string | undefined =>
873
+ field !== undefined ? `String(${payloadVar}[${JSON.stringify(field)}] ?? "")` : undefined;
874
+ const birthActorExpr = actorExpr(cb.birthActorField, "birthPayload");
801
875
  lines.push(
802
876
  `// ${n()}. CHILD BIRTH (G3 — birth is the defined consequence of an offer): drive \`${cb.parentDomain}/${cb.birthDirectiveId}\``,
803
877
  `// against the cloud; the kernel's offer-effect installs the child law in-chain + folds the child's`,
804
878
  `// genesis (estate/${cb.childSeedDirectiveId}) through the CHILD's own gate, then BORNS the child workspace.`,
805
879
  `{`,
880
+ ` const frameworkHashFor = async (workspace: string) => {`,
881
+ ` const st = await (await fetch(\`\${CLOUD}/v2/workspaces/\${workspace}\`)).json().catch(() => ({}));`,
882
+ ` const frameworkHash = st.controller?.domainHash ?? st.controllerHash ?? "";`,
883
+ ` if (!frameworkHash) fail(\`could not resolve the framework controller hash from workspace \${workspace} — needed for the child's in-chain installDomain\`);`,
884
+ ` return frameworkHash;`,
885
+ ` };`,
806
886
  ` const birthPayload: Record<string, unknown> = { ...${JSON.stringify(cb.birthPayload)} };`,
887
+ ...(
888
+ birthActorExpr !== undefined && cb.birthRequiresRelation !== undefined
889
+ ? [
890
+ ` const birthActor = ${birthActorExpr};`,
891
+ ` if (!birthActor) fail(${JSON.stringify(`child birth ${cb.parentDomain}/${cb.birthDirectiveId} is gated by .requires("${cb.birthRequiresRelation}") but the proof could not derive an actor from ${cb.birthActorField}`)});`,
892
+ ]
893
+ : [
894
+ ` const birthActor = PRINCIPAL;`,
895
+ ]
896
+ ),
897
+ ...(setup !== undefined
898
+ ? [
899
+ ` // Parent setup: this child birth is gated by authority seeded when the parent workspace is born.`,
900
+ ` const setupPayload: Record<string, unknown> = { ...${JSON.stringify(setup.birthPayload)} };`,
901
+ ...(setup.birthActorField !== undefined && cb.birthActorField !== undefined
902
+ ? [` setupPayload[${JSON.stringify(setup.birthActorField)}] = birthPayload[${JSON.stringify(cb.birthActorField)}];`]
903
+ : []),
904
+ ...(setup.frameworkHashField !== undefined ? [` setupPayload[${JSON.stringify(setup.frameworkHashField)}] = await frameworkHashFor(WS);`] : []),
905
+ ...(setup.lawHashField !== undefined ? [` setupPayload[${JSON.stringify(setup.lawHashField)}] = ${hashConst};`] : []),
906
+ ...setup.birthAutoStamped.map((f) => ` setupPayload[${JSON.stringify(f)}] = new Date().toISOString();`),
907
+ ` const setupActor = ${actorExpr(setup.birthActorField, "setupPayload") ?? "birthActor"} || birthActor;`,
908
+ ` const sr = await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/${setup.birthDirectiveId}\`, { method: "POST", headers: { "content-type": "application/json", "x-nomos-principal": setupActor }, body: JSON.stringify(setupPayload) });`,
909
+ ` const sd = await sr.json();`,
910
+ ` if (!sd.ok) fail(\`parent setup ${setup.parentDomain}/${setup.birthDirectiveId} refused: \${JSON.stringify(sd)}\`);`,
911
+ ` const setupBorn: Array<{ workspace?: string }> = sd.born ?? [];`,
912
+ ` const birthParentWs = setupBorn[0]?.workspace;`,
913
+ ` if (!birthParentWs) fail(\`parent setup admitted but spawned no workspace (born=\${JSON.stringify(setupBorn)})\`);`,
914
+ ` ok(\`parent setup — \${birthParentWs} born by ${setup.parentDomain}/${setup.birthDirectiveId} before gated child birth\`);`,
915
+ ]
916
+ : [
917
+ ` const birthParentWs = WS;`,
918
+ ]),
807
919
  ...(cb.frameworkHashField !== undefined
808
920
  ? [
809
921
  ` // The framework CONTROLLER hash (the bootstrap install-target the child runs as intent 0) — read it`,
810
922
  ` // from the workspace's own status (the kernel's controllerHash); the child install resolves the bytes`,
811
923
  ` // by hash from the parent's custody, so this MUST be the real framework hash (a guess would fail).`,
812
- ` const st = await (await fetch(\`\${CLOUD}/v2/workspaces/\${WS}\`)).json().catch(() => ({}));`,
813
- ` const frameworkHash = st.controller?.domainHash ?? st.controllerHash ?? "";`,
814
- ` if (!frameworkHash) fail("could not resolve the framework controller hash from the workspace status — needed for the child's in-chain installDomain");`,
815
- ` birthPayload[${JSON.stringify(cb.frameworkHashField)}] = frameworkHash;`,
924
+ ` birthPayload[${JSON.stringify(cb.frameworkHashField)}] = await frameworkHashFor(birthParentWs);`,
816
925
  ]
817
926
  : []),
818
927
  ` birthPayload[${JSON.stringify(cb.lawHashField ?? "lawHash")}] = ${hashConst};`,
819
928
  ...cb.birthAutoStamped.map((f) => ` birthPayload[${JSON.stringify(f)}] = new Date().toISOString();`),
820
- ` const br = await fetch(\`\${CLOUD}/v2/workspaces/\${WS}/${cb.birthDirectiveId}\`, { method: "POST", headers: { "content-type": "application/json", "x-nomos-principal": PRINCIPAL }, body: JSON.stringify(birthPayload) });`,
929
+ ` const br = await fetch(\`\${CLOUD}/v2/workspaces/\${birthParentWs}/${cb.birthDirectiveId}\`, { method: "POST", headers: { "content-type": "application/json", "x-nomos-principal": birthActor }, body: JSON.stringify(birthPayload) });`,
821
930
  ` const bd = await br.json();`,
822
931
  ` if (!bd.ok) fail(\`child birth offer ${cb.parentDomain}/${cb.birthDirectiveId} refused: \${JSON.stringify(bd)}\`);`,
823
932
  ` const born: Array<{ workspace?: string }> = bd.born ?? [];`,
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
@@ -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;
@@ -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 = requireParentAttestedBirth;
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
@@ -952,6 +952,12 @@ export const setPlatformPool = directive("setPlatformPool")
952
952
  * principals only WITHIN that platform's own tree. The cloud's own provider
953
953
  * (captain app) is the depth-0 case, configured by the host.
954
954
  */
955
+ /** THE AUTHPROVIDER SINGLETON INSTANCE ID — the ONE place this string lives DSL-side (the kernel owns the
956
+ * matching `nomos_identity::auth_provider::AUTH_PROVIDER_ID`; a drift-guard test keeps them identical). The
957
+ * kernel verifies presented tokens against the AuthProvider at this id, so its SHAPE is kernel-owned
958
+ * first-class machinery; `setAuthProvider`/`rotateKeys` compile to it. */
959
+ export const AUTH_PROVIDER_ID = "auth-provider:platform";
960
+
955
961
  export const AuthProvider = aggregate("AuthProvider", {
956
962
  /** The singleton key — always `"platform"`. */
957
963
  scope: t.string().merge(Lww),
@@ -982,6 +988,16 @@ export const AuthProvider = aggregate("AuthProvider", {
982
988
  * CA vouches) and each key matches its leaf cert — no host trust, no admin. Pinned once via the
983
989
  * admin-gated {@link setAuthProvider}. Absent ⇒ no trustless rotation (rotate via setAuthProvider). */
984
990
  rootCert: t.string().merge(Lww).optional(),
991
+ /** OPTIONAL — the JWT claim carrying the DEVICE BINDING for the keyless first-device enroll
992
+ * (the `enrollFirstDevice` lane, keyless_verified_onboard.md): the kernel asserts
993
+ * `token[deviceBindingClaim] == sha256(the signing device pubkey)`, so an on-chain token is useless to a
994
+ * thief (no device secret). Standard values: `"nonce"` (OIDC) or `"cnf"`. Absent ⇒ none configured. */
995
+ deviceBindingClaim: t.string().merge(Lww).optional(),
996
+ /** OPTIONAL — `"false"` to OPT OUT of the device-binding requirement (accept the bounded ~token-lifetime
997
+ * replay window — for IdPs that can't carry a nonce, eyes-open). The kernel applies STRICT (require the
998
+ * binding) when this is absent or `"true"` — strict-by-default, so a naked token never lands unless the
999
+ * tenant explicitly relaxes it. */
1000
+ requireDeviceBinding: t.string().merge(Lww).optional(),
985
1001
  /** Human label for audit ("acme supabase", ...). */
986
1002
  label: t.string().merge(Lww),
987
1003
  /** Who set it (attribution). */
@@ -1010,13 +1026,17 @@ export const setAuthProvider = directive("setAuthProvider")
1010
1026
  principalClaim: z.string().min(1).optional(),
1011
1027
  /** OPTIONAL — the pinned CA root (base64 DER) enabling trustless {@link rotateKeys}. */
1012
1028
  rootCert: z.string().min(1).optional(),
1029
+ /** OPTIONAL — the device-binding claim for keyless first-device enroll ("nonce" | "cnf"). */
1030
+ deviceBindingClaim: z.string().min(1).optional(),
1031
+ /** OPTIONAL — "false" to accept the bounded replay window (strict device-binding is the default). */
1032
+ requireDeviceBinding: z.enum(["true", "false"]).optional(),
1013
1033
  label: z.string().min(1),
1014
1034
  setBy: z.string().min(1),
1015
1035
  setAt: epochMs,
1016
1036
  }),
1017
1037
  )
1018
1038
  .plan((p) => {
1019
- const ap = instance(AuthProvider, "auth-provider:platform");
1039
+ const ap = instance(AuthProvider, AUTH_PROVIDER_ID);
1020
1040
  return [
1021
1041
  withMarker(set(ap, "scope", "platform"), "ensures"),
1022
1042
  set(ap, "issuer", p.issuer),
@@ -1025,6 +1045,8 @@ export const setAuthProvider = directive("setAuthProvider")
1025
1045
  ...(p.keys ? [set(ap, "keys", p.keys)] : []),
1026
1046
  ...(p.principalClaim ? [set(ap, "principalClaim", p.principalClaim)] : []),
1027
1047
  ...(p.rootCert ? [set(ap, "rootCert", p.rootCert)] : []),
1048
+ ...(p.deviceBindingClaim ? [set(ap, "deviceBindingClaim", p.deviceBindingClaim)] : []),
1049
+ ...(p.requireDeviceBinding ? [set(ap, "requireDeviceBinding", p.requireDeviceBinding)] : []),
1028
1050
  set(ap, "label", p.label),
1029
1051
  set(ap, "setBy", p.setBy),
1030
1052
  set(ap, "setAt", p.setAt),
@@ -1050,7 +1072,7 @@ export const rotateKeys = directive("rotateKeys")
1050
1072
  }),
1051
1073
  )
1052
1074
  .plan((p) => {
1053
- const ap = instance(AuthProvider, "auth-provider:platform");
1075
+ const ap = instance(AuthProvider, AUTH_PROVIDER_ID);
1054
1076
  // The kernel's x5c admission gate authorizes this (validate_rotate_keys); the plan just stores the
1055
1077
  // CA-vouched JWKS. The `ensures` marker upserts onto the existing AuthProvider singleton.
1056
1078
  return [withMarker(set(ap, "keys", p.keys), "ensures"), set(ap, "setAt", p.rotatedAt)];
package/src/index.ts CHANGED
@@ -267,6 +267,7 @@ export {
267
267
  signerEpochStrictlyIncreases,
268
268
  enrollSignerSelf,
269
269
  enrollSignerSelfRequiresVerified,
270
+ enrollFirstDevice,
270
271
  revokeSigner,
271
272
  revokeSignerEpochStrictlyIncreases,
272
273
  signerTuplesByObject,
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, discovered by composeDomainModule). Emit the
763
- // era key iff true; omit otherwise (a law that doesn't require it is byte-identical — the era rule).
764
- const nomosParentAttestedBirth =
765
- (l.module as { parentAttestedBirth?: boolean }).parentAttestedBirth === true ? { v: 1 as const } : undefined;
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 in the stack declares it
786
- // (a boolean union — once required, required). Omit-when-absent (era rule).
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
- if (stack.some((l) => l.nomosParentAttestedBirth !== undefined)) {
789
- parentAttestedBirth = { v: 1 };
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));