@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.
@@ -0,0 +1,56 @@
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 QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /// BRANDED IDENTITY TYPES for the generated cross-workspace birth methods.
7
+ ///
8
+ /// A `.births()` directive's generated wrapper takes an `actor` (WHO births) and an optional
9
+ /// `authorSecret` (the signing key). Both were previously bare strings — two adjacent same-shaped
10
+ /// params that a caller can transpose with NO compiler error and a silent runtime failure (the same
11
+ /// bug class as a raw `domain`/`directiveId` swap). These wrappers make the transposition a type
12
+ /// error: `NomosPrincipal` and `NomosDeviceSecret` are distinct types, so passing one where the other
13
+ /// belongs won't compile. The generated method unwraps `.wire` to call the (unchanged) runtime
14
+ /// primitive `NomosBridge.createWorkspace(actor: String, authorSecret: String?, …)`.
15
+
16
+ /// A verified principal identity (`user:<uid>` on the wire). Construct with [NomosPrincipal.user]
17
+ /// from a bare uid, or [NomosPrincipal.parse] from a `user:<uid>` wire string.
18
+ class NomosPrincipal {
19
+ /// The bare uid (no `user:` prefix).
20
+ final String uid;
21
+ const NomosPrincipal._(this.uid);
22
+
23
+ /// From a bare uid (e.g. an auth-provider user id) — the common case.
24
+ factory NomosPrincipal.user(String uid) => NomosPrincipal._(uid);
25
+
26
+ /// From a `user:<uid>` wire string — throws [ArgumentError] on any other shape.
27
+ factory NomosPrincipal.parse(String wire) {
28
+ if (!wire.startsWith('user:')) {
29
+ throw ArgumentError('NomosPrincipal.parse: expected "user:<uid>", got "$wire"');
30
+ }
31
+ return NomosPrincipal._(wire.substring(5));
32
+ }
33
+
34
+ /// The wire form the kernel seals as the captured principal: `user:<uid>`.
35
+ String get wire => 'user:$uid';
36
+
37
+ @override
38
+ bool operator ==(Object other) => other is NomosPrincipal && other.uid == uid;
39
+ @override
40
+ int get hashCode => uid.hashCode;
41
+ @override
42
+ String toString() => wire;
43
+ }
44
+
45
+ /// A device signing secret (base64). A distinct type from [NomosPrincipal] so it can't be swapped
46
+ /// with `actor`; its [toString] NEVER leaks the secret (redacted), so it is safe in logs/errors.
47
+ class NomosDeviceSecret {
48
+ final String _base64;
49
+ const NomosDeviceSecret(this._base64);
50
+
51
+ /// The base64 wire form handed to the (unchanged) runtime primitive.
52
+ String get wire => _base64;
53
+
54
+ @override
55
+ String toString() => 'NomosDeviceSecret(***)';
56
+ }
@@ -0,0 +1,208 @@
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.
5
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
6
+
7
+ /// SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md) — the
8
+ /// user-resolvable third merge posture. A field declared `.merge(Surfaced(inner))`
9
+ /// reads as the PLAIN scalar once resolved, or — while genuinely concurrent writes
10
+ /// await a human pick — as the pending envelope
11
+ /// `{"__surfaced":{"pending":[{value, author, occurredAt}, ...]}}` (ALWAYS 2+
12
+ /// competing observations — a single-observation set read-collapses to the plain
13
+ /// scalar; bounded, cap 8).
14
+ ///
15
+ /// The generated read models type such a field as `SurfacedValue<T>?`; app code
16
+ /// branches on [SurfacedValue.isPending] (or the generated `has…PendingConflict`
17
+ /// accessor) and resolves the conflict with an ORDINARY next write — a domain
18
+ /// directive that `set()`s the field. No transformation happens here: this types
19
+ /// the kernel's exact read-collapse JSON.
20
+ library;
21
+
22
+ import 'wire.dart' show Hlc;
23
+
24
+ /// The pending-envelope discriminator key in the read-collapse JSON.
25
+ const String surfacedEnvelopeKey = '__surfaced';
26
+
27
+ /// The JSON key of an observation's writer principal — pinned in ONE place
28
+ /// (kernel b215430d confirmed it as exactly `author`, a nullable uid string).
29
+ const String surfacedAuthorKey = 'author';
30
+
31
+ /// One competing observation retained on a pending Surfaced field: the written
32
+ /// [value], the HLC stamp it was written at, and the writer principal ([author],
33
+ /// NULLABLE — an observation folded where no intent context existed carries none).
34
+ class SurfacedObservation<T> {
35
+ final T value;
36
+ final Hlc occurredAt;
37
+ final String? author;
38
+
39
+ const SurfacedObservation({
40
+ required this.value,
41
+ required this.occurredAt,
42
+ this.author,
43
+ });
44
+
45
+ /// Decode one `{value, occurredAt, author?}` entry; [decodeT] turns the raw
46
+ /// JSON `value` leaf into the field's typed Dart value.
47
+ factory SurfacedObservation.fromJson(
48
+ Map<String, dynamic> json,
49
+ T Function(dynamic) decodeT,
50
+ ) =>
51
+ SurfacedObservation(
52
+ value: decodeT(json['value']),
53
+ occurredAt: Hlc.fromJson(
54
+ (json['occurredAt'] as Map).cast<String, dynamic>()),
55
+ author: json[surfacedAuthorKey] as String?,
56
+ );
57
+
58
+ @override
59
+ bool operator ==(Object other) =>
60
+ other is SurfacedObservation<T> &&
61
+ other.value == value &&
62
+ other.occurredAt == occurredAt &&
63
+ other.author == author;
64
+
65
+ @override
66
+ int get hashCode => Object.hash(value, occurredAt, author);
67
+
68
+ @override
69
+ String toString() =>
70
+ 'SurfacedObservation(value: $value, occurredAt: $occurredAt, author: $author)';
71
+ }
72
+
73
+ /// A Surfaced field's read value: [SurfacedResolved] (the plain scalar — the
74
+ /// steady state, byte-identical to any other field) or [SurfacedPending] (the
75
+ /// kernel retained BOTH sides of a genuinely concurrent write for a human pick).
76
+ sealed class SurfacedValue<T> {
77
+ const SurfacedValue();
78
+
79
+ /// Decode a raw read-collapse leaf: the `{"__surfaced":{...}}` envelope becomes
80
+ /// [SurfacedPending]; any other value is the resolved scalar (via [decodeT]).
81
+ factory SurfacedValue.fromJson(dynamic json, T Function(dynamic) decodeT) {
82
+ if (json is Map && json.containsKey(surfacedEnvelopeKey)) {
83
+ final env =
84
+ (json[surfacedEnvelopeKey] as Map).cast<String, dynamic>();
85
+ final rawPending = (env['pending'] as List? ?? const []);
86
+ return SurfacedPending<T>(List.unmodifiable(rawPending.map((o) =>
87
+ SurfacedObservation<T>.fromJson(
88
+ (o as Map).cast<String, dynamic>(), decodeT))));
89
+ }
90
+ return SurfacedResolved<T>(decodeT(json));
91
+ }
92
+
93
+ /// Tolerant field decode off a flat projection `data` map: absent/null ⇒ null
94
+ /// (the PARTIAL-FOLD doctrine — a freshly-folded aggregate may not carry the
95
+ /// field yet); present ⇒ [SurfacedValue.fromJson].
96
+ static SurfacedValue<T>? decode<T>(
97
+ Map<String, dynamic> data,
98
+ String key,
99
+ T Function(dynamic) decodeT,
100
+ ) {
101
+ final raw = data[key];
102
+ if (raw == null) return null;
103
+ return SurfacedValue<T>.fromJson(raw, decodeT);
104
+ }
105
+
106
+ /// True while the field awaits a resolution (competing observations retained).
107
+ bool get isPending => this is SurfacedPending<T>;
108
+
109
+ /// The resolved scalar, or null while pending.
110
+ T? get valueOrNull {
111
+ final self = this;
112
+ return self is SurfacedResolved<T> ? self.value : null;
113
+ }
114
+
115
+ /// The competing observations while pending; const empty once resolved.
116
+ List<SurfacedObservation<T>> get pending {
117
+ final self = this;
118
+ return self is SurfacedPending<T>
119
+ ? self.observations
120
+ : const [];
121
+ }
122
+ }
123
+
124
+ /// ONE row of the workspace-wide `pendingConflicts()` read (reserved id
125
+ /// `nomosPendingConflicts`): a (aggregate, field) whose folded state awaits a
126
+ /// human pick. MAINTAINED like counts/sums (fold-time, O(pending) to read) and
127
+ /// DERIVED, NOT DRAINED — a resolution write folding in removes the row by
128
+ /// derivation, never a consumer acking it. The workspace-wide surface is
129
+ /// HETEROGENEOUS, so each observation's `value` is `Object?` here — the
130
+ /// per-field typed seam stays on the read models ([SurfacedValue]).
131
+ class PendingConflictRow {
132
+ /// The aggregate's logical type (e.g. `Doc`).
133
+ final String aggregateType;
134
+
135
+ /// The conflicted instance's aggregate id.
136
+ final String aggregateId;
137
+
138
+ /// The Surfaced field awaiting a resolution.
139
+ final String field;
140
+
141
+ /// The competing observations (2+, bounded) — value/author/occurredAt.
142
+ final List<SurfacedObservation<Object?>> observations;
143
+
144
+ const PendingConflictRow({
145
+ required this.aggregateType,
146
+ required this.aggregateId,
147
+ required this.field,
148
+ required this.observations,
149
+ });
150
+
151
+ factory PendingConflictRow.fromJson(Map<String, dynamic> json) =>
152
+ PendingConflictRow(
153
+ aggregateType: json['aggregateType'] as String,
154
+ aggregateId: json['aggregateId'] as String,
155
+ field: json['field'] as String,
156
+ observations: List.unmodifiable(
157
+ (json['observations'] as List? ?? const []).map((o) =>
158
+ SurfacedObservation<Object?>.fromJson(
159
+ (o as Map).cast<String, dynamic>(), (v) => v))),
160
+ );
161
+
162
+ @override
163
+ String toString() =>
164
+ 'PendingConflictRow($aggregateType:$aggregateId.$field, ${observations.length} observations)';
165
+ }
166
+
167
+ /// The steady state: the field folded to one value (never conflicted, or a
168
+ /// resolution write superseded the pending set).
169
+ final class SurfacedResolved<T> extends SurfacedValue<T> {
170
+ final T value;
171
+ const SurfacedResolved(this.value);
172
+
173
+ @override
174
+ bool operator ==(Object other) =>
175
+ other is SurfacedResolved<T> && other.value == value;
176
+
177
+ @override
178
+ int get hashCode => Object.hash('SurfacedResolved', value);
179
+
180
+ @override
181
+ String toString() => 'SurfacedResolved($value)';
182
+ }
183
+
184
+ /// The conflict state: the bounded competing observations (ALWAYS 2+ — a
185
+ /// single-observation set read-collapses to the plain scalar; cap 8,
186
+ /// deterministically pruned by the kernel). Show them; let a human pick; write
187
+ /// the pick back with an ordinary domain directive.
188
+ final class SurfacedPending<T> extends SurfacedValue<T> {
189
+ final List<SurfacedObservation<T>> observations;
190
+ const SurfacedPending(this.observations);
191
+
192
+ @override
193
+ bool operator ==(Object other) {
194
+ if (other is! SurfacedPending<T>) return false;
195
+ if (other.observations.length != observations.length) return false;
196
+ for (var i = 0; i < observations.length; i++) {
197
+ if (other.observations[i] != observations[i]) return false;
198
+ }
199
+ return true;
200
+ }
201
+
202
+ @override
203
+ int get hashCode =>
204
+ Object.hashAll(['SurfacedPending', ...observations]);
205
+
206
+ @override
207
+ String toString() => 'SurfacedPending($observations)';
208
+ }
@@ -11,7 +11,7 @@ description: >-
11
11
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
12
12
  no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
13
13
  reactive read engine, schema validation, and data-only error/trace types.
14
- version: 0.1.4
14
+ version: 0.2.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -0,0 +1,69 @@
1
+ import 'package:nomos_types/nomos_types.dart';
2
+ import 'package:test/test.dart';
3
+
4
+ void main() {
5
+ group('SurfacedValue', () {
6
+ test('a plain scalar decodes RESOLVED', () {
7
+ final v = SurfacedValue<String>.fromJson('hello', (x) => x as String);
8
+ expect(v, isA<SurfacedResolved<String>>());
9
+ expect(v.isPending, isFalse);
10
+ expect(v.valueOrNull, 'hello');
11
+ expect(v.pending, isEmpty);
12
+ });
13
+
14
+ test('the __surfaced envelope decodes PENDING (value + occurredAt + nullable author)', () {
15
+ final v = SurfacedValue<String>.fromJson({
16
+ '__surfaced': {
17
+ 'pending': [
18
+ {
19
+ 'value': 'a',
20
+ 'occurredAt': {'physical': 10, 'logical': 1, 'replica': 7},
21
+ 'author': 'user:ada',
22
+ },
23
+ {
24
+ 'value': 'b',
25
+ 'occurredAt': {'physical': 11, 'logical': 0, 'replica': 9},
26
+ // no author — an observation folded without intent context.
27
+ },
28
+ ],
29
+ },
30
+ }, (x) => x as String);
31
+ expect(v, isA<SurfacedPending<String>>());
32
+ expect(v.isPending, isTrue);
33
+ expect(v.valueOrNull, isNull);
34
+ expect(v.pending, hasLength(2));
35
+ expect(v.pending[0].value, 'a');
36
+ expect(v.pending[0].author, 'user:ada');
37
+ expect(v.pending[0].occurredAt, const Hlc(physical: 10, logical: 1, replica: 7));
38
+ expect(v.pending[1].value, 'b');
39
+ expect(v.pending[1].author, isNull);
40
+ });
41
+
42
+ test('decode() off a flat data map: absent/null => null (partial-fold doctrine)', () {
43
+ expect(SurfacedValue.decode<String>({}, 'title', (x) => x as String), isNull);
44
+ expect(SurfacedValue.decode<String>({'title': null}, 'title', (x) => x as String), isNull);
45
+ final v = SurfacedValue.decode<String>({'title': 't'}, 'title', (x) => x as String);
46
+ expect(v, const SurfacedResolved<String>('t'));
47
+ });
48
+
49
+ test('value equality holds on both arms', () {
50
+ expect(const SurfacedResolved<String>('x'), const SurfacedResolved<String>('x'));
51
+ final p1 = SurfacedValue<String>.fromJson({
52
+ '__surfaced': {
53
+ 'pending': [
54
+ {'value': 'a', 'occurredAt': {'physical': 1, 'logical': 0, 'replica': 1}},
55
+ ],
56
+ },
57
+ }, (x) => x as String);
58
+ final p2 = SurfacedValue<String>.fromJson({
59
+ '__surfaced': {
60
+ 'pending': [
61
+ {'value': 'a', 'occurredAt': {'physical': 1, 'logical': 0, 'replica': 1}},
62
+ ],
63
+ },
64
+ }, (x) => x as String);
65
+ expect(p1, p2);
66
+ expect(p1.hashCode, p2.hashCode);
67
+ });
68
+ });
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.70.2",
3
+ "version": "0.72.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/aggregate.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * string: a typo'd handle is a compile error.
15
15
  */
16
16
  import type { Field } from "./fields.js";
17
+ import { admitsNull } from "./fields.js";
17
18
  import { satisfiesContract, type ContractHandle } from "./contract.js";
18
19
 
19
20
  /**
@@ -160,6 +161,14 @@ export interface AggregateHandle<
160
161
  * checked). Codegen marks the aggregate as implementing them so cross-domain refs can target the CONTRACT
161
162
  * instead of this concrete type. Additive metadata (not hashed law). OMITTED when it implements none. */
162
163
  readonly implementsContracts?: readonly string[];
164
+ /** THE DECLARED DART PRODUCT NAME (`.dartName("Site")`, architecture/dart_naming.md). When set, EVERY
165
+ * Dart codegen site that would otherwise derive a name from the aggregate WIRE id (the concrete
166
+ * aggregate class, the `<stem>ReadModel` projection class, the `<stem>Id` typed-id class, the
167
+ * `read/watch<Name>ById` accessors, and `ForeignRef<Target>` targets pointing at this aggregate) uses
168
+ * this name instead. Pure codegen PRESENTATION — the wire id / stable ids / projection type tag are
169
+ * UNCHANGED, and TS codegen stays id-derived. Additive, NON-law (never hashed — like `implementsContracts`);
170
+ * OMITTED when unset (a package with no `.dartName()` emits byte-identical Dart). */
171
+ readonly dartClassName?: string;
163
172
  /**
164
173
  * FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`)
165
174
  * ONLY on the framework-owned aggregate singletons declared in `dsl/src/framework/` — most
@@ -232,19 +241,34 @@ export interface AggregateHandle<
232
241
 
233
242
  /** The fluent AUDIENCE declarations returned by {@link aggregate} (secure-by-default). Each returns a
234
243
  * copy of the handle with the read audience set; you call exactly one at the declaration site. */
244
+ /** A settled aggregate handle that STILL accepts the cosmetic `.dartName()` (so it chains after a
245
+ * terminal audience declaration — `aggregate(...).public().dartName("Site")`), but not another
246
+ * audience choice. `.dartName()` itself returns the full fluent surface (so either order works). */
247
+ export type NameableHandle<Id extends string, F extends Record<string, Field>> = AggregateHandle<Id, F> & {
248
+ dartName(name: string): AggregateHandle<Id, F> & AudienceMethods<Id, F>;
249
+ };
250
+
235
251
  export interface AudienceMethods<Id extends string, F extends Record<string, Field>> {
236
252
  /** Anyone may read (plaintext) — the deliberate public exception. */
237
- public(): AggregateHandle<Id, F>;
253
+ public(): NameableHandle<Id, F>;
238
254
  /** Holders of `capability` may read (gate-enforced) — sugar over `requires(cap)`. */
239
- readableBy(capability: string): AggregateHandle<Id, F>;
255
+ readableBy(capability: string): NameableHandle<Id, F>;
240
256
  /** The owner (+ per-record grantees) read; owner = the verified author, stamped into `ownerField`
241
257
  * (default `__owner`); operator-blind. NOTE: full owned read-scoping + E2E land in the next slice. */
242
- owned(ownerField?: string): AggregateHandle<Id, F>;
258
+ owned(ownerField?: string): NameableHandle<Id, F>;
243
259
  /** DOMAIN CONTRACTS: assert this aggregate satisfies `contract`'s public shape (compile-checked — a
244
260
  * mismatch throws naming the field). Chainable; records the contract so cross-domain refs can target it. */
245
261
  implements(contract: ContractHandle): AggregateHandle<Id, F> & AudienceMethods<Id, F>;
262
+ /** DART PRODUCT NAME: declare the Dart-side name for this aggregate (`.dartName("Site")`) — Dart codegen
263
+ * only, non-law, additive (see {@link AggregateHandle.dartName}). Validated NOW: `name` must be a legal
264
+ * Dart type identifier (`/^[A-Z][A-Za-z0-9_]*$/`). Cross-aggregate collision is validated at compile.
265
+ * Chainable; re-declaration overwrites. */
266
+ dartName(name: string): AggregateHandle<Id, F> & AudienceMethods<Id, F>;
246
267
  }
247
268
 
269
+ /** A legal Dart TYPE identifier for `.dartName()` — PascalCase-ish (leading upper, then word chars). */
270
+ const DART_TYPE_NAME_RE = /^[A-Z][A-Za-z0-9_]*$/;
271
+
248
272
  export function aggregate<const Id extends string, F extends Record<string, Field>>(
249
273
  id: Id,
250
274
  fields: F,
@@ -300,6 +324,27 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
300
324
  }
301
325
  }
302
326
  }
327
+ // SURFACED MERGE CONFLICTS (architecture/surfaced_merge_conflict.md): `.merge(Surfaced(inner))`
328
+ // is v1-scoped to Lww-class SCALAR fields — the pending set holds competing scalar values a
329
+ // human picks between. The factory already refuses a non-Lww-class INNER; this refuses the
330
+ // wrapper on a field whose VALUE isn't a wholesale-replace scalar (a set/map/counter/position
331
+ // field's value space has no "pick one of two scalars" meaning) and on a null-admitting field
332
+ // (the tri-state NomosSlot and the pending envelope would fight over the same read surface).
333
+ for (const [name, f] of Object.entries(stored)) {
334
+ const field = f as Field;
335
+ if ((field.driver as { kind?: string }).kind !== "Surfaced") continue;
336
+ if (field.kind === "set" || field.kind === "map" || field.kind === "counter" || field.kind === "position") {
337
+ throw new Error(
338
+ `aggregate '${id}': field '${name}' is .merge(Surfaced(...)) but its kind is '${field.kind}' — Surfaced wraps Lww-class SCALAR fields only in v1 (a '${field.kind}' field already has a conflict-free structural merge)`,
339
+ );
340
+ }
341
+ if (admitsNull(field)) {
342
+ throw new Error(
343
+ `aggregate '${id}': field '${name}' is .merge(Surfaced(...)) but also admits null (t.nullable/t.clearable) — Surfaced + the tri-state null slot is not supported in v1; drop one of the two`,
344
+ );
345
+ }
346
+ }
347
+
303
348
  // The `.meta()` fields (plaintext-metadata opt-out on owned aggregates) — recorded for the compiler.
304
349
  const metaFields = Object.entries(stored)
305
350
  .filter(([, f]) => (f as Field).isMeta === true)
@@ -363,24 +408,36 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
363
408
  audience: Audience | undefined,
364
409
  visibility: ReadVisibility | undefined,
365
410
  contracts: readonly string[] = [],
411
+ dartNameDecl: string | undefined = undefined,
366
412
  ): AggregateHandle<Id, F> & AudienceMethods<Id, F> => ({
367
413
  ...base,
368
414
  ...(audience !== undefined ? { audience } : {}),
369
415
  ...(visibility !== undefined ? { visibility } : {}),
370
416
  ...(contracts.length > 0 ? { implementsContracts: contracts } : {}),
371
- public: () => build({ kind: "public" }, undefined, contracts),
417
+ ...(dartNameDecl !== undefined ? { dartClassName: dartNameDecl } : {}),
418
+ public: () => build({ kind: "public" }, undefined, contracts, dartNameDecl),
372
419
  readableBy: (cap: string) => {
373
420
  const v = requires(cap); // validates the capability name
374
- return build({ kind: "readableBy", cap: v.requires }, v, contracts);
421
+ return build({ kind: "readableBy", cap: v.requires }, v, contracts, dartNameDecl);
375
422
  },
376
- owned: (ownerField: string = OWNER_FIELD) => build({ kind: "owned", ownerField }, undefined, contracts),
423
+ owned: (ownerField: string = OWNER_FIELD) => build({ kind: "owned", ownerField }, undefined, contracts, dartNameDecl),
377
424
  // DOMAIN CONTRACTS: assert this aggregate satisfies `contractHandle`'s public shape — compile-checked
378
425
  // NOW (a mismatch throws a typed error naming the field). Records the contract so cross-domain refs may
379
426
  // target it. Chainable (`.implements(A).implements(B)`); the aggregate keeps its concrete fields.
380
427
  implements: (contractHandle: ContractHandle) => {
381
428
  const verdict = satisfiesContract(id, stored as Record<string, Field>, contractHandle);
382
429
  if (!verdict.ok) throw new Error(verdict.reason);
383
- return build(audience, visibility, contracts.includes(contractHandle.id) ? contracts : [...contracts, contractHandle.id]);
430
+ return build(audience, visibility, contracts.includes(contractHandle.id) ? contracts : [...contracts, contractHandle.id], dartNameDecl);
431
+ },
432
+ // DART PRODUCT NAME: validate the identifier shape NOW; store it (Dart codegen only, non-law).
433
+ dartName: (name: string) => {
434
+ if (!DART_TYPE_NAME_RE.test(name)) {
435
+ throw new Error(
436
+ `aggregate '${id}': .dartName(${JSON.stringify(name)}) must be a legal Dart type identifier ` +
437
+ `matching ${DART_TYPE_NAME_RE} (PascalCase — a leading uppercase letter, then letters/digits/underscore)`,
438
+ );
439
+ }
440
+ return build(audience, visibility, contracts, name);
384
441
  },
385
442
  });
386
443
 
@@ -77,6 +77,8 @@ import { expandCapabilityExports } from "./capability_exports.js";
77
77
  import { RelationTuple } from "./framework/relations.js";
78
78
  import { parentAttestedBirthMarker } from "./births.js";
79
79
  import { discoverUnsanctionedEnsures, sanctioningEnsureDirective } from "./secondary_ensure.js";
80
+ import { assertIdOfLint } from "./idof_lint.js";
81
+ import { assertSurfacedResolvers } from "./surfaced_lint.js";
80
82
 
81
83
  // ─────────────────────────────────────────────────────────────────────────────────
82
84
  // composeDomainModule — the named-keys `module(...)`
@@ -388,6 +390,23 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
388
390
  }
389
391
  : null;
390
392
 
393
+ // THE PAYLOAD-REF idOf LINT (dart_codegen_parity.md P2 — ref ergonomics): plan-probe every
394
+ // directive and refuse a directive whose payload feeds a ref/foreignRef field from a
395
+ // provably-bare (unbranded) `z.string()` — see `idof_lint.ts` for the exact, deliberately
396
+ // narrow scope. Compile-time only, no manifest/wire/hash effect.
397
+ assertIdOfLint(directives, spec.domain ?? spec.name);
398
+
399
+ // THE SURFACED-RESOLVER LINT (surfaced_lint.ts — the fail-closed teeth): every
400
+ // `.merge(Surfaced(...))` field must have EXACTLY ONE `.resolves()` directive, whose plan
401
+ // provably set()s the field from a payload field. Returns the resolver bindings the typed
402
+ // `resolve…` helper codegen consumes. Compile-time only — no manifest/wire/hash effect;
403
+ // a Surfaced-free domain runs zero checks (byte-identical).
404
+ const surfacedResolvers = assertSurfacedResolvers(
405
+ aggregates,
406
+ directives,
407
+ spec.domain ?? spec.name,
408
+ );
409
+
391
410
  return {
392
411
  name: spec.name,
393
412
  domain: spec.domain ?? spec.name,
@@ -398,6 +417,9 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
398
417
  // ROLE SUGAR codegen metadata — NOT law (not hashed): drives the `canEdit<Agg>`/`canView<Agg>`
399
418
  // UI-hint helpers. Omit-when-empty (a role-free domain is byte-identical).
400
419
  ...(roles.length > 0 ? { roles } : {}),
420
+ // SURFACED RESOLVER BINDINGS — NOT law (not hashed, like `roles`): drives the generated
421
+ // typed `resolve…` helpers + the `pendingConflicts` accessor. Omit-when-empty (era rule).
422
+ ...(surfacedResolvers.length > 0 ? { surfacedResolvers } : {}),
401
423
  ...(queries.length > 0 ? { queries } : {}),
402
424
  ...(counts.length > 0 ? { counts } : {}),
403
425
  ...(spatials.length > 0 ? { spatials } : {}),