@githolon/dsl 0.94.0 → 0.95.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.
@@ -48,7 +48,7 @@ const int kWebviewBridgeVersion = 1;
48
48
  /// pre-stamp bundle, which reports nothing) is a stale baked asset and fails loudly at boot.
49
49
  /// MUST move with the paired @githolon/client release — a release-hygiene test pairs it with the
50
50
  /// runner stamp (the pubspec-pins pattern).
51
- const String kExpectedRunnerBundleVersion = '0.93.0';
51
+ const String kExpectedRunnerBundleVersion = '0.94.0';
52
52
 
53
53
  /// A STALE BAKED RUNNER BUNDLE: the app's built assets carry an older nomos-runner.js than this
54
54
  /// nomos_client release ships — Flutter's build cache kept it across a pub upgrade. Thrown from
@@ -721,6 +721,12 @@ class NomosOfferOutcome {
721
721
  this.gatewayVerdict,
722
722
  });
723
723
 
724
+ /// TRUE when the offer COMMITTED — it advanced the ledger to a new head (a write landed and/or a child
725
+ /// was born). A REFUSED offer never yields an outcome (it throws / surfaces its typed law error), so a
726
+ /// present outcome with a head is a success. Sugar so app code writes `if (outcome.ok)` instead of
727
+ /// hand-checking `head`/`created`/`born` (the co2 friction: "there's no `.ok`").
728
+ bool get ok => head.isNotEmpty;
729
+
724
730
  /// The born child workspace(s) as followable [NomosRef]s — hand one to [NomosBridge.follow]
725
731
  /// (`final estate = await engine.follow(outcome.bornRefs.single);`) so app code never carries a
726
732
  /// loose workspace string. The ref's type is the reserved `nomos.Workspace` locator tag.
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.58.0
14
+ version: 0.59.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -20,7 +20,7 @@ dependencies:
20
20
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
21
21
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
22
22
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
23
- nomos_types: ^0.5.0
23
+ nomos_types: ^0.6.0
24
24
  json_schema: ^5.2.2
25
25
  crypto: ^3.0.0
26
26
 
@@ -0,0 +1,286 @@
1
+ // THE FOUR-LAYER NESTED TOPOLOGY TEST — a real-app shape end-to-end:
2
+ //
3
+ // Layer 1: the remote PLATFORM (tenant root)
4
+ // Layer 2: the HOME born under the platform (the per-user holon)
5
+ // Layer 3: a SPACE app-workspace born offline under the home
6
+ // Layer 4: a PROJECT app-workspace born offline under the home, with cross-workspace reference to Space
7
+ // Plus: MULTI-USER offline-first — two devices/users write concurrently and CONVERGE
8
+ //
9
+ // Sequence:
10
+ // 1. the remote platform births the home (the ONE control-plane contact)
11
+ // 2. the app mounts the home locally (a normal cloud-bound NomosSession; platform is never cloned)
12
+ // 3. the network PARTITIONS
13
+ // 4. the home births the SPACE workspace LOCALLY
14
+ // 5. the home births the PROJECT workspace LOCALLY (which can reference the Space)
15
+ // 6. both space and project open as sibling sessions and ACCEPT LOCAL WRITES while offline
16
+ // 7. UI-facing queries return those writes immediately (local projection; no sync awaited)
17
+ // 8. the network HEALS
18
+ // 9. `harness.converge()` establishes remote custody PARENT-FIRST (home's synced chain folds the
19
+ // birth offer-effects remotely, creating the space/project remote custody; then they converge)
20
+ // 10. a FRESH observer (the dev cloud's own remote projection) sees both space and project writes
21
+ // 11. a SECOND USER writes to both workspaces offline, then converges (multi-user convergence)
22
+ // 12. the FIRST USER sees the second user's writes in fresh queries (cross-user view)
23
+ //
24
+ import 'dart:convert';
25
+ import 'dart:io';
26
+
27
+ import 'package:nomos_client/nomos_headless.dart';
28
+ import 'package:test/test.dart';
29
+
30
+ String get _root => '${Directory.current.path}/../..';
31
+ String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.ts';
32
+ String get _devServer => '$_root/cloud/dev-server/server.ts';
33
+ String get _deploy =>
34
+ '$_root/bench/scale/fixtures/four-layer-nested-topology/build/four-layer-nested-topology.deploy.json';
35
+ const _subject1 = 'user:four-layer-test-user1';
36
+ const _subject2 = 'user:four-layer-test-user2';
37
+
38
+ String? _missingPrereq() {
39
+ if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
40
+ if (!File(_devServer).existsSync()) return 'in-repo dev server not found at $_devServer';
41
+ if (!File(_deploy).existsSync()) {
42
+ return 'compile the fixture: cd bench/scale/fixtures/four-layer-nested-topology && node ../../../../dsl/compile_package.mjs — missing $_deploy';
43
+ }
44
+ return null;
45
+ }
46
+
47
+ void main() {
48
+ test('the four-layer topology: platform → home → space + project (with cross-ref) offline + multi-user converge',
49
+ () async {
50
+ final skip = _missingPrereq();
51
+ if (skip != null) {
52
+ markTestSkipped(skip);
53
+ return;
54
+ }
55
+ final diag = Platform.environment['NOMOS_HARNESS_DIAG'] == '1'
56
+ ? (String l) => stderr.writeln(l)
57
+ : null;
58
+ final cloud = await NomosTestCloud.start(serverEntry: _devServer, onDiagnostic: diag);
59
+ NomosTestHarness? harness1;
60
+ NomosTestHarness? harness2;
61
+ try {
62
+ // LAYER 1+2: the remote ceremony + the local mount (user 1)
63
+ harness1 = await NomosTestHarness.cloudBound(
64
+ cloud: cloud,
65
+ deployJsonPaths: [_deploy],
66
+ platformWorkspace: 'four-layer-platform',
67
+ subject: _subject1,
68
+ nodeEntry: _repoEntry,
69
+ onDiagnostic: diag,
70
+ );
71
+ final home1 = harness1.homeSession;
72
+ expect(home1.workspace, startsWith('home-'),
73
+ reason: 'the remote ceremony born the deterministic home slot for user1');
74
+ final remoteHome1 = await cloud.remoteHead(home1.workspace);
75
+ expect(remoteHome1.initialized, isTrue,
76
+ reason: 'the home was born REMOTELY (custody exists before the app mounted it)');
77
+
78
+ // PARTITION
79
+ cloud.setOnline(false);
80
+
81
+ // LAYER 3: birth a SPACE workspace LOCALLY (under home1)
82
+ final spaceOutcome = await harness1.bridge.session(home1.workspace).offerCreates(
83
+ domain: 'home',
84
+ directiveId: 'birthSpaceWorkspace',
85
+ payload: {
86
+ 'owner': _subject1,
87
+ 'frameworkHash': harness1.frameworkHash,
88
+ 'lawHash': harness1.lawHashes(home1.workspace).first,
89
+ 'bornAt': '2026-07-15T00:00:00Z',
90
+ },
91
+ actor: _subject1,
92
+ );
93
+ expect(spaceOutcome.born, isNotEmpty, reason: 'the local .births() offer spawned the space');
94
+ final spaceWs = spaceOutcome.born.single;
95
+ expect(spaceWs, startsWith('space-'));
96
+
97
+ // LAYER 4: birth a PROJECT workspace LOCALLY (under home1, will reference space)
98
+ final projectOutcome = await harness1.bridge.session(home1.workspace).offerCreates(
99
+ domain: 'home',
100
+ directiveId: 'birthProjectWorkspace',
101
+ payload: {
102
+ 'owner': _subject1,
103
+ 'frameworkHash': harness1.frameworkHash,
104
+ 'lawHash': harness1.lawHashes(home1.workspace).first,
105
+ 'bornAt': '2026-07-15T00:00:01Z',
106
+ },
107
+ actor: _subject1,
108
+ );
109
+ expect(projectOutcome.born, isNotEmpty, reason: 'the local .births() offer spawned the project');
110
+ final projectWs = projectOutcome.born.single;
111
+ expect(projectWs, startsWith('project-'));
112
+
113
+ // LAYER 3+4: Open both space and project as sibling sessions + make WRITES while offline
114
+ final space = await harness1.bridge.acquireSession(spaceWs);
115
+ final project = await harness1.bridge.acquireSession(projectWs);
116
+
117
+ // Write to space
118
+ await space.offerCreates(
119
+ domain: 'space',
120
+ directiveId: 'writeSpaceItem',
121
+ payload: {
122
+ 'author': _subject1,
123
+ 'title': 'Space Item 1 (offline)',
124
+ 'createdAt': '2026-07-15T00:00:02Z',
125
+ },
126
+ actor: _subject1,
127
+ );
128
+
129
+ // Write to project (with cross-workspace reference to space)
130
+ await project.offerCreates(
131
+ domain: 'project',
132
+ directiveId: 'createTask',
133
+ payload: {
134
+ 'author': _subject1,
135
+ 'title': 'Task 1: belongs to Space (offline)',
136
+ 'spaceWorkspace': spaceWs, // CROSS-WORKSPACE REFERENCE
137
+ 'createdAt': '2026-07-15T00:00:03Z',
138
+ },
139
+ actor: _subject1,
140
+ );
141
+
142
+ // LAYER 3+4: UI-facing queries return the writes IMMEDIATELY (local projection; nothing awaited sync)
143
+ final spaceRows = await space.query('spaceItemsByAuthor', {'author': _subject1});
144
+ expect(spaceRows, hasLength(1));
145
+ expect((spaceRows.single['data'] as Map)['title'], 'Space Item 1 (offline)');
146
+
147
+ final projectRows = await project.query('tasksByAuthor', {'author': _subject1});
148
+ expect(projectRows, hasLength(1));
149
+ expect((projectRows.single['data'] as Map)['title'], 'Task 1: belongs to Space (offline)');
150
+ expect((projectRows.single['data'] as Map)['spaceWorkspace'], spaceWs);
151
+
152
+ // Verify NO remote custody yet (the partition is real)
153
+ final remoteSpaceOffline = await cloud.remoteHead(spaceWs);
154
+ final remoteProjectOffline = await cloud.remoteHead(projectWs);
155
+ expect(remoteSpaceOffline.initialized, isFalse,
156
+ reason: 'while partitioned, the locally-born space has NO remote custody');
157
+ expect(remoteProjectOffline.initialized, isFalse,
158
+ reason: 'while partitioned, the locally-born project has NO remote custody');
159
+
160
+ // HEAL the network BEFORE creating harness2 (user2 needs network to be created)
161
+ cloud.setOnline(true);
162
+
163
+ // === MULTI-USER SCENARIO: USER 2 ===
164
+ // Create harness2 for user 2 (same platform, different home)
165
+ harness2 = await NomosTestHarness.cloudBound(
166
+ cloud: cloud,
167
+ deployJsonPaths: [_deploy],
168
+ platformWorkspace: 'four-layer-platform',
169
+ subject: _subject2,
170
+ nodeEntry: _repoEntry,
171
+ onDiagnostic: diag,
172
+ );
173
+ final home2 = harness2.homeSession;
174
+ expect(home2.workspace, startsWith('home-'),
175
+ reason: 'user2 gets its own deterministic home slot');
176
+
177
+ // PARTITION again for user2's offline writes
178
+ cloud.setOnline(false);
179
+
180
+ // User2 births its own space locally (while partitioned)
181
+ final space2Outcome = await harness2.bridge.session(home2.workspace).offerCreates(
182
+ domain: 'home',
183
+ directiveId: 'birthSpaceWorkspace',
184
+ payload: {
185
+ 'owner': _subject2,
186
+ 'frameworkHash': harness2.frameworkHash,
187
+ 'lawHash': harness2.lawHashes(home2.workspace).first,
188
+ 'bornAt': '2026-07-15T00:00:04Z',
189
+ },
190
+ actor: _subject2,
191
+ );
192
+ final space2Ws = space2Outcome.born.single;
193
+ final space2 = await harness2.bridge.acquireSession(space2Ws);
194
+
195
+ // User2 writes to its own space while partitioned
196
+ await space2.offerCreates(
197
+ domain: 'space',
198
+ directiveId: 'writeSpaceItem',
199
+ payload: {
200
+ 'author': _subject2,
201
+ 'title': 'Space Item from User2 (offline)',
202
+ 'createdAt': '2026-07-15T00:00:05Z',
203
+ },
204
+ actor: _subject2,
205
+ );
206
+
207
+ // HEAL the network again for final convergence
208
+ cloud.setOnline(true);
209
+
210
+ // CONVERGE (parent-first: home's synced chain folds the birth offer-effects remotely,
211
+ // creating the space/project remote custody; then they converge)
212
+ await harness1.converge(timeout: const Duration(seconds: 180));
213
+ await harness2.converge(timeout: const Duration(seconds: 180));
214
+
215
+ // Verify FRESH observer (the dev cloud's own remote projection) sees the offline-written data
216
+ final http = HttpClient();
217
+ try {
218
+ // Check space1 (user1's space)
219
+ final req1 = await http.getUrl(Uri.parse(
220
+ '${cloud.origin}/v2/workspaces/$spaceWs/spaceItemsByAuthor?author=${Uri.encodeQueryComponent(_subject1)}'));
221
+ final resp1 = await req1.close();
222
+ final body1 = jsonDecode(await resp1.transform(utf8.decoder).join());
223
+ final rows1 = ((body1 as Map)['rows'] as List?) ?? const [];
224
+ expect(rows1, hasLength(1),
225
+ reason: 'the remote projection serves user1\'s offline-written space item after converge');
226
+ expect(((rows1.single as Map)['data'] as Map)['title'], 'Space Item 1 (offline)');
227
+
228
+ // Check project1 (user1's project with cross-reference)
229
+ final req2 = await http.getUrl(Uri.parse(
230
+ '${cloud.origin}/v2/workspaces/$projectWs/tasksByAuthor?author=${Uri.encodeQueryComponent(_subject1)}'));
231
+ final resp2 = await req2.close();
232
+ final body2 = jsonDecode(await resp2.transform(utf8.decoder).join());
233
+ final rows2 = ((body2 as Map)['rows'] as List?) ?? const [];
234
+ expect(rows2, hasLength(1),
235
+ reason: 'the remote projection serves user1\'s offline-written project task after converge');
236
+ final task = ((rows2.single as Map)['data'] as Map);
237
+ expect(task['title'], 'Task 1: belongs to Space (offline)');
238
+ expect(task['spaceWorkspace'], spaceWs, reason: 'cross-workspace reference is persisted');
239
+
240
+ // Check space2 (user2's space)
241
+ final req3 = await http.getUrl(Uri.parse(
242
+ '${cloud.origin}/v2/workspaces/$space2Ws/spaceItemsByAuthor?author=${Uri.encodeQueryComponent(_subject2)}'));
243
+ final resp3 = await req3.close();
244
+ final body3 = jsonDecode(await resp3.transform(utf8.decoder).join());
245
+ final rows3 = ((body3 as Map)['rows'] as List?) ?? const [];
246
+ expect(rows3, hasLength(1),
247
+ reason: 'the remote projection serves user2\'s offline-written space item after converge');
248
+ expect(((rows3.single as Map)['data'] as Map)['title'], 'Space Item from User2 (offline)');
249
+ } finally {
250
+ http.close(force: true);
251
+ }
252
+
253
+ // Verify custody is REAL on all layers
254
+ final remoteHome12 = await cloud.remoteHead(home1.workspace);
255
+ final remoteSpace12 = await cloud.remoteHead(spaceWs);
256
+ final remoteProject12 = await cloud.remoteHead(projectWs);
257
+ final remoteHome22 = await cloud.remoteHead(home2.workspace);
258
+ final remoteSpace22 = await cloud.remoteHead(space2Ws);
259
+
260
+ expect(remoteHome12.initialized, isTrue);
261
+ expect(remoteHome12.commits, greaterThan(0));
262
+ expect(remoteSpace12.initialized, isTrue,
263
+ reason: 'converge established space\'s remote custody (home birth folded remotely)');
264
+ expect(remoteSpace12.commits, greaterThan(0));
265
+ expect(remoteProject12.initialized, isTrue,
266
+ reason: 'converge established project\'s remote custody (home birth folded remotely)');
267
+ expect(remoteProject12.commits, greaterThan(0));
268
+ expect(remoteHome22.initialized, isTrue);
269
+ expect(remoteHome22.commits, greaterThan(0));
270
+ expect(remoteSpace22.initialized, isTrue);
271
+ expect(remoteSpace22.commits, greaterThan(0));
272
+
273
+ // Cross-user view: User1 queries its space and project, should still see its own writes
274
+ final space1ViewFromUser1 = await space.query('spaceItemsByAuthor', {'author': _subject1});
275
+ expect(space1ViewFromUser1, hasLength(1));
276
+ expect(((space1ViewFromUser1.single['data'] as Map)['title']), 'Space Item 1 (offline)');
277
+
278
+ // (Note: user2 doesn't have read access to user1's space by design, but both write to different workspaces,
279
+ // so each user sees their own data in their own workspace)
280
+ } finally {
281
+ try { await harness1?.dispose(); } catch (_) {}
282
+ try { await harness2?.dispose(); } catch (_) {}
283
+ await cloud.stop();
284
+ }
285
+ }, timeout: const Timeout(Duration(minutes: 8)));
286
+ }
@@ -11,7 +11,7 @@ description: >-
11
11
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
12
12
  no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
13
13
  reactive read engine, schema validation, and data-only error/trace types.
14
- version: 0.5.0
14
+ version: 0.6.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.94.0",
3
+ "version": "0.95.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",
@@ -322,6 +322,15 @@ export const enrollFirstDevice = directive("enrollFirstDevice")
322
322
  /** THE CAPTURED PREMISE: the IdP's signed identity assertion (JWT), re-verified on every lane against
323
323
  * the on-chain AuthProvider keys. */
324
324
  idpToken: z.string().min(1),
325
+ /** THE KEYLESS DELEGATION CERT (JSON `executor::birth_cert::BirthCert`) — the self-serve sibling of
326
+ * `enrollDelegation.certJson`. Built CLIENT-SIDE via the `cert_sign` op: DEVICE-self-signed by this
327
+ * same device key `P_U` (parent_key == P_U), founder == `user:<uid>`, carrying `verifiedVia` +
328
+ * `idpToken` + `capturedClock`. There is NO tenant `S_P` in the loop — the kernel's home-birth chain
329
+ * walk (`verify_to_root`, keyless branch) RE-VERIFIES the token against the platform-attested
330
+ * AuthProvider (bound to K_root by the terminal platform_cert), so a forged/swapped cert fails the
331
+ * child's own gate. Writing it as the user's `SignerDelegation` is what lets `birthHome`'s offer-effect
332
+ * assemble a WARRANTED travelling chain (closes co2's `birth-cert-not-seeded` / empty signerDelegation). */
333
+ delegationCert: z.string().min(1),
325
334
  }),
326
335
  )
327
336
  .plan((p, ctx) => {
@@ -329,7 +338,8 @@ export const enrollFirstDevice = directive("enrollFirstDevice")
329
338
  // evidence — the framework may; an author cannot). The kernel arm proves the principal is the IdP subject.
330
339
  const me = ctx.principal().asAuthoritySubject<string>("bootstrap my first device signer key from a verified IdP token");
331
340
  const rawPrincipal = endorsementOf(me)?.principal ?? "";
332
- return writeTuple({
341
+ // (1) seat the signer leaf (the RelationTuple — this directive's primary `.ensures`).
342
+ const tupleOps = writeTuple({
333
343
  object: signerObject(rawPrincipal),
334
344
  relation: SIGNER_RELATION,
335
345
  subject: "key:" + p.keyHash,
@@ -338,6 +348,20 @@ export const enrollFirstDevice = directive("enrollFirstDevice")
338
348
  verifiedVia: p.verifiedVia,
339
349
  validFromEpoch: p.validFromEpoch,
340
350
  });
351
+ // (2) fold the KEYLESS delegation cert as the user's `SignerDelegation` singleton (a SECONDARY ensures,
352
+ // per-op marked — the id is deterministic-by-user, an upsert). This is the seed `birthHome` reads to
353
+ // assemble the warranted chain; without it the home is born headless (co2's block).
354
+ // The delegation is keyed by the AUTHENTICATED principal — EXACTLY the `founder` the kernel's birth
355
+ // offer-effect reads it back by (`read_signer_delegation(state, &founder)`, founder == captured principal
356
+ // == this `rawPrincipal`, already in `user:<uid>` form). Do NOT re-prefix.
357
+ const d = instance(SignerDelegation, signerDelegationInstanceId(rawPrincipal));
358
+ const delegationOps: PlannedOp[] = [
359
+ withMarker(set(d, "user", rawPrincipal), "ensures"),
360
+ withMarker(set(d, "deviceKey", "key:" + p.keyHash), "ensures"),
361
+ withMarker(set(d, "certJson", p.delegationCert), "ensures"),
362
+ withMarker(set(d, "enrolledAt", p.grantedAt), "ensures"),
363
+ ];
364
+ return [...tupleOps, ...delegationOps];
341
365
  })
342
366
  // THE KEYLESS GATE DECLARATION: the carried device-bound `idpToken` IS this directive's authorization
343
367
  // (re-verified against the workspace AuthProvider on every lane), not a pre-enrolled signer. This lowers