@githolon/dsl 0.14.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.
Files changed (42) hide show
  1. package/dart/example/nomos_webview_transport.dart +2 -2
  2. package/dart/lib/nomos_dsl.dart +4 -4
  3. package/dart/lib/src/{dispatch.dart → offer.dart} +7 -7
  4. package/dart/lib/src/provider.dart +30 -30
  5. package/dart/lib/src/subscriptions.dart +3 -3
  6. package/dart/lib/src/webview_bridge.dart +56 -21
  7. package/dart/test/webview_bridge_test.dart +35 -34
  8. package/package.json +5 -3
  9. package/src/aggregate.ts +9 -1
  10. package/src/authoring.ts +10 -1
  11. package/src/build_package.ts +1 -1
  12. package/src/capability_exports.ts +2 -2
  13. package/src/codegen_dart.ts +34 -24
  14. package/src/codegen_dart_client.ts +74 -12
  15. package/src/codegen_provider_dart.ts +67 -61
  16. package/src/codegen_ts.ts +45 -17
  17. package/src/compile_package_main.ts +103 -36
  18. package/src/ctx.ts +13 -0
  19. package/src/engine_entry.ts +22 -5
  20. package/src/fields.ts +44 -0
  21. package/src/flow_gate.ts +208 -0
  22. package/src/framework/capability.ts +29 -11
  23. package/src/framework/encryption_scope.ts +9 -1
  24. package/src/framework/genesis.ts +5 -1
  25. package/src/framework/impure_capability.ts +73 -67
  26. package/src/framework/relations.ts +23 -4
  27. package/src/framework/signing_identity.ts +1 -1
  28. package/src/framework/sync_lifecycle.ts +3 -3
  29. package/src/framework/workspace_invariant.ts +29 -8
  30. package/src/framework/workspaces.ts +7 -0
  31. package/src/index.ts +25 -1
  32. package/src/manifest.ts +11 -17
  33. package/src/ops.ts +42 -1
  34. package/src/principal_evidence.ts +112 -0
  35. package/src/relation_sugar.ts +10 -4
  36. package/src/shape_compat.ts +208 -0
  37. package/src/usd.ts +287 -0
  38. package/src/usd_layers.ts +213 -26
  39. package/src/wire.ts +8 -0
  40. package/src/wire_encode.ts +75 -2
  41. package/src/workspace_sharding.ts +197 -24
  42. package/src/workspace_type.ts +1 -0
package/src/codegen_ts.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Dart apps get generated payload classes / read models / typed query accessors;
12
12
  * until this file, a web app on `@githolon/client` hand-typed directive ids and
13
- * payload shapes against the raw `dispatch()` surface. `generateTsClient(modules,
13
+ * payload shapes against the raw `offer()` surface. `generateTsClient(modules,
14
14
  * opts)` closes that: ONE generated `.ts` file per package, emitted by
15
15
  * `nomos-compile` AFTER the package is hashed (the deployed law's content hash is
16
16
  * baked in as a constant), carrying:
@@ -20,13 +20,13 @@
20
20
  * becomes `?`, `.int()` stays `number`), so a payload the type system accepts is
21
21
  * a payload the engine accepts; required string fields named `…At` are
22
22
  * CALLER-STAMPED TIMESTAMPS — optional in the type, auto-filled with ISO now()
23
- * at dispatch (see {@link autoStampFields});
23
+ * at offer (see {@link autoStampFields});
24
24
  * * a READ-MODEL interface per aggregate — derived from the DSL `Field` kinds the
25
25
  * projection decodes by (set→`T[]`, map→`Record<string, V>` via `mapValueKind`,
26
26
  * `t.jsonObject()`→`Record<string, unknown>`, plain `t.json()`→`unknown`), with
27
27
  * derived/combined read fields merged in (typed off their declared zod returns).
28
28
  * EVERY field is `?`: a projection row carries only what has folded so far;
29
- * * a CLIENT FACTORY per domain — typed `dispatch` wrappers bound to the baked
29
+ * * a CLIENT FACTORY per domain — typed `offer` wrappers bound to the baked
30
30
  * domainHash, typed declared-query accessors, and by-id read/watch helpers —
31
31
  * over a STRUCTURAL `NomosHolon` interface, so the file has ZERO imports and
32
32
  * binds to `@githolon/client`'s `connect()` result (browser or node) as-is.
@@ -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.
@@ -173,7 +176,7 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
173
176
  /**
174
177
  * Top-level REQUIRED string payload fields named `…At` (`signedAt`, `updatedAt`,
175
178
  * `stampedAt`, …) are CALLER-STAMPED TIMESTAMPS by convention: the generated client
176
- * fills an omitted one with `new Date().toISOString()` at dispatch. The stamp rides
179
+ * fills an omitted one with `new Date().toISOString()` at offer. The stamp rides
177
180
  * IN the payload — the plan stays a pure function of `(payload, ports)` and replays
178
181
  * byte-identical on every peer — so the domain dev stops thinking about clocks.
179
182
  * Passing the field explicitly always wins (scheduling-shaped fields like
@@ -210,11 +213,11 @@ function tsTypeOfPayload(
210
213
  f = zodDef(f).innerType!;
211
214
  }
212
215
  const note = stamped.has(name)
213
- ? " // auto-stamped with ISO now() at dispatch when omitted"
216
+ ? " // auto-stamped with ISO now() at offer when omitted"
214
217
  : mintedSet.has(name)
215
- ? " // kernel-minted at dispatch when omitted (the front-door mint; returned on the result)"
218
+ ? " // kernel-minted at offer when omitted (the front-door mint; returned on the result)"
216
219
  : placedSet.has(name)
217
- ? " // picked LEAST-LOADED over the coordinator's law-state at dispatch when omitted (sharding §3)"
220
+ ? " // picked LEAST-LOADED over the coordinator's law-state at offer when omitted (sharding §3)"
218
221
  : "";
219
222
  return ` ${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, " ")};${note}`;
220
223
  });
@@ -287,8 +290,12 @@ function aggregatesOf(mod: Record<string, unknown>): AggregateHandle[] {
287
290
  export interface TsClientOptions {
288
291
  /** The package name (`nomos.package.mjs` `name`) — used for the hash constant. */
289
292
  readonly packageName: string;
290
- /** sha256 of the built `.package.usda` — the law this client dispatches under. */
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).
@@ -319,15 +326,25 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
319
326
  `// const holon = await connect({ cloud, workspace, clientId });`,
320
327
  `// const app = ${tsClientFactoryName(modules[0]?.domain ?? "domain")}(holon);`,
321
328
  ``,
322
- `/** sha256 of the deployed \`.package.usda\` — the installed law this client dispatches under. */`,
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
  ``,
328
345
  `/** The holon surface this client binds to (structurally satisfied by @githolon/client). */`,
329
346
  `export interface NomosHolon {`,
330
- ` dispatch(domain: string, directiveId: string, payload: unknown, opts: { domainHash: string }): Promise<string>;`,
347
+ ` offer(intentBytes: string | Uint8Array): Promise<string>;`,
331
348
  ` query(queryId: string, params?: Record<string, unknown>): Promise<unknown>;`,
332
349
  ` queryById(aggregateId: string): Promise<unknown>;`,
333
350
  ` watchById(aggregateId: string, cb: (rows: unknown) => void, opts?: { intervalMs?: number }): () => void;`,
@@ -347,6 +364,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
347
364
  ` discardDeadLetter(id: string): Promise<{ ok: boolean; removed: number }>;`,
348
365
  `}`,
349
366
  ``,
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 } } : {}) }));`,
369
+ ``,
350
370
  );
351
371
 
352
372
  const anyAutoStamp = modules.some((mod) =>
@@ -553,7 +573,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
553
573
 
554
574
  const mintPlan = mintPlans.get(mod);
555
575
  // PLACEMENT directives (packed taxonomy births): their `shard` is AUTO-PICKED
556
- // least-loaded by the routed holon at dispatch — optional on the typed surface
576
+ // least-loaded by the routed holon at offer — optional on the typed surface
557
577
  // (the §7 transparency contract: a dev never names a shard).
558
578
  const placementDirs = new Set<string>();
559
579
  if (mod.workspaceTypes !== undefined && mod.workspaceTypes.length > 0) {
@@ -573,7 +593,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
573
593
  const mint = mintPlan?.get(d.id);
574
594
  const placed = placementDirs.has(d.id) ? ["shard"] : [];
575
595
  out.push(
576
- `/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at dispatch.` : ""}${mint ? ` Omitted \`${mint.field}\` is KERNEL-MINTED at dispatch (returned on the result).` : ""}${placed.length ? " Omitted `shard` is picked LEAST-LOADED at dispatch (the routed holon)." : ""} */`,
596
+ `/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at offer.` : ""}${mint ? ` Omitted \`${mint.field}\` is KERNEL-MINTED at offer (returned on the result).` : ""}${placed.length ? " Omitted `shard` is picked LEAST-LOADED at offer (the routed holon)." : ""} */`,
577
597
  `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed)};`,
578
598
  ``,
579
599
  );
@@ -592,7 +612,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
592
612
  ` * — ids stay internal plumbing — and returns it with the new local head. */`,
593
613
  ` ${d.id}: async (payload: ${payloadName}): Promise<{ head: string; ${mint.field}: string }> => {`,
594
614
  ` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
595
- ` const head = await holon.dispatch(${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));`,
596
616
  ` return { head, ${mint.field} };`,
597
617
  ` },`,
598
618
  );
@@ -601,8 +621,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
601
621
  methods.push(
602
622
  ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
603
623
  stamps.length
604
- ? ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), { domainHash }),`
605
- : ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${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)),`,
606
626
  );
607
627
  }
608
628
 
@@ -665,11 +685,19 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
665
685
  }
666
686
 
667
687
  out.push(
668
- `/** Typed client for the \`${domain}\` domain, bound to a connected holon. */`,
669
- `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;`,
670
695
  ` return {`,
671
696
  ` domain: ${JSON.stringify(domain)} as const,`,
672
697
  ` domainHash,`,
698
+ ...(opts.interface !== undefined
699
+ ? [` interface: ${interfaceConstName(opts.packageName)}[${JSON.stringify(domain)}],`]
700
+ : []),
673
701
  ...methods,
674
702
  ` };`,
675
703
  `}`,
@@ -30,13 +30,10 @@
30
30
  * It emits into `outDir` (default `<config dir>/build`):
31
31
  * * `<name>.package.usda` — the deployable package (engine bundle + USD IR);
32
32
  * its sha256 IS the deploy `domainHash` (`policy:{hash}`).
33
- * * `domain_manifests.json` — the READ manifest (field kinds + query routes).
34
- * * `domain_identity_manifests.json` + `domain_identity_hashes.json` (+ excluded
35
- * sidecar) — the per-domain IDENTITY artifacts (admission gates).
36
- * * `<name>.deploy.json` — `{packageUsda, readManifest, identityManifests,
37
- * identityHashes}`: the EXACT body `POST /v1/workspaces/:ws/domains` accepts as
38
- * `application/json` on Nomos Cloud (package + per-workspace manifest overlay in
39
- * one deploy).
33
+ * * `<name>.deploy.json` — `{packageUsda}`: the EXACT body
34
+ * `POST /v1/workspaces/:ws/domains` accepts as `application/json` on Nomos Cloud.
35
+ * Admission/projection manifests are embedded in the package USDA and are not host
36
+ * sidecars.
40
37
  * * `<name>.proof.mts` — the GENERATED PROOF (codegen_proof.ts): a
41
38
  * runnable, domain-shaped live e2e synthesized from the law's own directives /
42
39
  * queries / counts. Run it with `githolon proof`. Never fatal: an unsampleable
@@ -50,7 +47,7 @@
50
47
  */
51
48
  import { execFileSync } from "node:child_process";
52
49
  import { expandCapabilityExports, isCapabilityDecl } from "./capability_exports.js";
53
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
50
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
54
51
  import { createRequire } from "node:module";
55
52
  import path from "node:path";
56
53
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -106,6 +103,7 @@ import {
106
103
  } from "./stable_ids.js";
107
104
  import type { UsdLayer } from "./usd.js";
108
105
  import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
106
+ import { domainInterface, type DomainInterface } from "./shape_compat.js";
109
107
  import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
110
108
 
111
109
  const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -152,6 +150,8 @@ interface DomainConfig {
152
150
  };
153
151
  }
154
152
 
153
+ type DartSupportImport = "vendored" | "package";
154
+
155
155
  interface PackageConfig {
156
156
  readonly name: string;
157
157
  readonly domains: readonly DomainConfig[];
@@ -162,13 +162,16 @@ interface PackageConfig {
162
162
  * Typed DART frontend (the Flutter sibling of the generated TS client; #codegen_dart).
163
163
  * `false`/absent = skip (default). `true` = emit into `<outDir>/dart/`;
164
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.
165
168
  * Per composed domain one `<name>.dart` (payload classes + read models + typed read
166
169
  * accessors via the SAME `generateDartDomain` the co2 tenant emit uses), PLUS the
167
170
  * framework `nomos_dsl` Dart support files vendored alongside (the generated files'
168
171
  * `package:nomos_dsl/...` import is rewritten to a relative one) — so a Flutter app
169
172
  * can vendor the directory as-is, no pub deps.
170
173
  */
171
- readonly dart?: boolean | { readonly out?: string };
174
+ readonly dart?: boolean | { readonly out?: string; readonly support?: DartSupportImport };
172
175
  /**
173
176
  * OPT-IN layered USD emission (also `--layered` on the CLI). When true, ALSO emit
174
177
  * `build/layers/<domain>--<module>.usda` (+ `.layer.json`) — one REAL USD layer per
@@ -406,7 +409,7 @@ const DART_RELATIVE_IMPORT = `import 'nomos_dsl.dart';`;
406
409
  const DART_SUPPORT_SRC = [
407
410
  "wire.dart",
408
411
  "driver.dart",
409
- "dispatch.dart",
412
+ "offer.dart",
410
413
  "subscriptions.dart",
411
414
  "provider.dart",
412
415
  // The async WebView bridge runtime (NomosBridge / BridgeTransport) the generated
@@ -414,25 +417,41 @@ const DART_SUPPORT_SRC = [
414
417
  "webview_bridge.dart",
415
418
  ] as const;
416
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
+
417
425
  /**
418
426
  * Emit the typed Dart for the composed domain modules into `dartOut`: one
419
427
  * `<name>.dart` per domain (the SAME `generateDartDomain` path the co2 tenant's
420
428
  * `emit_dart.ts` drives) + the vendored `nomos_dsl.dart` barrel and its `src/`
421
429
  * support files, with the generated files' `package:` import rewritten relative.
422
430
  */
423
- function emitDart(modules: readonly DomainModule[], dartOut: string, domainHash: string): string[] {
424
- 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 });
425
439
 
426
- // 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
427
441
  // the barrel's schema_validation export (see DART_SUPPORT_SRC).
428
- const libDir = path.join(DSL_DIR, "dart", "lib");
429
- const barrel = readFileSync(path.join(libDir, "nomos_dsl.dart"), "utf8")
430
- .split("\n")
431
- .filter((line) => !line.includes("src/schema_validation.dart"))
432
- .join("\n");
433
- writeFileSync(path.join(dartOut, "nomos_dsl.dart"), barrel, "utf8");
434
- for (const f of DART_SUPPORT_SRC) {
435
- 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);
436
455
  }
437
456
 
438
457
  // 2. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
@@ -453,10 +472,18 @@ function emitDart(modules: readonly DomainModule[], dartOut: string, domainHash:
453
472
  );
454
473
  }
455
474
  const p = path.join(dartOut, `${mod.name}.dart`);
456
- 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
+ );
457
480
  written.push(p);
458
481
 
459
- const clientSrc = generateDartBridgeClient(modForDart, { domainHash });
482
+ const clientSrc = generateDartBridgeClient(modForDart, {
483
+ domainHash,
484
+ interface: packageInterface[modForDart.domain ?? modForDart.name],
485
+ supportImport,
486
+ });
460
487
  const cp = path.join(dartOut, `${mod.name}.client.dart`);
461
488
  writeFileSync(cp, clientSrc, "utf8");
462
489
  written.push(cp);
@@ -519,7 +546,7 @@ async function main(): Promise<void> {
519
546
  capability: string;
520
547
  domain: string;
521
548
  aggregateId: string;
522
- order: string;
549
+ obligation: string;
523
550
  complete: string;
524
551
  fail: string;
525
552
  block: string;
@@ -750,7 +777,7 @@ async function main(): Promise<void> {
750
777
  capability: cap,
751
778
  domain: d.key,
752
779
  aggregateId: c.aggregate.id,
753
- order: c.order.id,
780
+ obligation: c.obligation.id,
754
781
  complete: c.complete.id,
755
782
  fail: c.fail.id,
756
783
  block: c.block.id,
@@ -993,6 +1020,30 @@ async function main(): Promise<void> {
993
1020
  // identity across clean clones). Deterministic — re-compiles rewrite the same bytes.
994
1021
  writeFileSync(lockPath, JSON.stringify(nextLock, null, 2) + "\n", "utf8");
995
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
+
996
1047
  // ── reports ──
997
1048
  const entryReports: string[] = [];
998
1049
  for (const [reportId, ref] of Object.entries(cfg.reports ?? {})) {
@@ -1252,7 +1303,8 @@ async function main(): Promise<void> {
1252
1303
  relPaths.push(`layers/${base}.usda`);
1253
1304
  }
1254
1305
  }
1255
- const flatJson = JSON.stringify(flattenLayeredUsd(allLayers));
1306
+ const flat = flattenLayeredUsd(allLayers);
1307
+ const flatJson = JSON.stringify(flat);
1256
1308
  if (flatJson !== usdJson) {
1257
1309
  fail(
1258
1310
  `layered emission does not flatten to the canonical composed IR — the module fold and ` +
@@ -1264,11 +1316,13 @@ async function main(): Promise<void> {
1264
1316
  }
1265
1317
  for (const w of pendingWrites) writeFileSync(w.path, w.text, "utf8");
1266
1318
  layeredRootPath = path.join(outDir, `${cfg.name}.layered.usda`);
1267
- writeFileSync(layeredRootPath, layeredRootUsda(relPaths), "utf8");
1319
+ writeFileSync(layeredRootPath, layeredRootUsda(relPaths, flat), "utf8");
1268
1320
  layeredFileCount = relPaths.length;
1269
1321
  }
1270
1322
 
1271
1323
  // ── 3. the READ manifest + IDENTITY artifacts ──
1324
+ // These are generated from the same source functions embedded into USDA by emitUsd().
1325
+ // They remain local diagnostics for now; the deploy/cloud/runtime contract ignores them.
1272
1326
  const readManifest = buildReadManifest(domainModules);
1273
1327
  const readPath = path.join(outDir, "domain_manifests.json");
1274
1328
  writeFileSync(readPath, JSON.stringify(readManifest, null, 2) + "\n", "utf8");
@@ -1276,7 +1330,7 @@ async function main(): Promise<void> {
1276
1330
  const identity = buildIdentity(domainModules);
1277
1331
  const { manifestsPath } = writeIdentity(identity, outDir);
1278
1332
 
1279
- // ── 4. the one-call deploy body (package + per-workspace manifest overlay) ──
1333
+ // ── 4. the one-call deploy body (package only) ──
1280
1334
  // SELF-DESCRIBING: `summary` states what the (otherwise hex-opaque) package
1281
1335
  // contains — purely derived from the inputs (no timestamps), ignored by the
1282
1336
  // deploy endpoint, and mirrored as build/<name>.summary.txt for a human or
@@ -1297,13 +1351,13 @@ async function main(): Promise<void> {
1297
1351
  const deployBody = {
1298
1352
  summary,
1299
1353
  packageUsda: text,
1300
- readManifest,
1301
- identityManifests: identity.manifests,
1302
- identityHashes: identity.hashes,
1303
1354
  // The capability lowering (unhashed sidecar; omit-when-empty — see the
1304
1355
  // collector's doc above). The worker stages it on the manifest overlay:
1305
1356
  // the deploy-time binding warning + the executor's scan map read it there.
1306
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,
1307
1361
  };
1308
1362
  const deployPath = path.join(outDir, `${cfg.name}.deploy.json`);
1309
1363
  writeFileSync(deployPath, JSON.stringify(deployBody) + "\n", "utf8");
@@ -1353,14 +1407,25 @@ async function main(): Promise<void> {
1353
1407
  // ── 5. the typed TS client (#10 — the web sibling of codegen_dart), with the
1354
1408
  // deployed law's content hash baked in (emitted AFTER the package is hashed) ──
1355
1409
  const clientPath = path.join(outDir, `${cfg.name}.client.ts`);
1356
- writeFileSync(clientPath, generateTsClient(domainModules, { packageName: cfg.name, domainHash }), "utf8");
1410
+ writeFileSync(clientPath, generateTsClient(domainModules, { packageName: cfg.name, domainHash, interface: packageInterface }), "utf8");
1357
1411
 
1358
1412
  // ── 6. the typed Dart frontend (opt-in; the Flutter sibling of step 5, emitted
1359
1413
  // after the package hash like the TS client) ──
1360
1414
  const dartCfg = cfg.dart ?? false;
1361
- if (dartCfg !== false && dartCfg !== true && (typeof dartCfg !== "object" || dartCfg === null)) {
1362
- 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
+ );
1363
1426
  }
1427
+ const dartSupportImport: DartSupportImport =
1428
+ typeof dartCfg === "object" && dartCfg.support !== undefined ? dartCfg.support : "vendored";
1364
1429
  let dartOut: string | undefined;
1365
1430
  let dartFiles: string[] = [];
1366
1431
  if (dartCfg !== false) {
@@ -1368,7 +1433,7 @@ async function main(): Promise<void> {
1368
1433
  cfgDir,
1369
1434
  typeof dartCfg === "object" && dartCfg.out !== undefined ? dartCfg.out : path.join(outDir, "dart"),
1370
1435
  );
1371
- dartFiles = emitDart(domainModules, dartOut, domainHash);
1436
+ dartFiles = emitDart(domainModules, dartOut, domainHash, packageInterface, dartSupportImport);
1372
1437
  }
1373
1438
 
1374
1439
  // ── summary ──
@@ -1410,7 +1475,9 @@ async function main(): Promise<void> {
1410
1475
  console.log(` proof SKIPPED — ${proofSkip}`);
1411
1476
  }
1412
1477
  if (dartOut !== undefined) {
1413
- 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)`);
1414
1481
  }
1415
1482
  console.log(` deploy ${rel(deployPath)}`);
1416
1483
  console.log(``);
package/src/ctx.ts CHANGED
@@ -14,11 +14,19 @@
14
14
  * - ctx.rng -> Random port
15
15
  */
16
16
  import type { WireHlc } from "./wire.js";
17
+ import { principalEvidence, type PrincipalEvidence } from "./principal_evidence.js";
17
18
 
18
19
  export interface Ports {
19
20
  clock(): WireHlc;
20
21
  id(): string;
21
22
  rng(): number;
23
+ /**
24
+ * THE IDENTITY PORT (security_model.md — the endorse model): the captured, gate-verified
25
+ * authoring principal as opaque {@link PrincipalEvidence}. NOT a string — it can only become an
26
+ * attribution or an ENDORSED authority/standing value via its conversions, so identity cannot
27
+ * decide authority in plan code (the confused-deputy defence). Replay-stable (a captured port).
28
+ */
29
+ principal(): PrincipalEvidence;
22
30
  }
23
31
 
24
32
  /** A deterministic stub set of ports — seeded, replayable. */
@@ -27,6 +35,7 @@ export function deterministicPorts(seed: {
27
35
  logical?: number;
28
36
  replica?: number;
29
37
  idPrefix?: string;
38
+ principal?: string;
30
39
  } = {}): Ports {
31
40
  let physical = seed.physical ?? 1;
32
41
  let logical = seed.logical ?? 0;
@@ -34,7 +43,11 @@ export function deterministicPorts(seed: {
34
43
  const idPrefix = seed.idPrefix ?? "id";
35
44
  let idCounter = 0;
36
45
  let rngState = (seed.physical ?? 1) >>> 0 || 1;
46
+ const evidence = principalEvidence(seed.principal ?? "");
37
47
  return {
48
+ principal(): PrincipalEvidence {
49
+ return evidence;
50
+ },
38
51
  clock(): WireHlc {
39
52
  const h: WireHlc = { physical, logical, replica };
40
53
  // Advance: same physical -> bump logical (mirrors merge.md local-event rule).
@@ -57,6 +57,7 @@ import { RelationTuple } from "./framework/relations.js";
57
57
  import type { Directive } from "./directive.js";
58
58
  import type { AggregateHandle, AggregateInvariantFn, AggregateInvariantVerdict } from "./aggregate.js";
59
59
  import type { Ports } from "./ctx.js";
60
+ import { principalEvidence, type PrincipalEvidence } from "./principal_evidence.js";
60
61
  import type { WireEvent, WireHlc } from "./wire.js";
61
62
  import type { DerivedDecl } from "./derived.js";
62
63
  import type { CombinedDecl } from "./combined.js";
@@ -365,12 +366,18 @@ export function collectEngineRouting(
365
366
  * intent's HLC — the committed HLC/id is host-injected in Rust); `id()` derives a
366
367
  * deterministic per-dispatch value; `rng()` replays the captured draw.
367
368
  */
368
- function portsFromHost(): Ports {
369
+ function portsFromHost(principalRaw = ""): Ports {
369
370
  const hostPorts = (globalThis as { __ports?: { clock(): number; rng(): number } }).__ports;
370
371
  const clockScalar = hostPorts ? hostPorts.clock() : 0;
371
372
  const rngValue = hostPorts ? hostPorts.rng() : 0;
372
373
  let idCounter = 0;
374
+ // The captured, gate-verified principal (the engine serialises it into the dispatch job —
375
+ // host.rs EnginePortsSnapshot.principal) becomes the opaque PrincipalEvidence the plan reads.
376
+ const evidence = principalEvidence(principalRaw);
373
377
  return {
378
+ principal(): PrincipalEvidence {
379
+ return evidence;
380
+ },
374
381
  clock(): WireHlc {
375
382
  return { physical: clockScalar, logical: 0, replica: 0 };
376
383
  },
@@ -386,7 +393,7 @@ function portsFromHost(): Ports {
386
393
 
387
394
  /** The compiled engine surface `registerEngine` returns (and assigns onto `globalThis`). */
388
395
  export interface RegisteredEngine {
389
- plan(job: unknown): WireEvent[] | { events: WireEvent[]; strikes: string[] };
396
+ plan(job: unknown): WireEvent[] | { events: WireEvent[]; strikes?: string[]; endorse?: { sink: string; facet: string; reason: string | null }[] };
390
397
  planReport(job: unknown): string;
391
398
  }
392
399
 
@@ -709,7 +716,8 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
709
716
 
710
717
  function plan(job: {
711
718
  intent?: { domain?: string; directiveId?: string; payload?: unknown };
712
- }): WireEvent[] | { events: WireEvent[]; strikes: string[] } {
719
+ capturedPorts?: { principal?: string };
720
+ }): WireEvent[] | { events: WireEvent[]; strikes?: string[]; endorse?: { sink: string; facet: string; reason: string | null }[] } {
713
721
  const intent = job.intent ?? {};
714
722
  const domain = intent.domain;
715
723
  const directiveId = intent.directiveId;
@@ -723,11 +731,20 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
723
731
  if (entry === undefined) {
724
732
  throw new Error(`engine plan: no directive registered for (${domain}, ${directiveId})`);
725
733
  }
726
- const ctx = portsFromHost();
734
+ const ctx = portsFromHost(job.capturedPorts?.principal);
727
735
  const wire = executeDirectiveToIntent(entry.directive, entry.agg, intent.payload as never, ctx);
728
736
  // Emit {events, strikes} ONLY when a strike is present, else the bare event array —
729
737
  // so every strikes-free directive canonicalizes byte-identically to the pre-strikeout contract.
730
- return wire.strikes.length ? { events: wire.events, strikes: wire.strikes } : wire.events;
738
+ // The OBSERVED ENDORSE footprint (Slice B) rides the object form too — TRANSIENT (the gate reads
739
+ // it to check the law's nomosFlowGate.endorse; decode_plan_output ignores it for the byte-compare),
740
+ // so a principal-free intent still emits the bare array, byte-identical.
741
+ const endorse = wire.endorse;
742
+ if (!wire.strikes.length && !(endorse && endorse.length)) return wire.events;
743
+ return {
744
+ events: wire.events,
745
+ ...(wire.strikes.length ? { strikes: wire.strikes } : {}),
746
+ ...(endorse && endorse.length ? { endorse } : {}),
747
+ };
731
748
  }
732
749
 
733
750
  // The engine evals the bundle as a classic script and FREEZES globalThis before
package/src/fields.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import { z } from "zod";
18
18
  import { Conflict, Counter, LastPosition, Lww, type Driver } from "./drivers.js";
19
19
  import type { AggregateHandle } from "./aggregate.js";
20
+ import type { FieldFacet } from "./principal_evidence.js";
20
21
 
21
22
  export type FieldKind =
22
23
  | "string"
@@ -143,8 +144,20 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
143
144
  * Flag named `isMeta` — the `meta()` METHOD sets it, mirroring `encrypted()`/`optional()`.
144
145
  */
145
146
  readonly isMeta?: true;
147
+ /**
148
+ * THE INTEGRITY/CONFIDENTIALITY FACET (security_model.md — the endorse model). Set by the
149
+ * `authorityField`/`attributionField`/`standingField`/`secretField`/`publicField` wrappers (or the
150
+ * `.facet()` sugar). Drives `set()` acceptance of PrincipalEvidence: an `authority`/`standing` sink
151
+ * accepts only the matching ENDORSED conversion (never un-endorsed identity), so the confused deputy
152
+ * is unrepresentable. OMITTED on a plain field (accepts a plain value). The field's READ/wire type is
153
+ * unchanged — the facet governs WRITES, not what's stored.
154
+ */
155
+ readonly facet?: FieldFacet;
146
156
  /** Returns a copy with the chosen driver (kind literal preserved). */
147
157
  merge(driver: Driver): Field<T, K>;
158
+ /** Tag this field's facet, threaded through merge/optional (the canonical form is the
159
+ * `authorityField(...)`/`attributionField(...)`/… wrappers, which call this). */
160
+ withFacet<Fa extends FieldFacet>(facet: Fa): Field<T, K> & { readonly facet: Fa };
148
161
  /** Mark this field's value ENCRYPTED (sets {@link Field.isEncrypted}). Chainable + non-destructive. */
149
162
  encrypted(): Field<T, K>;
150
163
  /** Mark this field plaintext METADATA (sets {@link Field.isMeta}) — the indexable opt-out on an owned aggregate. */
@@ -173,6 +186,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
173
186
  jsonShape?: "object";
174
187
  encrypted?: true;
175
188
  meta?: true;
189
+ facet?: FieldFacet;
176
190
  } = {},
177
191
  ): Field<T, K> {
178
192
  const isOptional = extra.isOptional ?? false;
@@ -181,6 +195,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
181
195
  zod,
182
196
  driver,
183
197
  isOptional,
198
+ ...(extra.facet !== undefined ? { facet: extra.facet } : {}),
184
199
  ...(extra.refWorkspace !== undefined ? { refWorkspace: extra.refWorkspace } : {}),
185
200
  ...(extra.refAggregateId !== undefined ? { refAggregateId: extra.refAggregateId } : {}),
186
201
  ...(extra.viaField !== undefined ? { viaField: extra.viaField } : {}),
@@ -211,10 +226,39 @@ function makeField<T, K extends FieldKind = FieldKind>(
211
226
  // Driver/schema unchanged; the fact rides the aggregate's audience surface, not the wire kind.
212
227
  return makeField<T, K>(kind, zod, driver, { ...extra, meta: true });
213
228
  },
229
+ withFacet<Fa extends FieldFacet>(facet: Fa): Field<T, K> & { readonly facet: Fa } {
230
+ // The facet governs WRITES (set() acceptance), never the wire/read type — so the schema +
231
+ // driver are unchanged. Threaded through merge/optional (it rides `extra`).
232
+ return makeField<T, K>(kind, zod, driver, { ...extra, facet }) as Field<T, K> & { readonly facet: Fa };
233
+ },
214
234
  };
215
235
  return f;
216
236
  }
217
237
 
238
+ // ── FIELD FACETS (security_model.md — the endorse model) ─────────────────────────────────────
239
+ // Canonical wrappers that tag a field's facet; `set()` then gates which PrincipalEvidence
240
+ // conversions it accepts. The field's read/wire type is unchanged — only WRITES are governed.
241
+
242
+ /** An AUTHORITY sink (relation subject/relation/object, token audience/scope, policy/role/identity
243
+ * write). Accepts a plain value OR `principal().asAuthoritySubject(reason)` — never un-endorsed identity. */
244
+ export const authorityField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "authority" } =>
245
+ field.withFacet("authority");
246
+ /** A STANDING sink (accountability: owedBy/owedTo/responsible/issuer/escalation). Accepts a plain
247
+ * value OR `principal().asStandingParty(reason)`. */
248
+ export const standingField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "standing" } =>
249
+ field.withFacet("standing");
250
+ /** An ATTRIBUTION sink (audit: createdBy/grantedBy/submittedBy). Accepts a plain value OR
251
+ * `principal().asAttribution()` — free, no endorsement. Promote to `authorityField` if law later
252
+ * reads it to decide authority. */
253
+ export const attributionField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "attribution" } =>
254
+ field.withFacet("attribution");
255
+ /** A confidentiality SOURCE (the read-down lattice). */
256
+ export const secretField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "secret" } =>
257
+ field.withFacet("secret");
258
+ /** Explicitly PUBLIC (⊤) — the read-down lattice top. */
259
+ export const publicField = <T, K extends FieldKind>(field: Field<T, K>): Field<T, K> & { readonly facet: "public" } =>
260
+ field.withFacet("public");
261
+
218
262
  /** Infer the authored value type of a Field. */
219
263
  export type FieldValue<F> = F extends Field<infer T> ? T : never;
220
264