@githolon/dsl 0.103.4 → 0.103.5

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.
Files changed (40) hide show
  1. package/dart/CHANGELOG.md +8 -0
  2. package/dart/lib/src/runner/node-entry.mjs +333 -117
  3. package/dart/lib/src/runner/offline-install.mjs +173 -67
  4. package/dart/lib/src/session_bound.dart +111 -2
  5. package/dart/lib/src/test_harness.dart +51 -41
  6. package/dart/lib/src/webview_bridge.dart +714 -60
  7. package/dart/pubspec.lock +1 -1
  8. package/dart/pubspec.yaml +2 -2
  9. package/dart/test/acquire_session_test.dart +62 -5
  10. package/dart/test/cloud_bound_topology_test.dart +59 -38
  11. package/dart/test/session_bound_test.dart +25 -10
  12. package/dart/test/webview_bridge_test.dart +326 -7
  13. package/dart_types/CHANGELOG.md +5 -0
  14. package/dart_types/lib/nomos_types.dart +1 -0
  15. package/dart_types/lib/src/git_object_id.dart +37 -0
  16. package/dart_types/pubspec.yaml +1 -1
  17. package/package.json +1 -1
  18. package/runtime/generated-application-host.js +347 -113
  19. package/src/births.ts +16 -1
  20. package/src/business_birth.ts +55 -34
  21. package/src/codegen_dart.ts +68 -12
  22. package/src/codegen_dart_application.ts +114 -19
  23. package/src/codegen_dart_client.ts +149 -6
  24. package/src/codegen_proof.ts +16 -9
  25. package/src/codegen_provider_dart.ts +5 -0
  26. package/src/codegen_ts.ts +115 -19
  27. package/src/compile_package_main.ts +142 -40
  28. package/src/fields.ts +43 -0
  29. package/src/framework/domain_lifecycle.ts +108 -88
  30. package/src/framework/genesis.ts +2 -1
  31. package/src/framework/home.ts +6 -43
  32. package/src/framework/workspaces.ts +42 -25
  33. package/src/idof_lint.ts +65 -51
  34. package/src/index.ts +21 -1
  35. package/src/intent.ts +11 -1
  36. package/src/law_graph.ts +81 -11
  37. package/src/relation_schema.ts +18 -0
  38. package/src/shape_compat.ts +3 -0
  39. package/src/stable_ids_for_module.ts +7 -1
  40. package/src/value_object.ts +40 -0
package/src/idof_lint.ts CHANGED
@@ -105,72 +105,86 @@ export function assertIdOfLintForDirective(dir: Directive<unknown>, where: strin
105
105
  // tenant-facing idOf lint here would force framework wire schemas to masquerade as public
106
106
  // application schemas and, worse, reject every aggregate that opts into owned custody.
107
107
  if ((dir as { businessBirthInternal?: unknown }).businessBirthInternal === true) return;
108
- const probed = probePlanOps(dir, where);
109
- if (probed === undefined) return;
110
108
  let shape: Record<string, z.ZodTypeAny>;
111
109
  try {
112
110
  shape = zodObjectShape(dir.payloadSchema as z.ZodTypeAny);
113
111
  } catch {
114
112
  return;
115
113
  }
116
- // Reverse index: sampled STRING value → the top-level payload field name(s) that produced it.
117
- // `synthesizePayload` gives every string-kind field a DISTINCT `"sample-<field>"` value (or a
118
- // schema-constrained fallback), so an exact match is unambiguous in practice.
119
- const byValue = new Map<string, string>();
120
- for (const [name, value] of Object.entries(probed.synth.value)) {
121
- if (typeof value === "string") byValue.set(value, name);
122
- }
123
- for (const op of probed.ops) {
124
- // A bound mutation addresses one existing aggregate instance. The payload field carrying that
125
- // address is therefore not "some string" or merely "some branded id": it must be idOf(the exact
126
- // aggregate being mutated). This is the identity spine of the generated application API. Without
127
- // this guard a model can compile `entryId: z.string()`, after which every generated caller can mix
128
- // unrelated record ids and is encouraged to reconstruct identity as application state.
129
- const effectiveMarker = op.marker ?? (op.aggregateType === dir.aggregateId ? dir.marker : "mutates");
130
- if (op.aggregateType !== undefined && (effectiveMarker === "mutates" || effectiveMarker === "archives")) {
131
- const payloadField = byValue.get(op.aggregateId);
132
- if (payloadField !== undefined) {
133
- const fieldSchema = shape[payloadField];
134
- const actualTarget = fieldSchema === undefined ? undefined : idOfMetaOf(fieldSchema);
135
- if (actualTarget !== op.aggregateType) {
136
- const expected = shortAggName(op.aggregateType);
137
- const actual = actualTarget === undefined
138
- ? "an untyped string"
139
- : `idOf(${shortAggName(actualTarget)})`;
140
- const verb = effectiveMarker === "archives" ? "archive" : "mutate";
141
- throw new IdOfLintError(
114
+ // Probe both payload presences. The ordinary proof deliberately omits optional fields, but that
115
+ // would make `optional().setOptional(...)` invisible to the identity lint: exactly the shape that
116
+ // can smuggle a business string into a typed aggregate reference. The second probe includes every
117
+ // sampleable top-level optional and deliberately ignores a possibly sparse author example. It is
118
+ // lint-only; all existing proof/effect consumers retain the ordinary omitted-optional semantics.
119
+ const probedVariants = [
120
+ probePlanOps(dir, where),
121
+ probePlanOps(dir, `${where} (optional fields present)`, {
122
+ includeOptionalTopLevel: true,
123
+ useDirectiveExample: false,
124
+ }),
125
+ ];
126
+ if (probedVariants.every((variant) => variant === undefined)) return;
127
+ for (const probed of probedVariants) {
128
+ if (probed === undefined) continue;
129
+ // Reverse index: sampled STRING value → the top-level payload field name(s) that produced it.
130
+ // `synthesizePayload` gives every string-kind field a DISTINCT `"sample-<field>"` value (or a
131
+ // schema-constrained fallback), so an exact match is unambiguous in practice.
132
+ const byValue = new Map<string, string>();
133
+ for (const [name, value] of Object.entries(probed.synth.value)) {
134
+ if (typeof value === "string") byValue.set(value, name);
135
+ }
136
+ for (const op of probed.ops) {
137
+ // A bound mutation addresses one existing aggregate instance. The payload field carrying that
138
+ // address is therefore not "some string" or merely "some branded id": it must be idOf(the exact
139
+ // aggregate being mutated). This is the identity spine of the generated application API. Without
140
+ // this guard a model can compile `entryId: z.string()`, after which every generated caller can mix
141
+ // unrelated record ids and is encouraged to reconstruct identity as application state.
142
+ const effectiveMarker = op.marker ?? (op.aggregateType === dir.aggregateId ? dir.marker : "mutates");
143
+ if (op.aggregateType !== undefined && (effectiveMarker === "mutates" || effectiveMarker === "archives")) {
144
+ const payloadField = byValue.get(op.aggregateId);
145
+ if (payloadField !== undefined) {
146
+ const fieldSchema = shape[payloadField];
147
+ const actualTarget = fieldSchema === undefined ? undefined : idOfMetaOf(fieldSchema);
148
+ if (actualTarget !== op.aggregateType) {
149
+ const expected = shortAggName(op.aggregateType);
150
+ const actual = actualTarget === undefined
151
+ ? "an untyped string"
152
+ : `idOf(${shortAggName(actualTarget)})`;
153
+ const verb = effectiveMarker === "archives" ? "archive" : "mutate";
154
+ throw new IdOfLintError(
142
155
  `directive '${dir.id}': payload field '${payloadField}' selects the ${expected} record to ${verb} ` +
143
156
  `but carries ${actual} — declare it as idOf(${expected}) so create, update, find and watch use ` +
144
157
  `the same generated NomosRef<${expected}>.`,
145
- );
158
+ );
159
+ }
146
160
  }
147
161
  }
148
- }
149
- if (op.kind !== "set" && op.kind !== "setEntry") continue;
150
- if (typeof op.value !== "string") continue;
151
- const targetAgg = refTargetOf(op);
152
- if (targetAgg === undefined) continue; // not a ref-kind field (or one the DSL can't name a target for)
153
- const payloadField = byValue.get(op.value);
154
- if (payloadField === undefined) continue; // not traceable to a top-level payload field — no verdict
155
- const fieldSchema = shape[payloadField];
156
- if (fieldSchema === undefined) continue;
157
- const onAgg = shortAggName(op.aggregateType ?? op.aggregateId);
158
- const targetName = shortAggName(targetAgg);
159
- const actualTarget = idOfMetaOf(fieldSchema);
160
- if (actualTarget === targetAgg) continue;
161
- if (actualTarget !== undefined) {
162
+ if (op.kind !== "set" && op.kind !== "setEntry") continue;
163
+ if (typeof op.value !== "string") continue;
164
+ const targetAgg = refTargetOf(op);
165
+ if (targetAgg === undefined) continue; // not a ref-kind field (or one the DSL can't name a target for)
166
+ const payloadField = byValue.get(op.value);
167
+ if (payloadField === undefined) continue; // not traceable to a top-level payload field — no verdict
168
+ const fieldSchema = shape[payloadField];
169
+ if (fieldSchema === undefined) continue;
170
+ const onAgg = shortAggName(op.aggregateType ?? op.aggregateId);
171
+ const targetName = shortAggName(targetAgg);
172
+ const actualTarget = idOfMetaOf(fieldSchema);
173
+ if (actualTarget === targetAgg) continue;
174
+ if (actualTarget !== undefined) {
175
+ throw new IdOfLintError(
176
+ `directive '${dir.id}': payload field '${payloadField}' feeds ref field '${onAgg}.${op.field}' ` +
177
+ `(-> ${targetName}) but carries idOf(${shortAggName(actualTarget)}) — declare it as ` +
178
+ `idOf(${targetName}) so generated callers carry NomosRef<${targetName}>.`,
179
+ );
180
+ }
181
+ if (!isBareString(fieldSchema) && brandedIdMetaOf(fieldSchema) === undefined) continue;
162
182
  throw new IdOfLintError(
163
183
  `directive '${dir.id}': payload field '${payloadField}' feeds ref field '${onAgg}.${op.field}' ` +
164
- `(-> ${targetName}) but carries idOf(${shortAggName(actualTarget)}) — declare it as ` +
165
- `idOf(${targetName}) so generated callers carry NomosRef<${targetName}>.`,
184
+ `(-> ${targetName}) but is not idOf(${targetName}) — declare it as idOf(${targetName}) so generated ` +
185
+ `Dart/TS carry NomosRef<${targetName}>, matching the read model. (dart_codegen_parity.md P2)`,
166
186
  );
167
187
  }
168
- if (!isBareString(fieldSchema) && brandedIdMetaOf(fieldSchema) === undefined) continue;
169
- throw new IdOfLintError(
170
- `directive '${dir.id}': payload field '${payloadField}' feeds ref field '${onAgg}.${op.field}' ` +
171
- `(-> ${targetName}) but is not idOf(${targetName}) — declare it as idOf(${targetName}) so generated ` +
172
- `Dart/TS carry NomosRef<${targetName}>, matching the read model. (dart_codegen_parity.md P2)`,
173
- );
174
188
  }
175
189
  }
176
190
 
package/src/index.ts CHANGED
@@ -56,10 +56,16 @@ export {
56
56
  jsonWithNullSchema,
57
57
  type EvidenceRefValue,
58
58
  evidenceRefSchema,
59
+ type NomosRefValue,
60
+ nomosRefValueSchema,
59
61
  type Sha256Digest,
62
+ type GitObjectId,
60
63
  sha256Digest,
64
+ gitObjectId,
61
65
  isSha256DigestSchema,
66
+ isGitObjectIdSchema,
62
67
  NOMOS_SHA256_DIGEST_BRAND,
68
+ NOMOS_GIT_OBJECT_ID_BRAND,
63
69
  // FIELD FACETS (the endorse model — security_model.md): authority/standing/attribution gate
64
70
  // set() acceptance of PrincipalEvidence; secret/public are the confidentiality lattice.
65
71
  authorityField,
@@ -80,6 +86,8 @@ export {
80
86
  idOf,
81
87
  idOfMetaOf,
82
88
  idOfTargetKindOf,
89
+ refOf,
90
+ refOfMetaOf,
83
91
  versionedRefOf,
84
92
  versionedRefOfMetaOf,
85
93
  brandedId,
@@ -87,7 +95,9 @@ export {
87
95
  voMetaOf,
88
96
  voSample,
89
97
  NOMOS_VO_BRAND,
98
+ NOMOS_REFOF_BRAND,
90
99
  NOMOS_VERSIONED_REF_BRAND,
100
+ type RefOfMeta,
91
101
  type VersionedRefOfMeta,
92
102
  geo,
93
103
  type ValueObject,
@@ -617,13 +627,21 @@ export {
617
627
  } from "./framework/capability.js";
618
628
  export {
619
629
  DOMAIN_INSTALLATION_PHASES,
630
+ DOMAIN_LIFECYCLE_DIRECTIVES,
631
+ MODEL_LIFECYCLE_AUTHORITY,
620
632
  DomainInstallation,
633
+ DomainInstallExecutor,
634
+ DomainInstallationPhase,
635
+ currentLawReference,
636
+ domainInstallGenesisOffer,
621
637
  domainInstallationAggregateId,
622
638
  installDomain,
623
639
  stageDomain,
624
640
  promoteDomain,
625
641
  rollbackDomain,
626
- type DomainInstallationPhase,
642
+ type DomainLifecycleDirectiveId,
643
+ type CurrentLawReference,
644
+ type LawClosureReference,
627
645
  } from "./framework/domain_lifecycle.js";
628
646
  export {
629
647
  WORKSPACE_SYNC_DIRECTIONS,
@@ -666,6 +684,7 @@ export {
666
684
  // NATIVE ZANZIBAR RELATION SCHEMA (P1) — the userset-rewrite law as pure data.
667
685
  export {
668
686
  relationType,
687
+ relationRef,
669
688
  isRelationTypeDecl,
670
689
  validateRelationType,
671
690
  self,
@@ -691,6 +710,7 @@ export {
691
710
  selfObject,
692
711
  objectOf,
693
712
  type RelationObjectToken,
713
+ type RelationRef,
694
714
  type RelationTypeDecl,
695
715
  type RelationTypeBuilder,
696
716
  type RelationDefAst,
package/src/intent.ts CHANGED
@@ -24,7 +24,11 @@ import type {
24
24
  InvariantReadIdPart,
25
25
  WorkspaceInvariantDecl,
26
26
  } from "./framework/workspace_invariant.js";
27
- import { businessBirthConsequence, type BusinessBirthDirective } from "./business_birth.js";
27
+ import {
28
+ BUSINESS_BIRTH_POLICY_BINDING,
29
+ businessBirthConsequence,
30
+ type BusinessBirthDirective,
31
+ } from "./business_birth.js";
28
32
  import type { BusinessBirthDecl } from "./births.js";
29
33
  import type { WorkspaceSharingPolicy } from "./workspace_sharing.js";
30
34
 
@@ -164,6 +168,7 @@ function remapNativeReads(
164
168
 
165
169
  export interface BusinessIntent<I = unknown> extends Directive<I> {
166
170
  readonly [INTENT_BRAND]: true;
171
+ readonly [BUSINESS_BIRTH_POLICY_BINDING]?: (domains: readonly string[]) => void;
167
172
  readonly steps: readonly IntentStep<I, any>[];
168
173
  /** The complete public business boundary. Runtime lowering still uses Directive.payloadSchema privately. */
169
174
  readonly contract: IntentContract<I>;
@@ -433,6 +438,8 @@ class IntentBuilder<I> {
433
438
  };
434
439
 
435
440
  const businessBirth = (birthSteps[0]?.directive as BusinessBirthDirective | undefined)?.businessBirth;
441
+ const bindBusinessBirthPolicy =
442
+ (birthSteps[0]?.directive as BusinessBirthDirective | undefined)?.[BUSINESS_BIRTH_POLICY_BINDING];
436
443
  return {
437
444
  [INTENT_BRAND]: true,
438
445
  id: this.id,
@@ -472,6 +479,9 @@ class IntentBuilder<I> {
472
479
  ? {}
473
480
  : {
474
481
  birthsChild: true,
482
+ ...(bindBusinessBirthPolicy === undefined
483
+ ? {}
484
+ : { [BUSINESS_BIRTH_POLICY_BINDING]: bindBusinessBirthPolicy }),
475
485
  businessBirth: {
476
486
  ...businessBirth,
477
487
  publicPayloadSchema: this.contractDeclaration.accepts,
package/src/law_graph.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  lowerLawValueType,
32
32
  type LawConstant,
33
33
  type LawLiteral,
34
+ type LawValueMember,
34
35
  type LawValueConstraint,
35
36
  type LawValueType,
36
37
  } from "./law_value_type.js";
@@ -176,6 +177,47 @@ export interface LawPayloadContractNode {
176
177
  readonly fields: readonly LawStructuralRef<"value-field">[];
177
178
  }
178
179
 
180
+ /** Compiler-owned facts used to author `.owned()` effects. These are part of the
181
+ * internal typed USDA call contract, never the tenant-facing business contract. */
182
+ const OWNED_AUTHORING_PAYLOAD_MEMBERS = [
183
+ {
184
+ name: OWNED_KEY_PAYLOAD_FIELD,
185
+ required: false,
186
+ value: { kind: "string", constraints: [] },
187
+ },
188
+ {
189
+ name: OWNED_CUSTODY_PAYLOAD_FIELD,
190
+ required: false,
191
+ value: { kind: "string", constraints: [] },
192
+ },
193
+ {
194
+ name: OWNED_PREMINTED_RECORDS_PAYLOAD_FIELD,
195
+ required: false,
196
+ value: {
197
+ kind: "list",
198
+ element: {
199
+ kind: "record",
200
+ fields: [
201
+ { name: "type", required: true, value: { kind: "string", constraints: [] } },
202
+ { name: "id", required: true, value: { kind: "string", constraints: [] } },
203
+ ],
204
+ },
205
+ },
206
+ },
207
+ ] as const satisfies readonly LawValueMember[];
208
+
209
+ function internalPayloadContract(
210
+ tenantContract: LawValueType,
211
+ capabilities: ReadonlySet<"owned-authoring">,
212
+ ): LawValueType {
213
+ if (tenantContract.kind !== "record") return tenantContract;
214
+ if (!capabilities.has("owned-authoring")) return tenantContract;
215
+ return {
216
+ kind: "record",
217
+ fields: [...tenantContract.fields, ...OWNED_AUTHORING_PAYLOAD_MEMBERS],
218
+ };
219
+ }
220
+
179
221
  export interface LawReadParameter {
180
222
  readonly name: string;
181
223
  readonly field?: LawFieldPathRef;
@@ -1112,7 +1154,7 @@ export function discoverLawGraph(
1112
1154
  const intentNodes: LawIntentNode[] = intents.map((intent) => {
1113
1155
  const intentEntity = intentEntities.get(intent.id)!;
1114
1156
  const businessBirth = (intent as {
1115
- businessBirth?: { publicPayloadSchema?: z.ZodTypeAny };
1157
+ businessBirth?: { publicPayloadSchema?: z.ZodTypeAny; owned?: boolean };
1116
1158
  }).businessBirth;
1117
1159
  const payloadContractEntity = ownedEntity(
1118
1160
  "contract",
@@ -1122,21 +1164,32 @@ export function discoverLawGraph(
1122
1164
  `${intent.id}#accepts`,
1123
1165
  options.source?.("intent", intent.id, module.name),
1124
1166
  );
1125
- const payloadShape = lowerAnonymousContractFields(
1126
- lowerLawValueType(isBusinessIntent(intent) ? intent.contract.accepts : intent.payloadSchema),
1127
- payloadContractEntity,
1167
+ // A business birth has two deliberately different typed boundaries:
1168
+ //
1169
+ // * `contract.accepts` / `businessBirth.publicPayloadSchema` is the tenant-facing input;
1170
+ // * `payloadSchema` is the executable call contract after Nomos has added its private
1171
+ // principal, timestamp and (when needed) sealed-genesis facts.
1172
+ //
1173
+ // Collapsing both to `contract.accepts` made the generated client author the correct private
1174
+ // facts into a USDA call whose `nomos:accepts` contract did not declare them. Keep ordinary
1175
+ // compound intents on their public contract because they have no framework-only birth carrier;
1176
+ // only a declared business birth owns this second typed boundary.
1177
+ const tenantPayloadContract = lowerLawValueType(
1178
+ isBusinessIntent(intent) && businessBirth === undefined
1179
+ ? intent.contract.accepts
1180
+ : intent.payloadSchema,
1128
1181
  );
1129
- for (const node of payloadShape.nodes) anonymousValueNodes.set(node.entity.id, node);
1130
- for (const fieldRef of payloadShape.fields) {
1131
- const field = anonymousValueNodes.get(fieldRef.id);
1132
- if (field?.structuralKind !== "value-field") continue;
1133
- const reserved = [
1182
+ if (tenantPayloadContract.kind !== "record") {
1183
+ fail(`directive '${intent.id}': payload must lower to a record`);
1184
+ }
1185
+ for (const field of tenantPayloadContract.fields) {
1186
+ const reserved = new Set<string>([
1134
1187
  OWNED_KEY_PAYLOAD_FIELD,
1135
1188
  OWNED_CUSTODY_PAYLOAD_FIELD,
1136
1189
  OWNED_PREMINTED_RECORDS_PAYLOAD_FIELD,
1137
1190
  BUSINESS_BIRTH_SEED_PAYLOAD_FIELD,
1138
- ];
1139
- if (!reserved.includes(field.name)) continue;
1191
+ ]);
1192
+ if (!reserved.has(field.name)) continue;
1140
1193
  const frameworkBirthCarrier = field.name === BUSINESS_BIRTH_SEED_PAYLOAD_FIELD && businessBirth !== undefined;
1141
1194
  if (frameworkBirthCarrier) continue;
1142
1195
  fail(
@@ -1145,6 +1198,23 @@ export function discoverLawGraph(
1145
1198
  `a different name.`,
1146
1199
  );
1147
1200
  }
1201
+ const affectedAggregateIds = [
1202
+ intent.aggregateId,
1203
+ ...(intent.secondaryOutputs ?? []).map((output) => output.aggregateId),
1204
+ ];
1205
+ const frameworkCapabilities = new Set<"owned-authoring">();
1206
+ if (
1207
+ businessBirth?.owned === true ||
1208
+ affectedAggregateIds.some((aggregateId) =>
1209
+ aggregateByLabel.get(aggregateId)?.node.ownerField !== undefined)
1210
+ ) {
1211
+ frameworkCapabilities.add("owned-authoring");
1212
+ }
1213
+ const payloadShape = lowerAnonymousContractFields(
1214
+ internalPayloadContract(tenantPayloadContract, frameworkCapabilities),
1215
+ payloadContractEntity,
1216
+ );
1217
+ for (const node of payloadShape.nodes) anonymousValueNodes.set(node.entity.id, node);
1148
1218
  payloadContractNodes.push({
1149
1219
  kind: "payload-contract",
1150
1220
  entity: payloadContractEntity,
@@ -108,6 +108,24 @@ export interface RelationTypeDecl {
108
108
  readonly __isRelationType: true;
109
109
  }
110
110
 
111
+ /**
112
+ * A relation name that has deliberately crossed the authoring boundary.
113
+ *
114
+ * Plain strings remain accepted by the low-level DSL while existing tenant law migrates, but framework
115
+ * vocabulary should be coined once with {@link relationRef} and then reused by relation declarations,
116
+ * gates and tuple writes. That makes a rename a compiler-guided change instead of three unrelated string
117
+ * edits which can produce valid-looking, permanently unauthorized law.
118
+ */
119
+ declare const NOMOS_RELATION_REF: unique symbol;
120
+ export type RelationRef<Name extends string = string> = Name & {
121
+ readonly [NOMOS_RELATION_REF]: "NomosRelationRef";
122
+ };
123
+
124
+ /** Create one typed relation handle after validating the wire token once. */
125
+ export function relationRef<const Name extends string>(name: Name): RelationRef<Name> {
126
+ return requireName(name, "relation") as RelationRef<Name>;
127
+ }
128
+
111
129
  export function isRelationTypeDecl(v: unknown): v is RelationTypeDecl {
112
130
  return typeof v === "object" && v !== null && (v as { __isRelationType?: unknown }).__isRelationType === true;
113
131
  }
@@ -130,6 +130,9 @@ function payloadFieldKind(spec: PayloadFieldSpec): string {
130
130
  case "ref":
131
131
  // NomosRef<T> is aggregate-typed in the client and remains a string id on the wire.
132
132
  return "string";
133
+ case "addressRef":
134
+ // A workspace-qualified NomosRef<T> preserves its complete address object.
135
+ return "json";
133
136
  case "recipient":
134
137
  // Recipient<User> is nominal in clients and an opaque delivery address on the law wire.
135
138
  return "string";
@@ -254,7 +254,13 @@ function safeIdentityProjection(sourceShape: unknown, targetShape: unknown): boo
254
254
  const target = targetShape as Record<string, unknown>;
255
255
  const physical = (shape: Record<string, unknown>) => {
256
256
  const { kind, mapValueKind, jsonShape, encrypted } = shape;
257
- return { kind, mapValueKind, jsonShape, encrypted };
257
+ // `t.ref` and `t.foreignRef` store the target aggregate id as the same Str
258
+ // value as `t.string`; `ref` is a semantic/codegen contract, not a distinct
259
+ // kernel encoding. The JSON-schema subset check below still prevents an
260
+ // atomic NomosRef object (or any other non-string reference contract) from
261
+ // being mistaken for this identity migration.
262
+ const encodingKind = kind === "ref" ? "string" : kind;
263
+ return { kind: encodingKind, mapValueKind, jsonShape, encrypted };
258
264
  };
259
265
  return canonicalJson(physical(source)) === canonicalJson(physical(target))
260
266
  && jsonSchemaSubset(source.jsonSchema, target.jsonSchema);
@@ -321,6 +321,46 @@ const nomosAddressSchema = z.object({
321
321
  id: z.string().min(1),
322
322
  });
323
323
 
324
+ /**
325
+ * THE WORKSPACE-QUALIFIED REFERENCE BRAND — `refOf(CatalogueWorkspace)` is
326
+ * wire-identical to Nomos' typed `{ workspace, type, id }` address, while
327
+ * generated clients expose it as `NomosRef<CatalogueWorkspace>`. Unlike
328
+ * `idOf(...)`, this deliberately preserves the workspace because it is the
329
+ * canonical payload shape for cross-workspace navigation and custody effects.
330
+ */
331
+ export const NOMOS_REFOF_BRAND: unique symbol = Symbol.for("nomos.refOf");
332
+
333
+ export interface RefOfMeta {
334
+ readonly aggregateType: string;
335
+ readonly targetKind: "aggregate" | "contract";
336
+ }
337
+
338
+ export function refOf(target: RefTarget): z.ZodType<z.infer<typeof nomosAddressSchema>> {
339
+ const resolved = refTargetMeta(target, "refOf");
340
+ const schema = nomosAddressSchema.strict();
341
+ Object.defineProperty(schema, NOMOS_REFOF_BRAND, {
342
+ value: {
343
+ aggregateType: resolved.type,
344
+ targetKind: resolved.kind,
345
+ } satisfies RefOfMeta,
346
+ enumerable: false,
347
+ });
348
+ return schema;
349
+ }
350
+
351
+ /** Resolve `refOf(...)` metadata through omission/null wrappers. */
352
+ export function refOfMetaOf(zt: z.ZodTypeAny): RefOfMeta | undefined {
353
+ let cur: z.ZodTypeAny | undefined = zt;
354
+ for (let hops = 0; cur !== undefined && hops < 16; hops++) {
355
+ const branded = (cur as unknown as Record<symbol, unknown>)[NOMOS_REFOF_BRAND];
356
+ if (typeof branded === "object" && branded !== null) return branded as RefOfMeta;
357
+ const kind = zodKind(cur);
358
+ if (kind !== "optional" && kind !== "default" && kind !== "nullable") return undefined;
359
+ cur = zodDef(cur).innerType;
360
+ }
361
+ return undefined;
362
+ }
363
+
324
364
  /**
325
365
  * A payload field holding an immutable publication and its stable current-version
326
366
  * series. The kernel receives `{pinned, series}`; `current` is compiled law and