@githolon/dsl 0.50.0 → 0.51.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.
@@ -0,0 +1,82 @@
1
+ // COPY THIS INTO YOUR FLUTTER APP. It is an EXAMPLE, not part of the `nomos_client` library: it depends on
2
+ // `flutter_secure_storage`, which the pure-Dart `nomos_client` package deliberately does NOT depend on (so
3
+ // the protocol logic stays Flutter-free and unit-testable). This is the ~40 lines of app-specific glue for
4
+ // where your device secret lives — the E2E key CUSTODY policy is yours; the protocol plumbing is not.
5
+ //
6
+ // THE SEALING CLIENT (e2e_sealing_client.md): a generated `<Domain>Client(bridge, keyStore: ...)` seals
7
+ // encrypted directive fields before dispatch + decrypts encrypted read fields automatically. The `keyStore`
8
+ // is the ONE thing you supply. `WrappedKeyStore` (in `nomos_client`) is the default: it unwraps this
9
+ // principal's on-chain WrappedKeys with a DEVICE SECRET. This example shows where that device secret lives —
10
+ // the platform keychain / Keystore via `flutter_secure_storage` — so it survives app restarts, stays off the
11
+ // chain, and never leaves the device.
12
+ //
13
+ // WIRING (once per signed-in principal):
14
+ // final store = await SecureDeviceKeyStore.open(bridge: bridge, principal: myUid);
15
+ // final app = NotesClient(bridge, actor: myUid, keyStore: store); // reads decrypt, writes seal
16
+ // final rows = await app.notesByOwner(owner: myUid); // `body` comes back plaintext
17
+ //
18
+ // The OWNER ceremony (grantAndWrap / revokeAndRotate / rotate) uses the SAME device pubkey this store
19
+ // publishes — see `<Domain>Client(bridge, actor: owner).encryption`.
20
+
21
+ import 'dart:convert';
22
+
23
+ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
24
+ import 'package:nomos_client/nomos_client.dart';
25
+
26
+ /// A [NomosKeyStore] whose device secret is held in the platform keychain (iOS Keychain / Android Keystore)
27
+ /// via `flutter_secure_storage`. It delegates the scope-key unwrap to the framework's [WrappedKeyStore]; the
28
+ /// only thing it adds is DURABLE, PLATFORM-PROTECTED custody of the X25519 device secret.
29
+ class SecureDeviceKeyStore implements NomosKeyStore {
30
+ final WrappedKeyStore _inner;
31
+
32
+ /// This device's X25519 PUBLIC key — publish it (publishEncKey) / hand it to the owner as `publicKey` on
33
+ /// `grantAndWrap` so the scope key can be wrapped TO this device.
34
+ final String devicePublicKey;
35
+
36
+ SecureDeviceKeyStore._(this._inner, this.devicePublicKey);
37
+
38
+ /// Open (or first-time provision) the store for `principal`. On first run it MINTS an X25519 device keypair
39
+ /// in the kernel, persists the SECRET in secure storage, and returns the store; on later runs it reuses it.
40
+ static Future<SecureDeviceKeyStore> open({
41
+ required NomosBridge bridge,
42
+ required String principal,
43
+ FlutterSecureStorage storage = const FlutterSecureStorage(),
44
+ }) async {
45
+ final secretKey = 'nomos.enc.deviceSecret.$principal';
46
+ final publicKey = 'nomos.enc.devicePublic.$principal';
47
+ var secret = await storage.read(key: secretKey);
48
+ var public = await storage.read(key: publicKey);
49
+ if (secret == null || public == null) {
50
+ // FIRST RUN: mint the device keypair (secret stays client-side, never on chain) and persist it.
51
+ final kp = await bridge.cryptoKeygen();
52
+ secret = (kp['secret'] ?? '').toString();
53
+ public = (kp['public'] ?? '').toString();
54
+ await storage.write(key: secretKey, value: secret);
55
+ await storage.write(key: publicKey, value: public);
56
+ }
57
+ final inner = WrappedKeyStore(
58
+ bridge: bridge, principal: principal, deviceSecret: secret);
59
+ return SecureDeviceKeyStore._(inner, public);
60
+ }
61
+
62
+ @override
63
+ Future<NomosScopeKey?> scopeKey(String scope) => _inner.scopeKey(scope);
64
+
65
+ @override
66
+ Future<List<NomosScopeKey>> readKeys(List<String> scopes) =>
67
+ _inner.readKeys(scopes);
68
+
69
+ /// Convenience: the JSON a `publishEncKey` payload carries (bind the device pubkey to this principal on
70
+ /// chain so an owner's `grantAndWrap` can resolve it without you passing `publicKey` each time).
71
+ Map<String, Object?> publishPayload({required String publishedAt}) => {
72
+ 'principal': _inner.principal,
73
+ 'encPublicKey': devicePublicKey,
74
+ 'publishedAt': publishedAt,
75
+ };
76
+
77
+ @override
78
+ String toString() => 'SecureDeviceKeyStore(${jsonEncode({
79
+ 'principal': _inner.principal,
80
+ 'devicePublicKey': devicePublicKey,
81
+ })})';
82
+ }
@@ -15,6 +15,7 @@
15
15
  library;
16
16
 
17
17
  import 'dart:async';
18
+ import 'dart:convert';
18
19
  import 'dart:math';
19
20
 
20
21
  import 'errors.dart' show nomosErrorFromInfo;
@@ -382,6 +383,287 @@ class WrappedKeyStore implements NomosKeyStore {
382
383
  }
383
384
  }
384
385
 
386
+ // ── E2E OWNER CEREMONY (the sealing client, slice 2) — the owner-side twin of [WrappedKeyStore]. Domain-
387
+ // agnostic, so it lives here (re-exported per client) exactly like the KeyStore. It composes the framework
388
+ // key-epoch fan-out (`advanceKeyEpoch`, dispatched by its REAL id) with the domain's own grant/revoke
389
+ // directive, resolving the roster + each member's wrap-target pubkey FROM THE CHAIN — so the operator-blind
390
+ // handshake (grant → advance epoch → wrap to every member) is ONE call. Mirror of the TS `_ownerEncryption`. ──
391
+
392
+ /// Thrown by the owner ceremony when a roster member has NO wrap-target key (no publishEncKey on chain, no
393
+ /// prior wrap, no supplied override) — the ceremony NEVER wraps to a default (fail-closed; a keyless member
394
+ /// is "not yet a member", per e2e_encryption.md's FOOTGUNS).
395
+ class EncryptionMemberKeyMissing implements Exception {
396
+ final String principal;
397
+ final String scope;
398
+ String get code => 'nomos.crypto.member_key_missing';
399
+ String get recommendedAction => 'publishEncKey';
400
+ EncryptionMemberKeyMissing(this.principal, this.scope);
401
+ @override
402
+ String toString() =>
403
+ 'EncryptionMemberKeyMissing: cannot wrap the "$scope" scope key to member "$principal" — no encryption public key on chain (publishEncKey) and none supplied (fail-closed)';
404
+ }
405
+
406
+ /// Thrown when the resolved roster != the caller's declared `expectRoster` BEFORE dispatch — pre-firing the
407
+ /// kernel fan-out gate (wrap set == roster) so a rotation against a stale roster view never spends an epoch.
408
+ class EncryptionRosterMismatch implements Exception {
409
+ final String scope;
410
+ final List<String> missing;
411
+ final List<String> extra;
412
+ String get code => 'nomos.crypto.roster_mismatch';
413
+ String get recommendedAction => 'reconcileRoster';
414
+ EncryptionRosterMismatch(this.scope, this.missing, this.extra);
415
+ @override
416
+ String toString() =>
417
+ 'EncryptionRosterMismatch: roster for "$scope" != expected (the fan-out gate would refuse): missing $missing, unexpected $extra';
418
+ }
419
+
420
+ /// The outcome of an epoch-advancing ceremony verb: the new local [head], the [epoch] opened, and the
421
+ /// [roster] the key was wrapped to.
422
+ class NomosEpochResult {
423
+ final String head;
424
+ final int epoch;
425
+ final List<String> roster;
426
+ const NomosEpochResult(
427
+ {required this.head, required this.epoch, required this.roster});
428
+ }
429
+
430
+ /// THE OWNER CEREMONY surface — `<Domain>Client(bridge, actor: owner).encryption`. All three verbs open a
431
+ /// FRESH epoch (a fresh 32-byte secret) and fan the wraps to the resolved roster; the membership change (if
432
+ /// any) is the domain grant/revoke dispatched first, in the same call.
433
+ class NomosEncryption {
434
+ final NomosBridge bridge;
435
+ final String domain;
436
+ final String? domainHash;
437
+ final String? actor;
438
+
439
+ /// The encryption scopes this domain defines (each a `requires(cap)`).
440
+ final List<String> scopes;
441
+
442
+ NomosEncryption(
443
+ {required this.bridge,
444
+ required this.domain,
445
+ this.domainHash,
446
+ this.actor,
447
+ required this.scopes});
448
+
449
+ String _defScope([String? scope]) {
450
+ if (scope != null && scope.isNotEmpty) return scope;
451
+ if (scopes.length == 1) return scopes.first;
452
+ throw StateError(
453
+ 'this domain defines ${scopes.length} encryption scopes $scopes — pass a scope to pick one');
454
+ }
455
+
456
+ // A BARE scope IS `workspace:self#<relation>` (the era rule); a per-record scope is `<type>:<id>#<relation>`.
457
+ ({String object, String relation}) _parseScope(String scope) {
458
+ final h = scope.indexOf('#');
459
+ return h < 0
460
+ ? (object: 'workspace:self', relation: scope)
461
+ : (object: scope.substring(0, h), relation: scope.substring(h + 1));
462
+ }
463
+
464
+ /// The CURRENT roster of a scope (active direct-grant members, principal-form). Defaults to the sole scope.
465
+ Future<List<String>> roster([String? scope]) => _readRoster(_defScope(scope));
466
+
467
+ Future<List<String>> _readRoster(String scope) async {
468
+ final s = _parseScope(scope);
469
+ final rows =
470
+ await bridge.queryById('reltuple-${s.object}-${s.relation}');
471
+ final subjects = rows.isEmpty
472
+ ? const <String, dynamic>{}
473
+ : ((rows.first['data'] as Map?)?['subjects'] as Map?) ??
474
+ const <String, dynamic>{};
475
+ final out = <String>[];
476
+ subjects.forEach((subj, leafRaw) {
477
+ Map? leaf;
478
+ try {
479
+ leaf = leafRaw is String
480
+ ? jsonDecode(leafRaw) as Map
481
+ : (leafRaw as Map);
482
+ } catch (_) {
483
+ leaf = null;
484
+ }
485
+ final status = leaf?['status'];
486
+ if (status != null && status != 'active') return;
487
+ final p = subj.toString();
488
+ out.add(p.startsWith('user:') ? p.substring(5) : p);
489
+ });
490
+ out.sort();
491
+ return out;
492
+ }
493
+
494
+ Future<int> _currentEpoch(String scope) async {
495
+ final rows = await bridge.queryById('keyscope-$scope');
496
+ if (rows.isEmpty) return -1;
497
+ final e = (rows.first['data'] as Map?)?['currentEpoch'];
498
+ return e is num ? e.toInt() : -1;
499
+ }
500
+
501
+ Future<String> _freshScopeKey() async {
502
+ final kp = await bridge.cryptoKeygen();
503
+ final secret = base64Decode((kp['secret'] ?? '').toString());
504
+ return base64Encode(secret.sublist(0, 32)); // the 32-byte epoch secret; NEVER lands on chain
505
+ }
506
+
507
+ Future<String?> _memberPubKey(String principal,
508
+ {String? newcomer, String? newcomerPub, Map<String, String>? memberKeys}) async {
509
+ if (memberKeys != null && memberKeys[principal] != null) {
510
+ return memberKeys[principal];
511
+ }
512
+ if (newcomer == principal && newcomerPub != null) return newcomerPub;
513
+ // Published enc key (encKeyByPrincipal) — present only when the domain composed the registry; tolerate absence.
514
+ try {
515
+ final reg =
516
+ await bridge.query('encKeyByPrincipal', {'principal': principal});
517
+ if (reg.isNotEmpty) {
518
+ final pub = (reg.first['data'] as Map?)?['encPublicKey'];
519
+ if (pub != null && pub.toString().isNotEmpty) return pub.toString();
520
+ }
521
+ } catch (_) {/* not routed — fall through to the prior-wrap lane */}
522
+ // The member's most-recent WrappedKey pins the pubkey it was last sealed to (rotation needs no re-publish).
523
+ try {
524
+ final wraps = await bridge
525
+ .query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
526
+ int bestEpoch = -1;
527
+ String? bestPub;
528
+ for (final w in wraps) {
529
+ final d = (w['data'] as Map?) ?? const {};
530
+ final pub = d['recipientPubKey'];
531
+ final ep = (d['epoch'] as num?)?.toInt() ?? 0;
532
+ if (pub != null && ep >= bestEpoch) {
533
+ bestEpoch = ep;
534
+ bestPub = pub.toString();
535
+ }
536
+ }
537
+ if (bestPub != null) return bestPub;
538
+ } catch (_) {/* not routed */}
539
+ return null;
540
+ }
541
+
542
+ Future<NomosEpochResult> _advance(
543
+ String scope, {
544
+ String? addPrincipal,
545
+ String? addPublicKey,
546
+ List<String> remove = const [],
547
+ String? scopeKey,
548
+ Map<String, String>? memberKeys,
549
+ List<String>? expectRoster,
550
+ String? openedAt,
551
+ }) async {
552
+ final roster = (await _readRoster(scope)).toSet();
553
+ if (addPrincipal != null) roster.add(addPrincipal);
554
+ roster.removeAll(remove);
555
+ final members = roster.toList()..sort();
556
+ // PRE-FIRE the fan-out gate: if the caller declared the roster it means to wrap, refuse on drift.
557
+ if (expectRoster != null) {
558
+ final want = expectRoster.toSet();
559
+ final missing = (want.difference(roster).toList())..sort();
560
+ final extra = members.where((m) => !want.contains(m)).toList();
561
+ if (missing.isNotEmpty || extra.isNotEmpty) {
562
+ throw EncryptionRosterMismatch(scope, missing, extra);
563
+ }
564
+ }
565
+ final key = scopeKey ?? await _freshScopeKey();
566
+ final wraps = <Map<String, Object?>>[];
567
+ for (final principal in members) {
568
+ final pub = await _memberPubKey(principal,
569
+ newcomer: addPrincipal, newcomerPub: addPublicKey, memberKeys: memberKeys);
570
+ if (pub == null) throw EncryptionMemberKeyMissing(principal, scope);
571
+ final w = await bridge.cryptoWrapKey(recipientPub: pub, scopeKey: key);
572
+ wraps.add({
573
+ 'recipientPrincipal': principal,
574
+ 'recipientPubKey': pub,
575
+ 'hpkeEpk': (w['hpkeEpk'] ?? '').toString(),
576
+ 'ct': (w['ct'] ?? '').toString(),
577
+ });
578
+ }
579
+ final epoch = (await _currentEpoch(scope)) + 1; // forward-only: strictly increments
580
+ final head = await bridge.offerDirective(
581
+ domain: domain,
582
+ directiveId: 'advanceKeyEpoch',
583
+ payload: {
584
+ 'scope': scope,
585
+ 'epoch': epoch,
586
+ 'openedAt': openedAt ?? DateTime.now().toUtc().toIso8601String(),
587
+ 'wraps': wraps,
588
+ },
589
+ actor: actor,
590
+ domainHash: domainHash,
591
+ );
592
+ return NomosEpochResult(head: head, epoch: epoch, roster: members);
593
+ }
594
+
595
+ /// Grant a newcomer + open a fresh epoch wrapping the key to EVERY member (incl. the newcomer). Dispatches
596
+ /// `grantDirective(grantPayload)` first, then `advanceKeyEpoch`. `publicKey` is the newcomer's X25519 key
597
+ /// (else resolved from an on-chain publishEncKey). Existing members' keys come from their prior wraps.
598
+ Future<NomosEpochResult> grantAndWrap({
599
+ required String principal,
600
+ String? publicKey,
601
+ String? scope,
602
+ required String grantDirective,
603
+ required Map<String, Object?> grantPayload,
604
+ String? scopeKey,
605
+ Map<String, String>? memberKeys,
606
+ List<String>? expectRoster,
607
+ String? openedAt,
608
+ }) async {
609
+ final s = _defScope(scope);
610
+ await bridge.offerDirective(
611
+ domain: domain,
612
+ directiveId: grantDirective,
613
+ payload: grantPayload,
614
+ actor: actor,
615
+ domainHash: domainHash);
616
+ return _advance(s,
617
+ addPrincipal: principal,
618
+ addPublicKey: publicKey,
619
+ scopeKey: scopeKey,
620
+ memberKeys: memberKeys,
621
+ expectRoster: expectRoster,
622
+ openedAt: openedAt);
623
+ }
624
+
625
+ /// Revoke a member + open a fresh epoch re-wrapping the key to the SURVIVORS only. FORWARD-ONLY: the removed
626
+ /// member gets no NEW reads, but old ciphertext it already holds a key for stays readable (not re-encrypted).
627
+ Future<NomosEpochResult> revokeAndRotate({
628
+ required String principal,
629
+ String? scope,
630
+ required String revokeDirective,
631
+ required Map<String, Object?> revokePayload,
632
+ String? scopeKey,
633
+ Map<String, String>? memberKeys,
634
+ List<String>? expectRoster,
635
+ String? openedAt,
636
+ }) async {
637
+ final s = _defScope(scope);
638
+ await bridge.offerDirective(
639
+ domain: domain,
640
+ directiveId: revokeDirective,
641
+ payload: revokePayload,
642
+ actor: actor,
643
+ domainHash: domainHash);
644
+ return _advance(s,
645
+ remove: [principal],
646
+ scopeKey: scopeKey,
647
+ memberKeys: memberKeys,
648
+ expectRoster: expectRoster,
649
+ openedAt: openedAt);
650
+ }
651
+
652
+ /// Routine rotation — open a fresh epoch re-wrapping the key to the CURRENT roster (no membership change).
653
+ Future<NomosEpochResult> rotate({
654
+ String? scope,
655
+ String? scopeKey,
656
+ Map<String, String>? memberKeys,
657
+ List<String>? expectRoster,
658
+ String? openedAt,
659
+ }) =>
660
+ _advance(_defScope(scope),
661
+ scopeKey: scopeKey,
662
+ memberKeys: memberKeys,
663
+ expectRoster: expectRoster,
664
+ openedAt: openedAt);
665
+ }
666
+
385
667
  /// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
386
668
  class NomosOfferOutcome {
387
669
  final String head;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.50.0",
3
+ "version": "0.51.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",
@@ -10,13 +10,21 @@
10
10
  * discovery), runs {@link checkDomainModules}, prints the unified report, and exits
11
11
  * non-zero on any REFUSE-level finding. `compile` runs the same twins implicitly.
12
12
  */
13
- import { existsSync } from "node:fs";
13
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
14
14
  import path from "node:path";
15
15
  import { pathToFileURL } from "node:url";
16
16
 
17
17
  import { composeDomainModule, type Mod } from "./build_package.js";
18
18
  import type { DomainModule } from "./codegen_dart.js";
19
- import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
19
+ import { checkDomainModules, formatFindings, hasRefusal, type Finding } from "./law_check.js";
20
+ import {
21
+ currentEvolveLaw,
22
+ evolveDiff,
23
+ evolveLawFromDeployJson,
24
+ evolveLawFromLockfile,
25
+ type EvolveLaw,
26
+ type StableIdLockfile,
27
+ } from "./evolve_preview.js";
20
28
 
21
29
  interface DomainConfig {
22
30
  key: string;
@@ -64,11 +72,106 @@ export async function loadComposedModules(configArg: string): Promise<DomainModu
64
72
  return composed;
65
73
  }
66
74
 
75
+ /**
76
+ * Load the PRIOR law surface for `--against <path>` (the evolve preview's baseline). The supported
77
+ * prior is a DEPLOY.JSON (`build/<name>.deploy.json`), a BUILD DIR / package dir containing one, or
78
+ * the committed `nomos.stable-ids.json` LOCKFILE (aggregate identity only — no directive payloads).
79
+ * A live `https://…` prior is NOT supported (the installed package's stable-ids aren't retrievable
80
+ * from GET /domains); point `--against` at the deployed law's committed deploy.json instead.
81
+ */
82
+ function loadPriorEvolveLaw(arg: string): { law: EvolveLaw; source: string; note?: string } {
83
+ if (/^https?:\/\//.test(arg)) {
84
+ fail(
85
+ `--against does not support a live URL (${arg}) — the installed package's #58 stable-ids are ` +
86
+ `not retrievable from GET /domains. Point --against at the deployed law's build/<name>.deploy.json.`,
87
+ );
88
+ }
89
+ const abs = path.resolve(process.cwd(), arg);
90
+ if (!existsSync(abs)) fail(`--against: prior not found: ${abs}`);
91
+ // Resolve a directory to a deploy.json (build dir or package dir) or its lockfile.
92
+ let file = abs;
93
+ if (statSync(abs).isDirectory()) {
94
+ const deploy = findDeployJson(abs);
95
+ const lock = path.join(abs, "nomos.stable-ids.json");
96
+ if (deploy !== undefined) file = deploy;
97
+ else if (existsSync(lock)) file = lock;
98
+ else fail(`--against: no *.deploy.json or nomos.stable-ids.json under ${abs}`);
99
+ }
100
+ let parsed: unknown;
101
+ try {
102
+ parsed = JSON.parse(readFileSync(file, "utf8"));
103
+ } catch (e) {
104
+ fail(`--against: could not parse ${file}: ${(e as Error).message}`);
105
+ }
106
+ const p = parsed as Record<string, unknown>;
107
+ if (p.domains !== undefined && p.interface === undefined && p.packageUsda === undefined) {
108
+ return {
109
+ law: evolveLawFromLockfile(parsed as StableIdLockfile),
110
+ source: file,
111
+ note: `prior is a lockfile — aggregate/field identity is diffed, but directive payload changes cannot be (deploy.json carries the interface).`,
112
+ };
113
+ }
114
+ return { law: evolveLawFromDeployJson(parsed as never), source: file };
115
+ }
116
+
117
+ /** Find a `*.deploy.json` under a directory — directly, or one level down in `build/`. */
118
+ function findDeployJson(dir: string): string | undefined {
119
+ const scan = (d: string): string | undefined => {
120
+ if (!existsSync(d)) return undefined;
121
+ const hit = readdirSync(d).find((f) => f.endsWith(".deploy.json"));
122
+ return hit !== undefined ? path.join(d, hit) : undefined;
123
+ };
124
+ return scan(dir) ?? scan(path.join(dir, "build"));
125
+ }
126
+
127
+ /** Read the committed `nomos.stable-ids.json` lockfile beside the config (the CURRENT identity). */
128
+ function loadCwdLockfile(configArg: string): StableIdLockfile | undefined {
129
+ const cfgDir = path.dirname(path.resolve(process.cwd(), configArg));
130
+ const lockPath = path.join(cfgDir, "nomos.stable-ids.json");
131
+ if (!existsSync(lockPath)) return undefined;
132
+ try {
133
+ return JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLockfile;
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ }
138
+
67
139
  async function main(): Promise<void> {
68
140
  const argv = process.argv.slice(2);
69
- const configArg = argv.find((a) => !a.startsWith("--")) ?? "nomos.package.mjs";
141
+ // The positional config must SKIP flag VALUES (`check --against prior.json` has no
142
+ // positional — `prior.json` belongs to the flag, not the config slot).
143
+ const againstIdx = argv.findIndex((a) => a === "--against");
144
+ const positionals = argv.filter((a, i) => !a.startsWith("--") && i !== againstIdx + 1);
145
+ const configArg = positionals[0] ?? "nomos.package.mjs";
146
+ const againstArg =
147
+ againstIdx >= 0
148
+ ? argv[againstIdx + 1]
149
+ : argv.find((a) => a.startsWith("--against="))?.slice("--against=".length);
150
+
70
151
  const modules = await loadComposedModules(configArg);
71
- const findings = checkDomainModules(modules);
152
+ const findings: Finding[] = [...checkDomainModules(modules)];
153
+
154
+ // ── EVOLVE PREVIEW (--against): diff the current build against a prior law + pre-fire the gate. ──
155
+ let evolveDispositions: Record<string, unknown> | undefined;
156
+ if (againstIdx >= 0 || againstArg !== undefined) {
157
+ if (againstArg === undefined || againstArg.startsWith("--")) {
158
+ fail(`--against needs a path: --against <build/<name>.deploy.json | build dir | nomos.stable-ids.json>`);
159
+ }
160
+ const lock = loadCwdLockfile(configArg);
161
+ if (lock === undefined) {
162
+ console.log(
163
+ "githolon check: --against — no nomos.stable-ids.json in the config dir; the current law's stable ids " +
164
+ "fall back to NAMES (a rename would read as removal+add). Run `githolon compile` first for a sid-accurate preview.",
165
+ );
166
+ }
167
+ const prior = loadPriorEvolveLaw(againstArg);
168
+ if (prior.note !== undefined) console.log(`githolon check: --against note — ${prior.note}`);
169
+ const current = currentEvolveLaw(modules, lock);
170
+ const diff = evolveDiff(prior.law, current);
171
+ findings.push(...diff.findings);
172
+ if (Object.keys(diff.dispositions).length > 0) evolveDispositions = diff.dispositions;
173
+ console.log(`githolon check: evolve preview against ${prior.source}`);
174
+ }
72
175
 
73
176
  console.log(`githolon check: ${findings.length} finding(s)`);
74
177
  for (const line of formatFindings(findings)) {
@@ -77,6 +180,14 @@ async function main(): Promise<void> {
77
180
  }
78
181
  if (findings.length === 0) console.log(" all clear — no static twin flagged anything");
79
182
 
183
+ // The consolidated, pasteable dispositions the destructive evolves demand (per domain).
184
+ if (evolveDispositions !== undefined) {
185
+ console.log(
186
+ "\ngitholon check: to admit the destructive evolve, add `dispositions` to the domain's deploy JSON:",
187
+ );
188
+ console.log(JSON.stringify(evolveDispositions, null, 2));
189
+ }
190
+
80
191
  process.exit(hasRefusal(findings) ? 1 : 0);
81
192
  }
82
193
 
@@ -144,6 +144,15 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
144
144
  const scopes = readScopesFor(typeId);
145
145
  return scopes.length ? `, await _readKeys([${scopes.map((s) => dartStr(s)).join(", ")}])` : "";
146
146
  };
147
+ // The DISTINCT encryption scopes this domain defines (across encrypted reads + sealing directives) — the
148
+ // set the owner ceremony rotates. Empty ⇒ no `encryption` getter is emitted for this domain.
149
+ const domScopes: string[] = (() => {
150
+ if (encMap === null) return [];
151
+ const s = new Set<string>();
152
+ for (const r of Object.values(encMap.reads)) for (const f of Object.values(r)) s.add(f.scope);
153
+ for (const dir of Object.values(encMap.directives)) for (const f of Object.values(dir)) s.add(f.scope);
154
+ return [...s].sort();
155
+ })();
147
156
 
148
157
  const methods: string[] = [];
149
158
 
@@ -351,7 +360,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
351
360
  `// client file gives an app the whole typed surface.`,
352
361
  `export '${mod.name}.dart';`,
353
362
  ...(opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
354
- `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore" : ""};`,
363
+ `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore, NomosEncryption, NomosEpochResult, EncryptionMemberKeyMissing, EncryptionRosterMismatch" : ""};`,
355
364
  ``,
356
365
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
357
366
  `const String ${hashConst} = ${dartStr(opts.domainHash)};`,
@@ -402,6 +411,18 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
402
411
  ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor${encPresent ? ", this.keyStore" : ""}});`,
403
412
  ``,
404
413
  ...(encPresent ? dartEncClassMembers(encMap!) : []),
414
+ // THE OWNER CEREMONY (slice 2): grantAndWrap / revokeAndRotate / rotate — the operator-blind key-epoch
415
+ // handshake as one call. Bind `actor` (the owner) to author the fan-out. Only for a domain with a scope.
416
+ ...(domScopes.length > 0
417
+ ? [
418
+ ` /// OWNER CEREMONY (slice 2): \`grantAndWrap\` / \`revokeAndRotate\` / \`rotate\` — the operator-blind`,
419
+ ` /// key-epoch handshake as one call. Bind \`actor\` (the owner) to author the fan-out.`,
420
+ ` NomosEncryption get encryption => NomosEncryption(`,
421
+ ` bridge: bridge, domain: ${dartStr(domain)}, domainHash: domainHash, actor: actor,`,
422
+ ` scopes: const [${domScopes.map((s) => dartStr(s)).join(", ")}]);`,
423
+ ``,
424
+ ]
425
+ : []),
405
426
  ...(opts.interface !== undefined
406
427
  ? [
407
428
  ` /// This client's structural interface requirement (#72) as JSON — hand it to the`,