@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 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}) 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,
@@ -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.23.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.27.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",
@@ -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. several `status`
748
- // fields with distinct literal lists all want `StatusEnum`). Suffix on collision
749
- // so each distinct value-set gets a UNIQUE Dart enum (`StatusEnum`, `Status2Enum`).
750
- const base = `${dartTypeNameFromField(preferredName)}Enum`;
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(mod.aggregates, mod.directives);
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);
@@ -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