@githolon/dsl 0.70.2 → 0.72.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.
@@ -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,10 +191,20 @@ 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
- const dirs = (mod.directives as AnyDirective[]).filter(
200
+ // A `.births()` directive (birthsChild === true) is NOT a same-workspace write — it is a signed
201
+ // cross-workspace governance offer to a PARENT. It gets a typed `bridge.createWorkspace(...)` wrapper
202
+ // (below), NOT an `offerOutcome`; excluded here so a frontend dev can never call it as a local write.
203
+ const allDirs = (mod.directives as AnyDirective[]).filter(
188
204
  (d) => d && typeof d.id === "string" && typeof (d as { payloadSchema?: unknown }).payloadSchema === "object",
189
205
  );
206
+ const birthDirs = allDirs.filter((d) => (d as { birthsChild?: boolean }).birthsChild === true);
207
+ const dirs = allDirs.filter((d) => (d as { birthsChild?: boolean }).birthsChild !== true);
190
208
  for (const d of dirs) {
191
209
  const payloadClass = `${cap(d.id)}Payload`;
192
210
  const stamps = autoStampFields((d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema);
@@ -227,7 +245,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
227
245
  ]
228
246
  : []),
229
247
  ` await _sealPayload(data, ${dartStr(d.id)}, seal);`,
230
- ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
248
+ ` return _offer(${dartStr(d.id)}, data);`,
231
249
  ` }`,
232
250
  ``,
233
251
  );
@@ -242,7 +260,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
242
260
  ` final data = payload.toPayloadJson();`,
243
261
  ` final now = DateTime.now().toUtc().toIso8601String();`,
244
262
  ...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
245
- ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
263
+ ` return _offer(${dartStr(d.id)}, data);`,
246
264
  ` }`,
247
265
  ``,
248
266
  );
@@ -253,7 +271,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
253
271
  gateDoc,
254
272
  ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload) {`,
255
273
  gateGuard,
256
- ` 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());`,
257
275
  ` }`,
258
276
  ``,
259
277
  );
@@ -262,12 +280,85 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
262
280
  ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the typed [NomosOfferOutcome].${createsDoc}`,
263
281
  ...(gateDoc ? [gateDoc] : []),
264
282
  ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload) =>`,
265
- ` bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
283
+ ` _offer(${dartStr(d.id)}, payload.toPayloadJson());`,
266
284
  ``,
267
285
  );
268
286
  }
269
287
  }
270
288
 
289
+ // ── `.births()` directives → typed CROSS-WORKSPACE birth (bridge.createWorkspace) ──
290
+ // Each births a CHILD WORKSPACE under `parent` via a signed governance offer routed by the PARENT'S
291
+ // law (POST /v2/workspaces/<parent>/<directiveId>) — never a same-workspace write. The declaring
292
+ // domain's key + directiveId + baked domainHash are all bound at codegen time, so a frontend dev
293
+ // never hand-writes the four raw strings this replaces (the co2 production bug).
294
+ for (const d of birthDirs) {
295
+ const payloadClass = `${cap(d.id)}Payload`;
296
+ const method = safeMethod(d.id);
297
+ const gate = (d as { requiresRelationGate?: { relation: string } }).requiresRelationGate;
298
+ methods.push(
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}({`,
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,`,
317
+ ` String? authToken,`,
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(`,
327
+ ` domain: ${dartStr(domain)},`,
328
+ ` directiveId: ${dartStr(d.id)},`,
329
+ ` domainHash: domainHash,`,
330
+ ` parent: explicitParent,`,
331
+ ` actor: actor.wire,`,
332
+ ` authorSecret: authorSecret?.wire,`,
333
+ ` payload: payload.toPayloadJson(),`,
334
+ ` authToken: authToken,`,
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
+ ` }`,
358
+ ``,
359
+ );
360
+ }
361
+
271
362
  // ── declared queries → one-shot + reactive ──
272
363
  for (const q of (mod.queries ?? []) as QueryDecl[]) {
273
364
  const model = readModelClassName(q.returns);
@@ -324,11 +415,64 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
324
415
  );
325
416
  }
326
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
+
327
468
  // ── per-aggregate by-id read/watch + type-index list ──
328
469
  for (const agg of mod.aggregates) {
329
470
  const model = readModelClassName(agg.id);
330
- const lc = lcFirst(camel(agg.id));
331
- 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));
332
476
  methods.push(
333
477
  ` /// Read one ${agg.id} by aggregate id.${readScopesFor(agg.id).length ? " Encrypted field(s) decrypt when the bound [keyStore] holds the scope key." : ""}`,
334
478
  ` Future<List<${model}>> ${safeMethod(`${lc}ById`)}(String id) async =>`,
@@ -345,6 +489,67 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
345
489
  );
346
490
  }
347
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
+
348
553
  // ── activity feed → typed reactive + one-shot (declared `.activity(...)`) ──
349
554
  if (opts.emitActivity === true) {
350
555
  methods.push(
@@ -397,17 +602,20 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
397
602
  return [
398
603
  `// AUTO-GENERATED by nomos-compile — typed ASYNC Dart client for domain "${domain}".`,
399
604
  `// Drives the REAL githolon over a NomosBridge (the WebView runner). Regenerated every compile; do`,
400
- `// 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:`,
401
607
  `//`,
402
608
  // GENERATED-CODE lints: the bridge read/watch surface returns dynamic (JSON rows), so typed accessors
403
609
  // do dynamic member access + casts by construction. Silence the lints that only make sense for
404
610
  // hand-written code, so this file never adds noise to a tenant's analyzer output (co2 feedback).
405
- `// 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`,
406
614
  `//`,
407
- `// final bridge = NomosBridge(transport); // transport = your WebViewTransport`,
408
- `// await bridge.ready;`,
409
- `// await bridge.connect(cloud: cloud, workspace: ws, clientId: deviceId);`,
410
- `// 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)`,
411
619
  `// await app.${dirs[0] ? safeMethod(dirs[0].id) : "someDirective"}(/* typed payload */);`,
412
620
  ``,
413
621
  // `dart:convert` is needed ONLY for the interface block's `jsonDecode` — the offer lane no longer encodes
@@ -427,7 +635,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
427
635
  `// client file gives an app the whole typed surface.`,
428
636
  ...(opts.typesImportUri !== undefined ? [`export '${opts.typesImportUri}';`] : [`export '${mod.name}.dart';`]),
429
637
  ...(opts.typesImportUri === undefined && opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
430
- `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" : ""};`,
431
639
  ``,
432
640
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
433
641
  `const String ${hashConst} = ${dartStr(opts.domainHash)};`,
@@ -477,6 +685,29 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
477
685
  : []),
478
686
  ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor${encPresent ? ", this.keyStore" : ""}});`,
479
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
+ ``,
480
711
  ` /// The session workspace this client is bound to (from the [NomosSession] handle, when constructed with`,
481
712
  ` /// one) — the \`workspace\` of every read row's canonical [NomosRef]. '' on a bare (non-session) bridge.`,
482
713
  ` String get _ws {`,
@@ -484,6 +715,31 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
484
715
  ` return b is NomosSession ? b.workspace : '';`,
485
716
  ` }`,
486
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
+ ``,
487
743
  ...(encPresent ? dartEncClassMembers(encMap!) : []),
488
744
  // THE OWNER CEREMONY (slice 2): grantAndWrap / revokeAndRotate / rotate — the operator-blind key-epoch
489
745
  // handshake as one call. Bind `actor` (the owner) to author the fan-out. Only for a domain with a scope.
@@ -536,6 +792,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
536
792
  ...methods,
537
793
  `}`,
538
794
  ``,
795
+ ...(refExtensions.length > 0
796
+ ? [`// ---- ref-traversal accessors (dart_codegen_parity.md P2) ----`, refExtensions.join("\n\n"), ``]
797
+ : []),
539
798
  ...(roleHelpers !== ""
540
799
  ? [
541
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 {