@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.
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/dart_types/pubspec.yaml
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
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
|