@githolon/dsl 0.15.0 → 0.16.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.
@@ -41,6 +41,8 @@ class NomosBridge {
41
41
  final BridgeTransport _transport;
42
42
  final Map<int, Completer<Object?>> _pending = {};
43
43
  final Map<String, StreamController<List<dynamic>>> _watches = {};
44
+ // SHAPE COMPATIBILITY (#72): compat bindings emit a verdict MAP, not a row list.
45
+ final Map<String, StreamController<Map<String, Object?>>> _compatWatches = {};
44
46
  final StreamController<Map<String, Object?>> _syncEvents =
45
47
  StreamController<Map<String, Object?>>.broadcast();
46
48
  final StreamController<Map<String, Object?>> _signalEvents =
@@ -79,6 +81,17 @@ class NomosBridge {
79
81
  } else {
80
82
  ctrl.add((m['rows'] as List?) ?? const []);
81
83
  }
84
+ return;
85
+ }
86
+ // SHAPE COMPATIBILITY (#72): the compat binding emits a verdict OBJECT, not a row list.
87
+ final cctrl = _compatWatches[m['subId']];
88
+ if (cctrl != null && !cctrl.isClosed) {
89
+ if (m['ok'] == false) {
90
+ cctrl.addError(NomosBridgeError(
91
+ (m['error'] ?? 'the compat watch failed').toString()));
92
+ } else {
93
+ cctrl.add(((m['rows'] as Map?)?.cast<String, Object?>()) ?? const {});
94
+ }
82
95
  }
83
96
  return;
84
97
  }
@@ -244,6 +257,41 @@ class NomosBridge {
244
257
  Stream<List<dynamic>> watchById(String id) =>
245
258
  _watchStream(kind: 'byId', id: id);
246
259
 
260
+ /// SHAPE COMPATIBILITY (#72): one-shot verdict for this client's structural [requirement]
261
+ /// (its baked `…InterfaceJson`, decoded) against the workspace's ON-LEDGER offer (auto-sourced).
262
+ /// `{ ok, missing[], incompatible[] }` — names the exact directive/aggregate/field, not a hash.
263
+ Future<Map<String, Object?>> checkCompatibility(
264
+ Map<String, Object?> requirement,
265
+ [Map<String, Object?>? offer]) async =>
266
+ _asMap(await _call('checkCompatibility', {
267
+ 'requirement': requirement,
268
+ if (offer != null) 'offer': offer,
269
+ }));
270
+
271
+ /// SHAPE COMPATIBILITY as a LIVE BINDING (#72): a stream of verdicts that re-emits whenever the
272
+ /// workspace's installed law changes (another peer installs/upgrades/retires a domain and it
273
+ /// syncs in). The verdict carries `offerKnown`. The app degrades/prompts on `ok:false`.
274
+ Stream<Map<String, Object?>> watchCompatibility(
275
+ Map<String, Object?> requirement) {
276
+ final subId = 'c${++_seq}';
277
+ late final StreamController<Map<String, Object?>> ctrl;
278
+ ctrl = StreamController<Map<String, Object?>>(
279
+ onListen: () => _call('watch', {
280
+ 'subId': subId,
281
+ 'kind': 'compat',
282
+ 'requirement': requirement,
283
+ }),
284
+ onCancel: () async {
285
+ _compatWatches.remove(subId);
286
+ try {
287
+ await _call('unwatch', {'subId': subId});
288
+ } catch (_) {}
289
+ },
290
+ );
291
+ _compatWatches[subId] = ctrl;
292
+ return ctrl.stream;
293
+ }
294
+
247
295
  Stream<List<dynamic>> _watchStream(
248
296
  {required String kind,
249
297
  String? queryId,
@@ -281,6 +329,10 @@ class NomosBridge {
281
329
  if (!w.isClosed) await w.close();
282
330
  }
283
331
  _watches.clear();
332
+ for (final w in _compatWatches.values) {
333
+ if (!w.isClosed) await w.close();
334
+ }
335
+ _compatWatches.clear();
284
336
  if (!_syncEvents.isClosed) await _syncEvents.close();
285
337
  if (!_signalEvents.isClosed) await _signalEvents.close();
286
338
  if (!_flameEvents.isClosed) await _flameEvents.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.15.0",
3
+ "version": "0.16.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
@@ -144,6 +144,13 @@ export interface AggregateHandle<
144
144
  readonly hasInvariant?: true;
145
145
  /** The executable body — ships in the engine bundle, NOT the manifest. */
146
146
  readonly invariant?: AggregateInvariantFn;
147
+ /**
148
+ * THE DECLASSIFY WAIVER (nomosFlowGate rule 4): present iff the author acknowledged that this
149
+ * private aggregate's invariant refusal MAY correlate with a plaintext secret field. Rides the
150
+ * `nomosFlowGate` era key (replay-verified audit surface). Absent on a private+invariant
151
+ * aggregate ⇒ the flow gate refuses to compile (a secret-correlated refusal).
152
+ */
153
+ readonly invariantDeclassify?: string;
147
154
  /**
148
155
  * The LAW-DECLARED instance CAP (Jack's ruling: instance limits are DOMAIN POLICY,
149
156
  * never a fabric id-shape special case). `cap: 1` = at most one instance per
@@ -197,7 +204,7 @@ export interface AudienceMethods<Id extends string, F extends Record<string, Fie
197
204
  export function aggregate<const Id extends string, F extends Record<string, Field>>(
198
205
  id: Id,
199
206
  fields: F,
200
- opts?: { invariant?: AggregateInvariantFn; cap?: number; visibility?: ReadVisibility },
207
+ opts?: { invariant?: AggregateInvariantFn; invariantDeclassify?: string; cap?: number; visibility?: ReadVisibility },
201
208
  ): AggregateHandle<Id, F> & AudienceMethods<Id, F> {
202
209
  const { stored, hasMany } = partitionFields(fields);
203
210
  if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
@@ -244,6 +251,7 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
244
251
  ...(opts?.invariant !== undefined
245
252
  ? { hasInvariant: true as const, invariant: opts.invariant }
246
253
  : {}),
254
+ ...(opts?.invariantDeclassify !== undefined ? { invariantDeclassify: opts.invariantDeclassify } : {}),
247
255
  ...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
248
256
  ...(encryptedFields.length > 0 ? { encryptedFields } : {}),
249
257
  ...(metaFields.length > 0 ? { metaFields } : {}),
package/src/authoring.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  import type { AggregateHandle } from "./aggregate.js";
27
27
  import type { Field } from "./fields.js";
28
28
  import type { FieldOp, PlannedOp, PlannedEventMarker } from "./ops.js";
29
+ import { endorsementOf } from "./principal_evidence.js";
29
30
 
30
31
  // ── the per-dispatch authoring sink — library state the engine never sees (NOT a side channel out of
31
32
  // the sandbox; it is internal to the directive runtime, drained synchronously by `executeDirectiveToIntent`). ──
@@ -76,7 +77,15 @@ class Ref implements AggregateRef {
76
77
  }
77
78
  /** Emit a field op TARGETING THIS aggregate, stamped with this ref's marker (if any). */
78
79
  private emit(field: string, kind: FieldOp["kind"], extra: Partial<FieldOp>): void {
79
- const op: FieldOp = { aggregateId: this.__id, aggregateType: this.__type, field, kind, ...extra };
80
+ // MATERIALISE PrincipalEvidence on the fluent surface too (the endorse model — security_model.md):
81
+ // `.set("owner", ctx.principal().asAttribution())` stores the RAW principal + records the endorsement
82
+ // so the discovery harvest + runtime witness see the flow, exactly as the typed `set()` does. A plain
83
+ // value passes through untouched.
84
+ const endorsement = "value" in extra ? endorsementOf(extra.value) : null;
85
+ const resolved: Partial<FieldOp> = endorsement
86
+ ? { ...extra, value: endorsement.principal, endorsement: { facet: endorsement.facet, reason: endorsement.reason } }
87
+ : extra;
88
+ const op: FieldOp = { aggregateId: this.__id, aggregateType: this.__type, field, kind, ...resolved };
80
89
  sink.push(this.marker ? { ...op, marker: this.marker } : op);
81
90
  }
82
91
  set(field: string, value: unknown): this {
@@ -671,13 +671,24 @@ function aggregateFieldSpecs(
671
671
  f.kind === "string" && ownIdFields.has(name)
672
672
  ? aggregateIdDartType(agg.id)
673
673
  : undefined;
674
- const spec = aggFieldSpec(
674
+ const base = aggFieldSpec(
675
675
  name,
676
676
  f,
677
677
  enumDartTypes,
678
678
  refIdClassForAggregate,
679
679
  valueClass,
680
680
  );
681
+ // PARTIAL-FOLD DOCTRINE (every read-model field is nullable). The projection
682
+ // serves PARTIAL folds: a freshly-created or not-yet-fully-folded aggregate may
683
+ // carry no value for ANY scalar (only the fields touched so far appear in the
684
+ // flat `data`). So a read-model SCALAR is ALWAYS nullable — never a strict
685
+ // `as bool`/`readInt` that throws on an absent field (the `watch`/`read`
686
+ // decode would crash mid-fold). `.optional()` on the DSL field no longer
687
+ // changes the read surface; it was nullable in practice — this makes the type
688
+ // honest. Collections are unchanged (they already empty-default when absent).
689
+ const spec: AggFieldSpec = isCollectionKind(base.kind)
690
+ ? base
691
+ : { ...base, optional: true };
681
692
  if (used.has(spec.name)) {
682
693
  throw new Error(
683
694
  `aggregate '${agg.id}' field '${spec.wireName}' maps to duplicate Dart field '${spec.name}'`,
@@ -1049,9 +1060,9 @@ function emitAggregate(
1049
1060
  `/// projection \`data\` map (the \`{type,id,data}\` row \`query()\`/\`query_by_id()\``,
1050
1061
  `/// emits) — e.g. \`{"name":"HQ","createdAt":1000}\`. The kernel aggregate`,
1051
1062
  `/// [aggregateId] is carried from the row \`id\`. Read it reactively through the`,
1052
- `/// generated \`watch…\` accessors below. Collection fields (\`set\`/\`map\`) default to`,
1053
- `/// EMPTY and \`.optional()\` scalars to \`null\` when a freshly-folded aggregate has`,
1054
- `/// not yet folded them; must-be-folded scalars still throw when absent.`,
1063
+ `/// generated \`watch…\` accessors below. The projection serves PARTIAL folds, so`,
1064
+ `/// EVERY scalar field is nullable — \`null\` when a freshly-folded aggregate has not`,
1065
+ `/// yet folded it (never a strict cast that throws); collections default to EMPTY.`,
1055
1066
  `class ${className} {`,
1056
1067
  fieldDecls,
1057
1068
  ``,
@@ -1062,9 +1073,8 @@ function emitAggregate(
1062
1073
  ` /// Parse from the FLAT projection \`data\` map (the \`{type,id,data}\` row's`,
1063
1074
  ` /// \`data\` that \`query()\`/\`query_by_id()\` emit — fields already`,
1064
1075
  ` /// decoded to bare JSON), carrying the kernel aggregate [id] from the row \`id\``,
1065
- ` /// (defaults to '' for a bare-map call). Missing REQUIRED fields throw a`,
1066
- ` /// [FormatException]; absent collections decode empty and absent \`.optional()\``,
1067
- ` /// scalars decode \`null\`.`,
1076
+ ` /// (defaults to '' for a bare-map call). PARTIAL folds: every scalar decodes`,
1077
+ ` /// \`null\` when absent (never throws); absent collections decode empty.`,
1068
1078
  ` factory ${className}.fromJson(Map<String, dynamic> data, [String id = '']) {`,
1069
1079
  ` return ${className}(`,
1070
1080
  parseLines,
@@ -1471,8 +1481,9 @@ function emitReadModel(
1471
1481
  `/// DSL-named; a \`json\` value-object leaf (e.g. \`location\`)`,
1472
1482
  `/// is a STRUCTURED \`Map<String, Object?>\` — the decoded shape the engine emits, NOT a`,
1473
1483
  `/// JSON string. This class is fully DSL-derived.`,
1474
- `/// Collection fields (\`set\`/\`map\`) default to EMPTY and \`.optional()\` scalars to`,
1475
- `/// \`null\` when a freshly-folded aggregate has not yet folded them.`,
1484
+ `/// The projection serves PARTIAL folds, so EVERY scalar field is nullable —`,
1485
+ `/// \`null\` when a freshly-folded aggregate has not yet folded it (never a strict`,
1486
+ `/// cast that throws); collection fields (\`set\`/\`map\`) default to EMPTY.`,
1476
1487
  `class ${className} {`,
1477
1488
  fieldDecls,
1478
1489
  ``,
@@ -1483,9 +1494,8 @@ function emitReadModel(
1483
1494
  ` /// Parse from the Rust read engine's already-decoded projected \`data\` map (the`,
1484
1495
  ` /// \`{type,id,data}\` row's \`data\` that \`query()\`/\`query_by_id()\` emit — every field`,
1485
1496
  ` /// already decoded to bare/structured JSON), carrying the kernel aggregate [id] from`,
1486
- ` /// the row \`id\` (defaults to '' for a bare-map call). Missing REQUIRED fields throw a`,
1487
- ` /// [FormatException]; absent collections decode empty and absent \`.optional()\` scalars`,
1488
- ` /// decode \`null\`.`,
1497
+ ` /// the row \`id\` (defaults to '' for a bare-map call). PARTIAL folds: every scalar`,
1498
+ ` /// decodes \`null\` when absent (never throws); absent collections decode empty.`,
1489
1499
  ` factory ${className}.fromProjected(Map<String, dynamic> data, [String id = '']) {`,
1490
1500
  ` return ${className}(`,
1491
1501
  parseLines,
@@ -21,9 +21,10 @@
21
21
  * * by-id read/watch + a type-index `list…` per aggregate;
22
22
  * * typed O(1) count/sum accessors.
23
23
  *
24
- * It IMPORTS the sibling `<name>.dart` (for the read-model + payload classes) and the vendored
25
- * `nomos_dsl.dart` barrel (for `NomosBridge`). NO manifest/hash impact — pure frontend codegen, exactly
26
- * like `codegen_dart.ts`. NO FALLBACK: an unsupported shape throws at compile time.
24
+ * It IMPORTS the sibling `<name>.dart` (for the read-model + payload classes) and either the vendored
25
+ * `nomos_dsl.dart` barrel or the package `nomos_dsl` barrel (for `NomosBridge`). NO manifest/hash impact —
26
+ * pure frontend codegen, exactly like `codegen_dart.ts`. NO FALLBACK: an unsupported shape throws at compile
27
+ * time.
27
28
  */
28
29
  import type { z } from "zod";
29
30
 
@@ -56,6 +57,15 @@ export const dartClientClassName = (domain: string): string => `${cap(camel(doma
56
57
  /** `guestbook` → `guestbookDomainHash` — the Dart top-level const (lowerCamel by lint). */
57
58
  export const dartHashConstName = (fileBase: string): string => `${lcFirst(camel(fileBase))}DomainHash`;
58
59
 
60
+ /** `todo-nomos` → `todoNomosInterfaceJson` — the baked structural-interface JSON const (#72). */
61
+ export const interfaceConstNameDart = (fileBase: string): string => `${lcFirst(camel(fileBase))}InterfaceJson`;
62
+
63
+ /** Emit a Dart RAW triple-quoted string literal of an object's JSON (the interface is JSON-only
64
+ * data — no `'`/`$`/backslash escaping concerns under `r'''…'''`; JSON never contains `'''`). */
65
+ function dartRawJson(value: unknown): string {
66
+ return `r'''${JSON.stringify(value)}'''`;
67
+ }
68
+
59
69
  const dartStr = (s: string) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
60
70
 
61
71
  /** The Dart param type for a query index key (int-kinded keys → `int`, else `String`). */
@@ -83,6 +93,12 @@ function rowToModel(model: string, rowVar: string): string {
83
93
  export interface DartBridgeClientOptions {
84
94
  /** sha256 of the built `.package.usda` — the law this client offers under. */
85
95
  readonly domainHash: string;
96
+ /** This domain's STRUCTURAL INTERFACE (#72) — directives (payload shapes) + aggregates
97
+ * (read shapes, sid-keyed). Baked in as JSON so the app can negotiate shape-compatibility
98
+ * against the workspace's installed offer, NOT by law hash. */
99
+ readonly interface?: unknown;
100
+ /** Where the bridge/runtime support types are imported from. */
101
+ readonly supportImport?: "vendored" | "package";
86
102
  }
87
103
 
88
104
  /** Generate the `<name>.client.dart` async bridge client for one domain module. */
@@ -90,6 +106,10 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
90
106
  const domain = mod.domain ?? mod.name;
91
107
  const className = dartClientClassName(domain);
92
108
  const hashConst = dartHashConstName(mod.name);
109
+ const supportImport =
110
+ opts.supportImport === "package"
111
+ ? "package:nomos_dsl/nomos_dsl.dart"
112
+ : "nomos_dsl.dart";
93
113
  const aggById = new Map<string, AggregateHandle>();
94
114
  for (const a of mod.aggregates) aggById.set(a.id, a);
95
115
 
@@ -198,16 +218,26 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
198
218
  ``,
199
219
  `import 'dart:convert';`,
200
220
  ``,
201
- `import 'nomos_dsl.dart';`,
221
+ `import '${supportImport}';`,
202
222
  `import '${mod.name}.dart';`,
203
223
  `// Re-export the sibling types (read models + payloads + the bridge runtime) so ONE import of this`,
204
224
  `// client file gives an app the whole typed surface.`,
205
225
  `export '${mod.name}.dart';`,
206
- `export 'nomos_dsl.dart' show NomosBridge, BridgeTransport, NomosBridgeError, kWebviewBridgeVersion;`,
226
+ `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, kWebviewBridgeVersion;`,
207
227
  ``,
208
228
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
209
229
  `const String ${hashConst} = ${dartStr(opts.domainHash)};`,
210
230
  ``,
231
+ ...(opts.interface !== undefined
232
+ ? [
233
+ `/// This client's STRUCTURAL INTERFACE REQUIREMENT (#72) as JSON — the directives (payload`,
234
+ `/// shapes) + aggregates (read shapes, sid-keyed) it depends on. \`connect()\` negotiates this`,
235
+ `/// against the workspace's installed offer by SHAPE (stable-id subtyping), not by law hash,`,
236
+ `/// so the domain can evolve without republishing this client.`,
237
+ `const String ${interfaceConstNameDart(mod.name)} = ${dartRawJson(opts.interface)};`,
238
+ ``,
239
+ ]
240
+ : []),
211
241
  `String _encodeIntentOffer(String domain, String directiveId, Object? payload, String domainHash) =>`,
212
242
  ` base64Encode(utf8.encode(jsonEncode({`,
213
243
  ` 'v': 2,`,
@@ -225,6 +255,25 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
225
255
  ` final String domainHash;`,
226
256
  ` ${className}(this.bridge, {this.domainHash = ${hashConst}});`,
227
257
  ``,
258
+ ...(opts.interface !== undefined
259
+ ? [
260
+ ` /// This client's structural interface requirement (#72) as JSON — hand it to the`,
261
+ ` /// bridge's compatibility check to negotiate by SHAPE against the workspace's offer.`,
262
+ ` String get interfaceJson => ${interfaceConstNameDart(mod.name)};`,
263
+ ``,
264
+ ` Map<String, Object?> get _requirement =>`,
265
+ ` {${dartStr(domain)}: (jsonDecode(interfaceJson) as Map).cast<String, Object?>()};`,
266
+ ``,
267
+ ` /// SHAPE COMPATIBILITY (#72): one-shot verdict for THIS client against the workspace's`,
268
+ ` /// ON-LEDGER offer — \`{ ok, missing[], incompatible[] }\`, by shape not law hash.`,
269
+ ` Future<Map<String, Object?>> checkCompatibility() => bridge.checkCompatibility(_requirement);`,
270
+ ``,
271
+ ` /// SHAPE COMPATIBILITY as a LIVE BINDING (#72): a stream of verdicts that re-emits whenever`,
272
+ ` /// the workspace's installed law changes (a synced install/upgrade from another peer).`,
273
+ ` Stream<Map<String, Object?>> watchCompatibility() => bridge.watchCompatibility(_requirement);`,
274
+ ``,
275
+ ]
276
+ : []),
228
277
  ...methods,
229
278
  `}`,
230
279
  ``,
package/src/codegen_ts.ts CHANGED
@@ -64,6 +64,9 @@ export const tsClientFactoryName = (domain: string): string => `${lcFirst(camel(
64
64
  /** `tool-library` / `tool_library` / `Tool Library` → `TOOL_LIBRARY_DOMAIN_HASH` — the hash const. */
65
65
  export const tsHashConstName = (packageName: string): string => `${upperSnake(packageName)}_DOMAIN_HASH`;
66
66
 
67
+ /** `tool-library` → `TOOL_LIBRARY_INTERFACE` — the baked structural-interface const (#72). */
68
+ export const interfaceConstName = (packageName: string): string => `${upperSnake(packageName)}_INTERFACE`;
69
+
67
70
  // ── zod → TS (the same `_def ?? def` convention as codegen_dart's walker) ─────────
68
71
  // The walkers are exported: codegen_proof.ts synthesizes SAMPLE payloads from the
69
72
  // same zod shapes this file derives TYPES from — one introspection, two emitters.
@@ -289,6 +292,10 @@ export interface TsClientOptions {
289
292
  readonly packageName: string;
290
293
  /** sha256 of the built `.package.usda` — the law this client offers under. */
291
294
  readonly domainHash: string;
295
+ /** The package's STRUCTURAL INTERFACE (#72) — directives (payload shapes) + aggregates
296
+ * (read shapes, sid-keyed), keyed by domain. Baked in so `connect()` can negotiate
297
+ * shape-compatibility against the workspace's installed offer — NOT by hash. */
298
+ readonly interface?: Record<string, unknown>;
292
299
  }
293
300
 
294
301
  /** TRUE when any module declares a PACKED workspace taxonomy (the routed-holon shape).
@@ -322,6 +329,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
322
329
  `/** sha256 of the deployed \`.package.usda\` — the installed law this client offers under. */`,
323
330
  `export const ${hashConst} = ${JSON.stringify(opts.domainHash)};`,
324
331
  ``,
332
+ ...(opts.interface !== undefined
333
+ ? [
334
+ `/** This client's STRUCTURAL INTERFACE REQUIREMENT (#72): the directives (payload shapes)`,
335
+ ` * and aggregates (read shapes, sid-keyed) it depends on, keyed by domain. \`connect()\``,
336
+ ` * negotiates this against the workspace's installed offer by SHAPE (stable-id subtyping),`,
337
+ ` * not by law hash — so the domain can evolve without republishing this client. */`,
338
+ `export const ${interfaceConstName(opts.packageName)} = ${JSON.stringify(opts.interface)} as const;`,
339
+ ``,
340
+ ]
341
+ : []),
325
342
  `/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
326
343
  `export interface Row<T> { type: string; id: string; data: T }`,
327
344
  ``,
@@ -347,8 +364,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
347
364
  ` discardDeadLetter(id: string): Promise<{ ok: boolean; removed: number }>;`,
348
365
  `}`,
349
366
  ``,
350
- `const encodeIntentOffer = (domain: string, directiveId: string, payload: unknown, domainHash: string): Uint8Array =>`,
351
- ` new TextEncoder().encode(JSON.stringify({ v: 2, intent: { domain, directiveId, payload, domainHash } }));`,
367
+ `const encodeIntentOffer = (domain: string, directiveId: string, payload: unknown, domainHash: string, actor?: string): Uint8Array =>`,
368
+ ` new TextEncoder().encode(JSON.stringify({ v: 2, intent: { domain, directiveId, payload, domainHash }, ...(actor ? { security: { actor } } : {}) }));`,
352
369
  ``,
353
370
  );
354
371
 
@@ -595,7 +612,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
595
612
  ` * — ids stay internal plumbing — and returns it with the new local head. */`,
596
613
  ` ${d.id}: async (payload: ${payloadName}): Promise<{ head: string; ${mint.field}: string }> => {`,
597
614
  ` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
598
- ` const head = await holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, domainHash));`,
615
+ ` const head = await holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, domainHash, actor));`,
599
616
  ` return { head, ${mint.field} };`,
600
617
  ` },`,
601
618
  );
@@ -604,8 +621,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
604
621
  methods.push(
605
622
  ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
606
623
  stamps.length
607
- ? ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), domainHash)),`
608
- : ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, domainHash)),`,
624
+ ? ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), domainHash, actor)),`
625
+ : ` ${d.id}: (payload: ${payloadName}) => holon.offer(encodeIntentOffer(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, domainHash, actor)),`,
609
626
  );
610
627
  }
611
628
 
@@ -668,11 +685,19 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
668
685
  }
669
686
 
670
687
  out.push(
671
- `/** Typed client for the \`${domain}\` domain, bound to a connected holon. */`,
672
- `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, domainHash: string = ${hashConst}) {`,
688
+ `/** Typed client for the \`${domain}\` domain, bound to a connected holon.`,
689
+ ` * Pass \`{ actor }\` (the verified principal uid) to author AS that user — every write seals it as`,
690
+ ` * the captured principal, so the ReBAC \`.requires(...)\` gate + the integrity-dual endorse model`,
691
+ ` * judge the real author. Omit it for open (un-gated) domains. */`,
692
+ `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, opts: { domainHash?: string; actor?: string } = {}) {`,
693
+ ` const domainHash = opts.domainHash ?? ${hashConst};`,
694
+ ` const actor = opts.actor;`,
673
695
  ` return {`,
674
696
  ` domain: ${JSON.stringify(domain)} as const,`,
675
697
  ` domainHash,`,
698
+ ...(opts.interface !== undefined
699
+ ? [` interface: ${interfaceConstName(opts.packageName)}[${JSON.stringify(domain)}],`]
700
+ : []),
676
701
  ...methods,
677
702
  ` };`,
678
703
  `}`,
@@ -47,7 +47,7 @@
47
47
  */
48
48
  import { execFileSync } from "node:child_process";
49
49
  import { expandCapabilityExports, isCapabilityDecl } from "./capability_exports.js";
50
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
50
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
51
51
  import { createRequire } from "node:module";
52
52
  import path from "node:path";
53
53
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -103,6 +103,7 @@ import {
103
103
  } from "./stable_ids.js";
104
104
  import type { UsdLayer } from "./usd.js";
105
105
  import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
106
+ import { domainInterface, type DomainInterface } from "./shape_compat.js";
106
107
  import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
107
108
 
108
109
  const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -149,6 +150,8 @@ interface DomainConfig {
149
150
  };
150
151
  }
151
152
 
153
+ type DartSupportImport = "vendored" | "package";
154
+
152
155
  interface PackageConfig {
153
156
  readonly name: string;
154
157
  readonly domains: readonly DomainConfig[];
@@ -159,13 +162,16 @@ interface PackageConfig {
159
162
  * Typed DART frontend (the Flutter sibling of the generated TS client; #codegen_dart).
160
163
  * `false`/absent = skip (default). `true` = emit into `<outDir>/dart/`;
161
164
  * `{ out: "relative/dir" }` = emit there (resolved against the config dir).
165
+ * `{ support: "package" }` keeps `package:nomos_dsl/nomos_dsl.dart` imports for Flutter apps that
166
+ * already depend on `nomos_dsl`/`nomos_flutter`. The default `"vendored"` mode preserves the old
167
+ * self-contained directory.
162
168
  * Per composed domain one `<name>.dart` (payload classes + read models + typed read
163
169
  * accessors via the SAME `generateDartDomain` the co2 tenant emit uses), PLUS the
164
170
  * framework `nomos_dsl` Dart support files vendored alongside (the generated files'
165
171
  * `package:nomos_dsl/...` import is rewritten to a relative one) — so a Flutter app
166
172
  * can vendor the directory as-is, no pub deps.
167
173
  */
168
- readonly dart?: boolean | { readonly out?: string };
174
+ readonly dart?: boolean | { readonly out?: string; readonly support?: DartSupportImport };
169
175
  /**
170
176
  * OPT-IN layered USD emission (also `--layered` on the CLI). When true, ALSO emit
171
177
  * `build/layers/<domain>--<module>.usda` (+ `.layer.json`) — one REAL USD layer per
@@ -411,25 +417,41 @@ const DART_SUPPORT_SRC = [
411
417
  "webview_bridge.dart",
412
418
  ] as const;
413
419
 
420
+ function removeVendoredDartSupport(dartOut: string): void {
421
+ rmSync(path.join(dartOut, "nomos_dsl.dart"), { force: true });
422
+ rmSync(path.join(dartOut, "src"), { recursive: true, force: true });
423
+ }
424
+
414
425
  /**
415
426
  * Emit the typed Dart for the composed domain modules into `dartOut`: one
416
427
  * `<name>.dart` per domain (the SAME `generateDartDomain` path the co2 tenant's
417
428
  * `emit_dart.ts` drives) + the vendored `nomos_dsl.dart` barrel and its `src/`
418
429
  * support files, with the generated files' `package:` import rewritten relative.
419
430
  */
420
- function emitDart(modules: readonly DomainModule[], dartOut: string, domainHash: string): string[] {
421
- mkdirSync(path.join(dartOut, "src"), { recursive: true });
431
+ function emitDart(
432
+ modules: readonly DomainModule[],
433
+ dartOut: string,
434
+ domainHash: string,
435
+ packageInterface: Record<string, DomainInterface> = {},
436
+ supportImport: DartSupportImport = "vendored",
437
+ ): string[] {
438
+ mkdirSync(dartOut, { recursive: true });
422
439
 
423
- // 1. the vendored framework support (barrel + src/), copied byte-for-byte except
440
+ // 1. optional vendored framework support (barrel + src/), copied byte-for-byte except
424
441
  // the barrel's schema_validation export (see DART_SUPPORT_SRC).
425
- const libDir = path.join(DSL_DIR, "dart", "lib");
426
- const barrel = readFileSync(path.join(libDir, "nomos_dsl.dart"), "utf8")
427
- .split("\n")
428
- .filter((line) => !line.includes("src/schema_validation.dart"))
429
- .join("\n");
430
- writeFileSync(path.join(dartOut, "nomos_dsl.dart"), barrel, "utf8");
431
- for (const f of DART_SUPPORT_SRC) {
432
- copyFileSync(path.join(libDir, "src", f), path.join(dartOut, "src", f));
442
+ if (supportImport === "vendored") {
443
+ mkdirSync(path.join(dartOut, "src"), { recursive: true });
444
+ const libDir = path.join(DSL_DIR, "dart", "lib");
445
+ const barrel = readFileSync(path.join(libDir, "nomos_dsl.dart"), "utf8")
446
+ .split("\n")
447
+ .filter((line) => !line.includes("src/schema_validation.dart"))
448
+ .join("\n");
449
+ writeFileSync(path.join(dartOut, "nomos_dsl.dart"), barrel, "utf8");
450
+ for (const f of DART_SUPPORT_SRC) {
451
+ copyFileSync(path.join(libDir, "src", f), path.join(dartOut, "src", f));
452
+ }
453
+ } else {
454
+ removeVendoredDartSupport(dartOut);
433
455
  }
434
456
 
435
457
  // 2. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
@@ -450,10 +472,18 @@ function emitDart(modules: readonly DomainModule[], dartOut: string, domainHash:
450
472
  );
451
473
  }
452
474
  const p = path.join(dartOut, `${mod.name}.dart`);
453
- writeFileSync(p, src.replace(DART_PACKAGE_IMPORT, DART_RELATIVE_IMPORT), "utf8");
475
+ writeFileSync(
476
+ p,
477
+ supportImport === "vendored" ? src.replace(DART_PACKAGE_IMPORT, DART_RELATIVE_IMPORT) : src,
478
+ "utf8",
479
+ );
454
480
  written.push(p);
455
481
 
456
- const clientSrc = generateDartBridgeClient(modForDart, { domainHash });
482
+ const clientSrc = generateDartBridgeClient(modForDart, {
483
+ domainHash,
484
+ interface: packageInterface[modForDart.domain ?? modForDart.name],
485
+ supportImport,
486
+ });
457
487
  const cp = path.join(dartOut, `${mod.name}.client.dart`);
458
488
  writeFileSync(cp, clientSrc, "utf8");
459
489
  written.push(cp);
@@ -990,6 +1020,30 @@ async function main(): Promise<void> {
990
1020
  // identity across clean clones). Deterministic — re-compiles rewrite the same bytes.
991
1021
  writeFileSync(lockPath, JSON.stringify(nextLock, null, 2) + "\n", "utf8");
992
1022
 
1023
+ // ── THE STRUCTURAL INTERFACE (#72) — directives (payload shapes) + aggregates (read
1024
+ // shapes, sid-keyed), keyed by DOMAIN. The SAME object is (a) baked into the generated
1025
+ // clients as their requirement and (b) staged on the deploy as the workspace's offer, so
1026
+ // `connect()` negotiates compatibility by SHAPE (stable-id subtyping), not by law hash.
1027
+ // architecture/shape_compatibility.md. Multiple modules sharing a domain key merge.
1028
+ const packageInterface: Record<string, DomainInterface> = {};
1029
+ for (const mod of domainModules) {
1030
+ const key = mod.domain ?? mod.name;
1031
+ const di = domainInterface(mod, nextLock.domains[mod.name]?.stableIds);
1032
+ const prior = packageInterface[key];
1033
+ packageInterface[key] = prior
1034
+ ? {
1035
+ ...prior,
1036
+ directives: { ...prior.directives, ...di.directives },
1037
+ aggregates: { ...prior.aggregates, ...di.aggregates },
1038
+ }
1039
+ : di;
1040
+ }
1041
+ writeFileSync(
1042
+ path.join(outDir, `${cfg.name}.interface.json`),
1043
+ JSON.stringify(packageInterface, null, 2) + "\n",
1044
+ "utf8",
1045
+ );
1046
+
993
1047
  // ── reports ──
994
1048
  const entryReports: string[] = [];
995
1049
  for (const [reportId, ref] of Object.entries(cfg.reports ?? {})) {
@@ -1301,6 +1355,9 @@ async function main(): Promise<void> {
1301
1355
  // collector's doc above). The worker stages it on the manifest overlay:
1302
1356
  // the deploy-time binding warning + the executor's scan map read it there.
1303
1357
  ...(capabilityLowerings.length > 0 ? { capabilities: capabilityLowerings } : {}),
1358
+ // THE STRUCTURAL OFFER (#72): the per-domain interface, staged on the manifest overlay so
1359
+ // `connect()` pulls the workspace's installed shapes and negotiates by structural subtyping.
1360
+ interface: packageInterface,
1304
1361
  };
1305
1362
  const deployPath = path.join(outDir, `${cfg.name}.deploy.json`);
1306
1363
  writeFileSync(deployPath, JSON.stringify(deployBody) + "\n", "utf8");
@@ -1350,14 +1407,25 @@ async function main(): Promise<void> {
1350
1407
  // ── 5. the typed TS client (#10 — the web sibling of codegen_dart), with the
1351
1408
  // deployed law's content hash baked in (emitted AFTER the package is hashed) ──
1352
1409
  const clientPath = path.join(outDir, `${cfg.name}.client.ts`);
1353
- writeFileSync(clientPath, generateTsClient(domainModules, { packageName: cfg.name, domainHash }), "utf8");
1410
+ writeFileSync(clientPath, generateTsClient(domainModules, { packageName: cfg.name, domainHash, interface: packageInterface }), "utf8");
1354
1411
 
1355
1412
  // ── 6. the typed Dart frontend (opt-in; the Flutter sibling of step 5, emitted
1356
1413
  // after the package hash like the TS client) ──
1357
1414
  const dartCfg = cfg.dart ?? false;
1358
- if (dartCfg !== false && dartCfg !== true && (typeof dartCfg !== "object" || dartCfg === null)) {
1359
- fail(`config key 'dart' must be false, true, or { out: "relative/dir" }; got ${JSON.stringify(dartCfg)}`);
1415
+ if (
1416
+ dartCfg !== false &&
1417
+ dartCfg !== true &&
1418
+ (typeof dartCfg !== "object" ||
1419
+ dartCfg === null ||
1420
+ (dartCfg.out !== undefined && typeof dartCfg.out !== "string") ||
1421
+ (dartCfg.support !== undefined && dartCfg.support !== "vendored" && dartCfg.support !== "package"))
1422
+ ) {
1423
+ fail(
1424
+ `config key 'dart' must be false, true, or { out?: "relative/dir", support?: "vendored" | "package" }; got ${JSON.stringify(dartCfg)}`,
1425
+ );
1360
1426
  }
1427
+ const dartSupportImport: DartSupportImport =
1428
+ typeof dartCfg === "object" && dartCfg.support !== undefined ? dartCfg.support : "vendored";
1361
1429
  let dartOut: string | undefined;
1362
1430
  let dartFiles: string[] = [];
1363
1431
  if (dartCfg !== false) {
@@ -1365,7 +1433,7 @@ async function main(): Promise<void> {
1365
1433
  cfgDir,
1366
1434
  typeof dartCfg === "object" && dartCfg.out !== undefined ? dartCfg.out : path.join(outDir, "dart"),
1367
1435
  );
1368
- dartFiles = emitDart(domainModules, dartOut, domainHash);
1436
+ dartFiles = emitDart(domainModules, dartOut, domainHash, packageInterface, dartSupportImport);
1369
1437
  }
1370
1438
 
1371
1439
  // ── summary ──
@@ -1407,7 +1475,9 @@ async function main(): Promise<void> {
1407
1475
  console.log(` proof SKIPPED — ${proofSkip}`);
1408
1476
  }
1409
1477
  if (dartOut !== undefined) {
1410
- console.log(` dart ${rel(dartOut)}${path.sep} (${dartFiles.length} domain file(s) + vendored nomos_dsl support — typed Dart for Flutter)`);
1478
+ const supportText =
1479
+ dartSupportImport === "vendored" ? "vendored nomos_dsl support" : "package nomos_dsl imports";
1480
+ console.log(` dart ${rel(dartOut)}${path.sep} (${dartFiles.length} domain file(s) + ${supportText} — typed Dart for Flutter)`);
1411
1481
  }
1412
1482
  console.log(` deploy ${rel(deployPath)}`);
1413
1483
  console.log(``);