@githolon/dsl 0.102.2 → 0.102.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.102.2",
3
+ "version": "0.102.4",
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",
@@ -1707,7 +1707,7 @@ function dartTypeOfVoField(f: ValueObjectFieldDescriptor, voName: string, fieldN
1707
1707
  switch (f.type) {
1708
1708
  case "string":
1709
1709
  return f.idOf !== undefined
1710
- ? `NomosRef<${concreteClassName(f.idOf)}>`
1710
+ ? `NomosRef<${f.idOfContract === true ? contractTypeName(f.idOf) : concreteClassName(f.idOf)}>`
1711
1711
  : f.brand ?? "String"; // a brandedId(...) → the generated extension-type wrapper
1712
1712
  case "number":
1713
1713
  // a UNIT-tagged number is the generated zero-cost extension type; `.int()` is Dart int.
@@ -1742,7 +1742,7 @@ function voFromExpr(
1742
1742
  switch (f.type) {
1743
1743
  case "string":
1744
1744
  if (f.idOf !== undefined) {
1745
- const target = concreteClassName(f.idOf);
1745
+ const target = f.idOfContract === true ? contractTypeName(f.idOf) : concreteClassName(f.idOf);
1746
1746
  return `NmsInternalNomosRefs.create<${target}>(workspace: '', type: '${f.idOf}', id: ${expr} as String)`;
1747
1747
  }
1748
1748
  return f.brand !== undefined ? `${f.brand}(${expr} as String)` : `${expr} as String`;
@@ -31,6 +31,7 @@ import {
31
31
  } from "./framework/framework_aggregates.js";
32
32
  import { isBusinessIntent } from "./intent.js";
33
33
  import { contractTypeName } from "./contract.js";
34
+ import { admitsNull } from "./null_meta.js";
34
35
  import type {
35
36
  WorkspaceSharingPolicy,
36
37
  WorkspaceSharingReceipt,
@@ -720,6 +721,11 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
720
721
  const targetFile = owner?.name ?? mod.name;
721
722
  const targetApplication = dartApplicationClassName(targetDomain);
722
723
  const opener = `nomosInternalOpen${targetApplication}`;
724
+ const rootAggregate = aggregates.get(birth.aggregateId);
725
+ const rootType = concreteClassName(birth.aggregateId);
726
+ const rootCollection = member(plural(rootAggregate === undefined
727
+ ? birth.aggregateId
728
+ : concreteClassName(rootAggregate.id)));
723
729
  if (targetFile !== mod.name) {
724
730
  addSibling(`${targetFile}.application.dart`, targetApplication);
725
731
  addSibling(`${targetFile}.application.dart`, opener);
@@ -734,7 +740,9 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
734
740
  ` /// or call a lower-level client.`,
735
741
  ` Future<${targetApplication}> ${method}(${dartActionInputClassName(directive)} facts) async {`,
736
742
  ` final reference = await _client.${method}(facts);`,
737
- ` return ${opener}(_client.bridge, reference, _client.actor);`,
743
+ opts.supportImport === "package"
744
+ ? ` return ${rootCollection}._nmsOpenReference(reference);`
745
+ : ` return ${opener}(_client.bridge, reference, _client.actor);`,
738
746
  ` }`,
739
747
  ``,
740
748
  );
@@ -743,31 +751,47 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
743
751
  ` /// Give one ${birth.aggregateId} its own durable life.`,
744
752
  ` Future<${targetApplication}> birth(Create${cap(camel(birth.aggregateId))}Input facts) async {`,
745
753
  ` final reference = await _client.birth(facts);`,
746
- ` return ${opener}(_client.bridge, reference, _client.actor);`,
754
+ opts.supportImport === "package"
755
+ ? ` return _nmsOpenReference(reference);`
756
+ : ` return ${opener}(_client.bridge, reference, _client.actor);`,
747
757
  ` }`,
748
758
  ``,
749
759
  ]);
750
760
  }
751
761
  if (opts.supportImport === "package") {
752
762
  usesWorkspaceName = true;
753
- const rootAggregate = aggregates.get(birth.aggregateId);
754
763
  const rootModel = rootAggregate === undefined
755
764
  ? `${concreteClassName(birth.aggregateId)}ReadModel`
756
765
  : readModelClassName(rootAggregate.id);
757
- const rootType = concreteClassName(birth.aggregateId);
758
- const rootCollection = member(plural(rootAggregate === undefined
759
- ? birth.aggregateId
760
- : concreteClassName(rootAggregate.id)));
761
766
  addCollectionMember(birth.aggregateId, "open", [
767
+ ` final Map<String, Future<${targetApplication}>> _nmsApplications = {};`,
768
+ ``,
769
+ ` Future<${targetApplication}> _nmsOpenReference(NomosRef<${rootType}> reference) {`,
770
+ ` final workspace = NmsInternalNomosRefs.workspace(reference);`,
771
+ ` final existing = _nmsApplications[workspace];`,
772
+ ` if (existing != null) return existing;`,
773
+ ` late final Future<${targetApplication}> opened;`,
774
+ ` opened = (() async {`,
775
+ ` try {`,
776
+ ` return await ${opener}(_client.bridge, reference, _client.actor);`,
777
+ ` } catch (_) {`,
778
+ ` if (identical(_nmsApplications[workspace], opened)) {`,
779
+ ` _nmsApplications.remove(workspace);`,
780
+ ` }`,
781
+ ` rethrow;`,
782
+ ` }`,
783
+ ` })();`,
784
+ ` _nmsApplications[workspace] = opened;`,
785
+ ` return opened;`,
786
+ ` }`,
787
+ ``,
762
788
  ` /// Open the ${birth.aggregateId} whose ${birth.keyField} is [key].`,
763
- ` Future<${targetApplication}> open(String key) => ${opener}(`,
764
- ` _client.bridge,`,
789
+ ` Future<${targetApplication}> open(String key) => _nmsOpenReference(`,
765
790
  ` NmsInternalNomosRefs.create<${rootType}>(`,
766
791
  ` workspace: deriveWorkspaceName(${JSON.stringify(birth.namespace)}, key),`,
767
792
  ` type: ${JSON.stringify(birth.aggregateId)},`,
768
793
  ` id: ${JSON.stringify(birth.rootId)},`,
769
794
  ` ),`,
770
- ` _client.actor,`,
771
795
  ` );`,
772
796
  ``,
773
797
  ]);
@@ -789,14 +813,9 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
789
813
  ` if (raw is! Map) continue;`,
790
814
  ` final child = (raw['child'] ?? '').toString();`,
791
815
  ` if (!child.startsWith(${JSON.stringify(`${birth.namespace}-`)})) continue;`,
792
- ` final application = await ${opener}(`,
793
- ` _client.bridge,`,
794
- ` NmsInternalNomosRefs.create<${rootType}>(workspace: child, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)}),`,
795
- ` _client.actor,`,
796
- ` );`,
797
- ` final record = await application.${rootCollection}.find(`,
798
- ` NmsInternalNomosRefs.create<${rootType}>(workspace: child, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)}),`,
799
- ` );`,
816
+ ` final reference = NmsInternalNomosRefs.create<${rootType}>(workspace: child, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)});`,
817
+ ` final application = await _nmsOpenReference(reference);`,
818
+ ` final record = await application.${rootCollection}.find(reference);`,
800
819
  ` if (record != null) records.add(record);`,
801
820
  ` }`,
802
821
  ` return records;`,
@@ -832,14 +851,9 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
832
851
  ` object != 'workspace:self' ||`,
833
852
  ` prunedAt.isNotEmpty ||`,
834
853
  ` !seen.add(workspace)) continue;`,
835
- ` final application = await ${opener}(`,
836
- ` _client.bridge,`,
837
- ` NmsInternalNomosRefs.create<${rootType}>(workspace: workspace, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)}),`,
838
- ` _client.actor,`,
839
- ` );`,
840
- ` final record = await application.${rootCollection}.find(`,
841
- ` NmsInternalNomosRefs.create<${rootType}>(workspace: workspace, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)}),`,
842
- ` );`,
854
+ ` final reference = NmsInternalNomosRefs.create<${rootType}>(workspace: workspace, type: ${JSON.stringify(birth.aggregateId)}, id: ${JSON.stringify(birth.rootId)});`,
855
+ ` final application = await _nmsOpenReference(reference);`,
856
+ ` final record = await application.${rootCollection}.find(reference);`,
843
857
  ` if (record != null) records.add(record);`,
844
858
  ` }`,
845
859
  ` return records;`,
@@ -854,10 +868,8 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
854
868
  ` /// Continue working in the business application that produced [record].`,
855
869
  ` Future<${targetApplication}> applicationOf(${rootModel} record) {`,
856
870
  ` final origin = NmsInternalRecordOrigins.require(record);`,
857
- ` return ${opener}(`,
858
- ` _client.bridge,`,
871
+ ` return _nmsOpenReference(`,
859
872
  ` NmsInternalNomosRefs.create<${rootType}>(workspace: origin.workspace, type: ${JSON.stringify(birth.aggregateId)}, id: origin.id),`,
860
- ` _client.actor,`,
861
873
  ` );`,
862
874
  ` }`,
863
875
  ``,
@@ -1027,6 +1039,55 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1027
1039
  (candidate as { businessBirthInternal?: unknown }).businessBirthInternal !== true,
1028
1040
  )) {
1029
1041
  const sourceModel = readModelClassName(aggregate.id);
1042
+ for (const contractId of aggregate.implementsContracts ?? []) {
1043
+ const contract = contractTypeName(contractId);
1044
+ const method = member(`as${contract}`);
1045
+ addCollectionMember(aggregate.id, method, [
1046
+ ` /// Use [record] wherever another domain declares [${contract}].`,
1047
+ ` ///`,
1048
+ ` /// Nomos preserves the record's workspace-qualified identity while exposing only`,
1049
+ ` /// the contract that this aggregate explicitly implements. This does not grant access.`,
1050
+ ` NomosRef<${contract}> ${method}(${sourceModel} record) {`,
1051
+ ` final reference = record.ref;`,
1052
+ ` _nmsRequireReference(reference, ${JSON.stringify(aggregate.id)}, '');`,
1053
+ ` return NmsInternalNomosRefs.create<${contract}>(`,
1054
+ ` workspace: reference.workspace,`,
1055
+ ` type: ${JSON.stringify(contractId)},`,
1056
+ ` id: reference.id,`,
1057
+ ` );`,
1058
+ ` }`,
1059
+ ``,
1060
+ ]);
1061
+ const resolveMethod = member(`resolve${contract}`);
1062
+ addCollectionMember(aggregate.id, resolveMethod, [
1063
+ ` /// Resolve [reference] as the concrete ${sourceModel} that implements [${contract}].`,
1064
+ ` ///`,
1065
+ ` /// Use this after following a generated cross-application reference and opening`,
1066
+ ` /// its business application. Nomos checks the declared contract and access on read.`,
1067
+ ` Future<${sourceModel}?> ${resolveMethod}(ForeignRef<${contract}> reference) {`,
1068
+ ` _nmsRequireReference(reference.ref, ${JSON.stringify(contractId)}, '');`,
1069
+ ` return find(NmsInternalNomosRefs.create<${concreteClassName(aggregate.id)}>(`,
1070
+ ` workspace: reference.workspace,`,
1071
+ ` type: ${JSON.stringify(aggregate.id)},`,
1072
+ ` id: reference.id,`,
1073
+ ` ));`,
1074
+ ` }`,
1075
+ ``,
1076
+ ]);
1077
+ const watchMethod = member(`watch${contract}`);
1078
+ addCollectionMember(aggregate.id, watchMethod, [
1079
+ ` /// React when the concrete record behind [reference] changes.`,
1080
+ ` Stream<${sourceModel}?> ${watchMethod}(ForeignRef<${contract}> reference) {`,
1081
+ ` _nmsRequireReference(reference.ref, ${JSON.stringify(contractId)}, '');`,
1082
+ ` return watchOne(NmsInternalNomosRefs.create<${concreteClassName(aggregate.id)}>(`,
1083
+ ` workspace: reference.workspace,`,
1084
+ ` type: ${JSON.stringify(aggregate.id)},`,
1085
+ ` id: reference.id,`,
1086
+ ` ));`,
1087
+ ` }`,
1088
+ ``,
1089
+ ]);
1090
+ }
1030
1091
  const sameDomainRefs = Object.entries(aggregate.fields).filter(([, field]) =>
1031
1092
  field.kind === "ref" &&
1032
1093
  field.foreignRef === undefined &&
@@ -1051,7 +1112,10 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1051
1112
  ? sourceFieldMember.slice(0, -2)
1052
1113
  : `${sourceFieldMember}Ref`;
1053
1114
  const method = member(stem);
1054
- const memberNullable = field.isFromBirth !== true;
1115
+ const memberNullable = admitsNull(field) || field.isFromBirth !== true;
1116
+ const sourceFieldValue = admitsNull(field)
1117
+ ? `record.${sourceFieldMember}.valueOrNull`
1118
+ : `record.${sourceFieldMember}`;
1055
1119
  const isSameDomain = sameDomainRefs.some(([name]) => name === fieldName);
1056
1120
  const isCrossDomain = crossDomainRefs.some(([name]) => name === fieldName);
1057
1121
  if (!isSameDomain && !isCrossDomain) continue;
@@ -1064,7 +1128,7 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1064
1128
  ` Future<${targetModel}?> ${method}(${sourceModel} record) async {`,
1065
1129
  ...(memberNullable
1066
1130
  ? [
1067
- ` final id = record.${sourceFieldMember};`,
1131
+ ` final id = ${sourceFieldValue};`,
1068
1132
  ` if (id == null) return null;`,
1069
1133
  ` final rows = await _client.${targetById}(NmsInternalNomosRefs.id(id));`,
1070
1134
  ]
@@ -1095,7 +1159,7 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1095
1159
  ` Future<${targetModel}?> ${method}(${sourceModel} record) async {`,
1096
1160
  ...(memberNullable
1097
1161
  ? [
1098
- ` final id = record.${sourceFieldMember};`,
1162
+ ` final id = ${sourceFieldValue};`,
1099
1163
  ` if (id == null) return null;`,
1100
1164
  ` final application = await ${opener}(`,
1101
1165
  ` _client.bridge,`,
@@ -1107,7 +1171,7 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1107
1171
  ` );`,
1108
1172
  ]
1109
1173
  : [
1110
- ` final id = record.${sourceFieldMember};`,
1174
+ ` final id = ${sourceFieldValue};`,
1111
1175
  ` final application = await ${opener}(`,
1112
1176
  ` _client.bridge,`,
1113
1177
  ` id,`,
@@ -1126,7 +1190,7 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1126
1190
  ` Future<${targetApplication}?> ${applicationMethod}(${sourceModel} record) {`,
1127
1191
  ...(memberNullable
1128
1192
  ? [
1129
- ` final id = record.${sourceFieldMember};`,
1193
+ ` final id = ${sourceFieldValue};`,
1130
1194
  ` if (id == null) return Future<${targetApplication}?>.value(null);`,
1131
1195
  ` return ${opener}(`,
1132
1196
  ` _client.bridge,`,
@@ -1151,12 +1215,15 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1151
1215
  ? contractTypeName(foreign.targetAggregateId)
1152
1216
  : concreteClassName(foreign.targetAggregateId);
1153
1217
  const fieldMember = member(fieldName);
1218
+ const fieldValue = admitsNull(field)
1219
+ ? `record.${fieldMember}.valueOrNull`
1220
+ : `record.${fieldMember}`;
1154
1221
  const stem = fieldMember.endsWith("Id") ? fieldMember.slice(0, -2) : fieldMember;
1155
1222
  const method = member(`${stem}Of`);
1156
1223
  addCollectionMember(aggregate.id, method, [
1157
1224
  ` /// Follow [record]'s ${fieldName} to its typed business record.`,
1158
1225
  ` Future<${target}?> ${method}(${sourceModel} record) async {`,
1159
- ` final reference = record.${fieldMember};`,
1226
+ ` final reference = ${fieldValue};`,
1160
1227
  ` if (reference == null) return null;`,
1161
1228
  ` final target = await NmsInternalForeignRefs.resolve(reference, _client.bridge.foreignReader());`,
1162
1229
  ` if (target == null) return null;`,
@@ -1179,7 +1246,7 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1179
1246
  addCollectionMember(aggregate.id, applicationMethod, [
1180
1247
  ` /// Open the business application containing [record]'s ${fieldName}.`,
1181
1248
  ` Future<${targetApplication}?> ${applicationMethod}(${sourceModel} record) {`,
1182
- ` final reference = record.${fieldMember};`,
1249
+ ` final reference = ${fieldValue};`,
1183
1250
  ` if (reference == null) return Future<${targetApplication}?>.value(null);`,
1184
1251
  ` return ${opener}(`,
1185
1252
  ` _client.bridge,`,
@@ -1294,7 +1361,7 @@ export function generateDartApplication(mod: DomainModule, opts: DartApplication
1294
1361
  `/// a repository or parallel application state.`,
1295
1362
  `final class ${collection} {`,
1296
1363
  ` final ${client} _client;`,
1297
- ` const ${collection}._(this._client);`,
1364
+ ` ${collection}._(this._client);`,
1298
1365
  ``,
1299
1366
  ...members,
1300
1367
  `}`,
@@ -62,6 +62,7 @@ import { buildNomosDescribeModel, type NomosDescribeModel } from "./describe_mod
62
62
  import { assertModelClientMemberAvailable } from "./generated_client_names.js";
63
63
  import { ownedRecordActionDirectiveId } from "./framework/owned_custody.js";
64
64
  import { contractTypeName } from "./contract.js";
65
+ import { admitsNull } from "./null_meta.js";
65
66
 
66
67
  const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
67
68
  const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
@@ -1268,14 +1269,15 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
1268
1269
  // the DSL schema's `.optional()` — a partial fold decodes every scalar `null` when absent
1269
1270
  // (`emitReadModel`'s own doc: "PARTIAL folds"), so the member is nullable UNLESS the field is
1270
1271
  // `.fromBirth()` (birth-presence law — the ONE case the projection types non-null).
1271
- const memberNullable = f.isFromBirth !== true;
1272
+ const memberNullable = admitsNull(f) || f.isFromBirth !== true;
1273
+ const memberValue = admitsNull(f) ? `${member}.valueOrNull` : member;
1272
1274
  lines.push(
1273
1275
  ` /// Resolve \`${fieldName}\` (${agg.id}.${fieldName} -> ${targetType}) by dispatching to`,
1274
1276
  ` /// [${className}.${byIdMethod}] — the existing by-id read lane, never a second one.`,
1275
1277
  ` Future<${targetModel}?> ${method}(${className} client) async {`,
1276
1278
  ...(memberNullable
1277
1279
  ? [
1278
- ` final id = ${member};`,
1280
+ ` final id = ${memberValue};`,
1279
1281
  ` if (id == null) return null;`,
1280
1282
  ` final rows = await client.${byIdMethod}(NmsInternalNomosRefs.id(id));`,
1281
1283
  ]
@@ -1301,7 +1303,8 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
1301
1303
  const member = dartMemberName(fieldName);
1302
1304
  const methodStem = member.length > 2 && member.endsWith("Id") ? member.slice(0, -2) : `${member}Ref`;
1303
1305
  const method = safeMethod(methodStem);
1304
- const memberNullable = f.isFromBirth !== true;
1306
+ const memberNullable = admitsNull(f) || f.isFromBirth !== true;
1307
+ const memberValue = admitsNull(f) ? `${member}.valueOrNull` : member;
1305
1308
  lines.push(
1306
1309
  ` /// Resolve \`${fieldName}\` (${agg.id}.${fieldName} -> ${targetType}, a CROSS-DOMAIN ref) by`,
1307
1310
  ` /// dispatching to [${targetClient}.${byIdMethod}] on the target domain's client — same by-id`,
@@ -1310,7 +1313,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
1310
1313
  ` Future<${targetModel}?> ${method}(${targetClient} client) async {`,
1311
1314
  ...(memberNullable
1312
1315
  ? [
1313
- ` final id = ${member};`,
1316
+ ` final id = ${memberValue};`,
1314
1317
  ` if (id == null) return null;`,
1315
1318
  ` final rows = await client.${byIdMethod}(NmsInternalNomosRefs.id(id));`,
1316
1319
  ]
@@ -1341,6 +1344,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
1341
1344
  }
1342
1345
  const targetConcrete = concreteClassName(targetType);
1343
1346
  const member = dartMemberName(fieldName);
1347
+ const memberValue = admitsNull(f) ? `${member}.valueOrNull` : member;
1344
1348
  // The stored locator member is a typed `ForeignRef<Target>?` getter, so the resolver takes a
1345
1349
  // `remote…` name to avoid colliding with it (and to read as "the far side of the reference").
1346
1350
  const method = safeMethod(`remote${cap(camel(member))}`);
@@ -1350,7 +1354,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
1350
1354
  ` /// implementation opens the foreign workspace on demand; tenant code sees only this business method.`,
1351
1355
  ` /// Null locator (absent / empty id or workspace) ⇒ null.`,
1352
1356
  ` Future<${targetConcrete}?> ${method}(NomosEngine engine, {String? cloud}) {`,
1353
- ` final ref = ${member};`,
1357
+ ` final ref = ${memberValue};`,
1354
1358
  ` if (ref == null) return Future<${targetConcrete}?>.value(null);`,
1355
1359
  ` return NmsInternalForeignRefs.resolve(ref, engine.foreignReader(cloud: cloud));`,
1356
1360
  ` }`,
package/src/codegen_ts.ts CHANGED
@@ -598,7 +598,7 @@ function tsTypeOfVoField(f: ValueObjectFieldDescriptor): string {
598
598
  switch (f.type) {
599
599
  case "string":
600
600
  return f.idOf !== undefined
601
- ? `NomosRef<${f.idOf}>`
601
+ ? `NomosRef<${f.idOfContract === true ? contractTypeName(f.idOf) : f.idOf}>`
602
602
  : f.brand ?? "string"; // a brandedId(...) → the emitted branded alias
603
603
  case "number":
604
604
  return "number"; // `.int()`/units are Dart-side nominal products; TS keeps one number
package/src/fields.ts CHANGED
@@ -171,7 +171,14 @@ export type ValueObjectFieldDescriptor =
171
171
  /** A plain string, OR a BRANDED nominal string when `brand` is present (a `brandedId(...)`
172
172
  * — the generated TS branded alias / Dart extension-type wrapper's name). Wire-identical
173
173
  * to a plain string; the brand is a compile-time nominal tag only. */
174
- | { readonly type: "string"; readonly brand?: string; readonly idOf?: string; readonly optional?: true }
174
+ | {
175
+ readonly type: "string";
176
+ readonly brand?: string;
177
+ readonly idOf?: string;
178
+ /** Present only for an `idOf(recordContract(...))` target. Aggregate is the default. */
179
+ readonly idOfContract?: true;
180
+ readonly optional?: true;
181
+ }
175
182
  /** `int: true` when the zod carried `.int()` (Dart `int`, TS `number`); `unit` when the field
176
183
  * was declared through `unit(...)` — the generated Dart extension-type wrapper's name. */
177
184
  | { readonly type: "number"; readonly int?: true; readonly unit?: string; readonly optional?: true }
package/src/law_check.ts CHANGED
@@ -729,7 +729,7 @@ function dottedQueryKeyFindings(mod: DomainModule, aggById: ReadonlyMap<string,
729
729
  // admits before a null is lawful (t.optional / t.clearable / t.nullable / t.json({null})). These
730
730
  // twins pre-fire the three refusals the gate would raise: (a) a payload field that ADMITS null with
731
731
  // no null-admitting receiver; (b) a null-admitting field used where a null is structurally illegal
732
- // (authority gate, identity ref, spatial index, read key); (c) a plan op writing literal null into a
732
+ // (authority gate, spatial index, read key); (c) a plan op writing literal null into a
733
733
  // field that never declared null a meaning. All PAYLOAD-INDEPENDENT (pure declarations) ⇒ REFUSE.
734
734
 
735
735
  /** The verbatim null-semantics guidance (`null_semantics.md`) — the `fix` on twin N-a/N-c. */
@@ -843,7 +843,10 @@ function nullBanFindings(mod: DomainModule, aggById: ReadonlyMap<string, Aggrega
843
843
  });
844
844
  };
845
845
 
846
- // Facet gates (authority/standing) + identity refs — a null in these breaks the very thing they decide.
846
+ // Facet gates (authority/standing) — a null in these breaks the very thing they decide. A plain
847
+ // typed ref is an association, not structural identity: clearing `asset.spaceId`, for example,
848
+ // means the asset is no longer in a space. If a ref participates in a query/group/spatial key the
849
+ // corresponding structural check below still refuses it.
847
850
  for (const agg of mod.aggregates) {
848
851
  for (const [name, f0] of Object.entries(agg.fields)) {
849
852
  const f = f0 as Field;
@@ -851,9 +854,6 @@ function nullBanFindings(mod: DomainModule, aggById: ReadonlyMap<string, Aggrega
851
854
  if (f.facet === "authority" || f.facet === "standing") {
852
855
  ban(agg.id, name, "an authority/standing gate", "drop the null semantic — an authority sink must always resolve to a subject.");
853
856
  }
854
- if (f.kind === "ref") {
855
- ban(agg.id, name, "an identity reference", "an identity ref must always point somewhere — model absence with a separate presence flag, not a null ref.");
856
- }
857
857
  }
858
858
  }
859
859
  // Read keys / group-bys / spatial indexes — a null key row is a silent dead index.
@@ -396,12 +396,17 @@ function describeZod(
396
396
  switch (zodKind(schema)) {
397
397
  case "string": {
398
398
  const brandedBrand = (schema as unknown as Record<symbol, unknown>)[NOMOS_BRANDED_ID_BRAND];
399
- const idOfAgg = (schema as unknown as Record<symbol, unknown>)[NOMOS_IDOF_BRAND];
399
+ const idOfTarget = idOfMetaOf(schema);
400
400
  // idOf(Agg) REFERENCES the aggregate's canonical `NomosRef<Agg>`. Carry `idOf` in the stable
401
401
  // descriptor so every emitter can recover the aggregate type while the wire remains a string.
402
402
  // A standalone `brandedId("X")` remains an unrelated nominal string and declares its own wrapper.
403
- if (typeof idOfAgg === "string") {
404
- return { type: "string", brand: `${idOfAgg}Id`, idOf: idOfAgg };
403
+ if (idOfTarget !== undefined) {
404
+ return {
405
+ type: "string",
406
+ brand: `${idOfTarget}Id`,
407
+ idOf: idOfTarget,
408
+ ...(idOfTargetKindOf(schema) === "contract" ? { idOfContract: true as const } : {}),
409
+ };
405
410
  }
406
411
  return typeof brandedBrand === "string" ? { type: "string", brand: brandedBrand } : { type: "string" };
407
412
  }