@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 +20 -0
- package/dart/lib/src/webview_bridge.dart +194 -5
- package/dart/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/src/framework/workspaces.ts +42 -0
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
|
|
370
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|