@githolon/dsl 0.71.0 → 0.72.1

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.
@@ -33,7 +33,14 @@ import type { Field } from "./fields.js";
33
33
  import type { Directive } from "./directive.js";
34
34
  import type { QueryDecl } from "./query.js";
35
35
  import type { DomainModule } from "./codegen_dart.js";
36
- import { cap, readModelClassName, dartRoleHelpers } from "./codegen_dart.js";
36
+ import {
37
+ cap,
38
+ readModelClassName,
39
+ dartRoleHelpers,
40
+ registerDartNames,
41
+ concreteClassName,
42
+ dartMemberName,
43
+ } from "./codegen_dart.js";
37
44
  import { finishCount } from "./count.js";
38
45
  import { finishSum } from "./sum.js";
39
46
  import { autoStampFields, domainEncryptionMap, type EncDomainMap, type InterfaceAggShape } from "./codegen_ts.js";
@@ -139,6 +146,7 @@ export interface DartBridgeClientOptions {
139
146
 
140
147
  /** Generate the `<name>.client.dart` async bridge client for one domain module. */
141
148
  export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClientOptions): string {
149
+ registerDartNames(mod.aggregates); // `.dartName` overrides for read-model class names in the client.
142
150
  const domain = mod.domain ?? mod.name;
143
151
  const className = dartClientClassName(domain);
144
152
  const hashConst = dartHashConstName(mod.name);
@@ -183,6 +191,11 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
183
191
 
184
192
  const methods: string[] = [];
185
193
 
194
+ // THE STATIC INTERFACE DESCRIPTOR (`XClient.interface`, session_bound_clients.md asks 5/9) —
195
+ // what this client EXPECTS of a workspace: the domain key, the law hash it compiled under, and
196
+ // the structural interface (#72). `bind(session)` / `session.assertCompatible(...)` consume it.
197
+ const compatInterfaceHash = (opts.compat as { interfaceHash?: string } | undefined)?.interfaceHash;
198
+
186
199
  // ── directives → typed offer ──
187
200
  // A `.births()` directive (birthsChild === true) is NOT a same-workspace write — it is a signed
188
201
  // cross-workspace governance offer to a PARENT. It gets a typed `bridge.createWorkspace(...)` wrapper
@@ -232,7 +245,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
232
245
  ]
233
246
  : []),
234
247
  ` await _sealPayload(data, ${dartStr(d.id)}, seal);`,
235
- ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
248
+ ` return _offer(${dartStr(d.id)}, data);`,
236
249
  ` }`,
237
250
  ``,
238
251
  );
@@ -247,7 +260,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
247
260
  ` final data = payload.toPayloadJson();`,
248
261
  ` final now = DateTime.now().toUtc().toIso8601String();`,
249
262
  ...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
250
- ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
263
+ ` return _offer(${dartStr(d.id)}, data);`,
251
264
  ` }`,
252
265
  ``,
253
266
  );
@@ -258,7 +271,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
258
271
  gateDoc,
259
272
  ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload) {`,
260
273
  gateGuard,
261
- ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
274
+ ` return _offer(${dartStr(d.id)}, payload.toPayloadJson());`,
262
275
  ` }`,
263
276
  ``,
264
277
  );
@@ -267,7 +280,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
267
280
  ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the typed [NomosOfferOutcome].${createsDoc}`,
268
281
  ...(gateDoc ? [gateDoc] : []),
269
282
  ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload) =>`,
270
- ` bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
283
+ ` _offer(${dartStr(d.id)}, payload.toPayloadJson());`,
271
284
  ``,
272
285
  );
273
286
  }
@@ -281,31 +294,67 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
281
294
  for (const d of birthDirs) {
282
295
  const payloadClass = `${cap(d.id)}Payload`;
283
296
  const method = safeMethod(d.id);
297
+ const gate = (d as { requiresRelationGate?: { relation: string } }).requiresRelationGate;
284
298
  methods.push(
285
- ` /// Births a CHILD WORKSPACE named by \`${payloadClass}\` under [parent], via a SIGNED cross-workspace`,
286
- ` /// governance offer to the parent's \`${domain}/${d.id}\` law (POST /v2/workspaces/<parent>/${d.id}) —`,
287
- ` /// NOT a same-workspace write, which is why it takes [parent]/[actor] (the requesting principal, e.g.`,
288
- ` /// \`user:<uid>\`) unlike the other generated methods. The declaring domain, directiveId, and law hash`,
289
- ` /// are baked in. Returns the parent gateway's verdict; [authorSecret] defaults to the bridge's device key.`,
290
- ` /// [actor] is a [NomosPrincipal] and [authorSecret] a [NomosDeviceSecret] — distinct types so the two`,
291
- ` /// can never be transposed (a bare-string swap would silently misfire).`,
292
- ` Future<Map<String, Object?>> ${method}({`,
293
- ` required String parent,`,
294
- ` required NomosPrincipal actor,`,
295
- ` NomosDeviceSecret? authorSecret,`,
299
+ ` /// Births a CHILD WORKSPACE named by \`${payloadClass}\` — a \`.births()\` offer on THIS client's`,
300
+ ` /// session, whose workspace IS the parent (session_bound_clients.md ask 4: no loose parent string).`,
301
+ ` /// Returns the typed [NomosOfferOutcome]: \`outcome.born\` names the child, \`outcome.bornRefs\` are`,
302
+ ` /// followable — \`final child = await engine.follow(outcome.bornRefs.single);\` mounts it, typed`,
303
+ ` /// client construction is then \`await ChildClient.bind(child)\`. [actor] (a [NomosPrincipal])`,
304
+ ` /// overrides this client's own \`actor\` for the offer.`,
305
+ ` ///`,
306
+ ` /// COMPATIBILITY SHIM (one minor version): passing the deprecated [parent] with a workspace that is`,
307
+ ` /// NOT this session's routes the old SIGNED cross-workspace gateway relay`,
308
+ ` /// (POST /v2/workspaces/<parent>/${d.id}); its verdict rides \`outcome.gatewayVerdict\`. [authorSecret]/`,
309
+ ` /// [authToken] only apply on that legacy lane.`,
310
+ ` Future<NomosOfferOutcome> ${method}({`,
296
311
  ` required ${payloadClass} payload,`,
312
+ ` NomosPrincipal? actor,`,
313
+ ` @Deprecated('the birth parent IS the session workspace — bind this client to the PARENT session '`,
314
+ ` '(session_bound_clients.md ask 4); an explicit different parent rides the legacy gateway relay '`,
315
+ ` 'one more minor version') String? parent,`,
316
+ ` NomosDeviceSecret? authorSecret,`,
297
317
  ` String? authToken,`,
298
- ` }) =>`,
299
- ` bridge.createWorkspace(`,
318
+ ` }) async {`,
319
+ ` final ws = _ws;`,
320
+ ` final explicitParent = parent;`,
321
+ ` if (explicitParent != null && explicitParent.isNotEmpty && explicitParent != ws) {`,
322
+ ` // LEGACY GATEWAY SHIM — the pre-session-bound cross-workspace relay, delegated unchanged.`,
323
+ ` if (actor == null) {`,
324
+ ` throw MissingOfferAuthority(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, requires: 'a requesting principal (actor) on the legacy parent-relay lane');`,
325
+ ` }`,
326
+ ` final verdict = await bridge.createWorkspace(`,
300
327
  ` domain: ${dartStr(domain)},`,
301
328
  ` directiveId: ${dartStr(d.id)},`,
302
329
  ` domainHash: domainHash,`,
303
- ` parent: parent,`,
330
+ ` parent: explicitParent,`,
304
331
  ` actor: actor.wire,`,
305
332
  ` authorSecret: authorSecret?.wire,`,
306
333
  ` payload: payload.toPayloadJson(),`,
307
334
  ` authToken: authToken,`,
308
335
  ` );`,
336
+ ` return NomosOfferOutcome(`,
337
+ ` head: (verdict['head'] ?? '').toString(),`,
338
+ ` created: const [],`,
339
+ ` workspace: explicitParent,`,
340
+ ` gatewayVerdict: verdict);`,
341
+ ` }`,
342
+ ` if (ws.isEmpty) {`,
343
+ ` throw StateError(`,
344
+ ` '${method}: bind this client to the PARENT NomosSession (${className}.bind(session)) — '`,
345
+ ` 'the birth parent IS the session workspace, never a loose string');`,
346
+ ` }`,
347
+ ` final effActor = actor?.wire ?? this.actor;`,
348
+ ...(gate !== undefined
349
+ ? [
350
+ ` if (effActor == null || effActor.trim().isEmpty) {`,
351
+ ` throw MissingOfferAuthority(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, requires: ${dartStr(gate.relation)});`,
352
+ ` }`,
353
+ ]
354
+ : []),
355
+ ` await _preflightOnce();`,
356
+ ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: effActor, domainHash: domainHash);`,
357
+ ` }`,
309
358
  ``,
310
359
  );
311
360
  }
@@ -366,11 +415,64 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
366
415
  );
367
416
  }
368
417
 
418
+ // ── SURFACED CONFLICT DX (surfaced_merge_conflict.md): the workspace-wide pendingConflicts
419
+ // read/watch + one typed `resolve…` helper per designated `.resolves()` directive. Emitted iff
420
+ // this domain declares a Surfaced field (Surfaced-free packages emit nothing — the era rule).
421
+ const resolverBindings = (mod as { surfacedResolvers?: import("./surfaced_lint.js").SurfacedResolverBinding[] }).surfacedResolvers ?? [];
422
+ if (resolverBindings.length > 0) {
423
+ methods.push(
424
+ ` /// PENDING SURFACED CONFLICTS across the workspace — the maintained "decisions needed" read`,
425
+ ` /// (reserved id \`nomosPendingConflicts\`; DERIVED, NOT DRAINED: a resolution write folding in`,
426
+ ` /// removes its row by derivation). Pair each row with its typed \`resolve…\` helper below.`,
427
+ ` Future<List<PendingConflictRow>> pendingConflicts() async =>`,
428
+ ` (await bridge.pendingConflicts()).map(PendingConflictRow.fromJson).toList();`,
429
+ ``,
430
+ ` /// Reactive pending-conflicts read — re-emits whenever the maintained index changes.`,
431
+ ` Stream<List<PendingConflictRow>> watchPendingConflicts() =>`,
432
+ ` bridge.watchPendingConflicts().map((rows) => rows`,
433
+ ` .map((r) => PendingConflictRow.fromJson((r as Map).cast<String, dynamic>()))`,
434
+ ` .toList());`,
435
+ ``,
436
+ );
437
+ for (const b of resolverBindings) {
438
+ if (b.valuePayloadField === undefined || b.idPayloadField === undefined) continue; // probe couldn't bind — dispatch the directive method directly.
439
+ const agg = aggById.get(b.aggregateId);
440
+ const fieldKind = (agg?.fields[b.field] as Field | undefined)?.kind;
441
+ const chosenType = fieldKind === "string" ? "String" : fieldKind === "int" ? "int" : "Object?";
442
+ const helper = safeMethod(`resolve${cap(camel(concreteClassName(b.aggregateId)))}${cap(camel(b.field))}`);
443
+ const dir = allDirs.find((d) => d.id === b.directiveId);
444
+ const gate = (dir as { requiresRelationGate?: { relation: string } } | undefined)?.requiresRelationGate;
445
+ methods.push(
446
+ ` /// RESOLVE a pending conflict on \`${b.aggregateId}.${b.field}\` by dispatching the DESIGNATED`,
447
+ ` /// resolver \`${b.directiveId}\` with [chosen] (the conflict row names the instance; extra required`,
448
+ ` /// payload fields ride [rest]). ESCAPE HATCH: any gated directive that sets the field still`,
449
+ ` /// resolves the conflict in the kernel sense; \`.resolves()\` only designates which one THIS`,
450
+ ` /// typed helper dispatches.`,
451
+ ` Future<NomosOfferOutcome> ${helper}(PendingConflictRow conflict, ${chosenType} chosen,`,
452
+ ` {Map<String, Object?> rest = const {}}) {`,
453
+ ...(gate !== undefined
454
+ ? [
455
+ ` if (actor == null || actor!.trim().isEmpty) {`,
456
+ ` throw MissingOfferAuthority(domain: ${dartStr(domain)}, directiveId: ${dartStr(b.directiveId)}, requires: ${dartStr(gate.relation)});`,
457
+ ` }`,
458
+ ]
459
+ : []),
460
+ ` final data = <String, Object?>{...rest, ${dartStr(b.idPayloadField)}: conflict.aggregateId, ${dartStr(b.valuePayloadField)}: chosen};`,
461
+ ` return _offer(${dartStr(b.directiveId)}, data);`,
462
+ ` }`,
463
+ ``,
464
+ );
465
+ }
466
+ }
467
+
369
468
  // ── per-aggregate by-id read/watch + type-index list ──
370
469
  for (const agg of mod.aggregates) {
371
470
  const model = readModelClassName(agg.id);
372
- const lc = lcFirst(camel(agg.id));
373
- const Cap = cap(camel(agg.id));
471
+ // Accessor method-name stem honours `.dartName` (e.g. `EstateSite` + .dartName("Site") → `siteById`,
472
+ // `watchSiteById`, `listSites`) so the whole Dart surface renames consistently.
473
+ const name = concreteClassName(agg.id);
474
+ const lc = lcFirst(camel(name));
475
+ const Cap = cap(camel(name));
374
476
  methods.push(
375
477
  ` /// Read one ${agg.id} by aggregate id.${readScopesFor(agg.id).length ? " Encrypted field(s) decrypt when the bound [keyStore] holds the scope key." : ""}`,
376
478
  ` Future<List<${model}>> ${safeMethod(`${lc}ById`)}(String id) async =>`,
@@ -387,6 +489,67 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
387
489
  );
388
490
  }
389
491
 
492
+ // ── REF-TRAVERSAL ACCESSORS (dart_codegen_parity.md P2 — ref ergonomics) ──
493
+ // For every SAME-WORKSPACE `t.ref(Target)` field whose target aggregate is ALSO in THIS domain module
494
+ // (so its by-id accessor is right here on this SAME client class), emit a companion extension method
495
+ // that resolves the stored ref id to the actual target row — reusing the EXISTING `<target>ById`
496
+ // by-id accessor above (no second read path, no new bridge op). SCOPE (deliberately narrow):
497
+ // - `t.foreignRef` fields are NOT covered — a foreign ref crosses workspaces (its own locator +
498
+ // attested-read trust plumbing, a different lane entirely; `ForeignRef<T>` stays as-is).
499
+ // - a same-workspace ref field whose target aggregate is a CROSS-DOMAIN reference (not present in
500
+ // `mod.aggregates`) is skipped too — its by-id accessor lives on a DIFFERENT domain's generated
501
+ // client, unreachable from an extension scoped to this file.
502
+ // A domain with no resolvable ref field emits none of this (byte-identical to before this existed).
503
+ const refExtensions: string[] = [];
504
+ for (const agg of mod.aggregates) {
505
+ const model = readModelClassName(agg.id);
506
+ const refFields = Object.entries(agg.fields).filter(([, f]) => {
507
+ const field = f as Field;
508
+ return field.kind === "ref" && field.foreignRef === undefined && field.refAggregateId !== undefined;
509
+ }) as [string, Field][];
510
+ const targetableFields = refFields.filter(([, f]) => aggById.has(f.refAggregateId!));
511
+ if (targetableFields.length === 0) continue;
512
+ const lines: string[] = [
513
+ `/// Ref-traversal accessors for [${model}] (dart_codegen_parity.md P2) — resolve a stored ref id`,
514
+ `/// to the actual target row via the SAME by-id read lane already exposed above.`,
515
+ `extension ${model}Refs on ${model} {`,
516
+ ];
517
+ for (const [fieldName, f] of targetableFields) {
518
+ const targetType = f.refAggregateId!;
519
+ const targetModel = readModelClassName(targetType);
520
+ const targetConcrete = concreteClassName(targetType);
521
+ const targetLc = lcFirst(camel(targetConcrete));
522
+ const byIdMethod = safeMethod(`${targetLc}ById`);
523
+ const member = dartMemberName(fieldName);
524
+ // `siteId` → `site` (the common `<name>Id` ref-field convention); anything else gets a `Ref`
525
+ // suffix so the resolver method never collides with the stored field's own getter.
526
+ const methodStem = member.length > 2 && member.endsWith("Id") ? member.slice(0, -2) : `${member}Ref`;
527
+ const method = safeMethod(methodStem);
528
+ // NULLABILITY: the read-model member's Dart type follows the PROJECTION's own nullability, not
529
+ // the DSL schema's `.optional()` — a partial fold decodes every scalar `null` when absent
530
+ // (`emitReadModel`'s own doc: "PARTIAL folds"), so the member is nullable UNLESS the field is
531
+ // `.fromBirth()` (birth-presence law — the ONE case the projection types non-null).
532
+ const memberNullable = f.isFromBirth !== true;
533
+ lines.push(
534
+ ` /// Resolve \`${fieldName}\` (${agg.id}.${fieldName} -> ${targetType}) by dispatching to`,
535
+ ` /// [${className}.${byIdMethod}] — the existing by-id read lane, never a second one.`,
536
+ ` Future<${targetModel}?> ${method}(${className} client) async {`,
537
+ ...(memberNullable
538
+ ? [
539
+ ` final id = ${member};`,
540
+ ` if (id == null) return null;`,
541
+ ` final rows = await client.${byIdMethod}(id.value);`,
542
+ ]
543
+ : [` final rows = await client.${byIdMethod}(${member}.value);`]),
544
+ ` return rows.isEmpty ? null : rows.first;`,
545
+ ` }`,
546
+ ``,
547
+ );
548
+ }
549
+ lines.push(`}`);
550
+ refExtensions.push(lines.join("\n"));
551
+ }
552
+
390
553
  // ── activity feed → typed reactive + one-shot (declared `.activity(...)`) ──
391
554
  if (opts.emitActivity === true) {
392
555
  methods.push(
@@ -439,17 +602,20 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
439
602
  return [
440
603
  `// AUTO-GENERATED by nomos-compile — typed ASYNC Dart client for domain "${domain}".`,
441
604
  `// Drives the REAL githolon over a NomosBridge (the WebView runner). Regenerated every compile; do`,
442
- `// not edit. Construct with a connected bridge:`,
605
+ `// not edit. SESSION-BOUND (architecture/session_bound_clients.md): construct with a NomosSession —`,
606
+ `// one session, one workspace, one domain interface, one actor context:`,
443
607
  `//`,
444
608
  // GENERATED-CODE lints: the bridge read/watch surface returns dynamic (JSON rows), so typed accessors
445
609
  // do dynamic member access + casts by construction. Silence the lints that only make sense for
446
610
  // hand-written code, so this file never adds noise to a tenant's analyzer output (co2 feedback).
447
- `// ignore_for_file: avoid_dynamic_calls, unnecessary_this, prefer_const_constructors, unused_import, type_annotate_public_apis, always_specify_types`,
611
+ // (deprecated_member_use*: the compatibility shims intentionally delegate to the deprecated
612
+ // engine-level lanes for one minor version.)
613
+ `// ignore_for_file: avoid_dynamic_calls, unnecessary_this, prefer_const_constructors, unused_import, type_annotate_public_apis, always_specify_types, deprecated_member_use, deprecated_member_use_from_same_package`,
448
614
  `//`,
449
- `// final bridge = NomosBridge(transport); // transport = your WebViewTransport`,
450
- `// await bridge.ready;`,
451
- `// await bridge.connect(cloud: cloud, workspace: ws, clientId: deviceId);`,
452
- `// final app = ${className}(bridge);`,
615
+ `// final engine = NomosBridge(transport); // the device's local plane (NomosEngine)`,
616
+ `// await engine.ready;`,
617
+ `// final session = await engine.open(cloud: cloud, workspace: ws, clientId: deviceId);`,
618
+ `// final app = await ${className}.bind(session); // preflights law compatibility (ask 5)`,
453
619
  `// await app.${dirs[0] ? safeMethod(dirs[0].id) : "someDirective"}(/* typed payload */);`,
454
620
  ``,
455
621
  // `dart:convert` is needed ONLY for the interface block's `jsonDecode` — the offer lane no longer encodes
@@ -469,7 +635,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
469
635
  `// client file gives an app the whole typed surface.`,
470
636
  ...(opts.typesImportUri !== undefined ? [`export '${opts.typesImportUri}';`] : [`export '${mod.name}.dart';`]),
471
637
  ...(opts.typesImportUri === undefined && opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
472
- `export '${supportImport}' show NomosBridge, NomosSession, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion, NomosCompat, NomosCompatMember, NomosOfferOutcome, NomosCreated, NomosRef${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore, NomosEncryption, NomosEpochResult, EncryptionMemberKeyMissing, EncryptionRosterMismatch" : ""};`,
638
+ `export '${supportImport}' show NomosBridge, NomosEngine, NomosSession, NomosSessionCompat, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion, NomosCompat, NomosCompatMember, NomosOfferOutcome, NomosCreated, NomosRef, NomosClientInterface, DomainClientSessionMismatch, DomainNotInstalledForSession, SessionMismatchCause${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore, NomosEncryption, NomosEpochResult, EncryptionMemberKeyMissing, EncryptionRosterMismatch" : ""};`,
473
639
  ``,
474
640
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
475
641
  `const String ${hashConst} = ${dartStr(opts.domainHash)};`,
@@ -519,6 +685,29 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
519
685
  : []),
520
686
  ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor${encPresent ? ", this.keyStore" : ""}});`,
521
687
  ``,
688
+ ` /// THE STATIC INTERFACE DESCRIPTOR (session_bound_clients.md asks 5/9): what this client EXPECTS`,
689
+ ` /// of the workspace it binds to — the domain key, the law hash it was generated against, and the`,
690
+ ` /// structural interface (#72). Consumed by [bind] and by \`session.assertCompatible(${className}.interface)\`.`,
691
+ ` static const NomosClientInterface interface = NomosClientInterface(`,
692
+ ` domain: ${dartStr(domain)},`,
693
+ ` expectedDomainHash: ${hashConst},`,
694
+ ...(compatInterfaceHash !== undefined ? [` expectedInterfaceHash: ${dartStr(compatInterfaceHash)},`] : []),
695
+ ...(opts.interface !== undefined ? [` interfaceJson: ${interfaceConstNameDart(mod.name)},`] : []),
696
+ ...(opts.compat !== undefined ? [` compatJson: ${compatConstNameDart(mod.name)},`] : []),
697
+ ` );`,
698
+ ``,
699
+ ` /// THE SESSION-BOUND CONSTRUCTION PATH (session_bound_clients.md asks 2/5): bind this client to`,
700
+ ` /// [session] with an ASYNC PREFLIGHT — the session's workspace must be reachable, hold the`,
701
+ ` /// \`${domain}\` domain, and serve a structurally compatible interface (#72 shape-compat, the ONE`,
702
+ ` /// compatibility notion). An incompatible session throws [DomainClientSessionMismatch]`,
703
+ ` /// IMMEDIATELY — at provider construction, not two clicks into the app. The synchronous`,
704
+ ` /// constructor remains for codepaths that can't await; it lazily runs the SAME preflight on the`,
705
+ ` /// first dispatch.`,
706
+ ` static Future<${className}> bind(NomosSession session, {String? actor${encPresent ? ", NomosKeyStore? keyStore" : ""}}) async {`,
707
+ ` await session.assertCompatible(interface);`,
708
+ ` return ${className}(session, actor: actor${encPresent ? ", keyStore: keyStore" : ""}).._preflighted = true;`,
709
+ ` }`,
710
+ ``,
522
711
  ` /// The session workspace this client is bound to (from the [NomosSession] handle, when constructed with`,
523
712
  ` /// one) — the \`workspace\` of every read row's canonical [NomosRef]. '' on a bare (non-session) bridge.`,
524
713
  ` String get _ws {`,
@@ -526,6 +715,31 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
526
715
  ` return b is NomosSession ? b.workspace : '';`,
527
716
  ` }`,
528
717
  ``,
718
+ ` /// The [NomosSession] this client dispatches through, when session-bound (the blessed shape);`,
719
+ ` /// null on a bare (non-session) bridge. Inspectable context: \`client.session?.workspace\`.`,
720
+ ` NomosSession? get session {`,
721
+ ` final b = bridge;`,
722
+ ` return b is NomosSession ? b : null;`,
723
+ ` }`,
724
+ ``,
725
+ ` // LAZY PREFLIGHT (the can't-await constructor path): the first dispatch runs the same`,
726
+ ` // assertCompatible check bind() runs eagerly. Only when session-bound; a bare bridge keeps the`,
727
+ ` // pre-session behavior (the kernel gate remains the fail-closed floor either way).`,
728
+ ` bool _preflighted = false;`,
729
+ ` Future<void> _preflightOnce() async {`,
730
+ ` if (_preflighted) return;`,
731
+ ` _preflighted = true;`,
732
+ ` final b = bridge;`,
733
+ ` if (b is NomosSession) await b.assertCompatible(interface);`,
734
+ ` }`,
735
+ ``,
736
+ ` /// THE ONE DISPATCH LANE every generated directive method routes through: lazy preflight, then`,
737
+ ` /// the session's offer (the generated-internal use of the engine-level lane).`,
738
+ ` Future<NomosOfferOutcome> _offer(String directiveId, Object? payload) async {`,
739
+ ` await _preflightOnce();`,
740
+ ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: directiveId, payload: payload, actor: actor, domainHash: domainHash);`,
741
+ ` }`,
742
+ ``,
529
743
  ...(encPresent ? dartEncClassMembers(encMap!) : []),
530
744
  // THE OWNER CEREMONY (slice 2): grantAndWrap / revokeAndRotate / rotate — the operator-blind key-epoch
531
745
  // handshake as one call. Bind `actor` (the owner) to author the fan-out. Only for a domain with a scope.
@@ -578,6 +792,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
578
792
  ...methods,
579
793
  `}`,
580
794
  ``,
795
+ ...(refExtensions.length > 0
796
+ ? [`// ---- ref-traversal accessors (dart_codegen_parity.md P2) ----`, refExtensions.join("\n\n"), ``]
797
+ : []),
581
798
  ...(roleHelpers !== ""
582
799
  ? [
583
800
  `// ---- role policy (UI hints — the KERNEL GATE is the authority) ----`,
@@ -192,6 +192,26 @@ export function generateOpenApiFragment(
192
192
  { name: "bbox", in: "query", required: true, schema: { type: "string" }, description: "minX,minY,maxX,maxY" },
193
193
  ]);
194
194
  }
195
+ // THE PENDING-CONFLICTS READ (surfaced_merge_conflict.md — the DX layer): a domain declaring
196
+ // ≥1 `.merge(Surfaced(...))` field emits the reserved, workspace-wide `nomosPendingConflicts`
197
+ // read — the maintained "decisions needed" index (rows: {aggregateType, aggregateId, field,
198
+ // observations}). DECLARED BY THE LAW ITSELF (the era-key idiom, like nomosInvariantGate):
199
+ // emitted iff a Surfaced field is declared, so a Surfaced-free domain's fragment — and thus
200
+ // its openapi/package bytes — is byte-identical to before this existed. No params: the read
201
+ // is workspace-wide (the reserved id IS the selector).
202
+ const declaresSurfaced = module.aggregates.some((a) =>
203
+ Object.values((a.fields ?? {}) as Record<string, Field>).some(
204
+ (f) => (f.driver as { kind?: string } | undefined)?.kind === "Surfaced",
205
+ ),
206
+ );
207
+ if (declaresSurfaced) {
208
+ getRead(
209
+ "nomosPendingConflicts",
210
+ "pending Surfaced merge conflicts across the workspace (maintained; derived, not drained)",
211
+ { kind: "pendingConflicts" },
212
+ [],
213
+ );
214
+ }
195
215
 
196
216
  return {
197
217
  openapi: "3.1.0",
@@ -778,7 +778,11 @@ function probePlanOpsWithPayload(
778
778
  return ops;
779
779
  }
780
780
 
781
- function probePlanOps(
781
+ /** PUBLIC (dart_codegen_parity.md P2 / idof_lint.ts): the same plan-probe primitive the proof generator's
782
+ * own discovery legs use — run a directive's plan under a synthesized sample payload + deterministic probe
783
+ * ports and return the emitted field ops alongside the sample that produced them. Reused (not reinvented)
784
+ * by the payload-ref idOf lint to match an op's value back to the payload field that fed it. */
785
+ export function probePlanOps(
782
786
  dir: Directive<unknown>,
783
787
  where: string,
784
788
  ): { ops: FieldOp[]; synth: SynthesizedPayload; probePayload: Record<string, unknown> } | undefined {
package/src/codegen_ts.ts CHANGED
@@ -470,7 +470,16 @@ function tsTypeOfPayload(
470
470
  // ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
471
471
 
472
472
  function tsTypeOfField(field: Field): string {
473
- const base = tsBaseTypeOfField(field);
473
+ let base = tsBaseTypeOfField(field);
474
+ // SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): the FIRST driver that
475
+ // changes a field's generated type. A `.merge(Surfaced(...))` field reads as the plain scalar
476
+ // once resolved, or the `{__surfaced:{pending:[...]}}` envelope while genuinely concurrent
477
+ // writes await a human pick — typed as the discriminated union `SurfacedValue<T>` (emitted
478
+ // once per file that needs it). The client does NO transformation; this types the exact
479
+ // read-collapse JSON.
480
+ if ((field.driver as { kind?: string } | undefined)?.kind === "Surfaced") {
481
+ base = `SurfacedValue<${base}>`;
482
+ }
474
483
  // NULL SEMANTICS (null_semantics.md): a `nullable`/`explicit-empty` field ADMITS a domain `null`,
475
484
  // so its read-model type carries the `| null` arm (distinct from the `?` partial-fold absence a
476
485
  // non-null-admitting field gets). A `forbid` json/VO field NEVER gets the null arm.
@@ -792,6 +801,15 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
792
801
  const hasBirths = modules.some((m) =>
793
802
  (m.directives as Directive<unknown>[]).some((d) => (d as { birthsChild?: boolean })?.birthsChild === true),
794
803
  );
804
+ // SURFACED MERGE CONFLICTS: a package declaring ≥1 `.merge(Surfaced(...))` field emits the
805
+ // `SurfacedValue<T>` union + guard (below). Surfaced-free packages emit nothing (byte-identical).
806
+ const hasSurfaced = modules.some((m) =>
807
+ m.aggregates.some((a) =>
808
+ Object.values(a.fields as Record<string, Field>).some(
809
+ (f) => (f.driver as { kind?: string } | undefined)?.kind === "Surfaced",
810
+ ),
811
+ ),
812
+ );
795
813
  // ACTIVITY FEED (architecture/activity_descriptors.md): the package's declared `.activity(...)` descriptors.
796
814
  // Empty ⇒ NOTHING activity-related emits (byte-identical to a pre-activity compile).
797
815
  const activityEntries = collectActivityDescriptors(modules);
@@ -950,6 +968,45 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
950
968
  ` * at runtime and the recursive edge is typed here). Named recursion TS resolves fine. */`,
951
969
  `export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };`,
952
970
  ``,
971
+ // SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): the read-collapse union for a
972
+ // `.merge(Surfaced(...))` field — the plain scalar once resolved, or the pending envelope while
973
+ // genuinely concurrent writes await a human pick. Emitted ONLY when the package declares one.
974
+ ...(hasSurfaced
975
+ ? [
976
+ `/** One competing observation on a PENDING Surfaced field. \`author\` is the writer principal —`,
977
+ ` * nullable (an observation folded where no intent context existed carries none). The author`,
978
+ ` * JSON key is pinned in ONE place: this property name (kernel b215430d — exactly "author"). */`,
979
+ `export interface SurfacedObservation<T> {`,
980
+ ` value: T;`,
981
+ ` occurredAt: { physical: number; logical: number; replica: number };`,
982
+ ` author?: string | null;`,
983
+ `}`,
984
+ `/** The PENDING read-collapse of a \`.merge(Surfaced(...))\` field: the kernel retained BOTH sides`,
985
+ ` * of a genuinely concurrent write (ALWAYS 2+ observations — a single-observation set collapses`,
986
+ ` * to the plain scalar; bounded, cap 8) instead of silently picking a winner. Resolve`,
987
+ ` * it with an ORDINARY next write (a \`.mutates()\` directive that set()s the field) — the field`,
988
+ ` * then reads as the plain scalar again, indistinguishable from any other field. */`,
989
+ `export interface SurfacedPending<T> { __surfaced: { pending: SurfacedObservation<T>[] } }`,
990
+ `/** A Surfaced field's read value: the resolved scalar, or the pending envelope under conflict. */`,
991
+ `export type SurfacedValue<T> = T | SurfacedPending<T>;`,
992
+ `/** True iff \`v\` is the pending-conflict envelope (narrows to [SurfacedPending]). */`,
993
+ `export function isSurfacedPending<T>(v: SurfacedValue<T> | null | undefined): v is SurfacedPending<T> {`,
994
+ ` return typeof v === "object" && v !== null && "__surfaced" in (v as Record<string, unknown>);`,
995
+ `}`,
996
+ `/** ONE row of the workspace-wide \`pendingConflicts()\` read (reserved id \`nomosPendingConflicts\`):`,
997
+ ` * a (aggregate, field) whose folded state awaits a human pick. MAINTAINED like counts/sums`,
998
+ ` * (fold-time, O(pending) to read) and DERIVED, NOT DRAINED — a resolution write folding in removes`,
999
+ ` * the row by derivation. The workspace-wide surface is HETEROGENEOUS, so each observation's`,
1000
+ ` * \`value\` is \`unknown\` here — the per-field typed seam stays on the read models (\`SurfacedValue<T>\`). */`,
1001
+ `export interface PendingConflictRow {`,
1002
+ ` aggregateType: string;`,
1003
+ ` aggregateId: string;`,
1004
+ ` field: string;`,
1005
+ ` observations: SurfacedObservation<unknown>[];`,
1006
+ `}`,
1007
+ ``,
1008
+ ]
1009
+ : []),
953
1010
  `/** The holon surface this client binds to (structurally satisfied by @githolon/client). */`,
954
1011
  `export interface NomosHolon {`,
955
1012
  ` /** THE ONE ENCODER offer (the single v2-envelope codec lives in @githolon/client \`encodeIntentOfferBytes\`):`,
@@ -990,6 +1047,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
990
1047
  ` queryById(aggregateId: string): Promise<unknown>;`,
991
1048
  ]),
992
1049
  ` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
1050
+ ...(hasSurfaced
1051
+ ? [
1052
+ ` /** The workspace-wide pending Surfaced-conflict read (reserved id \`nomosPendingConflicts\`).`,
1053
+ ` * OPTIONAL member: structural binding tolerates a pre-Surfaced holon (the accessor throws at call). */`,
1054
+ ` pendingConflicts?(): Promise<PendingConflictRow[]>;`,
1055
+ ]
1056
+ : []),
993
1057
  ` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
994
1058
  ` count(countId: string, groupKey?: string): Promise<number>;`,
995
1059
  ` /** THE SPATIAL READ (R*Tree probe): rows intersecting the bbox — same row shape as query().`,
@@ -1718,10 +1782,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1718
1782
  for (const dir of Object.values(dm.directives)) for (const f of Object.values(dir)) s.add(f.scope);
1719
1783
  return [...s].sort();
1720
1784
  })();
1785
+ // directiveId → its emitted payload TYPE name (the surfaced `resolve…` helpers reference it).
1786
+ const dirPayloadNames = new Map<string, string>();
1721
1787
  for (const d of dirs) {
1722
1788
  let payloadName = `${cap(d.id)}Payload`;
1723
1789
  if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
1724
1790
  usedPayloadNames.add(payloadName);
1791
+ dirPayloadNames.set(d.id, payloadName);
1725
1792
  const schema = (d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema;
1726
1793
  const stamps = autoStampFields(schema);
1727
1794
  const mint = mintPlan?.get(d.id);
@@ -1875,6 +1942,51 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1875
1942
  : ` ${lcFirst(camel(s.id))}: () => holon.sum(${JSON.stringify(s.id)}),`,
1876
1943
  );
1877
1944
  }
1945
+
1946
+ // ── SURFACED CONFLICT DX (surfaced_merge_conflict.md): the workspace-wide pendingConflicts
1947
+ // accessor + one typed `resolve…` helper per designated `.resolves()` directive. Emitted iff
1948
+ // this domain declares a Surfaced field (byte-identical otherwise — the era rule).
1949
+ const resolverBindings = (mod as { surfacedResolvers?: import("./surfaced_lint.js").SurfacedResolverBinding[] }).surfacedResolvers ?? [];
1950
+ if (resolverBindings.length > 0) {
1951
+ methods.push(
1952
+ ` /** PENDING SURFACED CONFLICTS across the workspace — the maintained "decisions needed" read`,
1953
+ ` * (reserved id \`nomosPendingConflicts\`; derived, not drained: a resolution write removes its`,
1954
+ ` * row by derivation). Pair each row with its typed \`resolve…\` helper below. */`,
1955
+ ` pendingConflicts: () => {`,
1956
+ ` if (!holon.pendingConflicts) throw new Error("this holon predates pendingConflicts reads — upgrade @githolon/client");`,
1957
+ ` return holon.pendingConflicts();`,
1958
+ ` },`,
1959
+ ` /** Watch the pending Surfaced conflicts (local reactive read; returns stop()). */`,
1960
+ ` watchPendingConflicts: (cb: (rows: PendingConflictRow[]) => void, opts?: { intervalMs?: number }) => {`,
1961
+ ` if (!holon.pendingConflicts) throw new Error("this holon predates pendingConflicts reads — upgrade @githolon/client");`,
1962
+ ` let stopped = false;`,
1963
+ ` let last = "";`,
1964
+ ` const tick = async () => { try { const rows = await holon.pendingConflicts!(); const j = JSON.stringify(rows); if (!stopped && j !== last) { last = j; cb(rows); } } catch { /* projection catching up */ } };`,
1965
+ ` void tick();`,
1966
+ ` const t = setInterval(tick, opts?.intervalMs ?? 1000);`,
1967
+ ` return () => { stopped = true; clearInterval(t); };`,
1968
+ ` },`,
1969
+ );
1970
+ for (const b of resolverBindings) {
1971
+ if (b.valuePayloadField === undefined) continue; // probe couldn't bind — dispatch the directive directly.
1972
+ const payloadName = dirPayloadNames.get(b.directiveId);
1973
+ if (payloadName === undefined) continue;
1974
+ const helper = `resolve${cap(camel(b.aggregateId))}${cap(camel(b.field))}`;
1975
+ const idField = b.idPayloadField;
1976
+ const bound = [b.valuePayloadField, ...(idField !== undefined ? [idField] : [])];
1977
+ const restType = `Omit<${payloadName}, ${bound.map((f) => JSON.stringify(f)).join(" | ")}>`;
1978
+ const conflictParam = idField !== undefined ? `conflict: string | PendingConflictRow, ` : ``;
1979
+ const payloadExpr = `{ ...(rest ?? {}), ${idField !== undefined ? `${JSON.stringify(idField)}: typeof conflict === "string" ? conflict : conflict.aggregateId, ` : ``}${JSON.stringify(b.valuePayloadField)}: chosen } as ${payloadName}`;
1980
+ methods.push(
1981
+ ` /** RESOLVE a pending conflict on \`${b.aggregateId}.${b.field}\` by dispatching the DESIGNATED`,
1982
+ ` * resolver \`${b.directiveId}\` with the chosen value${idField !== undefined ? ` (the conflict row — or the bare aggregate id — names the instance)` : ""}.`,
1983
+ ` * ESCAPE HATCH: any gated directive that sets the field still resolves the conflict in the`,
1984
+ ` * kernel sense; \`.resolves()\` only designates which one THIS typed helper dispatches. */`,
1985
+ ` ${helper}: (${conflictParam}chosen: ${payloadName}[${JSON.stringify(b.valuePayloadField)}], rest?: ${restType}): Promise<NomosOutcome> =>`,
1986
+ ` _outcome(holon.workspace, holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(b.directiveId)}, ${payloadExpr}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>),`,
1987
+ );
1988
+ }
1989
+ }
1878
1990
  for (const agg of mod.aggregates as AggregateHandle[]) {
1879
1991
  const aScopes = readScopesFor(agg.id);
1880
1992
  methods.push(