@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.
- package/dart/CHANGELOG.md +28 -0
- package/dart/lib/nomos_client.dart +4 -0
- package/dart/lib/src/session_bound.dart +309 -0
- package/dart/lib/src/test_harness.dart +136 -4
- package/dart/lib/src/webview_bridge.dart +164 -25
- package/dart/pubspec.lock +1 -1
- package/dart/pubspec.yaml +2 -2
- package/dart/test/cluster_attested_test.dart +0 -1
- package/dart/test/session_bound_test.dart +289 -0
- package/dart_types/CHANGELOG.md +9 -0
- package/dart_types/lib/nomos_types.dart +3 -0
- package/dart_types/lib/src/surfaced.dart +208 -0
- package/dart_types/pubspec.yaml +1 -1
- package/dart_types/test/surfaced_test.dart +69 -0
- package/package.json +1 -1
- package/src/aggregate.ts +64 -7
- package/src/build_package.ts +22 -0
- package/src/codegen_dart.ts +155 -31
- package/src/codegen_dart_client.ts +245 -28
- package/src/codegen_openapi.ts +20 -0
- package/src/codegen_proof.ts +5 -1
- package/src/codegen_ts.ts +113 -1
- package/src/compile_package_main.ts +130 -14
- package/src/directive.ts +40 -0
- package/src/drivers.ts +63 -2
- package/src/idof_lint.ts +144 -0
- package/src/index.ts +1 -0
- package/src/surfaced_lint.ts +0 -0
- package/src/usd_layers.ts +5 -0
- package/src/wire.ts +4 -1
|
@@ -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.
|
|
875
|
-
//
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
//
|
|
879
|
-
|
|
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 = [
|
|
973
|
-
|
|
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
|
-
...
|
|
1148
|
+
...explicitBarrelNote,
|
|
1149
|
+
...aliasImportLines(barrelNames, barrelAliasImportUris),
|
|
1090
1150
|
`export 'package:nomos_types/nomos_types.dart';`,
|
|
1091
|
-
...
|
|
1092
|
-
...dartAliasLines(
|
|
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(
|
package/src/idof_lint.ts
ADDED
|
@@ -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
|
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>;
|