@githolon/dsl 0.74.0 → 0.75.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 +28 -0
- package/dart/lib/nomos_headless.dart +8 -0
- package/dart/lib/src/webview_bridge.dart +149 -84
- package/dart/pubspec.yaml +1 -1
- package/dart/test/acquire_session_credentials_test.dart +132 -0
- package/dart/test/cluster_births_test.dart +0 -1
- package/dart/test/dlq_session_surface_test.dart +203 -0
- package/dart/test/event_plane_seam_test.dart +188 -0
- package/dart/test/first_app_gauntlet_test.dart +301 -0
- package/dart/test/sealing_client_test.dart +179 -0
- package/dart/test/test_harness_test.dart +0 -1
- package/dart/test/watch_surfaces_test.dart +240 -0
- package/package.json +1 -1
- package/src/aggregate.ts +75 -1
- package/src/codegen_dart.ts +101 -0
- package/src/codegen_dart_client.ts +129 -18
- package/src/codegen_ts.ts +44 -0
- package/src/compile_package_main.ts +26 -1
- package/src/drivers.ts +9 -1
- package/src/fields.ts +23 -0
- package/src/manifest.ts +11 -0
- package/src/usd.ts +24 -0
- package/src/usd_layers.ts +32 -0
- package/src/wire.ts +5 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// THE REACTIVE PROMISED SURFACES (task #86 gaps 4/5/6): one cluster, three watch lanes the
|
|
2
|
+
// gauntlet build found uncovered from Dart —
|
|
3
|
+
// • watchSpatial — the R*Tree bbox probe as a LIVE stream (typed, via the generated client);
|
|
4
|
+
// • watchCompatibility / onLawChanged — law-change reactivity: subscribe under law v1, install the
|
|
5
|
+
// evolved v2 IN-SESSION (the real `nomos/installDomain` lane), assert both surfaces fire with the
|
|
6
|
+
// right shapes and the evolved directive works immediately;
|
|
7
|
+
// • watchActivity — declared `.activity()` directives stream as activity rows.
|
|
8
|
+
// House style: numbered legs, phase-timeout-bounded, loud failures, graceful skip.
|
|
9
|
+
@TestOn('vm')
|
|
10
|
+
library;
|
|
11
|
+
|
|
12
|
+
import 'dart:async';
|
|
13
|
+
import 'dart:convert';
|
|
14
|
+
import 'dart:io';
|
|
15
|
+
|
|
16
|
+
import 'package:crypto/crypto.dart' show sha256;
|
|
17
|
+
import 'package:nomos_client/nomos_headless.dart';
|
|
18
|
+
import 'package:test/test.dart';
|
|
19
|
+
|
|
20
|
+
import '../../../bench/scale/fixtures/co2-acceptance/build/dart/src/generated/estate.client.dart';
|
|
21
|
+
|
|
22
|
+
String get _root => '${Directory.current.path}/../..';
|
|
23
|
+
String get _repoEntry => '$_root/cloud/web-client/runner/node-entry.mjs';
|
|
24
|
+
String get _geoDeploy =>
|
|
25
|
+
'$_root/bench/scale/fixtures/co2-acceptance/build/co2-acceptance.deploy.json';
|
|
26
|
+
String get _actsDeploy =>
|
|
27
|
+
'$_root/bench/scale/fixtures/activity-feed/build/activity_repro.deploy.json';
|
|
28
|
+
String get _evoV1Deploy => '$_root/bench/scale/fixtures/evolution/v1/build/evo.deploy.json';
|
|
29
|
+
String get _evoV2Deploy => '$_root/bench/scale/fixtures/evolution/v2/build/evo.deploy.json';
|
|
30
|
+
String get _evoV2Interface =>
|
|
31
|
+
'$_root/bench/scale/fixtures/evolution/v2/build/evo.interface.json';
|
|
32
|
+
String get _runtimeDir =>
|
|
33
|
+
Platform.environment['NOMOS_DART_RUNTIME'] ??
|
|
34
|
+
'${Platform.environment['HOME'] ?? ''}/.holon/runtime';
|
|
35
|
+
|
|
36
|
+
String? _missingPrereq() {
|
|
37
|
+
if (!File(_repoEntry).existsSync()) return 'in-repo runner not found at $_repoEntry';
|
|
38
|
+
for (final e in {
|
|
39
|
+
_geoDeploy: 'bench/scale/fixtures/co2-acceptance',
|
|
40
|
+
_actsDeploy: 'bench/scale/fixtures/activity-feed',
|
|
41
|
+
_evoV1Deploy: 'bench/scale/fixtures/evolution/v1',
|
|
42
|
+
_evoV2Deploy: 'bench/scale/fixtures/evolution/v2',
|
|
43
|
+
}.entries) {
|
|
44
|
+
if (!File(e.key).existsSync()) {
|
|
45
|
+
return 'missing fixture ${e.key} — compile it: (cd ${e.value} && node ../../../../dsl/compile_package.mjs)';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!File('$_runtimeDir/holon.wasm').existsSync() ||
|
|
49
|
+
!File('$_runtimeDir/packages.json').existsSync()) {
|
|
50
|
+
return 'warm ~/.holon/runtime first (run any `githolon` command once)';
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
Future<T> _leg<T>(String name, Future<T> Function() body) async {
|
|
56
|
+
final sw = Stopwatch()..start();
|
|
57
|
+
// ignore: avoid_print
|
|
58
|
+
print('▶ WATCH $name …');
|
|
59
|
+
try {
|
|
60
|
+
final r = await body();
|
|
61
|
+
// ignore: avoid_print
|
|
62
|
+
print('✔ WATCH $name (${sw.elapsedMilliseconds}ms)');
|
|
63
|
+
return r;
|
|
64
|
+
} catch (e, st) {
|
|
65
|
+
throw TestFailure('WATCH $name FAILED after ${sw.elapsedMilliseconds}ms:\n$e\n$st');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Await the first stream element satisfying [where], bounded — a watch that never emits is a
|
|
70
|
+
/// loud, named failure, not a hang.
|
|
71
|
+
Future<T> _firstWhere<T>(Stream<T> s, bool Function(T) where, String what,
|
|
72
|
+
[Duration limit = const Duration(seconds: 30)]) =>
|
|
73
|
+
s.firstWhere(where).timeout(limit,
|
|
74
|
+
onTimeout: () => throw StateError('$what never emitted within ${limit.inSeconds}s'));
|
|
75
|
+
|
|
76
|
+
void main() {
|
|
77
|
+
test('reactive surfaces: watchSpatial → watchCompatibility/onLawChanged → watchActivity',
|
|
78
|
+
() async {
|
|
79
|
+
final missing = _missingPrereq();
|
|
80
|
+
if (missing != null) {
|
|
81
|
+
markTestSkipped(missing);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
final harness = await NomosTestHarness.cluster(
|
|
85
|
+
workspaces: {
|
|
86
|
+
'geo': [_geoDeploy],
|
|
87
|
+
'acts': [_actsDeploy],
|
|
88
|
+
'evo': [_evoV1Deploy],
|
|
89
|
+
},
|
|
90
|
+
nodeEntry: _repoEntry,
|
|
91
|
+
runtimeDir: _runtimeDir,
|
|
92
|
+
phaseTimeout: const Duration(seconds: 120),
|
|
93
|
+
);
|
|
94
|
+
try {
|
|
95
|
+
// ── LEG 1: watchSpatial — the typed reactive bbox probe. Write a point, the bbox watch
|
|
96
|
+
// emits it; write a second point inside the box, the watch re-emits with both. ──
|
|
97
|
+
await _leg('leg 1: watchSpatial (typed sitesByBounds bbox stream)', () async {
|
|
98
|
+
final estate = EstateClient(harness.session('geo'), actor: 'user:ada');
|
|
99
|
+
final bbox = NomosBbox(minX: -3, minY: 53, maxX: -2, maxY: 54);
|
|
100
|
+
Future<void> place(String name, List<double> coords) =>
|
|
101
|
+
estate.registerSite(RegisterSitePayload(
|
|
102
|
+
name: name,
|
|
103
|
+
location: SiteLocation(
|
|
104
|
+
point: GeoPoint(coordinates: coords),
|
|
105
|
+
source: SiteLocationSource.addressSearch,
|
|
106
|
+
),
|
|
107
|
+
registeredAt: DateTime.now().toUtc().toIso8601String(),
|
|
108
|
+
));
|
|
109
|
+
await place('inside-1', [-2.24, 53.48]);
|
|
110
|
+
await place('way-outside', [10.0, 10.0]);
|
|
111
|
+
final stream = estate.watchSitesByBounds(bbox: bbox).asBroadcastStream();
|
|
112
|
+
final first = await _firstWhere(
|
|
113
|
+
stream, (rows) => rows.isNotEmpty, 'watchSpatial first snapshot');
|
|
114
|
+
expect(first.map((r) => r.name), ['inside-1'],
|
|
115
|
+
reason: 'the first snapshot holds exactly the in-bbox site (probe, not full scan)');
|
|
116
|
+
final grown = _firstWhere(stream, (rows) => rows.length >= 2, 'watchSpatial re-emission');
|
|
117
|
+
await place('inside-2', [-2.5, 53.6]);
|
|
118
|
+
expect((await grown).map((r) => r.name).toSet(), {'inside-1', 'inside-2'},
|
|
119
|
+
reason: 'a new in-bbox write re-emits through the live spatial watch');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── LEG 2: watchCompatibility + onLawChanged — subscribe under v1, install the evolved v2
|
|
123
|
+
// (rename + added field + added directive) through the REAL nomos/installDomain lane,
|
|
124
|
+
// assert both reactive surfaces fire and the added directive works at once. ──
|
|
125
|
+
await _leg('leg 2: watchCompatibility/onLawChanged across a live law evolution', () async {
|
|
126
|
+
final evo = harness.session('evo');
|
|
127
|
+
final v1Hash = harness.lawHashes('evo').first;
|
|
128
|
+
final v2Deploy =
|
|
129
|
+
(jsonDecode(File(_evoV2Deploy).readAsStringSync()) as Map).cast<String, Object?>();
|
|
130
|
+
final v2Usda = v2Deploy['packageUsda'].toString();
|
|
131
|
+
final v2Hash = sha256.convert(utf8.encode(v2Usda)).toString();
|
|
132
|
+
expect(v2Hash, isNot(v1Hash), reason: 'v2 is a genuinely different law');
|
|
133
|
+
final v2Requirement =
|
|
134
|
+
(jsonDecode(File(_evoV2Interface).readAsStringSync()) as Map).cast<String, Object?>();
|
|
135
|
+
|
|
136
|
+
// Seed a v1 row (written under the OLD field name — the rename's era-1 side).
|
|
137
|
+
await evo.offerOutcome(
|
|
138
|
+
domain: 'evo',
|
|
139
|
+
directiveId: 'registerSite',
|
|
140
|
+
payload: {'name': 'era-1-site', 'geoPos': '51.5,-0.1'},
|
|
141
|
+
domainHash: v1Hash,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Arm the compat superpower AS the v2 client would: verdict against the current (v1) law.
|
|
145
|
+
final before = await evo.checkCompat({'lawHash': v2Hash}, v2Requirement);
|
|
146
|
+
expect(before, isNotNull);
|
|
147
|
+
expect(before!.matches, isFalse, reason: 'the workspace still runs v1');
|
|
148
|
+
expect(before.has('setMood'), isFalse,
|
|
149
|
+
reason: 'the v2-only directive is dark before the law lands');
|
|
150
|
+
|
|
151
|
+
// Subscribe BOTH reactive surfaces before the deploy.
|
|
152
|
+
final compatStream = evo.watchCompatibility(v2Requirement).asBroadcastStream();
|
|
153
|
+
final lawChanged = evo.onLawChanged.asBroadcastStream();
|
|
154
|
+
final firstVerdict = await _firstWhere(
|
|
155
|
+
compatStream, (v) => v.containsKey('ok'), 'watchCompatibility first snapshot');
|
|
156
|
+
expect(firstVerdict['ok'], isFalse,
|
|
157
|
+
reason: 'v2 requirement vs the v1 offer must not verify (setMood missing)');
|
|
158
|
+
expect(firstVerdict['offerKnown'], isTrue);
|
|
159
|
+
|
|
160
|
+
final lawEvent = _firstWhere(
|
|
161
|
+
lawChanged, (c) => c.has('setMood'), 'onLawChanged after the v2 install',
|
|
162
|
+
const Duration(seconds: 60));
|
|
163
|
+
final okVerdict = _firstWhere(
|
|
164
|
+
compatStream, (v) => v['ok'] == true, 'watchCompatibility after the v2 install',
|
|
165
|
+
const Duration(seconds: 60));
|
|
166
|
+
|
|
167
|
+
// THE EVOLVE: install v2 in-session (the same install lane the deploy endpoint drives).
|
|
168
|
+
await evo.offerOutcome(
|
|
169
|
+
domain: 'nomos',
|
|
170
|
+
directiveId: 'installDomain',
|
|
171
|
+
payload: {
|
|
172
|
+
'domainHash': v2Hash,
|
|
173
|
+
'packageUsda': v2Usda,
|
|
174
|
+
'installedBy': 'watch-surfaces-test',
|
|
175
|
+
'dependencies': <Object?>[],
|
|
176
|
+
'finalizers': <Object?>[],
|
|
177
|
+
},
|
|
178
|
+
domainHash: harness.frameworkHash,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
final changed = await lawEvent;
|
|
182
|
+
expect(changed.has('setMood'), isTrue, reason: 'the dark member lit up');
|
|
183
|
+
expect(changed.verdict, 'compatible');
|
|
184
|
+
expect((await okVerdict)['ok'], isTrue);
|
|
185
|
+
// The evolved law is live: the ADDED directive works on the era-1 row immediately,
|
|
186
|
+
// and the era-1 row reads back under the NEW label lineage.
|
|
187
|
+
final rows = await evo.query('sitesByName', {'name': 'era-1-site'});
|
|
188
|
+
expect(rows, hasLength(1));
|
|
189
|
+
final siteId = (rows.single['id'] ?? '').toString();
|
|
190
|
+
await evo.offerOutcome(
|
|
191
|
+
domain: 'evo',
|
|
192
|
+
directiveId: 'setMood',
|
|
193
|
+
payload: {'siteId': siteId, 'mood': 'thriving'},
|
|
194
|
+
domainHash: v2Hash,
|
|
195
|
+
);
|
|
196
|
+
final after = await evo.queryById(siteId);
|
|
197
|
+
expect(((after.single['data'] as Map)['mood']), 'thriving');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── LEG 3: watchActivity — `.activity()`-declared directives stream as activity rows. ──
|
|
201
|
+
await _leg('leg 3: watchActivity streams declared activity rows', () async {
|
|
202
|
+
final acts = harness.session('acts');
|
|
203
|
+
final actsHash = harness.lawHashes('acts').first;
|
|
204
|
+
final stream = acts.watchActivity().asBroadcastStream();
|
|
205
|
+
final rowsWithSite = _firstWhere(
|
|
206
|
+
stream,
|
|
207
|
+
(rows) => rows
|
|
208
|
+
.whereType<Map>()
|
|
209
|
+
.any((r) => r['directiveId'] == 'createSite' && r['domain'] == 'estate'),
|
|
210
|
+
'watchActivity emission carrying the createSite row');
|
|
211
|
+
await acts.offerOutcome(
|
|
212
|
+
domain: 'estate',
|
|
213
|
+
directiveId: 'createSite',
|
|
214
|
+
payload: {
|
|
215
|
+
'name': 'Boiler House',
|
|
216
|
+
'createdAt': DateTime.now().toUtc().toIso8601String()
|
|
217
|
+
},
|
|
218
|
+
actor: 'user:ada',
|
|
219
|
+
domainHash: actsHash,
|
|
220
|
+
);
|
|
221
|
+
final rows = await rowsWithSite;
|
|
222
|
+
final row = rows
|
|
223
|
+
.whereType<Map>()
|
|
224
|
+
.firstWhere((r) => r['directiveId'] == 'createSite')
|
|
225
|
+
.cast<String, Object?>();
|
|
226
|
+
expect(row['domain'], 'estate');
|
|
227
|
+
expect(row['intentId'], isNotNull);
|
|
228
|
+
expect(row['occurredAt'], isNotNull);
|
|
229
|
+
expect(((row['payload'] as Map?) ?? const {})['name'], 'Boiler House',
|
|
230
|
+
reason: 'the activity row carries the DECODED directive payload');
|
|
231
|
+
// The pull surface agrees with the stream (activityLog is the same read).
|
|
232
|
+
final log = await acts.activityLog();
|
|
233
|
+
expect(log.any((r) => r['directiveId'] == 'createSite'), isTrue);
|
|
234
|
+
});
|
|
235
|
+
} finally {
|
|
236
|
+
await harness.dispose().timeout(const Duration(seconds: 30),
|
|
237
|
+
onTimeout: () => throw StateError('harness disposal hung'));
|
|
238
|
+
}
|
|
239
|
+
}, timeout: const Timeout(Duration(minutes: 8)));
|
|
240
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.75.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/aggregate.ts
CHANGED
|
@@ -231,6 +231,17 @@ export interface AggregateHandle<
|
|
|
231
231
|
* aggregate is `visibility: requires(cap)` (the scope) and its driver is Lww-class.
|
|
232
232
|
*/
|
|
233
233
|
readonly encryptedFields?: string[];
|
|
234
|
+
/**
|
|
235
|
+
* ATTRIBUTED READS (architecture/field_provenance.md) — the STORED field names whose
|
|
236
|
+
* folded values keep AUTHOR PROVENANCE (`.attributed()` per field, or the aggregate
|
|
237
|
+
* option `{ attributed: true }` covering every eligible Lww-class scalar), SORTED.
|
|
238
|
+
* PRESENT only when ≥1 field is attributed; OMITTED otherwise (an unattributed
|
|
239
|
+
* aggregate is byte-identical, and the `nomosAttributedGate` era key is likewise
|
|
240
|
+
* omitted). The wire schema carries each such field's driver WRAPPED as
|
|
241
|
+
* `{"Attributed": <inner>}` — a LAW change (the #58 evolve gate applies); the value
|
|
242
|
+
* semantics are the inner driver's exactly.
|
|
243
|
+
*/
|
|
244
|
+
readonly attributedFields?: string[];
|
|
234
245
|
/**
|
|
235
246
|
* THE `.meta()` FIELDS (secure-by-default) — the STORED field names marked `.meta()` (plaintext
|
|
236
247
|
* metadata, the indexable opt-out on an owned aggregate), SORTED. PRESENT only when ≥1; OMITTED
|
|
@@ -272,7 +283,7 @@ const DART_TYPE_NAME_RE = /^[A-Z][A-Za-z0-9_]*$/;
|
|
|
272
283
|
export function aggregate<const Id extends string, F extends Record<string, Field>>(
|
|
273
284
|
id: Id,
|
|
274
285
|
fields: F,
|
|
275
|
-
opts?: { invariant?: AggregateInvariantFn; invariantDeclassify?: string; cap?: number; visibility?: ReadVisibility; global?: boolean },
|
|
286
|
+
opts?: { invariant?: AggregateInvariantFn; invariantDeclassify?: string; cap?: number; visibility?: ReadVisibility; global?: boolean; attributed?: boolean },
|
|
276
287
|
): AggregateHandle<Id, F> & AudienceMethods<Id, F> {
|
|
277
288
|
const { stored, hasMany } = partitionFields(fields);
|
|
278
289
|
// FOREIGN-REF SIBLING CHECK (pain point #4): a `t.foreignRef({ workspace, target })` field names a
|
|
@@ -345,6 +356,68 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
345
356
|
}
|
|
346
357
|
}
|
|
347
358
|
|
|
359
|
+
// ATTRIBUTED READS (architecture/field_provenance.md): `.attributed()` per field, or the
|
|
360
|
+
// aggregate-level `{ attributed: true }` covering every ELIGIBLE field. Eligibility =
|
|
361
|
+
// an Lww-class SCALAR (Lww / LwwCausal / LwwBy — the wrapper's value semantics are the
|
|
362
|
+
// inner driver's, and "who wrote the current value" only means something for a
|
|
363
|
+
// wholesale-replaced scalar). A structural CRDT (set/map/counter/position/list) has no
|
|
364
|
+
// single last author; a `Surfaced` field ALREADY carries per-observation authors (the
|
|
365
|
+
// retention rung above this) — attributing it is refused as redundant. Per-field
|
|
366
|
+
// `.attributed()` on an ineligible field THROWS (you asked for something impossible);
|
|
367
|
+
// the aggregate-level flag SKIPS ineligible fields silently (it means "everything that
|
|
368
|
+
// can be attributed"). Each attributed field's wire driver is wrapped
|
|
369
|
+
// `{"Attributed": <inner>}` — the law-era mechanism (old kernels refuse it cleanly;
|
|
370
|
+
// unattributed fields/domains stay byte-identical).
|
|
371
|
+
const LWW_CLASS_KINDS = new Set(["Lww", "LwwCausal", "LwwBy"]);
|
|
372
|
+
const attributableDriver = (f: Field) => LWW_CLASS_KINDS.has((f.driver as { kind?: string }).kind ?? "");
|
|
373
|
+
const attributableKind = (f: Field) =>
|
|
374
|
+
f.kind !== "set" && f.kind !== "map" && f.kind !== "counter" && f.kind !== "position";
|
|
375
|
+
for (const [name, f] of Object.entries(stored)) {
|
|
376
|
+
const field = f as Field;
|
|
377
|
+
if (field.isAttributed !== true) continue;
|
|
378
|
+
if ((field.driver as { kind?: string }).kind === "Surfaced") {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`aggregate '${id}': field '${name}' is .attributed() but its driver is Surfaced(...) — a Surfaced field already retains per-observation authors (attributed → surfaced is a ladder, not a stack); drop .attributed()`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (!attributableDriver(field) || !attributableKind(field)) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`aggregate '${id}': field '${name}' is .attributed() but attribution needs an Lww-class SCALAR (Lww/LwwCausal/LwwBy) — a '${field.kind}' field under '${(field.driver as { kind?: string }).kind}' has no single last author`,
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const attributedFields = Object.entries(stored)
|
|
390
|
+
.filter(([, f]) => {
|
|
391
|
+
const field = f as Field;
|
|
392
|
+
if (field.isAttributed === true) return true;
|
|
393
|
+
return (
|
|
394
|
+
opts?.attributed === true &&
|
|
395
|
+
attributableDriver(field) &&
|
|
396
|
+
attributableKind(field) &&
|
|
397
|
+
(field.driver as { kind?: string }).kind !== "Surfaced"
|
|
398
|
+
);
|
|
399
|
+
})
|
|
400
|
+
.map(([name]) => name)
|
|
401
|
+
.sort();
|
|
402
|
+
for (const name of attributedFields) {
|
|
403
|
+
const f = stored[name] as Field;
|
|
404
|
+
// Clone by PROPERTY DESCRIPTORS (not spread): a lazy `t.ref(() => Agg)` field
|
|
405
|
+
// exposes `refAggregateId` as a memoized getter that must NOT be forced at
|
|
406
|
+
// aggregate() time (module consts may not have initialized yet); a spread would
|
|
407
|
+
// evaluate it. The clone keeps every descriptor and swaps only the driver.
|
|
408
|
+
const clone = Object.defineProperties(
|
|
409
|
+
{},
|
|
410
|
+
Object.getOwnPropertyDescriptors(f),
|
|
411
|
+
) as Field;
|
|
412
|
+
Object.defineProperty(clone, "driver", {
|
|
413
|
+
value: { kind: "Attributed", inner: f.driver },
|
|
414
|
+
enumerable: true,
|
|
415
|
+
configurable: true,
|
|
416
|
+
writable: false,
|
|
417
|
+
});
|
|
418
|
+
(stored as Record<string, Field>)[name] = clone;
|
|
419
|
+
}
|
|
420
|
+
|
|
348
421
|
// The `.meta()` fields (plaintext-metadata opt-out on owned aggregates) — recorded for the compiler.
|
|
349
422
|
const metaFields = Object.entries(stored)
|
|
350
423
|
.filter(([, f]) => (f as Field).isMeta === true)
|
|
@@ -399,6 +472,7 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
399
472
|
...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
|
|
400
473
|
...(opts?.global === true ? { global: true } : {}),
|
|
401
474
|
...(encryptedFields.length > 0 ? { encryptedFields } : {}),
|
|
475
|
+
...(attributedFields.length > 0 ? { attributedFields } : {}),
|
|
402
476
|
...(metaFields.length > 0 ? { metaFields } : {}),
|
|
403
477
|
};
|
|
404
478
|
|
package/src/codegen_dart.ts
CHANGED
|
@@ -1249,6 +1249,10 @@ function emitAggregate(
|
|
|
1249
1249
|
refIdClassForAggregate,
|
|
1250
1250
|
ownIdFields,
|
|
1251
1251
|
);
|
|
1252
|
+
// ATTRIBUTED READS (field_provenance.md): the `.attributed()` field names — drives the
|
|
1253
|
+
// `$provenance` map + per-field `<field>Provenance` getters + `lastAuthors`. Empty for
|
|
1254
|
+
// an unattributed aggregate ⇒ the generated class is byte-identical to before.
|
|
1255
|
+
const attributedFields = (agg as { attributedFields?: readonly string[] }).attributedFields ?? [];
|
|
1252
1256
|
|
|
1253
1257
|
// GAP 2 — the kernel aggregate id. The projection strips the reserved `__id`/
|
|
1254
1258
|
// `__type` from each row's flat `data`; the row-level `id` (== the kernel aggregate
|
|
@@ -1271,6 +1275,15 @@ function emitAggregate(
|
|
|
1271
1275
|
` /// [NomosRef] (\`nomos://<workspace>/<type>/<id>\`) — composed at decode from the session workspace +`,
|
|
1272
1276
|
` /// the row's type/id. Workspace is '' when decoded off the bare bridge (the client accessors fill it).`,
|
|
1273
1277
|
` final NomosRef ref;`,
|
|
1278
|
+
...(attributedFields.length > 0
|
|
1279
|
+
? [
|
|
1280
|
+
` /// ATTRIBUTED READS (field_provenance.md): per-field author provenance for the`,
|
|
1281
|
+
` /// \`.attributed()\` fields, decoded from the row's reserved \`__prov\` map.`,
|
|
1282
|
+
` /// Metadata beside the values — excluded from ==/hashCode. Prefer the typed`,
|
|
1283
|
+
` /// \`<field>Provenance\` getters / [lastAuthors] below.`,
|
|
1284
|
+
` final Map<String, FieldProvenance> \$provenance;`,
|
|
1285
|
+
]
|
|
1286
|
+
: []),
|
|
1274
1287
|
...specs.flatMap((s) => {
|
|
1275
1288
|
const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
|
|
1276
1289
|
// SURFACED MERGE CONFLICTS: the field reads as the plain scalar, or [SurfacedPending]
|
|
@@ -1291,6 +1304,7 @@ function emitAggregate(
|
|
|
1291
1304
|
const ctorParams = [
|
|
1292
1305
|
` this.aggregateId = const ${idType}.fromRow(''),`,
|
|
1293
1306
|
` this.ref = const NomosRef(workspace: '', type: '${agg.id}', id: ''),`,
|
|
1307
|
+
...(attributedFields.length > 0 ? [` this.\$provenance = const {},`] : []),
|
|
1294
1308
|
...specs.map(
|
|
1295
1309
|
// A required scalar stays `required`; an optional scalar OR a collection is not
|
|
1296
1310
|
// `required` (an absent optional scalar is null; an absent collection is empty).
|
|
@@ -1313,6 +1327,9 @@ function emitAggregate(
|
|
|
1313
1327
|
const parseLines = [
|
|
1314
1328
|
` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
|
|
1315
1329
|
` ref: NomosRef(workspace: workspace, type: type.isEmpty ? '${agg.id}' : type, id: id),`,
|
|
1330
|
+
...(attributedFields.length > 0
|
|
1331
|
+
? [` \$provenance: FieldProvenance.mapFromJson(data['__prov']),`]
|
|
1332
|
+
: []),
|
|
1316
1333
|
...specs.map((s) =>
|
|
1317
1334
|
s.surfaced === true
|
|
1318
1335
|
? ` ${s.name}: SurfacedValue.decode<${s.dartType}>(data, '${s.wireName}', ${slotDecodeCallback(s, "aggregate")}),`
|
|
@@ -1376,6 +1393,25 @@ function emitAggregate(
|
|
|
1376
1393
|
` );`,
|
|
1377
1394
|
` }`,
|
|
1378
1395
|
``,
|
|
1396
|
+
...(attributedFields.length > 0
|
|
1397
|
+
? [
|
|
1398
|
+
[
|
|
1399
|
+
...attributedFields.flatMap((f) => [
|
|
1400
|
+
` /// Author provenance of [${f}] (who wrote the current value + whether a`,
|
|
1401
|
+
` /// concurrent competing write was auto-merged away). Null when the field`,
|
|
1402
|
+
` /// has no folded provenance yet (pre-attribution rows).`,
|
|
1403
|
+
` FieldProvenance? get ${f}Provenance => \$provenance['${f}'];`,
|
|
1404
|
+
]),
|
|
1405
|
+
` /// The last verified author per attributed field (fields with an unknown`,
|
|
1406
|
+
` /// author are omitted) — the "who last touched this row" convenience.`,
|
|
1407
|
+
` Map<String, String> get lastAuthors => {`,
|
|
1408
|
+
` for (final e in \$provenance.entries)`,
|
|
1409
|
+
` if (e.value.author != null) e.key: e.value.author!,`,
|
|
1410
|
+
` };`,
|
|
1411
|
+
].join("\n"),
|
|
1412
|
+
``,
|
|
1413
|
+
]
|
|
1414
|
+
: []),
|
|
1379
1415
|
` @override`,
|
|
1380
1416
|
` bool operator ==(Object other) =>`,
|
|
1381
1417
|
` other is ${className} &&`,
|
|
@@ -2241,6 +2277,64 @@ export function generateDartActivityRegistry(entries: readonly PackageActivityEn
|
|
|
2241
2277
|
|
|
2242
2278
|
/** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
|
|
2243
2279
|
* shared [emitEvidenceRefClass] once. */
|
|
2280
|
+
/** Does any aggregate in this module declare `.attributed()` fields (field_provenance.md)?
|
|
2281
|
+
* Gates the one-per-file [FieldProvenance] class + the per-aggregate provenance surface —
|
|
2282
|
+
* a provenance-free module's generated Dart is byte-identical to before the feature. */
|
|
2283
|
+
function moduleUsesAttribution(aggregates: readonly AggregateHandle[]): boolean {
|
|
2284
|
+
return aggregates.some(
|
|
2285
|
+
(a) => ((a as { attributedFields?: readonly string[] }).attributedFields ?? []).length > 0,
|
|
2286
|
+
);
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
/** The shared attributed-reads provenance class (architecture/field_provenance.md) —
|
|
2290
|
+
* emitted ONCE per generated file when any aggregate declares `.attributed()` fields
|
|
2291
|
+
* (the EvidenceRef discipline). A typed read of the projection row's reserved
|
|
2292
|
+
* `__prov` map entry: the verified author of the field's CURRENT value + whether an
|
|
2293
|
+
* auto-merge discarded a concurrent competing write since it landed. */
|
|
2294
|
+
function emitFieldProvenanceClass(): string {
|
|
2295
|
+
return [
|
|
2296
|
+
`/// Per-field AUTHOR PROVENANCE (attributed reads — field_provenance.md). Read off`,
|
|
2297
|
+
`/// an aggregate's \`<field>Provenance\` getter (generated for \`.attributed()\``,
|
|
2298
|
+
`/// fields only): who wrote the field's CURRENT value, and whether an auto-merge`,
|
|
2299
|
+
`/// discarded a concurrent competing write since it landed (\`wasContested\` —`,
|
|
2300
|
+
`/// escalate the field to Surfaced(...) law if you need the discarded values).`,
|
|
2301
|
+
`class FieldProvenance {`,
|
|
2302
|
+
` /// The verified authoring principal of the current value; null when unknown`,
|
|
2303
|
+
` /// (an anonymous or pre-attribution-era write) — never a fabrication.`,
|
|
2304
|
+
` final String? author;`,
|
|
2305
|
+
` /// Whether an auto-merge discarded a concurrent competing write since the`,
|
|
2306
|
+
` /// current value landed (reset by every causally-superseding write).`,
|
|
2307
|
+
` final bool wasContested;`,
|
|
2308
|
+
``,
|
|
2309
|
+
` const FieldProvenance({this.author, this.wasContested = false});`,
|
|
2310
|
+
``,
|
|
2311
|
+
` /// Read one entry of the row's \`__prov\` map (\`{author, wasContested}\`).`,
|
|
2312
|
+
` factory FieldProvenance.fromJson(Map<String, Object?> m) => FieldProvenance(`,
|
|
2313
|
+
` author: m['author'] as String?,`,
|
|
2314
|
+
` wasContested: m['wasContested'] == true,`,
|
|
2315
|
+
` );`,
|
|
2316
|
+
``,
|
|
2317
|
+
` /// Decode a row's whole \`__prov\` map (empty when the row carries none).`,
|
|
2318
|
+
` static Map<String, FieldProvenance> mapFromJson(Object? prov) {`,
|
|
2319
|
+
` if (prov is! Map) return const {};`,
|
|
2320
|
+
` return {`,
|
|
2321
|
+
` for (final e in prov.entries)`,
|
|
2322
|
+
` if (e.value is Map)`,
|
|
2323
|
+
` e.key as String:`,
|
|
2324
|
+
` FieldProvenance.fromJson((e.value as Map).cast<String, Object?>()),`,
|
|
2325
|
+
` };`,
|
|
2326
|
+
` }`,
|
|
2327
|
+
``,
|
|
2328
|
+
` @override`,
|
|
2329
|
+
` bool operator ==(Object other) =>`,
|
|
2330
|
+
` other is FieldProvenance && other.author == author && other.wasContested == wasContested;`,
|
|
2331
|
+
``,
|
|
2332
|
+
` @override`,
|
|
2333
|
+
` int get hashCode => Object.hash(author, wasContested);`,
|
|
2334
|
+
`}`,
|
|
2335
|
+
].join("\n");
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2244
2338
|
function moduleUsesEvidence(aggregates: readonly AggregateHandle[]): boolean {
|
|
2245
2339
|
return aggregates.some((a) =>
|
|
2246
2340
|
Object.values(a.fields as Record<string, Field>).some((f) => f.kind === "evidence"),
|
|
@@ -4444,6 +4538,13 @@ export function generateDartDomain(mod: DomainModule, opts: GenerateDartDomainOp
|
|
|
4444
4538
|
``,
|
|
4445
4539
|
]
|
|
4446
4540
|
: []),
|
|
4541
|
+
...(moduleUsesAttribution(localAggregates)
|
|
4542
|
+
? [
|
|
4543
|
+
`// ---- attributed reads (per-field author provenance; field_provenance.md) ----`,
|
|
4544
|
+
emitFieldProvenanceClass(),
|
|
4545
|
+
``,
|
|
4546
|
+
]
|
|
4547
|
+
: []),
|
|
4447
4548
|
...(voBlock !== ""
|
|
4448
4549
|
? [
|
|
4449
4550
|
`// ---- value objects (named composable json-object leaves; value_object.ts) ----`,
|