@githolon/dsl 0.26.0 → 0.27.1

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,23 @@
1
+ ## 0.27.0
2
+
3
+ - TYPED SNAPSHOT RECOVERY (corrupt local custody): `NomosBridge.connect(recover: true)` recovers IN-PLACE from a corrupt restoreFrom snapshot — salvages local-only authored writes (via the fallible `intents_above`, which stops at a missing-ancestor gap) + the DLQ, refolds FRESH from cloud, re-offers the salvaged work (deduped), re-queues the DLQ. The report rides `bridge.lastRecovery` = `{salvagedWrites, requeuedDeadLetters, unsalvageable}` — a write the current law refuses lands in `unsalvageable` + the DLQ (surfaced, never dropped). Also `holon.salvage()` / `holon.absorbSalvage()`. Closes the restoreFrom-leaves-read-model-empty case.
4
+
5
+ ## 0.26.0
6
+
7
+ - TIER-2 SYNC SIZE: `NomosBridge.syncSize()` → `{bytes, kind}` — how many bytes this workspace would transfer to catch up, WITHOUT downloading (a HEAD on the serving pack; the host answers Content-Length, cached ~15s). Sum across open workspaces for an aggregate "X MB to sync" total before any download.
8
+
9
+ ## 0.25.0
10
+
11
+ - SYNC STATE + REAL LOADING BARS: `NomosBridge` gains `syncStatus` (Stream<SyncStatus> — phase/ahead/behind/saved/onMain/deadLetters/transfer/lastSyncedAt, derived from sync rounds + doorbell + transfer + a 4s metrics poll), `transferProgress` (Stream<TransferProgress> — live pack byte-progress; `total` is the host Content-Length so the bar is truthful, never estimated), and `currentStatus`. New types: SyncStatus, SyncPhase, TransferProgress.
12
+
13
+ ## 0.24.0
14
+
15
+ - `createWorkspace` / `shareWith` no longer require `authorSecret` — it defaults to the connection's signing key (the home device key `NomosScope.home` loaded). A frontend dev never threads a key through make-workspace or share.
16
+
17
+ ## 0.23.0
18
+
19
+ - `NomosBridge.shareWith` — one-call cross-custody share (sign quote + deliver recordShare to the subjects home). Closes the shared-with-me loop.
20
+
1
21
  ## 0.22.0
2
22
 
3
23
  - `NomosBridge.offerIntent` (canonical write verb; `offerDirective` kept as alias) + one-call `NomosBridge.createWorkspace` (sign + relay a control-plane offer).
@@ -202,6 +202,67 @@ class NomosBridgeError implements Exception {
202
202
  String toString() => 'NomosBridgeError: $message';
203
203
  }
204
204
 
205
+ /// Live byte-progress of a custody pack transfer (the big initial clone / heavy resync over `/pack`).
206
+ /// [total] is the host's REAL packed size from `Content-Length` (known before the first body byte — the
207
+ /// warm host packs into memory then sends), so [fraction] is a truthful 0→1 bar, never an estimate.
208
+ /// [total] is null only if the host omitted the length (then show an indeterminate spinner, not a fake bar).
209
+ class TransferProgress {
210
+ final int received;
211
+ final int? total;
212
+ final String kind; // 'delta' (incremental) | 'full' (initial clone)
213
+ const TransferProgress({required this.received, this.total, this.kind = 'delta'});
214
+ double? get fraction => (total == null || total == 0) ? null : (received / total!).clamp(0.0, 1.0);
215
+ bool get done => total != null && received >= total!;
216
+ factory TransferProgress.fromMessage(Map<String, Object?> m) => TransferProgress(
217
+ received: (m['received'] as num?)?.toInt() ?? 0,
218
+ total: (m['total'] as num?)?.toInt(),
219
+ kind: m['kind']?.toString() ?? 'delta',
220
+ );
221
+ }
222
+
223
+ enum SyncPhase { idle, syncing, transferring, behind, offline, error }
224
+
225
+ /// The app-ready sync state — a truthful aggregation of the low-level signals (sync rounds, the doorbell,
226
+ /// transfer progress, periodic metrics). Map it straight to a sync-state page; no fake progress.
227
+ class SyncStatus {
228
+ final SyncPhase phase;
229
+ final int ahead; // local writes not yet on canonical main
230
+ final int behind; // commits available to pull
231
+ final int saved; // durably persisted locally
232
+ final int onMain; // accepted on canonical main
233
+ final int deadLetters; // refused work awaiting a law fix
234
+ final TransferProgress? transfer; // non-null while a pack is moving
235
+ final DateTime? lastSyncedAt;
236
+ final String? lastError;
237
+ const SyncStatus({
238
+ this.phase = SyncPhase.idle,
239
+ this.ahead = 0,
240
+ this.behind = 0,
241
+ this.saved = 0,
242
+ this.onMain = 0,
243
+ this.deadLetters = 0,
244
+ this.transfer,
245
+ this.lastSyncedAt,
246
+ this.lastError,
247
+ });
248
+ SyncStatus copyWith({
249
+ SyncPhase? phase, int? ahead, int? behind, int? saved, int? onMain, int? deadLetters,
250
+ TransferProgress? transfer, bool clearTransfer = false, DateTime? lastSyncedAt,
251
+ String? lastError, bool clearError = false,
252
+ }) =>
253
+ SyncStatus(
254
+ phase: phase ?? this.phase,
255
+ ahead: ahead ?? this.ahead,
256
+ behind: behind ?? this.behind,
257
+ saved: saved ?? this.saved,
258
+ onMain: onMain ?? this.onMain,
259
+ deadLetters: deadLetters ?? this.deadLetters,
260
+ transfer: clearTransfer ? null : (transfer ?? this.transfer),
261
+ lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
262
+ lastError: clearError ? null : (lastError ?? this.lastError),
263
+ );
264
+ }
265
+
205
266
  /// THE BRIDGE. Construct over a [BridgeTransport], `await ready`, then `connect(...)` and drive the holon.
206
267
  class NomosBridge {
207
268
  final BridgeTransport _transport;
@@ -221,6 +282,15 @@ class NomosBridge {
221
282
  StreamController<Map<String, Object?>>.broadcast();
222
283
  final List<Map<String, Object?>> _spanBuffer =
223
284
  []; // recent span events — so BOOT spans (emitted during connect, before the UI subscribes) survive
285
+ final StreamController<TransferProgress> _transferEvents =
286
+ StreamController<TransferProgress>.broadcast();
287
+ // The DERIVED app-ready sync state. seeded-on-listen with a metrics poll; updated on sync/signal/transfer.
288
+ late final StreamController<SyncStatus> _syncStatusCtrl = StreamController<SyncStatus>.broadcast(
289
+ onListen: _startStatusPoll,
290
+ onCancel: () { if (!_syncStatusCtrl.hasListener) _stopStatusPoll(); },
291
+ );
292
+ SyncStatus _status = const SyncStatus();
293
+ Timer? _metricsTimer;
224
294
  final Completer<void> _ready = Completer<void>();
225
295
  int _seq = 0;
226
296
  final String _traceId = _newTraceId();
@@ -289,6 +359,22 @@ class NomosBridge {
289
359
  if (event == 'sync' || event == 'link') {
290
360
  // autoSync telemetry: each round { event:'sync', reason, result } and socket state { event:'link', status }.
291
361
  if (!_syncEvents.isClosed) _syncEvents.add(m);
362
+ if (event == 'sync') {
363
+ // a completed round → idle (the next metrics poll folds in residual ahead/behind), stamp the time.
364
+ _pushStatus(_status.copyWith(
365
+ phase: SyncPhase.idle, clearTransfer: true, lastSyncedAt: DateTime.now()));
366
+ } else if (m['status'] == 'closed' || m['status'] == 'offline') {
367
+ _pushStatus(_status.copyWith(phase: SyncPhase.offline));
368
+ }
369
+ return;
370
+ }
371
+ if (event == 'transfer') {
372
+ // LIVE pack byte-progress { received, total, kind } — the real loading bar (host Content-Length).
373
+ final tp = TransferProgress.fromMessage(m);
374
+ if (!_transferEvents.isClosed) _transferEvents.add(tp);
375
+ _pushStatus(_status.copyWith(
376
+ phase: tp.done ? SyncPhase.syncing : SyncPhase.transferring,
377
+ transfer: tp, clearTransfer: tp.done));
292
378
  return;
293
379
  }
294
380
  if (event == 'trace') {
@@ -299,6 +385,8 @@ class NomosBridge {
299
385
  if (event == 'signal') {
300
386
  // signal holon projection: ephemeral coordination such as peer frontiers / lag.
301
387
  if (!_signalEvents.isClosed) _signalEvents.add(m);
388
+ // the doorbell says canonical main advanced → we're behind until the next pull lands.
389
+ if (_status.phase == SyncPhase.idle) _pushStatus(_status.copyWith(phase: SyncPhase.behind));
302
390
  return;
303
391
  }
304
392
  if (event == 'span') {
@@ -366,8 +454,9 @@ class NomosBridge {
366
454
  String? authToken,
367
455
  String? authorSecret,
368
456
  String? restoreFrom,
369
- bool allowUnborn = false}) async {
370
- await _call('connect', {
457
+ bool allowUnborn = false,
458
+ bool recover = false}) async {
459
+ final r = _asMap(await _call('connect', {
371
460
  'cloud': cloud,
372
461
  'workspace': workspace,
373
462
  if (clientId != null) 'clientId': clientId,
@@ -377,9 +466,19 @@ class NomosBridge {
377
466
  if (authorSecret != null) 'authorSecret': authorSecret,
378
467
  if (restoreFrom != null) 'restoreFrom': restoreFrom,
379
468
  if (allowUnborn) 'allowUnborn': true,
380
- });
469
+ // RECOVER-IN-PLACE over a corrupt restoreFrom snapshot: salvage local-only writes + DLQ, refold fresh
470
+ // from cloud, re-offer the salvaged work. The report lands on [lastRecovery]. Nothing local is lost.
471
+ if (recover) 'recover': true,
472
+ }));
473
+ _lastRecovery = (r['recovery'] as Map?)?.cast<String, Object?>();
381
474
  }
382
475
 
476
+ /// The salvage report from the most recent `connect(recover: true)` — `{ salvagedWrites, requeuedDeadLetters,
477
+ /// unsalvageable }`, or null if the connection didn't recover. Show "recovered N writes" / surface
478
+ /// `unsalvageable` (writes the current law refused — kept in the DLQ, never dropped).
479
+ Map<String, Object?>? get lastRecovery => _lastRecovery;
480
+ Map<String, Object?>? _lastRecovery;
481
+
383
482
  /// LOCAL-FIRST offer: carry opaque intent bytes into the GitHolon. Returns the new local head; durable on [sync].
384
483
  Future<String> offer({required String intentBytes}) async {
385
484
  final r = _asMap(await _call('offer', {'intentBytes': intentBytes}));
@@ -438,17 +537,19 @@ class NomosBridge {
438
537
  Future<Map<String, Object?>> createWorkspace({
439
538
  required String parent,
440
539
  required String actor,
441
- required String authorSecret,
540
+ String? authorSecret,
442
541
  String? name,
443
542
  Object? payload,
444
543
  String directiveId = 'createWorkspace',
445
544
  String? domainHash,
446
545
  String? authToken,
447
546
  }) async {
547
+ // [authorSecret] defaults to the connection's signing key (the home key NomosScope.home loaded) —
548
+ // a frontend dev never threads a key through.
448
549
  final r = _asMap(await _call('createWorkspace', {
449
550
  'parent': parent,
450
551
  'actor': actor,
451
- 'authorSecret': authorSecret,
552
+ if (authorSecret != null) 'authorSecret': authorSecret,
452
553
  if (name != null) 'name': name,
453
554
  if (payload != null) 'payload': payload,
454
555
  'directiveId': directiveId,
@@ -458,6 +559,39 @@ class NomosBridge {
458
559
  return (r['verdict'] as Map?)?.cast<String, Object?>() ?? r;
459
560
  }
460
561
 
562
+ /// SHARE a workspace/record with a user — the one-call cross-custody grant that lands in their home's
563
+ /// "shared-with-me" list. This holon signs a quote `<asserterWorkspace>:<object>#<relation>@<subject>` with
564
+ /// [authorSecret] (the asserting owner's device key — the signature IS the grant) and delivers a
565
+ /// `recordShare` to the subject's home; the home's gate verifies it (no key needed there). Live authority
566
+ /// stays the forward Check on the asserter when the user opens it. Returns the worker verdict; an unborn
567
+ /// home (the user never logged in) comes back `{ok:false, needsHomeBirth:true}`.
568
+ /// `await bridge.shareWith(subject:'user:bob', relation:'editor', authorSecret: key, asserterPrincipal:'user:ada', homeLawHash: h)`.
569
+ Future<Map<String, Object?>> shareWith({
570
+ required String subject,
571
+ required String relation,
572
+ required String asserterPrincipal,
573
+ required String homeLawHash,
574
+ String? authorSecret,
575
+ String object = 'workspace:self',
576
+ String? asserterWorkspace,
577
+ String? attestedAt,
578
+ String? authToken,
579
+ }) async {
580
+ // [authorSecret] defaults to the connection's signing key — the frontend dev never handles a key.
581
+ final r = _asMap(await _call('shareWith', {
582
+ 'subject': subject,
583
+ 'relation': relation,
584
+ if (authorSecret != null) 'authorSecret': authorSecret,
585
+ 'asserterPrincipal': asserterPrincipal,
586
+ 'homeLawHash': homeLawHash,
587
+ 'object': object,
588
+ if (asserterWorkspace != null) 'asserterWorkspace': asserterWorkspace,
589
+ if (attestedAt != null) 'attestedAt': attestedAt,
590
+ if (authToken != null) 'authToken': authToken,
591
+ }));
592
+ return (r['verdict'] as Map?)?.cast<String, Object?>() ?? r;
593
+ }
594
+
461
595
  Future<Map<String, Object?>> undoMostRecent({
462
596
  String scope = 'all',
463
597
  List<String>? actions,
@@ -615,6 +749,52 @@ class NomosBridge {
615
749
  /// Background-sync telemetry: each autoSync round (`event:'sync'`) and doorbell socket state (`event:'link'`).
616
750
  Stream<Map<String, Object?>> get syncEvents => _syncEvents.stream;
617
751
 
752
+ /// LIVE pack byte-progress — the real loading bar. Each event carries `received`/`total` (the host's
753
+ /// actual `Content-Length`, known before the body) + `kind` ('delta'|'full'). Emits only while a `/pack`
754
+ /// transfer is moving (small syncs ride the inline offer-result piggyback — instant, no bar).
755
+ Stream<TransferProgress> get transferProgress => _transferEvents.stream;
756
+
757
+ /// THE APP-READY SYNC STATE — a truthful aggregation of sync rounds, the doorbell, transfer progress, and a
758
+ /// periodic metrics poll. Map straight to a sync-state page. Starts a lightweight 4s metrics poll while
759
+ /// listened to (stops on cancel). [currentStatus] is the latest snapshot for a non-stream read.
760
+ Stream<SyncStatus> get syncStatus => _syncStatusCtrl.stream;
761
+ SyncStatus get currentStatus => _status;
762
+
763
+ void _pushStatus(SyncStatus s) {
764
+ _status = s;
765
+ if (!_syncStatusCtrl.isClosed) _syncStatusCtrl.add(s);
766
+ }
767
+
768
+ void _startStatusPoll() {
769
+ _pushStatus(_status); // seed the new listener with the current snapshot
770
+ _pollMetricsOnce();
771
+ _metricsTimer ??= Timer.periodic(const Duration(seconds: 4), (_) => _pollMetricsOnce());
772
+ }
773
+
774
+ void _stopStatusPoll() {
775
+ _metricsTimer?.cancel();
776
+ _metricsTimer = null;
777
+ }
778
+
779
+ Future<void> _pollMetricsOnce() async {
780
+ try {
781
+ final m = await metrics();
782
+ int? n(String k) => m.containsKey(k) ? ((m[k] as num?)?.toInt() ?? 0) : null;
783
+ final ahead = n('ahead') ?? _status.ahead;
784
+ final behind = n('behind') ?? _status.behind;
785
+ // Don't stomp an in-flight transfer's phase; otherwise derive idle/behind/syncing from the counts.
786
+ final phase = _status.transfer != null
787
+ ? _status.phase
788
+ : (behind > 0 ? SyncPhase.behind : (ahead > 0 ? SyncPhase.syncing : SyncPhase.idle));
789
+ _pushStatus(_status.copyWith(
790
+ ahead: ahead, behind: behind, saved: n('saved'), onMain: n('onMain'),
791
+ deadLetters: n('deadLetters'), phase: phase, clearError: true,
792
+ ));
793
+ } catch (e) {
794
+ _pushStatus(_status.copyWith(phase: SyncPhase.error, lastError: e.toString()));
795
+ }
796
+ }
797
+
618
798
  /// Ephemeral signal-holon projections (`event:'signal'`), currently peer frontiers / lag for the workspace.
619
799
  Stream<Map<String, Object?>> get signalEvents => _signalEvents.stream;
620
800
 
@@ -660,6 +840,12 @@ class NomosBridge {
660
840
  Future<Map<String, Object?>> metrics() async =>
661
841
  _asMap(await _call('metrics'));
662
842
 
843
+ /// TIER-2 SYNC SIZE — how many bytes this workspace would transfer to catch up to main, WITHOUT downloading
844
+ /// them (a HEAD on the serving pack; the host answers Content-Length only, cached ~15s). Sum across your open
845
+ /// workspaces for an aggregate "X MB to sync" total BEFORE downloading. `{ bytes, kind:'delta'|'full'|
846
+ /// 'up-to-date' }`; `bytes` is null if the host omitted the length.
847
+ Future<Map<String, Object?>> syncSize() async => _asMap(await _call('syncSize'));
848
+
663
849
  /// Diagnostics — is the expected law installed/active/drift-free here? (The generated client surfaces it
664
850
  /// so the app can answer "can I author this directive, and if not what deploy fixes it".)
665
851
  Future<Map<String, Object?>> lawStatus(String hash) async =>
@@ -759,9 +945,12 @@ class NomosBridge {
759
945
  if (!w.isClosed) await w.close();
760
946
  }
761
947
  _compatWatches.clear();
948
+ _stopStatusPoll();
762
949
  if (!_syncEvents.isClosed) await _syncEvents.close();
763
950
  if (!_signalEvents.isClosed) await _signalEvents.close();
764
951
  if (!_flameEvents.isClosed) await _flameEvents.close();
765
952
  if (!_spanEvents.isClosed) await _spanEvents.close();
953
+ if (!_transferEvents.isClosed) await _transferEvents.close();
954
+ if (!_syncStatusCtrl.isClosed) await _syncStatusCtrl.close();
766
955
  }
767
956
  }
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.22.0
14
+ version: 0.27.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
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",
@@ -783,6 +783,48 @@ export const retireWorkspace = directive("retireWorkspace")
783
783
  })
784
784
  .requires("admin");
785
785
 
786
+ /**
787
+ * retireOwnWorkspace — SELF-SERVICE retirement: the principal who CREATED a workspace may retire it (you
788
+ * made it, you can withdraw it — e.g. `githolon proof --live` cleaning up its throwaway workspace). Same
789
+ * effect as {@link retireWorkspace} (status → retired, custody untouched) but gated by `creator` (the birth
790
+ * authority) and SCOPED by {@link retireOwnOnly} to records THIS principal created — so a creator can only
791
+ * retire its OWN workspaces, never another's. Admins keep the unrestricted {@link retireWorkspace}.
792
+ */
793
+ export const retireOwnWorkspace = directive("retireOwnWorkspace")
794
+ .mutates(CloudWorkspace)
795
+ .payload(
796
+ z.object({
797
+ workspaceId: z.string().min(1),
798
+ retiredBy: z.string().min(1),
799
+ retiredAt: epochMs,
800
+ }),
801
+ )
802
+ .plan((p) => {
803
+ const ws = instance(CloudWorkspace, p.workspaceId);
804
+ return [set(ws, "status", "retired" as const)];
805
+ })
806
+ .requires("creator");
807
+
808
+ /** A creator may retire ONLY a workspace it created — the captured author must equal the record's
809
+ * `createdBy` (set at {@link createWorkspace}/birth). A record read of the target (no new declared query),
810
+ * so the read manifest is unchanged (era-/evolve-safe). */
811
+ export const retireOwnOnly = workspaceInvariant("RetireOwnOnly")
812
+ .on("retireOwnWorkspace")
813
+ .reads((ctx) => [
814
+ refAs("target", "CloudWorkspace", "pre:" + String((ctx?.intent ?? {}).workspaceId ?? "")),
815
+ ])
816
+ .assert((refs, ctx) => {
817
+ const target = (refs as { target?: { principal?: string; createdBy?: string } }).target;
818
+ const author = ctx?.principal ?? "";
819
+ // The captured, VERIFIED author must equal the record's recorded creator (its `principal`/`createdBy`,
820
+ // set by the same identity at createWorkspace). So a creator may retire ONLY its own workspaces.
821
+ return author &&
822
+ ((target?.principal && target.principal === author) ||
823
+ (target?.createdBy && target.createdBy === author))
824
+ ? { accept: true }
825
+ : { reject: "retire-own-only: only the workspace's creator may retire it" };
826
+ });
827
+
786
828
  /**
787
829
  * reclaimWorkspace — THE LAWFUL CUSTODY DISCARD. Where {@link retireWorkspace}
788
830
  * withdraws the law (a status, the bytes untouched), reclaim is the law AUTHORIZING