@githolon/dsl 0.27.0 → 0.28.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 +16 -0
- package/dart/lib/src/webview_bridge.dart +164 -7
- package/dart/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/src/codegen_dart.ts +18 -6
- package/src/codegen_proof.ts +262 -1
- package/src/directive.ts +26 -0
- package/src/framework/workspaces.ts +42 -0
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
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
|
+
|
|
1
17
|
## 0.23.0
|
|
2
18
|
|
|
3
19
|
- `NomosBridge.shareWith` — one-call cross-custody share (sign quote + deliver recordShare to the subjects home). Closes the shared-with-me loop.
|
|
@@ -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,
|
|
@@ -468,18 +569,19 @@ class NomosBridge {
|
|
|
468
569
|
Future<Map<String, Object?>> shareWith({
|
|
469
570
|
required String subject,
|
|
470
571
|
required String relation,
|
|
471
|
-
required String authorSecret,
|
|
472
572
|
required String asserterPrincipal,
|
|
473
573
|
required String homeLawHash,
|
|
574
|
+
String? authorSecret,
|
|
474
575
|
String object = 'workspace:self',
|
|
475
576
|
String? asserterWorkspace,
|
|
476
577
|
String? attestedAt,
|
|
477
578
|
String? authToken,
|
|
478
579
|
}) async {
|
|
580
|
+
// [authorSecret] defaults to the connection's signing key — the frontend dev never handles a key.
|
|
479
581
|
final r = _asMap(await _call('shareWith', {
|
|
480
582
|
'subject': subject,
|
|
481
583
|
'relation': relation,
|
|
482
|
-
'authorSecret': authorSecret,
|
|
584
|
+
if (authorSecret != null) 'authorSecret': authorSecret,
|
|
483
585
|
'asserterPrincipal': asserterPrincipal,
|
|
484
586
|
'homeLawHash': homeLawHash,
|
|
485
587
|
'object': object,
|
|
@@ -647,6 +749,52 @@ class NomosBridge {
|
|
|
647
749
|
/// Background-sync telemetry: each autoSync round (`event:'sync'`) and doorbell socket state (`event:'link'`).
|
|
648
750
|
Stream<Map<String, Object?>> get syncEvents => _syncEvents.stream;
|
|
649
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
|
+
|
|
650
798
|
/// Ephemeral signal-holon projections (`event:'signal'`), currently peer frontiers / lag for the workspace.
|
|
651
799
|
Stream<Map<String, Object?>> get signalEvents => _signalEvents.stream;
|
|
652
800
|
|
|
@@ -692,6 +840,12 @@ class NomosBridge {
|
|
|
692
840
|
Future<Map<String, Object?>> metrics() async =>
|
|
693
841
|
_asMap(await _call('metrics'));
|
|
694
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
|
+
|
|
695
849
|
/// Diagnostics — is the expected law installed/active/drift-free here? (The generated client surfaces it
|
|
696
850
|
/// so the app can answer "can I author this directive, and if not what deploy fixes it".)
|
|
697
851
|
Future<Map<String, Object?>> lawStatus(String hash) async =>
|
|
@@ -791,9 +945,12 @@ class NomosBridge {
|
|
|
791
945
|
if (!w.isClosed) await w.close();
|
|
792
946
|
}
|
|
793
947
|
_compatWatches.clear();
|
|
948
|
+
_stopStatusPoll();
|
|
794
949
|
if (!_syncEvents.isClosed) await _syncEvents.close();
|
|
795
950
|
if (!_signalEvents.isClosed) await _signalEvents.close();
|
|
796
951
|
if (!_flameEvents.isClosed) await _flameEvents.close();
|
|
797
952
|
if (!_spanEvents.isClosed) await _spanEvents.close();
|
|
953
|
+
if (!_transferEvents.isClosed) await _transferEvents.close();
|
|
954
|
+
if (!_syncStatusCtrl.isClosed) await _syncStatusCtrl.close();
|
|
798
955
|
}
|
|
799
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.28.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/codegen_dart.ts
CHANGED
|
@@ -737,22 +737,30 @@ function typedIdClassForAggregateType(aggregateType: string): string {
|
|
|
737
737
|
function collectEnums(
|
|
738
738
|
aggregates: AggregateHandle[],
|
|
739
739
|
directives: AnyDirective[],
|
|
740
|
+
domainKey: string,
|
|
740
741
|
): { dartTypes: Map<string, string>; decls: string[] } {
|
|
741
742
|
const dartTypes = new Map<string, string>(); // "a|b|c" -> DartEnumName
|
|
742
743
|
const usedNames = new Set<string>(); // every emitted enum name (collision guard)
|
|
743
744
|
const decls: string[] = [];
|
|
745
|
+
// Generated enum types are DOMAIN-QUALIFIED (`EstateStatusEnum`,
|
|
746
|
+
// `Co2PlatformStatusEnum`) so two domains re-exported through one Dart barrel
|
|
747
|
+
// never collide on a bare `StatusEnum` — the collision that otherwise forces a
|
|
748
|
+
// hand-maintained `hide` list on the package barrel. The prefix is the field's
|
|
749
|
+
// semantic home, not an accident of import order.
|
|
750
|
+
const domainPrefix = dartTypeNameFromField(domainKey);
|
|
744
751
|
const register = (preferredName: string, values: readonly string[]) => {
|
|
745
752
|
const key = values.join("|");
|
|
746
753
|
if (dartTypes.has(key)) return;
|
|
747
|
-
// Two DIFFERENT value-sets can prefer the SAME name (e.g.
|
|
748
|
-
// fields with distinct literal lists all want `StatusEnum`).
|
|
749
|
-
// so each distinct value-set gets a UNIQUE Dart enum
|
|
750
|
-
|
|
754
|
+
// Two DIFFERENT value-sets WITHIN one domain can prefer the SAME name (e.g.
|
|
755
|
+
// several `status` fields with distinct literal lists all want `StatusEnum`).
|
|
756
|
+
// Suffix on collision so each distinct value-set gets a UNIQUE Dart enum
|
|
757
|
+
// (`EstateStatusEnum`, `EstateStatus2Enum`).
|
|
758
|
+
const base = `${domainPrefix}${dartTypeNameFromField(preferredName)}Enum`;
|
|
751
759
|
let name = base;
|
|
752
760
|
let n = 1;
|
|
753
761
|
while (usedNames.has(name)) {
|
|
754
762
|
n += 1;
|
|
755
|
-
name = `${dartTypeNameFromField(preferredName)}${n}Enum`;
|
|
763
|
+
name = `${domainPrefix}${dartTypeNameFromField(preferredName)}${n}Enum`;
|
|
756
764
|
}
|
|
757
765
|
usedNames.add(name);
|
|
758
766
|
dartTypes.set(key, name);
|
|
@@ -2560,7 +2568,11 @@ export interface DomainModule {
|
|
|
2560
2568
|
/** Generate the full Dart source for one domain module. */
|
|
2561
2569
|
export function generateDartDomain(mod: DomainModule): string {
|
|
2562
2570
|
const domainKey = mod.domain ?? mod.name;
|
|
2563
|
-
const { dartTypes, decls } = collectEnums(
|
|
2571
|
+
const { dartTypes, decls } = collectEnums(
|
|
2572
|
+
mod.aggregates,
|
|
2573
|
+
mod.directives,
|
|
2574
|
+
domainKey,
|
|
2575
|
+
);
|
|
2564
2576
|
const refIdClassForAggregate = refIdClassResolver(mod);
|
|
2565
2577
|
const aggregatesById = new Map<string, AggregateHandle>();
|
|
2566
2578
|
for (const a of mod.aggregates) aggregatesById.set(a.id, a);
|
package/src/codegen_proof.ts
CHANGED
|
@@ -40,6 +40,7 @@ import type { Field } from "./fields.js";
|
|
|
40
40
|
import type { Directive } from "./directive.js";
|
|
41
41
|
import type { DomainModule } from "./codegen_dart.js";
|
|
42
42
|
import type { QueryDecl } from "./query.js";
|
|
43
|
+
import type { FieldOp } from "./ops.js";
|
|
43
44
|
import { finishCount } from "./count.js";
|
|
44
45
|
import { mintedCreateField } from "./workspace_routing.js";
|
|
45
46
|
import { deterministicPorts } from "./ctx.js";
|
|
@@ -336,6 +337,240 @@ function authorityConformanceLegLines(
|
|
|
336
337
|
];
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
/**
|
|
341
|
+
* THE RELATION-BOOTSTRAP LEG (the seed-then-prove POSITIVE — complements the negative conformance leg). On the
|
|
342
|
+
* LOCAL holon with fetch trapped: (1) dispatch the GRANT directive under the proof actor (its subject fields
|
|
343
|
+
* overwritten to the actor so the relation seats ON it), then (2) dispatch the GATED directive under the SAME
|
|
344
|
+
* actor and assert it SUCCEEDS — proving that once relation R is granted, an actor holding R authors the gated
|
|
345
|
+
* write (the exact loop the negative leg can't cover). Emits nothing when no granter is discoverable.
|
|
346
|
+
*/
|
|
347
|
+
function relationSeedLegLines(
|
|
348
|
+
leg: RelationBootstrapLeg | undefined,
|
|
349
|
+
factory: string,
|
|
350
|
+
n: () => number,
|
|
351
|
+
): string[] {
|
|
352
|
+
if (leg === undefined) return [];
|
|
353
|
+
const ACTOR = `"user:proof-conformance"`;
|
|
354
|
+
// Grant payload: overwrite the routed subject/grantedBy fields (and any name-shaped fallback) with the actor.
|
|
355
|
+
const grantPayloadLit = (() => {
|
|
356
|
+
const overwrite = new Set<string>([...leg.grantSubjectFields, ...leg.grantGrantedByFields]);
|
|
357
|
+
const entries = Object.entries(leg.grantPayload).map(([k, v]) => {
|
|
358
|
+
// Also catch conventional subject/grantedBy field names the plan-probe couldn't value-match.
|
|
359
|
+
const looksSubject = /^(subject|principal|grantedBy|grantee|member|user)$/i.test(k);
|
|
360
|
+
return overwrite.has(k) || looksSubject
|
|
361
|
+
? `${JSON.stringify(k)}: ${ACTOR}`
|
|
362
|
+
: `${JSON.stringify(k)}: ${JSON.stringify(v)}`;
|
|
363
|
+
});
|
|
364
|
+
return `{ ${entries.join(", ")} }`;
|
|
365
|
+
})();
|
|
366
|
+
const gatedPayloadLit = leg.gatedMintField !== undefined && leg.gatedAggregateId !== undefined
|
|
367
|
+
? `{ ...${JSON.stringify(leg.gatedPayload)}, ${JSON.stringify(leg.gatedMintField)}: await holon.mint(${JSON.stringify(leg.gatedAggregateId)}) }`
|
|
368
|
+
: JSON.stringify(leg.gatedPayload);
|
|
369
|
+
return [
|
|
370
|
+
`// ${n()}. RELATION BOOTSTRAP (seed-then-prove the POSITIVE): \`${leg.domain}/${leg.gatedDirectiveId}\` is GATED`,
|
|
371
|
+
`// (\`.requires("${leg.relation}")\`). \`${leg.domain}/${leg.grantDirectiveId}\` GRANTS "${leg.relation}" (${leg.via}).`,
|
|
372
|
+
`// Grant it to the proof actor, THEN prove the gated write SUCCEEDS under that actor (what the negative`,
|
|
373
|
+
`// conformance leg above can't cover — a seeded holder authoring a gated write).`,
|
|
374
|
+
`{`,
|
|
375
|
+
` const realFetch3 = globalThis.fetch;`,
|
|
376
|
+
` globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: relation-bootstrap touched the network"); }) as typeof fetch;`,
|
|
377
|
+
` try {`,
|
|
378
|
+
` const ACTOR = ${ACTOR};`,
|
|
379
|
+
` // (1) grant "${leg.relation}" to ACTOR (the grant folds a writeTuple; subject seated on ACTOR).`,
|
|
380
|
+
` await ${factory}(holon, { actor: ACTOR }).${leg.grantDirectiveId}(${grantPayloadLit});`,
|
|
381
|
+
` ok(\`relation bootstrap (grant) — ${leg.domain}/${leg.grantDirectiveId} granted "${leg.relation}" to \${ACTOR}\`);`,
|
|
382
|
+
` // (2) the GATED write now SUCCEEDS under the seeded actor (holds "${leg.relation}").`,
|
|
383
|
+
` await ${factory}(holon, { actor: ACTOR }).${leg.gatedDirectiveId}(${gatedPayloadLit});`,
|
|
384
|
+
` ok(\`relation bootstrap (prove) — ${leg.domain}/${leg.gatedDirectiveId} authored under the seeded holder of "${leg.relation}" (gated write SUCCEEDS once granted)\`);`,
|
|
385
|
+
` } finally {`,
|
|
386
|
+
` globalThis.fetch = realFetch3;`,
|
|
387
|
+
` }`,
|
|
388
|
+
`}`,
|
|
389
|
+
``,
|
|
390
|
+
];
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ── relation-bootstrap discovery (seed the gate: grant relation R, then prove a .requires(R) write SUCCEEDS) ──
|
|
394
|
+
//
|
|
395
|
+
// The NEGATIVE conformance leg above proves an ACTORLESS gated offer can't be generated. This POSITIVE leg
|
|
396
|
+
// proves the other half: with the relation GRANTED, an actor that holds it authors the gated write SUCCESS-
|
|
397
|
+
// fully. It needs a sibling directive that ESTABLISHES the relation (folds a granting `writeTuple`). We find
|
|
398
|
+
// it two ways: FIRST a `.grants(R)` producer marker (the static, unambiguous lane — symmetric to `.requires`);
|
|
399
|
+
// ELSE plan-probe each sibling for a `writeTuple`/relation-tuple op whose `relation === R` (inference).
|
|
400
|
+
|
|
401
|
+
/** A discovered RELATION-BOOTSTRAP leg: a gated (`.requires(R)`) directive plus the sibling directive that
|
|
402
|
+
* GRANTS R. The proof dispatches the grant FIRST (its subject/grantedBy set to the seeded proof actor), then
|
|
403
|
+
* dispatches the gated directive under that actor and asserts it SUCCEEDS — the seed-then-prove positive. */
|
|
404
|
+
export interface RelationBootstrapLeg {
|
|
405
|
+
/** The gated directive's domain. */
|
|
406
|
+
readonly domain: string;
|
|
407
|
+
/** The gated directive id (`.requires(relation)`). */
|
|
408
|
+
readonly gatedDirectiveId: string;
|
|
409
|
+
/** The relation the gate requires (and the grant establishes). */
|
|
410
|
+
readonly relation: string;
|
|
411
|
+
/** The synthesized gated-directive payload (auto-stamped `…At` omitted; mintField removed). */
|
|
412
|
+
readonly gatedPayload: Record<string, unknown>;
|
|
413
|
+
readonly gatedAutoStamped: readonly string[];
|
|
414
|
+
/** When the gated directive's own id is kernel-minted, the field to mint at runtime. */
|
|
415
|
+
readonly gatedMintField?: string;
|
|
416
|
+
/** The gated create's aggregate id (the mint's type tag) — present when `gatedMintField` is. */
|
|
417
|
+
readonly gatedAggregateId?: string;
|
|
418
|
+
/** The granting directive id (the sibling that folds the granting `writeTuple`). */
|
|
419
|
+
readonly grantDirectiveId: string;
|
|
420
|
+
/** The synthesized grant payload (auto-stamped `…At` omitted). The SUBJECT fields (below) are overwritten
|
|
421
|
+
* at runtime with the seeded proof actor so the grant seats authority ON that actor. */
|
|
422
|
+
readonly grantPayload: Record<string, unknown>;
|
|
423
|
+
readonly grantAutoStamped: readonly string[];
|
|
424
|
+
/** The grant-payload field(s) whose value the plan routed into the tuple SUBJECT — set to the proof actor
|
|
425
|
+
* at dispatch so the granted relation lands on the actor the gated write runs as. */
|
|
426
|
+
readonly grantSubjectFields: readonly string[];
|
|
427
|
+
/** Grant-payload field(s) routed into the tuple `grantedBy` — set to the proof actor (free attribution). */
|
|
428
|
+
readonly grantGrantedByFields: readonly string[];
|
|
429
|
+
/** How the granter was resolved — `"marker"` (`.grants(R)`) or `"inferred"` (plan-probe). Cosmetic. */
|
|
430
|
+
readonly via: "marker" | "inferred";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Run a directive's plan with a synthesized+probe payload under birth-probe globals and return the drained
|
|
434
|
+
* field ops (authoring sink + returned ops). `undefined` ⇒ the plan couldn't be probed. */
|
|
435
|
+
function probePlanOps(
|
|
436
|
+
dir: Directive<unknown>,
|
|
437
|
+
where: string,
|
|
438
|
+
): { ops: FieldOp[]; synth: SynthesizedPayload; probePayload: Record<string, unknown> } | undefined {
|
|
439
|
+
let synth: SynthesizedPayload;
|
|
440
|
+
try {
|
|
441
|
+
synth = synthesizePayload(dir.payloadSchema as z.ZodTypeAny, where);
|
|
442
|
+
} catch {
|
|
443
|
+
return undefined;
|
|
444
|
+
}
|
|
445
|
+
const probePayload: Record<string, unknown> = { ...synth.value };
|
|
446
|
+
for (const f of synth.autoStamped) probePayload[f] = "2026-01-01T00:00:00Z";
|
|
447
|
+
let ops: FieldOp[] = [];
|
|
448
|
+
try {
|
|
449
|
+
withBirthProbeGlobals(() => {
|
|
450
|
+
__resetAuthoring();
|
|
451
|
+
__resetReads();
|
|
452
|
+
__resetBirths();
|
|
453
|
+
try {
|
|
454
|
+
const returned = dir.plan(probePayload as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
|
|
455
|
+
const drained = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
|
|
456
|
+
ops = drained.filter((o): o is FieldOp => (o as { kind?: string }).kind !== "strike");
|
|
457
|
+
} finally {
|
|
458
|
+
try { __drainReads(); __drainBirths(); } catch { /* drained */ }
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
} catch {
|
|
462
|
+
return undefined;
|
|
463
|
+
}
|
|
464
|
+
return { ops, synth, probePayload };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Does this directive's plan GRANT `relation` — i.e. fold a relation-tuple write whose `relation` field is
|
|
468
|
+
* set to R (the `writeTuple`/`relateSubject` shape: a `set(rt, "relation", R)` op on a RelationTuple)? */
|
|
469
|
+
function probeGrantsRelation(dir: Directive<unknown>, relation: string, where: string): boolean {
|
|
470
|
+
const probed = probePlanOps(dir, where);
|
|
471
|
+
if (probed === undefined) return false;
|
|
472
|
+
return probed.ops.some((o) => o.kind === "set" && o.field === "relation" && o.value === relation);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Resolve WHICH grant-payload fields route into the tuple subject / grantedBy — by matching the op values
|
|
476
|
+
* the plan emitted (the subject `setEntry`'s entryKey, the `grantedBy` leaf) back to synthesized payload
|
|
477
|
+
* values. Best-effort: an unmatched routing just leaves the field list empty (runtime falls back). */
|
|
478
|
+
function resolveGrantSubjectFields(
|
|
479
|
+
probed: { ops: FieldOp[]; synth: SynthesizedPayload },
|
|
480
|
+
): { subjectFields: string[]; grantedByFields: string[] } {
|
|
481
|
+
const { ops, synth } = probed;
|
|
482
|
+
const fieldForValue = (v: unknown): string | undefined =>
|
|
483
|
+
typeof v === "string" && v.length > 0
|
|
484
|
+
? Object.entries(synth.value).find(([, sv]) => sv === v)?.[0]
|
|
485
|
+
: undefined;
|
|
486
|
+
const subjectFields = new Set<string>();
|
|
487
|
+
const grantedByFields = new Set<string>();
|
|
488
|
+
for (const o of ops) {
|
|
489
|
+
// The subject rides the tuple map entry KEY (`setEntry(rt, "subjects", <subjectRaw>, leaf)`).
|
|
490
|
+
if (o.kind === "setEntry" && o.field === "subjects" && typeof o.entryKey === "string") {
|
|
491
|
+
const f = fieldForValue(o.entryKey);
|
|
492
|
+
if (f !== undefined) subjectFields.add(f);
|
|
493
|
+
// grantedBy rides the JSON leaf value — scan the serialized leaf for any synthesized payload value.
|
|
494
|
+
if (typeof o.value === "string") {
|
|
495
|
+
for (const [name, sv] of Object.entries(synth.value)) {
|
|
496
|
+
if (typeof sv === "string" && sv.length > 0 && name !== fieldForValue(o.entryKey) && o.value.includes(sv)) {
|
|
497
|
+
grantedByFields.add(name);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return { subjectFields: [...subjectFields], grantedByFields: [...grantedByFields] };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Discover the RELATION-BOOTSTRAP leg for a module: the first gated (`.requires(R)`) directive plus the
|
|
507
|
+
* sibling that ESTABLISHES R (prefer a `.grants(R)` marker; else plan-probe for a granting `writeTuple`).
|
|
508
|
+
* `undefined` ⇒ no gated directive, or no discoverable granter (the negative conformance leg still runs). */
|
|
509
|
+
export function discoverRelationBootstrapLeg(mod: DomainModule): RelationBootstrapLeg | undefined {
|
|
510
|
+
const domain = mod.domain ?? mod.name;
|
|
511
|
+
const directives = mod.directives as Directive<unknown>[];
|
|
512
|
+
const gated = directives.find(
|
|
513
|
+
(d) =>
|
|
514
|
+
d?.requiresRelationGate !== undefined &&
|
|
515
|
+
typeof d.payloadSchema === "object" &&
|
|
516
|
+
d.birthsChild !== true &&
|
|
517
|
+
d.attestationAuthorizedGate === undefined,
|
|
518
|
+
);
|
|
519
|
+
if (gated === undefined) return undefined;
|
|
520
|
+
const relation = gated.requiresRelationGate!.relation;
|
|
521
|
+
|
|
522
|
+
// Synthesize the gated payload (mirror of the conformance leg — mintField removed, auto-stamped omitted).
|
|
523
|
+
let gatedSynth: SynthesizedPayload;
|
|
524
|
+
try {
|
|
525
|
+
gatedSynth = synthesizePayload(gated.payloadSchema as z.ZodTypeAny, `${domain}/${gated.id}`);
|
|
526
|
+
} catch {
|
|
527
|
+
return undefined;
|
|
528
|
+
}
|
|
529
|
+
const gatedAgg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === gated.aggregateId);
|
|
530
|
+
const gatedMintField = gatedAgg !== undefined ? mintedCreateField(gated, gatedAgg) : undefined;
|
|
531
|
+
const gatedPayload: Record<string, unknown> = { ...gatedSynth.value };
|
|
532
|
+
if (gatedMintField !== undefined) delete gatedPayload[gatedMintField];
|
|
533
|
+
|
|
534
|
+
// Find the granter. FIRST prefer a `.grants(R)` marker (static, unambiguous). ELSE plan-probe siblings for
|
|
535
|
+
// a granting `writeTuple` whose relation === R. Never the gated directive itself.
|
|
536
|
+
const candidates = directives.filter((d) => d !== gated && typeof d?.payloadSchema === "object" && d.birthsChild !== true);
|
|
537
|
+
let via: "marker" | "inferred" = "marker";
|
|
538
|
+
let granter = candidates.find((d) => (d.grantsRelation ?? []).includes(relation));
|
|
539
|
+
if (granter === undefined) {
|
|
540
|
+
via = "inferred";
|
|
541
|
+
granter = candidates.find((d) => probeGrantsRelation(d, relation, `${domain}/${d.id}`));
|
|
542
|
+
}
|
|
543
|
+
if (granter === undefined) return undefined;
|
|
544
|
+
|
|
545
|
+
// Synthesize the grant payload + resolve which fields route into subject / grantedBy (so runtime seats the
|
|
546
|
+
// grant on the proof actor). Best-effort: an un-probable grant plan still emits the leg (runtime overwrites
|
|
547
|
+
// any field whose name looks like a subject/grantedBy anyway).
|
|
548
|
+
let grantSynth: SynthesizedPayload;
|
|
549
|
+
try {
|
|
550
|
+
grantSynth = synthesizePayload(granter.payloadSchema as z.ZodTypeAny, `${domain}/${granter.id}`);
|
|
551
|
+
} catch {
|
|
552
|
+
return undefined;
|
|
553
|
+
}
|
|
554
|
+
const grantPayload: Record<string, unknown> = { ...grantSynth.value };
|
|
555
|
+
const probed = probePlanOps(granter, `${domain}/${granter.id}`);
|
|
556
|
+
const routed = probed !== undefined ? resolveGrantSubjectFields(probed) : { subjectFields: [], grantedByFields: [] };
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
domain,
|
|
560
|
+
gatedDirectiveId: gated.id,
|
|
561
|
+
relation,
|
|
562
|
+
gatedPayload,
|
|
563
|
+
gatedAutoStamped: gatedSynth.autoStamped,
|
|
564
|
+
...(gatedMintField !== undefined ? { gatedMintField, gatedAggregateId: gated.aggregateId } : {}),
|
|
565
|
+
grantDirectiveId: granter.id,
|
|
566
|
+
grantPayload,
|
|
567
|
+
grantAutoStamped: grantSynth.autoStamped,
|
|
568
|
+
grantSubjectFields: routed.subjectFields,
|
|
569
|
+
grantGrantedByFields: routed.grantedByFields,
|
|
570
|
+
via,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
339
574
|
// ── child-birth discovery (the G3 `.births()` lane — prove a parent birth end-to-end into the child) ──────
|
|
340
575
|
|
|
341
576
|
/** ONE step of a discovered birth recipe's genesis chain (the subset codegen reads). */
|
|
@@ -634,8 +869,9 @@ export interface TsProofOptions {
|
|
|
634
869
|
* · `compile-valid` — the law compiled (always — every compiled domain is at least this).
|
|
635
870
|
* · `standalone-provable` — a `.creates` write proven offline (dispatch → declared query/count locally).
|
|
636
871
|
* · `child-birth-provable` — a `.births()` directive proven END-TO-END into the child it births.
|
|
872
|
+
* · `relation-bootstrap-provable` — a `.requires(R)`-gated write proven to SUCCEED after granting R (seed-then-prove).
|
|
637
873
|
* · `live-cloud-provable` — the full cloud loop (the `.mts` `--live` lane) completes (the primary domain). */
|
|
638
|
-
export type ProofKind = "compile-valid" | "standalone-provable" | "child-birth-provable" | "live-cloud-provable";
|
|
874
|
+
export type ProofKind = "compile-valid" | "standalone-provable" | "child-birth-provable" | "relation-bootstrap-provable" | "live-cloud-provable";
|
|
639
875
|
|
|
640
876
|
/** The machine-readable OFFLINE legs for ONE domain (the `@nomos-proof-legs`
|
|
641
877
|
* marker shape; the `proof-legs.json` index is a map of these by domain key). */
|
|
@@ -657,6 +893,10 @@ export interface ProofLegs {
|
|
|
657
893
|
* When this domain is itself the BORN child's law (no standalone create), the index also registers the
|
|
658
894
|
* child domain key pointing at the SAME leg, so `proof --domain <child>` resolves through the parent birth. */
|
|
659
895
|
readonly childBirth?: ChildBirthLeg;
|
|
896
|
+
/** THE RELATION-BOOTSTRAP LEG — present iff this domain has a `.requires(R)`-gated directive AND a
|
|
897
|
+
* discoverable granter of R (a `.grants(R)` marker, or a plan-probed granting `writeTuple`). Proves the
|
|
898
|
+
* gated write SUCCEEDS once R is granted (the seed-then-prove positive). Additive: absent ⇒ older/ungated. */
|
|
899
|
+
readonly relationBootstrap?: RelationBootstrapLeg;
|
|
660
900
|
}
|
|
661
901
|
|
|
662
902
|
/** Synthesize the standalone-create offline legs for one domain module, or `null` when it has no
|
|
@@ -727,9 +967,19 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
|
|
|
727
967
|
const childBirth = birthDir !== undefined ? allBirths.find((b) => b.parentDomain === domain && b.birthDirectiveId === birthDir.id) : undefined;
|
|
728
968
|
const childBirthWithSetup = childBirth !== undefined ? attachParentSetup(childBirth, allBirths) : undefined;
|
|
729
969
|
|
|
970
|
+
// The relation-bootstrap leg (when the domain has a `.requires(R)`-gated directive AND a discoverable
|
|
971
|
+
// granter of R). FAIL SOFT: no granter → no leg (the negative conformance leg still covers the gate).
|
|
972
|
+
let relationBootstrap: RelationBootstrapLeg | undefined;
|
|
973
|
+
try {
|
|
974
|
+
relationBootstrap = discoverRelationBootstrapLeg(mod);
|
|
975
|
+
} catch {
|
|
976
|
+
relationBootstrap = undefined;
|
|
977
|
+
}
|
|
978
|
+
|
|
730
979
|
const legs: ProofKind[] = ["compile-valid"];
|
|
731
980
|
if (standalone !== null) legs.push("standalone-provable");
|
|
732
981
|
if (childBirthWithSetup !== undefined) legs.push("child-birth-provable");
|
|
982
|
+
if (relationBootstrap !== undefined) legs.push("relation-bootstrap-provable");
|
|
733
983
|
|
|
734
984
|
return {
|
|
735
985
|
domain,
|
|
@@ -746,6 +996,7 @@ export function legsForDomain(mod: DomainModule, modules: readonly DomainModule[
|
|
|
746
996
|
}
|
|
747
997
|
: {}),
|
|
748
998
|
...(childBirthWithSetup !== undefined ? { childBirth: childBirthWithSetup } : {}),
|
|
999
|
+
...(relationBootstrap !== undefined ? { relationBootstrap } : {}),
|
|
749
1000
|
};
|
|
750
1001
|
}
|
|
751
1002
|
|
|
@@ -1090,6 +1341,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
|
|
|
1090
1341
|
// throws MissingOfferAuthority. `undefined` ⇒ a documented no-op (the domain authors openly).
|
|
1091
1342
|
const conformance = discoverAuthorityConformanceLeg(mod);
|
|
1092
1343
|
|
|
1344
|
+
// THE RELATION-BOOTSTRAP LEG (the seed-then-prove positive): grant relation R to the proof actor, then prove
|
|
1345
|
+
// a `.requires(R)`-gated write SUCCEEDS under that actor. `undefined` ⇒ no gated directive or no discoverable
|
|
1346
|
+
// granter (the negative conformance leg still runs). Comes from the SAME `legsForDomain` synthesis (marker).
|
|
1347
|
+
const relationBootstrap = primaryLegs.relationBootstrap;
|
|
1348
|
+
|
|
1093
1349
|
// Workspace-name slug: any package name → a safe, recognizable ws prefix.
|
|
1094
1350
|
const slug =
|
|
1095
1351
|
opts.packageName
|
|
@@ -1281,6 +1537,11 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
|
|
|
1281
1537
|
// offer is impossible to GENERATE (throws MissingOfferAuthority before any submit). No-op when un-gated.
|
|
1282
1538
|
out.push(...authorityConformanceLegLines(conformance, factory, n));
|
|
1283
1539
|
|
|
1540
|
+
// THE RELATION-BOOTSTRAP LEG (seed-then-prove the POSITIVE): grant relation R to the proof actor, then prove
|
|
1541
|
+
// the gated write SUCCEEDS under that actor. Runs on the LOCAL holon (fetch re-trapped). Emits nothing when
|
|
1542
|
+
// no granter is discoverable (the negative conformance leg still covers the gate).
|
|
1543
|
+
out.push(...relationSeedLegLines(relationBootstrap, factory, n));
|
|
1544
|
+
|
|
1284
1545
|
// THE CHILD-BIRTH LEG (G3, gap 2): when the primary domain has a `.births()` directive, prove the birth
|
|
1285
1546
|
// END-TO-END against the cloud — drive the parent birth offer, then read the BORN child workspace it spawns.
|
|
1286
1547
|
if (childBirth !== undefined) {
|
package/src/directive.ts
CHANGED
|
@@ -175,6 +175,17 @@ export interface Directive<P = unknown> {
|
|
|
175
175
|
* `attestationAuthorizedGate`/`.attestationAuthorized()` naming convention; the declaration is the noun.)
|
|
176
176
|
*/
|
|
177
177
|
readonly birthsChild?: boolean;
|
|
178
|
+
/**
|
|
179
|
+
* PROOF-TOOLING PRODUCER MARKER (relation-seed leg). Declares that this directive ESTABLISHES the named
|
|
180
|
+
* relation(s) — i.e. its `plan` folds a `writeTuple` that grants the relation. Purely a static hint for
|
|
181
|
+
* the proof generator's relation-bootstrap leg (`discoverRelationBootstrapLeg`): it disambiguates which
|
|
182
|
+
* sibling directive to run FIRST to seed the actor before proving a `.requires(R)`-gated write succeeds,
|
|
183
|
+
* and resolves the "who grants the granter" / multiple-candidate ambiguity that pure plan-probe inference
|
|
184
|
+
* cannot. NO kernel/manifest effect (it is NOT lowered into any era key — the gate is the `writeTuple` the
|
|
185
|
+
* plan already folds); a directive that never calls `.grants()` is byte-identical to before. Set via the
|
|
186
|
+
* chainable `.grants(relation)` (symmetric to `.requires`).
|
|
187
|
+
*/
|
|
188
|
+
readonly grantsRelation?: string[];
|
|
178
189
|
/**
|
|
179
190
|
* The directive's DECLARED required HOST CAPABILITIES — the capability PORTS the HOST must
|
|
180
191
|
* PROVIDE for this directive to run (`OFFER_KERNEL_ARCHITECTURE.dot` cluster_ports / `OFFER_FLOW.dot`
|
|
@@ -301,6 +312,14 @@ export interface RequirableDirective<P> extends Directive<P> {
|
|
|
301
312
|
* the canonical manifest to before this existed (the era rule). Returns a NEW requirable directive.
|
|
302
313
|
*/
|
|
303
314
|
births(): RequirableDirective<P>;
|
|
315
|
+
/**
|
|
316
|
+
* PROOF-TOOLING PRODUCER MARKER (symmetric to `.requires`): declare that this directive ESTABLISHES the
|
|
317
|
+
* named relation (its `plan` folds a `writeTuple` granting it). A pure proof-generator hint — NO kernel /
|
|
318
|
+
* manifest / era-key effect. It tells the relation-bootstrap proof leg which sibling to run FIRST to seed
|
|
319
|
+
* the actor before proving a `.requires(relation)`-gated write succeeds, and resolves inference ambiguity.
|
|
320
|
+
* Accumulates + dedups; additive + non-destructive. A directive that never calls this is byte-identical.
|
|
321
|
+
*/
|
|
322
|
+
grants(relation: string): RequirableDirective<P>;
|
|
304
323
|
}
|
|
305
324
|
|
|
306
325
|
/** Stage 1: id + a referential marker (which fixes the target aggregate). */
|
|
@@ -492,6 +511,13 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
|
|
|
492
511
|
// the birth after admit. Era-ruled: a non-birthing directive omits the flag and is byte-identical.
|
|
493
512
|
return makeRequirable({ ...base, birthsChild: true });
|
|
494
513
|
},
|
|
514
|
+
grants(relation: string): RequirableDirective<P> {
|
|
515
|
+
// PROOF-TOOLING PRODUCER MARKER (symmetric to `.requires`): record that this directive establishes
|
|
516
|
+
// `relation` (its plan folds a granting `writeTuple`). NO kernel/manifest effect — a pure static hint
|
|
517
|
+
// the proof generator's relation-bootstrap leg reads. Accumulate + dedup; additive + non-destructive.
|
|
518
|
+
const merged = [...new Set([...(base.grantsRelation ?? []), relation])].sort();
|
|
519
|
+
return makeRequirable({ ...base, grantsRelation: merged });
|
|
520
|
+
},
|
|
495
521
|
};
|
|
496
522
|
}
|
|
497
523
|
|
|
@@ -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
|