@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.
@@ -225,6 +225,30 @@ interface PackageConfig {
225
225
  readonly types: string;
226
226
  readonly client: string;
227
227
  readonly test?: string;
228
+ /**
229
+ * MIGRATION-ONLY COMPAT PACKAGE (architecture/dart_naming.md). When set, `dart.names` alias
230
+ * `typedef`s are routed HERE (a standalone `<compat>` pub package of pure typedefs over the
231
+ * real `types` package) INSTEAD of the types barrel — a clearly-labeled, deletable-when-done
232
+ * shim so callers can migrate to the real names at their own pace. Its `pubspec.yaml` is
233
+ * scaffolded only-if-missing (tenant-owned versioning) and depends on the types package. When
234
+ * UNSET, `dart.names` land in the types barrel exactly as before (fully backward compatible).
235
+ */
236
+ readonly compat?: string;
237
+ /**
238
+ * BARREL PROMOTION POLICY (Jack's namespaces-not-prefixes directive, architecture/dart_naming.md).
239
+ * Each generated per-domain `.dart` file is ALREADY its own Dart library — `import
240
+ * 'package:<types>/src/generated/estate.dart' as estate;` then `estate.Site` is real, working
241
+ * namespacing regardless of this flag. This ONLY governs whether the top-level package barrel
242
+ * ALSO flat-re-exports every domain's classes into one unqualified scope:
243
+ * • `'all'` (DEFAULT) — flat-export every domain file (today's ergonomics; the single-domain
244
+ * common case is unaffected). Byte-identical to before this flag existed.
245
+ * • `'explicit'` — for MULTI-domain packages: the barrel re-exports only the SHARED core
246
+ * (framework/contracts/activity + nomos_types), NOT the per-domain aggregate/read-model
247
+ * classes, so two domains that both name a `Site` never silently share a flat scope —
248
+ * consumers disambiguate with `as`-prefixed per-domain imports. A SINGLE-domain package is
249
+ * unaffected (no collision risk ⇒ the lone domain stays flat-exported, byte-identical).
250
+ */
251
+ readonly barrelExports?: "all" | "explicit";
228
252
  };
229
253
  /**
230
254
  * PUBLIC ALIASES for generated type names (`{ "EstateEstateCustomFieldDefinitionFieldType":
@@ -439,13 +463,16 @@ const DART_SUPPORT_CORE_SRC = [
439
463
  // The custody-lifecycle types (NomosCustodyBreak/NomosCustodyStatus/CustodyBreakPolicy) — the generated
440
464
  // client + the vendored bridge reference them through the barrel, so they must vendor. Pure data.
441
465
  "custody.dart",
466
+ // SURFACED MERGE CONFLICTS: SurfacedValue<T>/SurfacedObservation<T> — a generated read model with a
467
+ // `.merge(Surfaced(...))` field references them through the barrel, so they must vendor. Pure data.
468
+ "surfaced.dart",
442
469
  ] as const;
443
470
  // The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
444
471
  // bridge the generated `<name>.client.dart` drives, and the provider surface. Their
445
472
  // `package:nomos_types/...` imports are rewritten to the local vendored shim (see emitDart).
446
473
  // (`home.dart` is NOT vendored — the home-onboarding runtime drags `package:crypto`; nothing the
447
474
  // codegen emits references it.)
448
- const DART_SUPPORT_RUNTIME_SRC = ["provider.dart", "webview_bridge.dart"] as const;
475
+ const DART_SUPPORT_RUNTIME_SRC = ["provider.dart", "webview_bridge.dart", "session_bound.dart"] as const;
449
476
  const DART_SUPPORT_SRC = [...DART_SUPPORT_CORE_SRC, ...DART_SUPPORT_RUNTIME_SRC] as const;
450
477
  /** The local shim replacing `package:nomos_types/nomos_types.dart` inside vendored runtime files. */
451
478
  const VENDORED_TYPES_SHIM = "nomos_types.dart";
@@ -871,12 +898,16 @@ const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
871
898
  /** Version constraints the scaffolded pubspecs pin — move together with the Dart packages' releases.
872
899
  * Caret on 0.x is EXACT-MINOR (`^0.45.0` = `<0.46.0`), so these must track the CURRENT release, not
873
900
  * just the floor generated code needs — a lagging pin forces tenants into dependency_overrides. */
874
- // 0.1.4: custody-lifecycle types (CustodyBreakPolicy et al).
875
- // 0.2.0: branded birth identities (NomosPrincipal/NomosDeviceSecret) — the generated cross-workspace
876
- // `.births()` methods type actor/authorSecret as these, so a layered package needs nomos_types 0.2.0.
877
- const NOMOS_TYPES_DEP = "^0.2.0";
878
- // 0.46.0: custody lifecycle on NomosBridge/NomosSession (onCustodyBreak/custodyStatus/salvage lanes).
879
- const NOMOS_CLIENT_DEP = "^0.46.0";
901
+ // 0.3.0: SurfacedValue<T>/SurfacedObservation/PendingConflictRow — generated Surfaced-field typing +
902
+ // the pendingConflicts read rows.
903
+ const NOMOS_TYPES_DEP = "^0.3.0";
904
+ // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
905
+ // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
906
+ // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
907
+ const NOMOS_CLIENT_DEP = "^0.48.1";
908
+ // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
909
+ // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
910
+ // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
880
911
 
881
912
  const isPubPackageName = (s: string): boolean => /^[a-z][a-z0-9_]*$/.test(s);
882
913
 
@@ -960,7 +991,7 @@ function emitDartPackages(
960
991
  dartOut: string,
961
992
  domainHash: string,
962
993
  packageInterface: Record<string, DomainInterface>,
963
- packages: { readonly types: string; readonly client: string; readonly test?: string },
994
+ packages: { readonly types: string; readonly client: string; readonly test?: string; readonly compat?: string; readonly barrelExports?: "all" | "explicit" },
964
995
  names: Readonly<Record<string, string>> | undefined,
965
996
  compat?: NomosCompat,
966
997
  ): { written: string[]; cursed: string[] } {
@@ -969,8 +1000,13 @@ function emitDartPackages(
969
1000
  fail(`dart.packages.${role}: '${String(name)}' is not a valid pub package name (lower_snake_case)`);
970
1001
  }
971
1002
  }
972
- const roleNames = [packages.types, packages.client, ...(packages.test !== undefined ? [packages.test] : [])];
973
- if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test names must be distinct`);
1003
+ const roleNames = [
1004
+ packages.types,
1005
+ packages.client,
1006
+ ...(packages.test !== undefined ? [packages.test] : []),
1007
+ ...(packages.compat !== undefined ? [packages.compat] : []),
1008
+ ];
1009
+ if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test/compat names must be distinct`);
974
1010
 
975
1011
  const shared = collectDartSharedSymbols(modules);
976
1012
  const written: string[] = [];
@@ -1080,16 +1116,40 @@ function emitDartPackages(
1080
1116
  const aliasImportUris = [...typesSources.keys()]
1081
1117
  .filter((f) => [...(declaredByFile.get(f) ?? [])].some((n) => aliasTargets.has(n)))
1082
1118
  .map((f) => `${genRel}/${f}.dart`);
1119
+ // COMPAT ROUTING (architecture/dart_naming.md): when `packages.compat` is set, the `dart.names`
1120
+ // typedefs go into a SEPARATE migration-only package instead of the types barrel. The types barrel
1121
+ // then carries NO aliases (fully humane vs. compat separation); when compat is UNSET, aliases land
1122
+ // in the types barrel exactly as before.
1123
+ const compatPackage = packages.compat;
1124
+ const routeAliasesToCompat = compatPackage !== undefined && compatPackage !== "" && names !== undefined && Object.keys(names).length > 0;
1125
+ const barrelNames = routeAliasesToCompat ? undefined : names;
1126
+ const barrelAliasImportUris = routeAliasesToCompat ? [] : aliasImportUris;
1127
+ // BARREL PROMOTION POLICY (dart_naming.md): `'explicit'` on a MULTI-domain package stops the barrel
1128
+ // flat-exporting per-domain aggregate/read-model classes (consumers use `as`-prefixed per-domain
1129
+ // imports); the shared core (framework/contracts/activity) still flat-exports. A single-domain package
1130
+ // (no collision risk) is UNAFFECTED — byte-identical to `'all'`.
1131
+ const domainFileBases = new Set(modules.map((m) => m.name));
1132
+ const explicitBarrel = packages.barrelExports === "explicit" && modules.length > 1;
1133
+ const typesExportKeys = explicitBarrel
1134
+ ? [...typesSources.keys()].filter((f) => !domainFileBases.has(f))
1135
+ : [...typesSources.keys()];
1136
+ const explicitBarrelNote = explicitBarrel
1137
+ ? [
1138
+ `// barrelExports: 'explicit' — per-domain aggregate/read-model classes are NOT flat-exported here.`,
1139
+ `// Import a domain with a prefix instead, e.g. \`import '${genRel}/<domain>.dart' as <domain>;\`.`,
1140
+ ]
1141
+ : [];
1083
1142
  const typesBarrel = [
1084
1143
  "// GENERATED by nomos-compile — the TYPES package's public entrypoint. DO NOT EDIT.",
1085
1144
  "// Pure data: payload classes, read models, VOs/enums, typed read accessors over the",
1086
1145
  "// injected NomosReads seams. Frontend-safe — no bridge, no sessions, no lifecycle.",
1087
1146
  "library;",
1088
1147
  "",
1089
- ...aliasImportLines(names, aliasImportUris),
1148
+ ...explicitBarrelNote,
1149
+ ...aliasImportLines(barrelNames, barrelAliasImportUris),
1090
1150
  `export 'package:nomos_types/nomos_types.dart';`,
1091
- ...[...typesSources.keys()].map((f) => `export '${genRel}/${f}.dart';`),
1092
- ...dartAliasLines(names, declared),
1151
+ ...typesExportKeys.map((f) => `export '${genRel}/${f}.dart';`),
1152
+ ...dartAliasLines(barrelNames, declared),
1093
1153
  "",
1094
1154
  ];
1095
1155
  const typesBarrelPath = path.join(pkgDir(packages.types), "lib", `${packages.types}.dart`);
@@ -1192,6 +1252,62 @@ function emitDartPackages(
1192
1252
  );
1193
1253
  }
1194
1254
 
1255
+ // ── the COMPAT package (optional, MIGRATION-ONLY) — pure `dart.names` typedefs over the types package ──
1256
+ // Routed here (instead of the types barrel) when `dart.packages.compat` is set: a standalone, clearly
1257
+ // labeled, deletable-when-done shim. The typedefs resolve because they import the types barrel (which
1258
+ // re-exports every generated symbol). Validated fail-closed by `dartAliasLines` against the types surface.
1259
+ if (routeAliasesToCompat) {
1260
+ const compatName = compatPackage!;
1261
+ if (!isPubPackageName(compatName)) {
1262
+ fail(`dart.packages.compat: '${compatName}' is not a valid pub package name (lower_snake_case)`);
1263
+ }
1264
+ const compatGen = freshGenDir(compatName);
1265
+ const typesImportForCompat = `package:${packages.types}/${packages.types}.dart`;
1266
+ const compatFile = [
1267
+ "// GENERATED by nomos-compile — MIGRATION-ONLY compat aliases. DO NOT EDIT.",
1268
+ `//`,
1269
+ `// MIGRATION-ONLY — delete this package (\`${compatName}\`) once callers have moved to the real`,
1270
+ `// names in \`${packages.types}\`. Every symbol below is a plain \`typedef\` over the real generated`,
1271
+ `// type; it exists solely to keep pre-rename imports compiling during a migration. It adds NO`,
1272
+ `// behaviour and SHOULD NOT accrete new entries — new code imports \`${packages.types}\` directly.`,
1273
+ "library;",
1274
+ "",
1275
+ `import '${typesImportForCompat}';`,
1276
+ ...dartAliasLines(names, declared),
1277
+ "",
1278
+ ];
1279
+ const compatSrcPath = path.join(compatGen, `${compatName}_aliases.dart`);
1280
+ writeFileSync(compatSrcPath, compatFile.join("\n"), "utf8");
1281
+ written.push(compatSrcPath);
1282
+ const compatBarrel = [
1283
+ "// GENERATED by nomos-compile — the COMPAT package's public entrypoint. DO NOT EDIT.",
1284
+ `// MIGRATION-ONLY — delete \`${compatName}\` once callers use the real names in \`${packages.types}\`.`,
1285
+ "library;",
1286
+ "",
1287
+ `export '${typesImportForCompat}';`,
1288
+ `export '${genRel}/${compatName}_aliases.dart';`,
1289
+ "",
1290
+ ];
1291
+ const compatBarrelPath = path.join(pkgDir(compatName), "lib", `${compatName}.dart`);
1292
+ writeFileSync(compatBarrelPath, compatBarrel.join("\n"), "utf8");
1293
+ written.push(compatBarrelPath);
1294
+ scaffoldPubspec(
1295
+ pkgDir(compatName),
1296
+ [
1297
+ `name: ${compatName}`,
1298
+ `description: MIGRATION-ONLY Nomos name-compat shim (typedefs over ${packages.types}). Delete once migrated.`,
1299
+ `version: 0.1.0`,
1300
+ `publish_to: none # tenant-owned: remove to publish`,
1301
+ `environment:`,
1302
+ ` sdk: ^3.5.0`,
1303
+ `dependencies:`,
1304
+ ` ${packages.types}:`,
1305
+ ` path: ../${packages.types} # co-emitted sibling; replace with a hosted constraint when publishing`,
1306
+ ``,
1307
+ ].join("\n"),
1308
+ );
1309
+ }
1310
+
1195
1311
  return { written, cursed: cursedNameHints(declared, names) };
1196
1312
  }
1197
1313
 
@@ -2350,7 +2466,7 @@ async function main(): Promise<void> {
2350
2466
  typeof dartCfg.packages.client !== "string")))
2351
2467
  ) {
2352
2468
  fail(
2353
- `config key 'dart' must be false, true, or { out?: "relative/dir", support?: "vendored" | "package", barrel?: "snake_case_name", packages?: { types, client, test? }, names?: { Generated: "Alias" } }; got ${JSON.stringify(dartCfg)}`,
2469
+ `config key 'dart' must be false, true, or { out?: "relative/dir", support?: "vendored" | "package", barrel?: "snake_case_name", packages?: { types, client, test?, compat? }, names?: { Generated: "Alias" } }; got ${JSON.stringify(dartCfg)}`,
2354
2470
  );
2355
2471
  }
2356
2472
  const dartPackages = typeof dartCfg === "object" ? dartCfg.packages : undefined;
package/src/directive.ts CHANGED
@@ -310,6 +310,17 @@ export interface Directive<P = unknown> {
310
310
  * to before this existed (the markers only emit the already-existing invariant era key, when used).
311
311
  */
312
312
  readonly preconditions?: PreconditionDescriptor[];
313
+ /**
314
+ * THE DESIGNATED SURFACED RESOLVER (surfaced_merge_conflict.md — the DX layer). Set by
315
+ * `.resolves("field")`: this directive is THE designated resolver of its target aggregate's named
316
+ * `.merge(Surfaced(...))` field — the one the generated typed `resolve…` helper dispatches. EXACTLY ONE
317
+ * directive may resolve each Surfaced field, and every Surfaced field MUST have one (both enforced at
318
+ * `composeDomainModule`, fail-closed — see `surfaced_lint.ts`). NON-LAW, additive codegen metadata (the
319
+ * `description`/`grantsRelation` class): it changes NO manifest/hash — in the KERNEL sense any gated
320
+ * directive that set()s the field still resolves the conflict; this only designates which one the typed
321
+ * helper binds. Omit-when-absent (a non-resolving directive is byte-identical).
322
+ */
323
+ readonly resolvesField?: string;
313
324
  /**
314
325
  * CALLER-SUPPLIED-ID CREATE-ONCE (`.creates(Agg)….withId(idFn)`). When set, the directive `creates` the
315
326
  * aggregate at an APP-SUPPLIED stable id (`idFn(payload)`, like `.ensures`) rather than a minted id,
@@ -485,6 +496,17 @@ export interface RequirableDirective<P> extends Directive<P> {
485
496
  * Accumulates + dedups; additive + non-destructive. A directive that never calls this is byte-identical.
486
497
  */
487
498
  grants(relation: string): RequirableDirective<P>;
499
+ /**
500
+ * Designate this directive THE resolver of its target aggregate's Surfaced `field`
501
+ * (`directive("retitleDoc").mutates(Doc)….resolves("title")`). The generated typed resolution helper
502
+ * (TS + Dart) binds to it: `resolve…(conflict, chosenValue)` desugars to dispatching THIS directive.
503
+ * ESCAPE HATCH (kernel truth): any gated directive that set()s the field still resolves the conflict
504
+ * in the kernel sense — `.resolves()` only designates which one the typed helper dispatches. Enforced
505
+ * fail-closed at compile: the field must be a declared `.merge(Surfaced(...))` field of the target
506
+ * aggregate, EXACTLY ONE directive may resolve it, and every Surfaced field must have a resolver.
507
+ * NON-LAW + additive (the `.doc()` class) — a directive that never calls this is byte-identical.
508
+ */
509
+ resolves(field: string): RequirableDirective<P>;
488
510
  }
489
511
 
490
512
  /** Stage 1: id + a referential marker (which fixes the target aggregate). */
@@ -871,6 +893,24 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
871
893
  const mergedQueries = [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
872
894
  return makeRequirable({ ...base, birthsChild: true, declaredQueryReads: mergedQueries });
873
895
  },
896
+ resolves(field: string): RequirableDirective<P> {
897
+ // THE DESIGNATED SURFACED RESOLVER: record which Surfaced field this directive resolves. Field
898
+ // existence / Surfaced-ness / exactly-one-per-field are judged at `composeDomainModule` (the
899
+ // aggregate handles are in hand there); here only the shape is checked. Non-law, additive.
900
+ if (typeof field !== "string" || field.length === 0) {
901
+ throw new Error(
902
+ `directive('${base.id}').resolves(field) needs a non-empty field name — the Surfaced field ` +
903
+ `this directive is the designated resolver of (e.g. .resolves("title")).`,
904
+ );
905
+ }
906
+ if (base.resolvesField !== undefined && base.resolvesField !== field) {
907
+ throw new Error(
908
+ `directive('${base.id}').resolves("${field}"): already .resolves("${base.resolvesField}") — ` +
909
+ `a directive designates AT MOST ONE Surfaced field (write a second directive for the other).`,
910
+ );
911
+ }
912
+ return makeRequirable({ ...base, resolvesField: field });
913
+ },
874
914
  grants(relation: string): RequirableDirective<P> {
875
915
  // PROOF-TOOLING PRODUCER MARKER (symmetric to `.requires`): record that this directive establishes
876
916
  // `relation` (its plan folds a granting `writeTuple`). NO kernel/manifest effect — a pure static hint
package/src/drivers.ts CHANGED
@@ -36,7 +36,8 @@ export type KernelDriverKind =
36
36
  | "Conflict"
37
37
  | "RemoveWins"
38
38
  | "Counter"
39
- | "LastPosition";
39
+ | "LastPosition"
40
+ | "Surfaced";
40
41
  /**
41
42
  * Drivers in the design palette the kernel does not yet merge. Currently EMPTY —
42
43
  * every palette driver encodes. Kept as a (never-)union so a future palette
@@ -48,7 +49,7 @@ export type DriverKind = KernelDriverKind | PaletteOnlyKind;
48
49
 
49
50
  export interface Driver {
50
51
  readonly kind: DriverKind;
51
- /** Only present for MapOf. */
52
+ /** Only present for MapOf / Surfaced. */
52
53
  readonly inner?: Driver;
53
54
  /** Only present for `LwwBy`: the named time-authority principal (a verified userId). */
54
55
  readonly principal?: string;
@@ -95,6 +96,61 @@ export const LastPosition: Driver = { kind: "LastPosition" };
95
96
  export const RemoveWins: Driver = { kind: "RemoveWins" };
96
97
  export const Counter: Driver = { kind: "Counter" };
97
98
 
99
+ /** The Lww-class inner drivers `Surfaced` may wrap in v1 (wholesale-replace scalars —
100
+ * the pending set holds competing SCALAR values a human picks between). */
101
+ const SURFACED_INNER_KINDS: ReadonlySet<DriverKind> = new Set<DriverKind>([
102
+ "Lww",
103
+ "LwwCausal",
104
+ "LwwBy",
105
+ ]);
106
+
107
+ /**
108
+ * `merge(Surfaced(inner))` — the USER-RESOLVABLE third merge posture
109
+ * (architecture/surfaced_merge_conflict.md; kernel `merge_field` arm in
110
+ * `nomos2/kernel/src/lib.rs`). On a GENUINELY CONCURRENT write (the divergent
111
+ * 3-way merge lane — two session branches touching the field between syncs) the
112
+ * kernel neither silently folds to a winner NOR drops the field: it retains BOTH
113
+ * competing values as a bounded pending-observation set (cap 8, deterministically
114
+ * pruned) that a read surfaces as `{"__surfaced":{"pending":[{value, author,
115
+ * occurredAt}, ...]}}` — ALWAYS 2+ competing observations (a single-observation
116
+ * set read-collapses to the plain scalar) — so an app can show a "pick one" UI.
117
+ * Linear writes and
118
+ * fast-forwards behave EXACTLY as the inner driver (`Surfaced(LwwBy(p))` keeps
119
+ * `p`'s write filter); pending sets form ONLY under real concurrency.
120
+ *
121
+ * RESOLUTION IS AN ORDINARY NEXT WRITE — a `.mutates(...)` directive the domain
122
+ * author writes that `set()`s the field on top of the pending state. It
123
+ * fast-forwards the field to plain Resolved (byte-identical to any other field
124
+ * thereafter); no new write-authority primitive exists or is needed.
125
+ *
126
+ * CAVEATS: observations carry `{value, author, occurredAt}` where `author` (the
127
+ * writer principal) is nullable — an observation folded where no intent context
128
+ * exists carries no author. v1 wraps Lww-class SCALAR inners only: `Lww`,
129
+ * `LwwCausal`, `LwwBy(principal)` (a concurrent add to an `AddWins` set already
130
+ * commutes without conflict; surfacing a map/counter has no v1 meaning).
131
+ */
132
+ export const Surfaced = (inner: Driver): Driver => {
133
+ if (inner === null || typeof inner !== "object" || typeof inner.kind !== "string") {
134
+ throw new Error(
135
+ `Surfaced(inner) needs an inner merge driver — e.g. Surfaced(Lww) or Surfaced(LwwBy("updatedAt")).`,
136
+ );
137
+ }
138
+ if (!SURFACED_INNER_KINDS.has(inner.kind)) {
139
+ throw new Error(
140
+ `Surfaced(${inner.kind}) is not supported — Surfaced wraps an Lww-class SCALAR inner driver only ` +
141
+ `(Lww, LwwCausal, LwwBy) in v1. A pending set holds competing scalar values a human picks between; ` +
142
+ `'${inner.kind}' ${
143
+ inner.kind === "Surfaced"
144
+ ? "is already Surfaced — nesting adds nothing"
145
+ : inner.kind === "AddWins" || inner.kind === "RemoveWins" || inner.kind === "Counter"
146
+ ? "already merges concurrent writes without conflict by design"
147
+ : "is not a wholesale-replace scalar merge"
148
+ }.`,
149
+ );
150
+ }
151
+ return { kind: "Surfaced", inner };
152
+ };
153
+
98
154
  // Drivers in the palette the kernel does NOT yet merge — currently NONE. A future
99
155
  // palette driver added ahead of its `merge_field` arm goes here; `encodeDriver`
100
156
  // throws for it rather than emit a wire driver the kernel can't merge.
@@ -127,6 +183,11 @@ export function encodeDriver(d: Driver): WireDriver {
127
183
  case "MapOf":
128
184
  if (!d.inner) throw new Error("MapOf driver is missing its inner driver");
129
185
  return { MapOf: encodeDriver(d.inner) };
186
+ case "Surfaced":
187
+ // serde newtype variant `Driver::Surfaced(Box<Driver>)` → `{ "Surfaced": <inner> }`
188
+ // (externally tagged, mirroring MapOf exactly).
189
+ if (!d.inner) throw new Error("Surfaced driver is missing its inner driver");
190
+ return { Surfaced: encodeDriver(d.inner) };
130
191
  default:
131
192
  if (PALETTE_ONLY.has(d.kind)) {
132
193
  throw new Error(
@@ -0,0 +1,144 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * THE PAYLOAD-REF idOf LINT (`architecture/dart_codegen_parity.md` P2 — ref ergonomics). A
10
+ * `t.ref(Target)` / `t.foreignRef(Target)` field on the AGGREGATE/read-model side already
11
+ * generates a TYPED id class (`SiteId`, `codegen_dart.ts`). Nothing enforced the payload SIDE
12
+ * carried the matching brand — a directive author can write `siteId: z.string()` in the
13
+ * payload and never discover `idOf(Site)` exists, so the generated Dart payload class carries a
14
+ * raw `String` while the read model carries `SiteId`: an asymmetry that forces manual
15
+ * `.value` unwrap/rewrap at the UI boundary and lets a mistyped id compile silently.
16
+ *
17
+ * THIS PASS closes that gap at COMPILE time: for every directive, plan-PROBE it (reusing the
18
+ * exact primitive the proof generator's own discovery legs use — `probePlanOps` /
19
+ * `synthesizePayload`, `codegen_proof.ts` — NOT a new AST interpreter) and, for every emitted
20
+ * field op that targets a ref-kind field, check whether the op's value is traceable to a
21
+ * TOP-LEVEL payload field whose zod schema is a bare (unbranded) string. If so: REFUSE, naming
22
+ * the directive, the payload field, the ref field, and its target aggregate, with the fix.
23
+ *
24
+ * SCOPE (deliberately narrow — a narrower lint with zero false positives beats a broad one with
25
+ * false refusals, per the authoring guidance):
26
+ * - Only DIRECT, first-order plans: `create(Agg).set("ref", payload.path)` /
27
+ * `setEntry(instance(Agg,...), "ref", key, payload.path)` — i.e. shapes the plan-probe
28
+ * mechanism already observes as ONE emitted op whose `value` is byte-identical to a
29
+ * TOP-LEVEL synthesized payload field's sample value. A value that is transformed,
30
+ * concatenated, or nested more than one level deep inside the payload does NOT match
31
+ * (no false positive — it simply isn't flagged; this is intentionally NOT full dataflow
32
+ * analysis over the plan body).
33
+ * - Only fires when the payload field's own sampled value round-trips EXACTLY into the op
34
+ * (relies on `synthesizePayload`'s per-field-distinguishable string samples,
35
+ * `"sample-<field>"` — the same discriminator the proof generator's own subject/grant-field
36
+ * resolution already trusts, see `resolveGrantSubjectFields`).
37
+ * - A directive whose plan can't be probed (unsynthesizable payload, or the plan throws under
38
+ * the synthesized sample) is SKIPPED — best-effort, matching every other plan-probe
39
+ * consumer in `codegen_proof.ts` (never a false refusal from an exotic shape).
40
+ * - `idOf(Agg)` AND `brandedId(Name)` both satisfy the lint (either brand types the Dart/TS
41
+ * side); only a truly BARE `z.string()` (through `.optional()`/`.default()`/`.nullable()`)
42
+ * refuses.
43
+ *
44
+ * NOT era-gated: this is a pure Dart/TS-codegen build-time guard (like the domain-contracts
45
+ * circular-import guard in `compile_package_main.ts`) — it has no wire/manifest/kernel effect,
46
+ * changes no hash, and never touches already-deployed law. It only judges whether `nomos-compile`
47
+ * accepts a directive's SOURCE shape, exactly the same posture as that circular-dependency guard.
48
+ * A domain already using `idOf`/`brandedId` (or one with no ref-fed payload field at all) compiles
49
+ * exactly as before; only a directive whose payload feeds a ref field with a provably-bare string
50
+ * newly refuses — which is the intended fail-closed behaviour, not an accidental regression.
51
+ */
52
+ import type { z } from "zod";
53
+
54
+ import type { Directive } from "./directive.js";
55
+ import type { FieldOp } from "./ops.js";
56
+ import { probePlanOps } from "./codegen_proof.js";
57
+ import { zodKind, zodDef, zodObjectShape } from "./codegen_ts.js";
58
+ import { idOfMetaOf, brandedIdMetaOf } from "./value_object.js";
59
+
60
+ /** A typed idOf-lint violation — the message names the directive, field, ref, and target. */
61
+ export class IdOfLintError extends Error {
62
+ constructor(message: string) {
63
+ super(message);
64
+ this.name = "IdOfLintError";
65
+ }
66
+ }
67
+
68
+ /** Short, lower-camel/PascalCase-preserving stem of an aggregate id (`inventory.EstateSite` → `EstateSite`),
69
+ * matching the same "identity is the tail segment" convention `directive.ts`'s `shortName` uses. */
70
+ function shortAggName(aggregateId: string): string {
71
+ return aggregateId.includes(".") ? aggregateId.slice(aggregateId.lastIndexOf(".") + 1) : aggregateId;
72
+ }
73
+
74
+ /** Peel `.optional()`/`.default()`/`.nullable()` to the innermost schema (mirrors `idOfMetaOf`/
75
+ * `brandedIdMetaOf`'s own peeling, so a wrapped bare string is still caught). */
76
+ function isBareString(zt: z.ZodTypeAny): boolean {
77
+ let cur: z.ZodTypeAny | undefined = zt;
78
+ for (let hops = 0; cur !== undefined && hops < 16; hops++) {
79
+ if (idOfMetaOf(cur) !== undefined || brandedIdMetaOf(cur) !== undefined) return false;
80
+ const kind = zodKind(cur);
81
+ if (kind === "string") return true;
82
+ if (kind !== "optional" && kind !== "default" && kind !== "nullable") return false;
83
+ cur = zodDef(cur).innerType;
84
+ }
85
+ return false;
86
+ }
87
+
88
+ /** Resolve a ref-kind field op's TARGET aggregate id (`t.ref` → `refAggregateId`/thunk; `t.foreignRef` →
89
+ * `foreignRef.targetAggregateId`). `undefined` when neither meta is present (an exotic/hand-built field). */
90
+ function refTargetOf(op: FieldOp): string | undefined {
91
+ const f = op.targetField as
92
+ | { refAggregateId?: string; foreignRef?: { targetAggregateId?: string } }
93
+ | undefined;
94
+ if (f === undefined) return undefined;
95
+ return f.refAggregateId ?? f.foreignRef?.targetAggregateId;
96
+ }
97
+
98
+ /**
99
+ * Run the idOf lint over one directive: plan-probe it and refuse on the first payload-fed
100
+ * ref-field mismatch. Throws {@link IdOfLintError}; a directive the prober can't run through
101
+ * (unsynthesizable payload / plan throws) is silently skipped — best-effort, no false refusal.
102
+ */
103
+ export function assertIdOfLintForDirective(dir: Directive<unknown>, where: string): void {
104
+ const probed = probePlanOps(dir, where);
105
+ if (probed === undefined) return;
106
+ let shape: Record<string, z.ZodTypeAny>;
107
+ try {
108
+ shape = zodObjectShape(dir.payloadSchema as z.ZodTypeAny);
109
+ } catch {
110
+ return;
111
+ }
112
+ // Reverse index: sampled STRING value → the top-level payload field name(s) that produced it.
113
+ // `synthesizePayload` gives every string-kind field a DISTINCT `"sample-<field>"` value (or a
114
+ // schema-constrained fallback), so an exact match is unambiguous in practice.
115
+ const byValue = new Map<string, string>();
116
+ for (const [name, value] of Object.entries(probed.synth.value)) {
117
+ if (typeof value === "string") byValue.set(value, name);
118
+ }
119
+ for (const op of probed.ops) {
120
+ if (op.kind !== "set" && op.kind !== "setEntry") continue;
121
+ if (typeof op.value !== "string") continue;
122
+ const targetAgg = refTargetOf(op);
123
+ if (targetAgg === undefined) continue; // not a ref-kind field (or one the DSL can't name a target for)
124
+ const payloadField = byValue.get(op.value);
125
+ if (payloadField === undefined) continue; // not traceable to a top-level payload field — no verdict
126
+ const fieldSchema = shape[payloadField];
127
+ if (fieldSchema === undefined || !isBareString(fieldSchema)) continue; // already idOf/brandedId — fine
128
+ const onAgg = shortAggName(op.aggregateType ?? op.aggregateId);
129
+ const targetName = shortAggName(targetAgg);
130
+ throw new IdOfLintError(
131
+ `directive '${dir.id}': payload field '${payloadField}' feeds ref field '${onAgg}.${op.field}' ` +
132
+ `(-> ${targetName}) but is a plain z.string() — wrap it as idOf(${targetName}) so the generated ` +
133
+ `Dart/TS carry the typed id, matching the read model. (dart_codegen_parity.md P2)`,
134
+ );
135
+ }
136
+ }
137
+
138
+ /** Run the idOf lint over every directive in a domain. `domain` names the module for the probe's
139
+ * error-context strings (`synthesizePayload`'s `where`). Throws on the FIRST violation. */
140
+ export function assertIdOfLint(directives: readonly Directive<unknown>[], domain: string): void {
141
+ for (const dir of directives) {
142
+ assertIdOfLintForDirective(dir, `${domain}/${dir.id}`);
143
+ }
144
+ }
package/src/index.ts CHANGED
@@ -93,6 +93,7 @@ export {
93
93
  LastPosition,
94
94
  RemoveWins,
95
95
  Counter,
96
+ Surfaced,
96
97
  encodeDriver,
97
98
  type Driver,
98
99
  type DriverKind,
Binary file
package/src/usd_layers.ts CHANGED
@@ -109,6 +109,8 @@ export function usdaStringArray(items: readonly string[]): string {
109
109
  export function driverToken(driver: WireDriver): string {
110
110
  if (typeof driver === "string") return driver;
111
111
  if ("LwwBy" in driver) return `LwwBy(${driver.LwwBy})`;
112
+ // `Surfaced(<inner>)` — the user-resolvable posture stays legible in the tree too.
113
+ if ("Surfaced" in driver) return `Surfaced(${driverToken(driver.Surfaced)})`;
112
114
  return `MapOf(${driverToken(driver.MapOf)})`;
113
115
  }
114
116
 
@@ -117,6 +119,9 @@ export function parseDriverToken(token: string): WireDriver {
117
119
  if (token.startsWith("MapOf(") && token.endsWith(")")) {
118
120
  return { MapOf: parseDriverToken(token.slice("MapOf(".length, -1)) };
119
121
  }
122
+ if (token.startsWith("Surfaced(") && token.endsWith(")")) {
123
+ return { Surfaced: parseDriverToken(token.slice("Surfaced(".length, -1)) };
124
+ }
120
125
  // `LwwBy(<principal>)` — the principal is an opaque non-empty string (uids carry
121
126
  // hyphens, so it is NOT the bare-identifier shape the unit-driver branch validates).
122
127
  if (token.startsWith("LwwBy(") && token.endsWith(")")) {
package/src/wire.ts CHANGED
@@ -219,7 +219,10 @@ export type WireDriver =
219
219
  | "Counter"
220
220
  | "LastPosition"
221
221
  | { LwwBy: string }
222
- | { MapOf: WireDriver };
222
+ | { MapOf: WireDriver }
223
+ // `Surfaced(Box<Driver>)` — the user-resolvable third merge posture
224
+ // (architecture/surfaced_merge_conflict.md): `{ "Surfaced": <inner> }`.
225
+ | { Surfaced: WireDriver };
223
226
 
224
227
  /** `Schema = BTreeMap<FieldId, Driver>` — a plain object, field -> Driver. */
225
228
  export type WireSchema = Record<string, WireDriver>;