@githolon/dsl 0.70.2 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/codegen_ts.ts CHANGED
@@ -470,7 +470,16 @@ function tsTypeOfPayload(
470
470
  // ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
471
471
 
472
472
  function tsTypeOfField(field: Field): string {
473
- const base = tsBaseTypeOfField(field);
473
+ let base = tsBaseTypeOfField(field);
474
+ // SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): the FIRST driver that
475
+ // changes a field's generated type. A `.merge(Surfaced(...))` field reads as the plain scalar
476
+ // once resolved, or the `{__surfaced:{pending:[...]}}` envelope while genuinely concurrent
477
+ // writes await a human pick — typed as the discriminated union `SurfacedValue<T>` (emitted
478
+ // once per file that needs it). The client does NO transformation; this types the exact
479
+ // read-collapse JSON.
480
+ if ((field.driver as { kind?: string } | undefined)?.kind === "Surfaced") {
481
+ base = `SurfacedValue<${base}>`;
482
+ }
474
483
  // NULL SEMANTICS (null_semantics.md): a `nullable`/`explicit-empty` field ADMITS a domain `null`,
475
484
  // so its read-model type carries the `| null` arm (distinct from the `?` partial-fold absence a
476
485
  // non-null-admitting field gets). A `forbid` json/VO field NEVER gets the null arm.
@@ -787,6 +796,20 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
787
796
  );
788
797
  const encPresent = encMap !== null;
789
798
  const encConst = encryptionConstName(opts.packageName);
799
+ // A package declaring ≥1 `.births()` directive emits the branded identity wrappers (below) its typed
800
+ // birth methods take for `actor`/`authorSecret`. Births-free packages emit nothing (byte-identical).
801
+ const hasBirths = modules.some((m) =>
802
+ (m.directives as Directive<unknown>[]).some((d) => (d as { birthsChild?: boolean })?.birthsChild === true),
803
+ );
804
+ // SURFACED MERGE CONFLICTS: a package declaring ≥1 `.merge(Surfaced(...))` field emits the
805
+ // `SurfacedValue<T>` union + guard (below). Surfaced-free packages emit nothing (byte-identical).
806
+ const hasSurfaced = modules.some((m) =>
807
+ m.aggregates.some((a) =>
808
+ Object.values(a.fields as Record<string, Field>).some(
809
+ (f) => (f.driver as { kind?: string } | undefined)?.kind === "Surfaced",
810
+ ),
811
+ ),
812
+ );
790
813
  // ACTIVITY FEED (architecture/activity_descriptors.md): the package's declared `.activity(...)` descriptors.
791
814
  // Empty ⇒ NOTHING activity-related emits (byte-identical to a pre-activity compile).
792
815
  const activityEntries = collectActivityDescriptors(modules);
@@ -945,6 +968,45 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
945
968
  ` * at runtime and the recursive edge is typed here). Named recursion TS resolves fine. */`,
946
969
  `export type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };`,
947
970
  ``,
971
+ // SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): the read-collapse union for a
972
+ // `.merge(Surfaced(...))` field — the plain scalar once resolved, or the pending envelope while
973
+ // genuinely concurrent writes await a human pick. Emitted ONLY when the package declares one.
974
+ ...(hasSurfaced
975
+ ? [
976
+ `/** One competing observation on a PENDING Surfaced field. \`author\` is the writer principal —`,
977
+ ` * nullable (an observation folded where no intent context existed carries none). The author`,
978
+ ` * JSON key is pinned in ONE place: this property name (kernel b215430d — exactly "author"). */`,
979
+ `export interface SurfacedObservation<T> {`,
980
+ ` value: T;`,
981
+ ` occurredAt: { physical: number; logical: number; replica: number };`,
982
+ ` author?: string | null;`,
983
+ `}`,
984
+ `/** The PENDING read-collapse of a \`.merge(Surfaced(...))\` field: the kernel retained BOTH sides`,
985
+ ` * of a genuinely concurrent write (ALWAYS 2+ observations — a single-observation set collapses`,
986
+ ` * to the plain scalar; bounded, cap 8) instead of silently picking a winner. Resolve`,
987
+ ` * it with an ORDINARY next write (a \`.mutates()\` directive that set()s the field) — the field`,
988
+ ` * then reads as the plain scalar again, indistinguishable from any other field. */`,
989
+ `export interface SurfacedPending<T> { __surfaced: { pending: SurfacedObservation<T>[] } }`,
990
+ `/** A Surfaced field's read value: the resolved scalar, or the pending envelope under conflict. */`,
991
+ `export type SurfacedValue<T> = T | SurfacedPending<T>;`,
992
+ `/** True iff \`v\` is the pending-conflict envelope (narrows to [SurfacedPending]). */`,
993
+ `export function isSurfacedPending<T>(v: SurfacedValue<T> | null | undefined): v is SurfacedPending<T> {`,
994
+ ` return typeof v === "object" && v !== null && "__surfaced" in (v as Record<string, unknown>);`,
995
+ `}`,
996
+ `/** ONE row of the workspace-wide \`pendingConflicts()\` read (reserved id \`nomosPendingConflicts\`):`,
997
+ ` * a (aggregate, field) whose folded state awaits a human pick. MAINTAINED like counts/sums`,
998
+ ` * (fold-time, O(pending) to read) and DERIVED, NOT DRAINED — a resolution write folding in removes`,
999
+ ` * the row by derivation. The workspace-wide surface is HETEROGENEOUS, so each observation's`,
1000
+ ` * \`value\` is \`unknown\` here — the per-field typed seam stays on the read models (\`SurfacedValue<T>\`). */`,
1001
+ `export interface PendingConflictRow {`,
1002
+ ` aggregateType: string;`,
1003
+ ` aggregateId: string;`,
1004
+ ` field: string;`,
1005
+ ` observations: SurfacedObservation<unknown>[];`,
1006
+ `}`,
1007
+ ``,
1008
+ ]
1009
+ : []),
948
1010
  `/** The holon surface this client binds to (structurally satisfied by @githolon/client). */`,
949
1011
  `export interface NomosHolon {`,
950
1012
  ` /** THE ONE ENCODER offer (the single v2-envelope codec lives in @githolon/client \`encodeIntentOfferBytes\`):`,
@@ -953,6 +1015,14 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
953
1015
  ` offerDirective(domain: string, directiveId: string, payload: unknown, opts?: { domainHash?: string; actor?: string; returnCreated?: boolean }): Promise<string | { head: string; created?: { type: string; id: string }[]; workspace?: string }>;`,
954
1016
  ` /** Opaque-bytes offer — retained for carriers that already hold an encoded envelope (back-compat). */`,
955
1017
  ` offer(intentBytes: string | Uint8Array): Promise<string>;`,
1018
+ ...(hasBirths
1019
+ ? [
1020
+ ` /** BIRTH A CHILD WORKSPACE (a signed CROSS-WORKSPACE governance offer to \`parent\`) — OPTIONAL:`,
1021
+ ` * @githolon/client's connect() attaches it. The generated \`.births()\` methods call this, binding`,
1022
+ ` * the declaring domain/directiveId/law hash so a frontend dev never hand-writes those raw strings. */`,
1023
+ ` createWorkspace?(opts: { domain: string; directiveId: string; domainHash?: string; parent: string; actor: string; authorSecret?: string; payload?: unknown; name?: string; authToken?: string }): Promise<{ ok?: boolean; status?: number; body?: unknown }>;`,
1024
+ ]
1025
+ : []),
956
1026
  ` /** The workspace this holon is bound to — the \`workspace\` of every [NomosRef] this client composes. */`,
957
1027
  ` workspace: string;`,
958
1028
  ...(encPresent
@@ -977,6 +1047,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
977
1047
  ` queryById(aggregateId: string): Promise<unknown>;`,
978
1048
  ]),
979
1049
  ` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
1050
+ ...(hasSurfaced
1051
+ ? [
1052
+ ` /** The workspace-wide pending Surfaced-conflict read (reserved id \`nomosPendingConflicts\`).`,
1053
+ ` * OPTIONAL member: structural binding tolerates a pre-Surfaced holon (the accessor throws at call). */`,
1054
+ ` pendingConflicts?(): Promise<PendingConflictRow[]>;`,
1055
+ ]
1056
+ : []),
980
1057
  ` /** Declared-count / declared-sum O(1) reads (maintained by the read engine). */`,
981
1058
  ` count(countId: string, groupKey?: string): Promise<number>;`,
982
1059
  ` /** THE SPATIAL READ (R*Tree probe): rows intersecting the bbox — same row shape as query().`,
@@ -1046,6 +1123,25 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1046
1123
  ``,
1047
1124
  );
1048
1125
 
1126
+ // BRANDED IDENTITY WRAPPERS for the typed cross-workspace birth methods: `actor` (NomosPrincipal) and
1127
+ // `authorSecret` (NomosDeviceSecret) are distinct branded strings, so a caller can't transpose the two
1128
+ // (a bare-string swap would compile and silently misfire). Emitted iff the package declares `.births()`.
1129
+ if (hasBirths) {
1130
+ out.push(
1131
+ `/** A verified principal identity (\`user:<uid>\` on the wire) — construct with [nomosPrincipal]. A distinct`,
1132
+ ` * branded type from [NomosDeviceSecret] so the two birth-method params can never be transposed. */`,
1133
+ `export type NomosPrincipal = string & { readonly __nomosPrincipal: unique symbol };`,
1134
+ `/** A device signing secret (base64) — construct with [nomosDeviceSecret]. Distinct from [NomosPrincipal]. */`,
1135
+ `export type NomosDeviceSecret = string & { readonly __nomosDeviceSecret: unique symbol };`,
1136
+ `/** Brand a principal: a bare uid (\`ada\`) or a wire string (\`user:ada\`) → \`user:ada\`. */`,
1137
+ `export const nomosPrincipal = (uidOrWire: string): NomosPrincipal =>`,
1138
+ ` (uidOrWire.startsWith("user:") ? uidOrWire : \`user:\${uidOrWire}\`) as NomosPrincipal;`,
1139
+ `/** Brand a device signing secret (base64). */`,
1140
+ `export const nomosDeviceSecret = (base64: string): NomosDeviceSecret => base64 as NomosDeviceSecret;`,
1141
+ ``,
1142
+ );
1143
+ }
1144
+
1049
1145
  if (encPresent) {
1050
1146
  out.push(
1051
1147
  `// ── E2E SEALING (the sealing client) — baked map + KeyStore + seal/read helpers ──`,
@@ -1686,10 +1782,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1686
1782
  for (const dir of Object.values(dm.directives)) for (const f of Object.values(dir)) s.add(f.scope);
1687
1783
  return [...s].sort();
1688
1784
  })();
1785
+ // directiveId → its emitted payload TYPE name (the surfaced `resolve…` helpers reference it).
1786
+ const dirPayloadNames = new Map<string, string>();
1689
1787
  for (const d of dirs) {
1690
1788
  let payloadName = `${cap(d.id)}Payload`;
1691
1789
  if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
1692
1790
  usedPayloadNames.add(payloadName);
1791
+ dirPayloadNames.set(d.id, payloadName);
1693
1792
  const schema = (d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema;
1694
1793
  const stamps = autoStampFields(schema);
1695
1794
  const mint = mintPlan?.get(d.id);
@@ -1723,6 +1822,27 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1723
1822
  `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed, lazyReg, d.id)};`,
1724
1823
  ``,
1725
1824
  );
1825
+ // A `.births()` directive is NOT a same-workspace write — it is a signed CROSS-WORKSPACE governance
1826
+ // offer to a PARENT. It gets a typed `holon.createWorkspace(...)` wrapper (declaring domain +
1827
+ // directiveId + baked domainHash bound at codegen time), NOT an `offerDirective`, so a frontend dev
1828
+ // never hand-writes the four raw strings this replaces (the co2 production bug).
1829
+ if ((d as { birthsChild?: boolean }).birthsChild === true) {
1830
+ const birthPayload = stamps.length ? `stampNow(payload, ${JSON.stringify(stamps)})` : "payload";
1831
+ const gateBirthDoc = gate ? ` GATED: needs \`actor\` (\`${gate.relation}\`).` : "";
1832
+ methods.push(
1833
+ ` /** Births a CHILD WORKSPACE (named by \`${payloadName}\`) under \`parent\`, via a SIGNED cross-workspace`,
1834
+ ` * governance offer to the parent's \`${domain}/${d.id}\` law (POST /v2/workspaces/<parent>/${d.id}) — NOT a`,
1835
+ ` * same-workspace write, hence \`parent\`/\`actor\` (the requesting principal, e.g. \`user:<uid>\`) unlike the`,
1836
+ ` * other methods. Declaring domain, directiveId, and law hash are baked in; returns the parent gateway's`,
1837
+ ` * verdict. \`authorSecret\` defaults to the holon's device key.${gateBirthDoc} */`,
1838
+ ` ${d.id}: (opts: { parent: string; actor: NomosPrincipal; authorSecret?: NomosDeviceSecret; payload: ${payloadName}; authToken?: string }): Promise<{ ok?: boolean; status?: number; body?: unknown }> => {`,
1839
+ ...(gateGuard ? [` if (!opts.actor || !opts.actor.trim()) throw new MissingOfferAuthority(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${JSON.stringify(gate!.relation)});`] : []),
1840
+ ` const _payload = opts.payload;`,
1841
+ ` return (holon.createWorkspace ?? (() => { throw new Error("this holon predates cross-workspace births — upgrade @githolon/client"); }))({ domain: ${JSON.stringify(domain)}, directiveId: ${JSON.stringify(d.id)}, domainHash, parent: opts.parent, actor: opts.actor, authorSecret: opts.authorSecret, payload: ${birthPayload.replace("payload", "_payload")}, authToken: opts.authToken });`,
1842
+ ` },`,
1843
+ );
1844
+ continue;
1845
+ }
1726
1846
  // THE ONE WRITE OUTCOME (architecture/nomos_ref.md): EVERY directive method returns Promise<NomosOutcome>
1727
1847
  // — `created` (typed refs of all minted aggregates, from returnCreated) + `head`, plus `target` for the
1728
1848
  // front-door-mint lane (the one statically-known addressed instance). One async block covers every case.
@@ -1822,6 +1942,51 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1822
1942
  : ` ${lcFirst(camel(s.id))}: () => holon.sum(${JSON.stringify(s.id)}),`,
1823
1943
  );
1824
1944
  }
1945
+
1946
+ // ── SURFACED CONFLICT DX (surfaced_merge_conflict.md): the workspace-wide pendingConflicts
1947
+ // accessor + one typed `resolve…` helper per designated `.resolves()` directive. Emitted iff
1948
+ // this domain declares a Surfaced field (byte-identical otherwise — the era rule).
1949
+ const resolverBindings = (mod as { surfacedResolvers?: import("./surfaced_lint.js").SurfacedResolverBinding[] }).surfacedResolvers ?? [];
1950
+ if (resolverBindings.length > 0) {
1951
+ methods.push(
1952
+ ` /** PENDING SURFACED CONFLICTS across the workspace — the maintained "decisions needed" read`,
1953
+ ` * (reserved id \`nomosPendingConflicts\`; derived, not drained: a resolution write removes its`,
1954
+ ` * row by derivation). Pair each row with its typed \`resolve…\` helper below. */`,
1955
+ ` pendingConflicts: () => {`,
1956
+ ` if (!holon.pendingConflicts) throw new Error("this holon predates pendingConflicts reads — upgrade @githolon/client");`,
1957
+ ` return holon.pendingConflicts();`,
1958
+ ` },`,
1959
+ ` /** Watch the pending Surfaced conflicts (local reactive read; returns stop()). */`,
1960
+ ` watchPendingConflicts: (cb: (rows: PendingConflictRow[]) => void, opts?: { intervalMs?: number }) => {`,
1961
+ ` if (!holon.pendingConflicts) throw new Error("this holon predates pendingConflicts reads — upgrade @githolon/client");`,
1962
+ ` let stopped = false;`,
1963
+ ` let last = "";`,
1964
+ ` const tick = async () => { try { const rows = await holon.pendingConflicts!(); const j = JSON.stringify(rows); if (!stopped && j !== last) { last = j; cb(rows); } } catch { /* projection catching up */ } };`,
1965
+ ` void tick();`,
1966
+ ` const t = setInterval(tick, opts?.intervalMs ?? 1000);`,
1967
+ ` return () => { stopped = true; clearInterval(t); };`,
1968
+ ` },`,
1969
+ );
1970
+ for (const b of resolverBindings) {
1971
+ if (b.valuePayloadField === undefined) continue; // probe couldn't bind — dispatch the directive directly.
1972
+ const payloadName = dirPayloadNames.get(b.directiveId);
1973
+ if (payloadName === undefined) continue;
1974
+ const helper = `resolve${cap(camel(b.aggregateId))}${cap(camel(b.field))}`;
1975
+ const idField = b.idPayloadField;
1976
+ const bound = [b.valuePayloadField, ...(idField !== undefined ? [idField] : [])];
1977
+ const restType = `Omit<${payloadName}, ${bound.map((f) => JSON.stringify(f)).join(" | ")}>`;
1978
+ const conflictParam = idField !== undefined ? `conflict: string | PendingConflictRow, ` : ``;
1979
+ const payloadExpr = `{ ...(rest ?? {}), ${idField !== undefined ? `${JSON.stringify(idField)}: typeof conflict === "string" ? conflict : conflict.aggregateId, ` : ``}${JSON.stringify(b.valuePayloadField)}: chosen } as ${payloadName}`;
1980
+ methods.push(
1981
+ ` /** RESOLVE a pending conflict on \`${b.aggregateId}.${b.field}\` by dispatching the DESIGNATED`,
1982
+ ` * resolver \`${b.directiveId}\` with the chosen value${idField !== undefined ? ` (the conflict row — or the bare aggregate id — names the instance)` : ""}.`,
1983
+ ` * ESCAPE HATCH: any gated directive that sets the field still resolves the conflict in the`,
1984
+ ` * kernel sense; \`.resolves()\` only designates which one THIS typed helper dispatches. */`,
1985
+ ` ${helper}: (${conflictParam}chosen: ${payloadName}[${JSON.stringify(b.valuePayloadField)}], rest?: ${restType}): Promise<NomosOutcome> =>`,
1986
+ ` _outcome(holon.workspace, holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(b.directiveId)}, ${payloadExpr}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>),`,
1987
+ );
1988
+ }
1989
+ }
1825
1990
  for (const agg of mod.aggregates as AggregateHandle[]) {
1826
1991
  const aScopes = readScopesFor(agg.id);
1827
1992
  methods.push(
@@ -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":
@@ -433,16 +457,22 @@ const DART_SUPPORT_CORE_SRC = [
433
457
  "foreign_ref.dart",
434
458
  "activity.dart",
435
459
  "nomos_ref.dart",
460
+ // Branded identity wrappers (NomosPrincipal/NomosDeviceSecret) — the generated cross-workspace birth
461
+ // methods type `actor`/`authorSecret` as these (not bare strings), so both must vendor. Pure data.
462
+ "principal.dart",
436
463
  // The custody-lifecycle types (NomosCustodyBreak/NomosCustodyStatus/CustodyBreakPolicy) — the generated
437
464
  // client + the vendored bridge reference them through the barrel, so they must vendor. Pure data.
438
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",
439
469
  ] as const;
440
470
  // The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
441
471
  // bridge the generated `<name>.client.dart` drives, and the provider surface. Their
442
472
  // `package:nomos_types/...` imports are rewritten to the local vendored shim (see emitDart).
443
473
  // (`home.dart` is NOT vendored — the home-onboarding runtime drags `package:crypto`; nothing the
444
474
  // codegen emits references it.)
445
- 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;
446
476
  const DART_SUPPORT_SRC = [...DART_SUPPORT_CORE_SRC, ...DART_SUPPORT_RUNTIME_SRC] as const;
447
477
  /** The local shim replacing `package:nomos_types/nomos_types.dart` inside vendored runtime files. */
448
478
  const VENDORED_TYPES_SHIM = "nomos_types.dart";
@@ -869,7 +899,9 @@ const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
869
899
  * Caret on 0.x is EXACT-MINOR (`^0.45.0` = `<0.46.0`), so these must track the CURRENT release, not
870
900
  * just the floor generated code needs — a lagging pin forces tenants into dependency_overrides. */
871
901
  // 0.1.4: custody-lifecycle types (CustodyBreakPolicy et al).
872
- const NOMOS_TYPES_DEP = "^0.1.4";
902
+ // 0.2.0: branded birth identities (NomosPrincipal/NomosDeviceSecret) — the generated cross-workspace
903
+ // `.births()` methods type actor/authorSecret as these, so a layered package needs nomos_types 0.2.0.
904
+ const NOMOS_TYPES_DEP = "^0.2.0";
873
905
  // 0.46.0: custody lifecycle on NomosBridge/NomosSession (onCustodyBreak/custodyStatus/salvage lanes).
874
906
  const NOMOS_CLIENT_DEP = "^0.46.0";
875
907
 
@@ -955,7 +987,7 @@ function emitDartPackages(
955
987
  dartOut: string,
956
988
  domainHash: string,
957
989
  packageInterface: Record<string, DomainInterface>,
958
- packages: { readonly types: string; readonly client: string; readonly test?: string },
990
+ packages: { readonly types: string; readonly client: string; readonly test?: string; readonly compat?: string; readonly barrelExports?: "all" | "explicit" },
959
991
  names: Readonly<Record<string, string>> | undefined,
960
992
  compat?: NomosCompat,
961
993
  ): { written: string[]; cursed: string[] } {
@@ -964,8 +996,13 @@ function emitDartPackages(
964
996
  fail(`dart.packages.${role}: '${String(name)}' is not a valid pub package name (lower_snake_case)`);
965
997
  }
966
998
  }
967
- const roleNames = [packages.types, packages.client, ...(packages.test !== undefined ? [packages.test] : [])];
968
- if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test names must be distinct`);
999
+ const roleNames = [
1000
+ packages.types,
1001
+ packages.client,
1002
+ ...(packages.test !== undefined ? [packages.test] : []),
1003
+ ...(packages.compat !== undefined ? [packages.compat] : []),
1004
+ ];
1005
+ if (new Set(roleNames).size !== roleNames.length) fail(`dart.packages: types/client/test/compat names must be distinct`);
969
1006
 
970
1007
  const shared = collectDartSharedSymbols(modules);
971
1008
  const written: string[] = [];
@@ -1075,16 +1112,40 @@ function emitDartPackages(
1075
1112
  const aliasImportUris = [...typesSources.keys()]
1076
1113
  .filter((f) => [...(declaredByFile.get(f) ?? [])].some((n) => aliasTargets.has(n)))
1077
1114
  .map((f) => `${genRel}/${f}.dart`);
1115
+ // COMPAT ROUTING (architecture/dart_naming.md): when `packages.compat` is set, the `dart.names`
1116
+ // typedefs go into a SEPARATE migration-only package instead of the types barrel. The types barrel
1117
+ // then carries NO aliases (fully humane vs. compat separation); when compat is UNSET, aliases land
1118
+ // in the types barrel exactly as before.
1119
+ const compatPackage = packages.compat;
1120
+ const routeAliasesToCompat = compatPackage !== undefined && compatPackage !== "" && names !== undefined && Object.keys(names).length > 0;
1121
+ const barrelNames = routeAliasesToCompat ? undefined : names;
1122
+ const barrelAliasImportUris = routeAliasesToCompat ? [] : aliasImportUris;
1123
+ // BARREL PROMOTION POLICY (dart_naming.md): `'explicit'` on a MULTI-domain package stops the barrel
1124
+ // flat-exporting per-domain aggregate/read-model classes (consumers use `as`-prefixed per-domain
1125
+ // imports); the shared core (framework/contracts/activity) still flat-exports. A single-domain package
1126
+ // (no collision risk) is UNAFFECTED — byte-identical to `'all'`.
1127
+ const domainFileBases = new Set(modules.map((m) => m.name));
1128
+ const explicitBarrel = packages.barrelExports === "explicit" && modules.length > 1;
1129
+ const typesExportKeys = explicitBarrel
1130
+ ? [...typesSources.keys()].filter((f) => !domainFileBases.has(f))
1131
+ : [...typesSources.keys()];
1132
+ const explicitBarrelNote = explicitBarrel
1133
+ ? [
1134
+ `// barrelExports: 'explicit' — per-domain aggregate/read-model classes are NOT flat-exported here.`,
1135
+ `// Import a domain with a prefix instead, e.g. \`import '${genRel}/<domain>.dart' as <domain>;\`.`,
1136
+ ]
1137
+ : [];
1078
1138
  const typesBarrel = [
1079
1139
  "// GENERATED by nomos-compile — the TYPES package's public entrypoint. DO NOT EDIT.",
1080
1140
  "// Pure data: payload classes, read models, VOs/enums, typed read accessors over the",
1081
1141
  "// injected NomosReads seams. Frontend-safe — no bridge, no sessions, no lifecycle.",
1082
1142
  "library;",
1083
1143
  "",
1084
- ...aliasImportLines(names, aliasImportUris),
1144
+ ...explicitBarrelNote,
1145
+ ...aliasImportLines(barrelNames, barrelAliasImportUris),
1085
1146
  `export 'package:nomos_types/nomos_types.dart';`,
1086
- ...[...typesSources.keys()].map((f) => `export '${genRel}/${f}.dart';`),
1087
- ...dartAliasLines(names, declared),
1147
+ ...typesExportKeys.map((f) => `export '${genRel}/${f}.dart';`),
1148
+ ...dartAliasLines(barrelNames, declared),
1088
1149
  "",
1089
1150
  ];
1090
1151
  const typesBarrelPath = path.join(pkgDir(packages.types), "lib", `${packages.types}.dart`);
@@ -1187,6 +1248,62 @@ function emitDartPackages(
1187
1248
  );
1188
1249
  }
1189
1250
 
1251
+ // ── the COMPAT package (optional, MIGRATION-ONLY) — pure `dart.names` typedefs over the types package ──
1252
+ // Routed here (instead of the types barrel) when `dart.packages.compat` is set: a standalone, clearly
1253
+ // labeled, deletable-when-done shim. The typedefs resolve because they import the types barrel (which
1254
+ // re-exports every generated symbol). Validated fail-closed by `dartAliasLines` against the types surface.
1255
+ if (routeAliasesToCompat) {
1256
+ const compatName = compatPackage!;
1257
+ if (!isPubPackageName(compatName)) {
1258
+ fail(`dart.packages.compat: '${compatName}' is not a valid pub package name (lower_snake_case)`);
1259
+ }
1260
+ const compatGen = freshGenDir(compatName);
1261
+ const typesImportForCompat = `package:${packages.types}/${packages.types}.dart`;
1262
+ const compatFile = [
1263
+ "// GENERATED by nomos-compile — MIGRATION-ONLY compat aliases. DO NOT EDIT.",
1264
+ `//`,
1265
+ `// MIGRATION-ONLY — delete this package (\`${compatName}\`) once callers have moved to the real`,
1266
+ `// names in \`${packages.types}\`. Every symbol below is a plain \`typedef\` over the real generated`,
1267
+ `// type; it exists solely to keep pre-rename imports compiling during a migration. It adds NO`,
1268
+ `// behaviour and SHOULD NOT accrete new entries — new code imports \`${packages.types}\` directly.`,
1269
+ "library;",
1270
+ "",
1271
+ `import '${typesImportForCompat}';`,
1272
+ ...dartAliasLines(names, declared),
1273
+ "",
1274
+ ];
1275
+ const compatSrcPath = path.join(compatGen, `${compatName}_aliases.dart`);
1276
+ writeFileSync(compatSrcPath, compatFile.join("\n"), "utf8");
1277
+ written.push(compatSrcPath);
1278
+ const compatBarrel = [
1279
+ "// GENERATED by nomos-compile — the COMPAT package's public entrypoint. DO NOT EDIT.",
1280
+ `// MIGRATION-ONLY — delete \`${compatName}\` once callers use the real names in \`${packages.types}\`.`,
1281
+ "library;",
1282
+ "",
1283
+ `export '${typesImportForCompat}';`,
1284
+ `export '${genRel}/${compatName}_aliases.dart';`,
1285
+ "",
1286
+ ];
1287
+ const compatBarrelPath = path.join(pkgDir(compatName), "lib", `${compatName}.dart`);
1288
+ writeFileSync(compatBarrelPath, compatBarrel.join("\n"), "utf8");
1289
+ written.push(compatBarrelPath);
1290
+ scaffoldPubspec(
1291
+ pkgDir(compatName),
1292
+ [
1293
+ `name: ${compatName}`,
1294
+ `description: MIGRATION-ONLY Nomos name-compat shim (typedefs over ${packages.types}). Delete once migrated.`,
1295
+ `version: 0.1.0`,
1296
+ `publish_to: none # tenant-owned: remove to publish`,
1297
+ `environment:`,
1298
+ ` sdk: ^3.5.0`,
1299
+ `dependencies:`,
1300
+ ` ${packages.types}:`,
1301
+ ` path: ../${packages.types} # co-emitted sibling; replace with a hosted constraint when publishing`,
1302
+ ``,
1303
+ ].join("\n"),
1304
+ );
1305
+ }
1306
+
1190
1307
  return { written, cursed: cursedNameHints(declared, names) };
1191
1308
  }
1192
1309
 
@@ -2345,7 +2462,7 @@ async function main(): Promise<void> {
2345
2462
  typeof dartCfg.packages.client !== "string")))
2346
2463
  ) {
2347
2464
  fail(
2348
- `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)}`,
2465
+ `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)}`,
2349
2466
  );
2350
2467
  }
2351
2468
  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(