@githolon/dsl 0.32.0 → 0.33.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.
@@ -14,7 +14,111 @@
14
14
  /// All extend [NomosBridgeError] so existing `catch (NomosBridgeError)` handlers still catch them.
15
15
  library;
16
16
 
17
- import 'webview_bridge.dart' show NomosBridgeError;
17
+ import 'webview_bridge.dart' show NomosBridgeError, NomosErrorInfo;
18
+
19
+ /// Mint the MOST SPECIFIC typed error for a structured refusal — the one construction point the bridge
20
+ /// uses everywhere it surfaces an `errorInfo`. A `nomos.custody.*` code becomes a [CustodyError] (typed,
21
+ /// recoverable, carrying the eviction/escalation decision fields); everything else stays the generic
22
+ /// [NomosBridgeError]. Apps `switch` on the type / `code`, never scrape prose.
23
+ NomosBridgeError nomosErrorFromInfo(NomosErrorInfo info) {
24
+ if (info.code.startsWith('nomos.custody.')) return CustodyError.fromInfo(info);
25
+ return NomosBridgeError(info.message, info: info);
26
+ }
27
+
28
+ /// The custody phase a [CustodyError] was raised in.
29
+ enum CustodyPhase { fold, author, fetch, unknown }
30
+
31
+ /// Where the incomplete custody came from — the axis of the recovery decision.
32
+ enum CustodySource { localSnapshot, cloudPack, unknown }
33
+
34
+ /// A TYPED, RECOVERABLE CUSTODY ERROR — local custody (the clone/snapshot this holon folds from) is
35
+ /// missing history the operation needed (`nomos.custody.shallow_insufficient` /
36
+ /// `nomos.custody.missing_object`), and the in-client gap-fill repair could not complete it.
37
+ ///
38
+ /// This replaces pattern-matching `shallow-insufficient` out of a stringly [NomosBridgeError]: the app
39
+ /// switches on [code] and decides from the structured fields whether to EVICT LOCAL CUSTODY
40
+ /// ([recommendedAction] == `quarantineLocalSnapshotAndReclone` — quarantine the snapshot, `inspectSnapshot`
41
+ /// / `recoverFrom`, reconnect fresh) or ESCALATE CLOUD CUSTODY (`refetchFullHistoryAndRetry` — the cloud
42
+ /// must serve the full chain). Trace IDs ride [NomosBridgeError.info.trace] as on every bridge error.
43
+ class CustodyError extends NomosBridgeError {
44
+ /// The stable machine code, e.g. `nomos.custody.shallow_insufficient`.
45
+ final String code;
46
+
47
+ /// The workspace whose custody is incomplete.
48
+ final String? workspace;
49
+
50
+ /// The ref being folded (e.g. `refs/heads/main`).
51
+ final String? ref;
52
+
53
+ /// The ref's head commit at the time of the fault, when resolvable.
54
+ final String? head;
55
+
56
+ /// The missing object id the fold stopped at, when the kernel named one.
57
+ final String? missingObject;
58
+
59
+ /// The operation phase that hit the gap: fold (read), author (offer), fetch (the repair itself).
60
+ final CustodyPhase phase;
61
+
62
+ /// Provenance of the incomplete custody: a restored local snapshot, or a cloud-served pack.
63
+ final CustodySource source;
64
+
65
+ /// True when a known recovery lane exists (it always does for custody gaps — see [recommendedAction]).
66
+ final bool recoverable;
67
+
68
+ /// The named remedy: `quarantineLocalSnapshotAndReclone` | `refetchFullHistoryAndRetry`.
69
+ final String recommendedAction;
70
+
71
+ CustodyError({
72
+ required this.code,
73
+ required String message,
74
+ this.workspace,
75
+ this.ref,
76
+ this.head,
77
+ this.missingObject,
78
+ this.phase = CustodyPhase.unknown,
79
+ this.source = CustodySource.unknown,
80
+ this.recoverable = true,
81
+ this.recommendedAction = 'refetchFullHistoryAndRetry',
82
+ NomosErrorInfo? info,
83
+ }) : super(message, info: info);
84
+
85
+ /// Decode the structured custody payload the client attaches (`nomos.custody.*` attributes).
86
+ factory CustodyError.fromInfo(NomosErrorInfo info) {
87
+ String? attr(String name) {
88
+ final v = info.attributes['nomos.custody.$name'] ?? info.attributes[name];
89
+ return v?.toString();
90
+ }
91
+
92
+ return CustodyError(
93
+ code: info.code,
94
+ message: info.message,
95
+ workspace: info.workspace,
96
+ ref: attr('ref'),
97
+ head: attr('head'),
98
+ missingObject: attr('missing_object') ?? attr('missingObject'),
99
+ phase: CustodyPhase.values.firstWhere(
100
+ (p) => p.name == attr('phase'),
101
+ orElse: () => CustodyPhase.unknown,
102
+ ),
103
+ source: CustodySource.values.firstWhere(
104
+ (s) => s.name == attr('source'),
105
+ orElse: () => CustodySource.unknown,
106
+ ),
107
+ recoverable: attr('recoverable') != 'false',
108
+ recommendedAction: attr('recommended_action') ?? attr('recommendedAction') ?? 'refetchFullHistoryAndRetry',
109
+ info: info,
110
+ );
111
+ }
112
+
113
+ /// True when the remedy is to quarantine the local snapshot and re-clone (evict local custody).
114
+ bool get shouldEvictLocalCustody => recommendedAction == 'quarantineLocalSnapshotAndReclone';
115
+
116
+ @override
117
+ String toString() =>
118
+ 'CustodyError($code${workspace != null ? ', workspace: $workspace' : ''}'
119
+ '${ref != null ? ', ref: $ref' : ''}${missingObject != null ? ', missing: $missingObject' : ''}'
120
+ ', phase: ${phase.name}, source: ${source.name}, recommendedAction: $recommendedAction): $message';
121
+ }
18
122
 
19
123
  /// The auth-provider access token the cloud was given was missing, malformed, expired, or rejected — the
20
124
  /// cloud answered 401 / "invalid token" on a lane that requires a verified principal (every birth does).
@@ -17,6 +17,8 @@ library;
17
17
  import 'dart:async';
18
18
  import 'dart:math';
19
19
 
20
+ import 'errors.dart' show nomosErrorFromInfo;
21
+
20
22
  /// The protocol version the runner and the bridge agree on (`webview-runner.mjs` `WEBVIEW_BRIDGE_VERSION`).
21
23
  const int kWebviewBridgeVersion = 1;
22
24
 
@@ -336,7 +338,7 @@ class NomosBridge {
336
338
  final info = NomosErrorInfo.fromBridgeMessage(m,
337
339
  fallbackMessage: 'the watch read failed',
338
340
  fallbackOperation: 'watch');
339
- ctrl.addError(NomosBridgeError(info.message, info: info));
341
+ ctrl.addError(nomosErrorFromInfo(info));
340
342
  } else {
341
343
  ctrl.add((m['rows'] as List?) ?? const []);
342
344
  }
@@ -349,7 +351,7 @@ class NomosBridge {
349
351
  final info = NomosErrorInfo.fromBridgeMessage(m,
350
352
  fallbackMessage: 'the compat watch failed',
351
353
  fallbackOperation: 'watchCompatibility');
352
- cctrl.addError(NomosBridgeError(info.message, info: info));
354
+ cctrl.addError(nomosErrorFromInfo(info));
353
355
  } else {
354
356
  cctrl.add(((m['rows'] as Map?)?.cast<String, Object?>()) ?? const {});
355
357
  }
@@ -410,7 +412,7 @@ class NomosBridge {
410
412
  fallbackOperation: op,
411
413
  fallbackTrace: trace,
412
414
  fallbackMessage: 'the githolon refused the request');
413
- completer.completeError(NomosBridgeError(info.message, info: info));
415
+ completer.completeError(nomosErrorFromInfo(info));
414
416
  }
415
417
  }
416
418
 
@@ -931,7 +933,7 @@ class NomosBridge {
931
933
  code: 'nomos.bridge.disposed',
932
934
  operation: _pendingOps[entry.key],
933
935
  trace: trace);
934
- c.completeError(NomosBridgeError(info.message, info: info));
936
+ c.completeError(nomosErrorFromInfo(info));
935
937
  }
936
938
  }
937
939
  _pending.clear();
@@ -61,6 +61,36 @@ class FakeRunner implements BridgeTransport {
61
61
  'attributes': {'test.case': 'refusal'}
62
62
  }
63
63
  });
64
+ } else if (intentJson.contains('"directiveId":"custodyGap"')) {
65
+ // The client's typed custody error: structured errorInfo, code=nomos.custody.*.
66
+ _incoming.add({
67
+ 'v': kWebviewBridgeVersion,
68
+ 'id': id,
69
+ 'ok': false,
70
+ 'error':
71
+ 'shallow-insufficient: fold refs/heads/main: find commit: NotFound',
72
+ 'errorInfo': {
73
+ 'code': 'nomos.custody.shallow_insufficient',
74
+ 'message':
75
+ 'shallow-insufficient: fold refs/heads/main: find commit: NotFound',
76
+ 'component': 'nomos.custody',
77
+ 'operation': 'offer',
78
+ 'workspace': 'ws-1',
79
+ 'trace': message['trace'],
80
+ 'attributes': {
81
+ 'nomos.custody.ref': 'refs/heads/main',
82
+ 'nomos.custody.head':
83
+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
84
+ 'nomos.custody.missing_object':
85
+ 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
86
+ 'nomos.custody.phase': 'author',
87
+ 'nomos.custody.source': 'localSnapshot',
88
+ 'nomos.custody.recoverable': true,
89
+ 'nomos.custody.recommended_action':
90
+ 'quarantineLocalSnapshotAndReclone',
91
+ }
92
+ }
93
+ });
64
94
  } else {
65
95
  _incoming.add(reply({'head': 'HEAD-offer'}));
66
96
  }
@@ -420,6 +450,41 @@ void main() {
420
450
  expect(e.info.toTelemetryAttributes()['nomos.operation'], 'offer');
421
451
  });
422
452
 
453
+ test(
454
+ 'a custody fault surfaces as the TYPED CustodyError (switch on code, never scrape prose)',
455
+ () async {
456
+ await bridge.ready;
457
+ Object? err;
458
+ try {
459
+ await bridge.offer(
460
+ intentBytes: base64Encode(utf8.encode(jsonEncode({
461
+ 'v': 2,
462
+ 'intent': {
463
+ 'domain': 'x',
464
+ 'directiveId': 'custodyGap',
465
+ 'payload': {},
466
+ 'domainHash': 'h'
467
+ }
468
+ }))));
469
+ } on NomosBridgeError catch (e) {
470
+ err = e; // still catchable as the generic bridge error
471
+ }
472
+ expect(err, isA<CustodyError>());
473
+ final e = err as CustodyError;
474
+ expect(e.code, 'nomos.custody.shallow_insufficient');
475
+ expect(e.workspace, 'ws-1');
476
+ expect(e.ref, 'refs/heads/main');
477
+ expect(e.head, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
478
+ expect(e.missingObject, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
479
+ expect(e.phase, CustodyPhase.author);
480
+ expect(e.source, CustodySource.localSnapshot);
481
+ expect(e.recoverable, isTrue);
482
+ expect(e.recommendedAction, 'quarantineLocalSnapshotAndReclone');
483
+ expect(e.shouldEvictLocalCustody, isTrue);
484
+ expect(e.info.trace.traceId, isNotEmpty); // trace IDs ride as on every bridge error
485
+ expect(e.message, contains('shallow-insufficient'));
486
+ });
487
+
423
488
  test(
424
489
  'legacy string-only runner errors still get operation and trace metadata',
425
490
  () async {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.32.0",
3
+ "version": "0.33.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",
@@ -56,6 +56,7 @@ import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js"
56
56
  import { synthesizePreconditionInvariants } from "./precondition.js";
57
57
  import type { RelationTypeDecl } from "./relation_schema.js";
58
58
  import { domainHash, emitManifestBytes } from "./manifest.js";
59
+ import { collectValueObjectDescriptors } from "./value_object.js";
59
60
  import { emitUsd } from "./usd.js";
60
61
  import { expandCapabilityExports } from "./capability_exports.js";
61
62
  import { RelationTuple } from "./framework/relations.js";
@@ -188,6 +189,27 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
188
189
  // a directive with no precondition synthesizes nothing (byte-identical manifest).
189
190
  for (const d of directives) workspaceInvariants.push(...synthesizePreconditionInvariants(d));
190
191
  const relationTypes = [...(spec.relationTypes ?? [])];
192
+ // FIRST-CLASS VALUE OBJECTS (value_object.ts): the deduped, sorted descriptors of every
193
+ // named VO the domain's aggregates carry (transitive nested VOs included) — the input to
194
+ // the additive `nomosValueObjects` USD key. Omit-when-empty (era rule): a VO-free domain's
195
+ // module — and thus its USD/package bytes — is byte-identical to before VOs existed.
196
+ const valueObjects = collectValueObjectDescriptors(aggregates);
197
+ // SPATIAL × VO PAIRING (fail at build, not at fold): a spatial index over a VO-typed field
198
+ // must index a GEO value object (an RFC 7946 geometry — the R*Tree's bbox source). A
199
+ // structural VO (e.g. a PostalAddress) has no geometry to index. Non-VO fields (a
200
+ // hand-rolled `t.jsonObject()` GeoJSON leaf) pass through unchecked, exactly as before.
201
+ for (const s of spatials) {
202
+ const agg = byId.get(s.of);
203
+ const field = agg?.fields[s.on];
204
+ const vo = field?.valueObject;
205
+ if (vo !== undefined && vo.geometry === undefined) {
206
+ throw new Error(
207
+ `build-package: spatial '${s.id}' indexes '${s.of}.${s.on}', a '${vo.name}' value object with NO ` +
208
+ `geometry — the R*Tree needs an RFC 7946 geometry leaf. Give the geometry its OWN field ` +
209
+ `(e.g. \`point: geo.point().field()\`) and index that (v1: no sub-path indexing).`,
210
+ );
211
+ }
212
+ }
191
213
  const dartImports = [...(spec.dartImports ?? [])];
192
214
  // THE PEER-SOVEREIGN ROOT-OF-TRUST: a home/child law that exports `requireParentAttestedBirth` declares
193
215
  // it requires a parent-attested-birth cert chain. Discovered by shape across the module list (the SAME
@@ -233,6 +255,9 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
233
255
  // Omit-when-empty — the relation schema is hashed law (P1); a relation-free domain's
234
256
  // canonical manifest/USD stays byte-identical (the nomosRelations era rule).
235
257
  ...(relationTypes.length > 0 ? { relationTypes } : {}),
258
+ // Omit-when-empty — the `nomosValueObjects` era rule: a VO-free domain's USD/package
259
+ // bytes are byte-identical to before value objects existed.
260
+ ...(valueObjects.length > 0 ? { valueObjects } : {}),
236
261
  // THE PEER-SOVEREIGN ROOT-OF-TRUST: omit-when-false — a law that doesn't require parent-attested
237
262
  // birth is byte-identical in the canonical manifest/USD (the nomosParentAttestedBirth era rule).
238
263
  ...(parentAttestedBirth ? { parentAttestedBirth } : {}),