@githolon/dsl 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dart/CHANGELOG.md +2 -0
  2. package/dart/FINDINGS.md +2 -2
  3. package/dart/LICENSE +45 -0
  4. package/dart/README.md +9 -0
  5. package/dart/example/nomos_webview_transport.dart +4 -4
  6. package/dart/lib/src/offer.dart +2 -2
  7. package/dart/lib/src/subscriptions.dart +1 -1
  8. package/dart/lib/src/webview_bridge.dart +52 -0
  9. package/dart/pubspec.yaml +3 -3
  10. package/dart/test/schema_validation_test.dart +1 -1
  11. package/dart/test/webview_bridge_test.dart +1 -1
  12. package/dart/test/wire_test.dart +1 -1
  13. package/package.json +1 -1
  14. package/src/aggregate.ts +9 -1
  15. package/src/authoring.ts +10 -1
  16. package/src/codegen_dart.ts +25 -15
  17. package/src/codegen_dart_client.ts +54 -5
  18. package/src/codegen_openapi.ts +239 -0
  19. package/src/codegen_proof.ts +16 -9
  20. package/src/codegen_ts.ts +83 -22
  21. package/src/compile_package_main.ts +117 -29
  22. package/src/ctx.ts +13 -0
  23. package/src/directive.ts +1 -1
  24. package/src/engine_entry.ts +22 -5
  25. package/src/fields.ts +44 -0
  26. package/src/flow_gate.ts +208 -0
  27. package/src/framework/capability.ts +19 -1
  28. package/src/framework/encryption_scope.ts +9 -1
  29. package/src/framework/genesis.ts +5 -1
  30. package/src/framework/relations.ts +23 -4
  31. package/src/framework/repair.ts +2 -2
  32. package/src/framework/signing_identity.ts +1 -1
  33. package/src/framework/workspace_invariant.ts +29 -8
  34. package/src/framework/workspaces.ts +147 -15
  35. package/src/index.ts +24 -0
  36. package/src/manifest.ts +12 -18
  37. package/src/ops.ts +42 -1
  38. package/src/principal_evidence.ts +112 -0
  39. package/src/relation_sugar.ts +10 -4
  40. package/src/shape_compat.ts +208 -0
  41. package/src/usd.ts +131 -0
  42. package/src/usd_layers.ts +43 -0
  43. package/src/wire.ts +8 -0
  44. package/src/wire_encode.ts +75 -2
  45. package/src/workspace_sharding.ts +196 -18
  46. package/src/workspace_type.ts +3 -2
  47. /package/dart/lib/{nomos_dsl.dart → nomos_client.dart} +0 -0
@@ -31,7 +31,7 @@
31
31
  * * `<name>.package.usda` — the deployable package (engine bundle + USD IR);
32
32
  * its sha256 IS the deploy `domainHash` (`policy:{hash}`).
33
33
  * * `<name>.deploy.json` — `{packageUsda}`: the EXACT body
34
- * `POST /v1/workspaces/:ws/domains` accepts as `application/json` on Nomos Cloud.
34
+ * `POST /v2/workspaces/:ws/domains` accepts as `application/json` on Nomos Cloud.
35
35
  * Admission/projection manifests are embedded in the package USDA and are not host
36
36
  * sidecars.
37
37
  * * `<name>.proof.mts` — the GENERATED PROOF (codegen_proof.ts): a
@@ -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,8 @@ 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 { generateOpenApiFragment, mergeOpenApiFragments } from "./codegen_openapi.js";
107
+ import { domainInterface, type DomainInterface } from "./shape_compat.js";
106
108
  import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
107
109
 
108
110
  const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -149,6 +151,8 @@ interface DomainConfig {
149
151
  };
150
152
  }
151
153
 
154
+ type DartSupportImport = "vendored" | "package";
155
+
152
156
  interface PackageConfig {
153
157
  readonly name: string;
154
158
  readonly domains: readonly DomainConfig[];
@@ -159,13 +163,16 @@ interface PackageConfig {
159
163
  * Typed DART frontend (the Flutter sibling of the generated TS client; #codegen_dart).
160
164
  * `false`/absent = skip (default). `true` = emit into `<outDir>/dart/`;
161
165
  * `{ out: "relative/dir" }` = emit there (resolved against the config dir).
166
+ * `{ support: "package" }` keeps `package:nomos_client/nomos_client.dart` imports for Flutter apps that
167
+ * already depend on `nomos_client`/`nomos_flutter`. The default `"vendored"` mode preserves the old
168
+ * self-contained directory.
162
169
  * Per composed domain one `<name>.dart` (payload classes + read models + typed read
163
170
  * accessors via the SAME `generateDartDomain` the co2 tenant emit uses), PLUS the
164
- * framework `nomos_dsl` Dart support files vendored alongside (the generated files'
165
- * `package:nomos_dsl/...` import is rewritten to a relative one) — so a Flutter app
171
+ * framework `nomos_client` Dart support files vendored alongside (the generated files'
172
+ * `package:nomos_client/...` import is rewritten to a relative one) — so a Flutter app
166
173
  * can vendor the directory as-is, no pub deps.
167
174
  */
168
- readonly dart?: boolean | { readonly out?: string };
175
+ readonly dart?: boolean | { readonly out?: string; readonly support?: DartSupportImport };
169
176
  /**
170
177
  * OPT-IN layered USD emission (also `--layered` on the CLI). When true, ALSO emit
171
178
  * `build/layers/<domain>--<module>.usda` (+ `.layer.json`) — one REAL USD layer per
@@ -391,11 +398,11 @@ function resolveNamed<T>(merged: Mod, names: readonly string[] | undefined, what
391
398
  * Vendored output rewrites it to the relative barrel so the directory is
392
399
  * self-contained (no pub path-dependency on the framework package).
393
400
  */
394
- const DART_PACKAGE_IMPORT = `import 'package:nomos_dsl/nomos_dsl.dart';`;
395
- const DART_RELATIVE_IMPORT = `import 'nomos_dsl.dart';`;
401
+ const DART_PACKAGE_IMPORT = `import 'package:nomos_client/nomos_client.dart';`;
402
+ const DART_RELATIVE_IMPORT = `import 'nomos_client.dart';`;
396
403
 
397
404
  /**
398
- * The framework `nomos_dsl` Dart support files the generated code resolves through
405
+ * The framework `nomos_client` Dart support files the generated code resolves through
399
406
  * the barrel (`dsl/dart/lib/`). `schema_validation.dart` is deliberately NOT
400
407
  * vendored: nothing the codegen emits references it, and it would drag the external
401
408
  * `json_schema` pub dependency into every vendoring app.
@@ -411,25 +418,41 @@ const DART_SUPPORT_SRC = [
411
418
  "webview_bridge.dart",
412
419
  ] as const;
413
420
 
421
+ function removeVendoredDartSupport(dartOut: string): void {
422
+ rmSync(path.join(dartOut, "nomos_client.dart"), { force: true });
423
+ rmSync(path.join(dartOut, "src"), { recursive: true, force: true });
424
+ }
425
+
414
426
  /**
415
427
  * Emit the typed Dart for the composed domain modules into `dartOut`: one
416
428
  * `<name>.dart` per domain (the SAME `generateDartDomain` path the co2 tenant's
417
- * `emit_dart.ts` drives) + the vendored `nomos_dsl.dart` barrel and its `src/`
429
+ * `emit_dart.ts` drives) + the vendored `nomos_client.dart` barrel and its `src/`
418
430
  * support files, with the generated files' `package:` import rewritten relative.
419
431
  */
420
- function emitDart(modules: readonly DomainModule[], dartOut: string, domainHash: string): string[] {
421
- mkdirSync(path.join(dartOut, "src"), { recursive: true });
432
+ function emitDart(
433
+ modules: readonly DomainModule[],
434
+ dartOut: string,
435
+ domainHash: string,
436
+ packageInterface: Record<string, DomainInterface> = {},
437
+ supportImport: DartSupportImport = "vendored",
438
+ ): string[] {
439
+ mkdirSync(dartOut, { recursive: true });
422
440
 
423
- // 1. the vendored framework support (barrel + src/), copied byte-for-byte except
441
+ // 1. optional vendored framework support (barrel + src/), copied byte-for-byte except
424
442
  // 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));
443
+ if (supportImport === "vendored") {
444
+ mkdirSync(path.join(dartOut, "src"), { recursive: true });
445
+ const libDir = path.join(DSL_DIR, "dart", "lib");
446
+ const barrel = readFileSync(path.join(libDir, "nomos_client.dart"), "utf8")
447
+ .split("\n")
448
+ .filter((line) => !line.includes("src/schema_validation.dart"))
449
+ .join("\n");
450
+ writeFileSync(path.join(dartOut, "nomos_client.dart"), barrel, "utf8");
451
+ for (const f of DART_SUPPORT_SRC) {
452
+ copyFileSync(path.join(libDir, "src", f), path.join(dartOut, "src", f));
453
+ }
454
+ } else {
455
+ removeVendoredDartSupport(dartOut);
433
456
  }
434
457
 
435
458
  // 2. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
@@ -450,10 +473,18 @@ function emitDart(modules: readonly DomainModule[], dartOut: string, domainHash:
450
473
  );
451
474
  }
452
475
  const p = path.join(dartOut, `${mod.name}.dart`);
453
- writeFileSync(p, src.replace(DART_PACKAGE_IMPORT, DART_RELATIVE_IMPORT), "utf8");
476
+ writeFileSync(
477
+ p,
478
+ supportImport === "vendored" ? src.replace(DART_PACKAGE_IMPORT, DART_RELATIVE_IMPORT) : src,
479
+ "utf8",
480
+ );
454
481
  written.push(p);
455
482
 
456
- const clientSrc = generateDartBridgeClient(modForDart, { domainHash });
483
+ const clientSrc = generateDartBridgeClient(modForDart, {
484
+ domainHash,
485
+ interface: packageInterface[modForDart.domain ?? modForDart.name],
486
+ supportImport,
487
+ });
457
488
  const cp = path.join(dartOut, `${mod.name}.client.dart`);
458
489
  writeFileSync(cp, clientSrc, "utf8");
459
490
  written.push(cp);
@@ -990,6 +1021,30 @@ async function main(): Promise<void> {
990
1021
  // identity across clean clones). Deterministic — re-compiles rewrite the same bytes.
991
1022
  writeFileSync(lockPath, JSON.stringify(nextLock, null, 2) + "\n", "utf8");
992
1023
 
1024
+ // ── THE STRUCTURAL INTERFACE (#72) — directives (payload shapes) + aggregates (read
1025
+ // shapes, sid-keyed), keyed by DOMAIN. The SAME object is (a) baked into the generated
1026
+ // clients as their requirement and (b) staged on the deploy as the workspace's offer, so
1027
+ // `connect()` negotiates compatibility by SHAPE (stable-id subtyping), not by law hash.
1028
+ // architecture/shape_compatibility.md. Multiple modules sharing a domain key merge.
1029
+ const packageInterface: Record<string, DomainInterface> = {};
1030
+ for (const mod of domainModules) {
1031
+ const key = mod.domain ?? mod.name;
1032
+ const di = domainInterface(mod, nextLock.domains[mod.name]?.stableIds);
1033
+ const prior = packageInterface[key];
1034
+ packageInterface[key] = prior
1035
+ ? {
1036
+ ...prior,
1037
+ directives: { ...prior.directives, ...di.directives },
1038
+ aggregates: { ...prior.aggregates, ...di.aggregates },
1039
+ }
1040
+ : di;
1041
+ }
1042
+ writeFileSync(
1043
+ path.join(outDir, `${cfg.name}.interface.json`),
1044
+ JSON.stringify(packageInterface, null, 2) + "\n",
1045
+ "utf8",
1046
+ );
1047
+
993
1048
  // ── reports ──
994
1049
  const entryReports: string[] = [];
995
1050
  for (const [reportId, ref] of Object.entries(cfg.reports ?? {})) {
@@ -1276,6 +1331,19 @@ async function main(): Promise<void> {
1276
1331
  const identity = buildIdentity(domainModules);
1277
1332
  const { manifestsPath } = writeIdentity(identity, outDir);
1278
1333
 
1334
+ // ── 3b. the OpenAPI spec — THE PACKAGE'S SELF-DESCRIPTION (the host's runtime routing table;
1335
+ // architecture/githolon_host_contract.md "The Self-Describing API"). Each domain emits its fragment
1336
+ // from its OWN zod (z.toJSONSchema); the package's spec is the law-blind merge. Deploy STAGES it like
1337
+ // the read overlay; the workspace's full spec is the runtime merge of installed packages' specs
1338
+ // (currentLaw); the host ingests it and builds its router from it — no domain/directive name in host
1339
+ // source. Self-describing for the whole web ecosystem (Swagger/codegen/Hono/agent tool-calling).
1340
+ const openapi = mergeOpenApiFragments(
1341
+ domainModules.map((m) => generateOpenApiFragment(m, { lawHash: domainHash })),
1342
+ { title: `${cfg.name} (nomos package)`, lawHash: domainHash },
1343
+ );
1344
+ const openapiPath = path.join(outDir, `${cfg.name}.openapi.json`);
1345
+ writeFileSync(openapiPath, JSON.stringify(openapi, null, 2) + "\n", "utf8");
1346
+
1279
1347
  // ── 4. the one-call deploy body (package only) ──
1280
1348
  // SELF-DESCRIBING: `summary` states what the (otherwise hex-opaque) package
1281
1349
  // contains — purely derived from the inputs (no timestamps), ignored by the
@@ -1301,6 +1369,12 @@ async function main(): Promise<void> {
1301
1369
  // collector's doc above). The worker stages it on the manifest overlay:
1302
1370
  // the deploy-time binding warning + the executor's scan map read it there.
1303
1371
  ...(capabilityLowerings.length > 0 ? { capabilities: capabilityLowerings } : {}),
1372
+ // THE STRUCTURAL OFFER (#72): the per-domain interface, staged on the manifest overlay so
1373
+ // `connect()` pulls the workspace's installed shapes and negotiates by structural subtyping.
1374
+ interface: packageInterface,
1375
+ // THE SELF-DESCRIBING API: this package's OpenAPI spec (law-derived). The worker stages it on the
1376
+ // manifest overlay; a read serves the workspace's merged spec; the host builds its router from it.
1377
+ openapi,
1304
1378
  };
1305
1379
  const deployPath = path.join(outDir, `${cfg.name}.deploy.json`);
1306
1380
  writeFileSync(deployPath, JSON.stringify(deployBody) + "\n", "utf8");
@@ -1338,7 +1412,7 @@ async function main(): Promise<void> {
1338
1412
  ` directives: ${d.directives.join(", ") || "—"}`,
1339
1413
  ` queries: ${d.queries.join(", ") || "—"} counts: ${d.counts.join(", ") || "—"} sums: ${d.sums.join(", ") || "—"}`,
1340
1414
  ]),
1341
- `deploy: POST ${cfg.name}.deploy.json to /v1/workspaces/<ws>/domains (or: githolon deploy <ws>)`,
1415
+ `deploy: POST ${cfg.name}.deploy.json to /v2/workspaces/<ws>/domains (or: githolon deploy <ws>)`,
1342
1416
  `typed client: ${cfg.name}.client.ts — ${tsClientFactoryName(domainModules[0]?.domain ?? domainModules[0]?.name ?? cfg.name)}(holon), law hash baked in`,
1343
1417
  proofPath !== undefined
1344
1418
  ? `generated proof: ${cfg.name}.proof.mts — run: githolon proof (live; ends ALL GREEN or names the jam)`
@@ -1350,14 +1424,25 @@ async function main(): Promise<void> {
1350
1424
  // ── 5. the typed TS client (#10 — the web sibling of codegen_dart), with the
1351
1425
  // deployed law's content hash baked in (emitted AFTER the package is hashed) ──
1352
1426
  const clientPath = path.join(outDir, `${cfg.name}.client.ts`);
1353
- writeFileSync(clientPath, generateTsClient(domainModules, { packageName: cfg.name, domainHash }), "utf8");
1427
+ writeFileSync(clientPath, generateTsClient(domainModules, { packageName: cfg.name, domainHash, interface: packageInterface }), "utf8");
1354
1428
 
1355
1429
  // ── 6. the typed Dart frontend (opt-in; the Flutter sibling of step 5, emitted
1356
1430
  // after the package hash like the TS client) ──
1357
1431
  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)}`);
1432
+ if (
1433
+ dartCfg !== false &&
1434
+ dartCfg !== true &&
1435
+ (typeof dartCfg !== "object" ||
1436
+ dartCfg === null ||
1437
+ (dartCfg.out !== undefined && typeof dartCfg.out !== "string") ||
1438
+ (dartCfg.support !== undefined && dartCfg.support !== "vendored" && dartCfg.support !== "package"))
1439
+ ) {
1440
+ fail(
1441
+ `config key 'dart' must be false, true, or { out?: "relative/dir", support?: "vendored" | "package" }; got ${JSON.stringify(dartCfg)}`,
1442
+ );
1360
1443
  }
1444
+ const dartSupportImport: DartSupportImport =
1445
+ typeof dartCfg === "object" && dartCfg.support !== undefined ? dartCfg.support : "vendored";
1361
1446
  let dartOut: string | undefined;
1362
1447
  let dartFiles: string[] = [];
1363
1448
  if (dartCfg !== false) {
@@ -1365,7 +1450,7 @@ async function main(): Promise<void> {
1365
1450
  cfgDir,
1366
1451
  typeof dartCfg === "object" && dartCfg.out !== undefined ? dartCfg.out : path.join(outDir, "dart"),
1367
1452
  );
1368
- dartFiles = emitDart(domainModules, dartOut, domainHash);
1453
+ dartFiles = emitDart(domainModules, dartOut, domainHash, packageInterface, dartSupportImport);
1369
1454
  }
1370
1455
 
1371
1456
  // ── summary ──
@@ -1401,20 +1486,23 @@ async function main(): Promise<void> {
1401
1486
  }
1402
1487
  }
1403
1488
  console.log(` client ${rel(clientPath)} (typed TS client — payloads, read models, query accessors)`);
1489
+ console.log(` openapi ${rel(openapiPath)} (${Object.keys(openapi.paths).length} operation(s) — the law's self-describing API; the host's runtime routing table)`);
1404
1490
  if (proofPath !== undefined) {
1405
1491
  console.log(` proof ${rel(proofPath)} (a runnable e2e GENERATED from your law — run: githolon proof)`);
1406
1492
  } else {
1407
1493
  console.log(` proof SKIPPED — ${proofSkip}`);
1408
1494
  }
1409
1495
  if (dartOut !== undefined) {
1410
- console.log(` dart ${rel(dartOut)}${path.sep} (${dartFiles.length} domain file(s) + vendored nomos_dsl support — typed Dart for Flutter)`);
1496
+ const supportText =
1497
+ dartSupportImport === "vendored" ? "vendored nomos_client support" : "package nomos_client imports";
1498
+ console.log(` dart ${rel(dartOut)}${path.sep} (${dartFiles.length} domain file(s) + ${supportText} — typed Dart for Flutter)`);
1411
1499
  }
1412
1500
  console.log(` deploy ${rel(deployPath)}`);
1413
1501
  console.log(``);
1414
1502
  console.log(`deploy (Nomos Cloud):`);
1415
1503
  console.log(` githolon ws create <ws> && githolon deploy <ws> # the secret is saved + sent for you`);
1416
1504
  console.log(` # raw lane: curl -X POST -H 'content-type: application/json' -H 'Authorization: Bearer <workspaceSecret>' \\`);
1417
- console.log(` # --data-binary @${rel(deployPath)} https://nomos.captainapp.co.uk/v1/workspaces/<ws>/domains`);
1505
+ console.log(` # --data-binary @${rel(deployPath)} https://nomos.captainapp.co.uk/v2/workspaces/<ws>/domains`);
1418
1506
  if (proofPath !== undefined) {
1419
1507
  console.log(`prove it OFFLINE first: npx githolon proof # the generated proof's offline legs on a LOCAL holon (no cloud workspace)`);
1420
1508
  console.log(` inner loop: npx githolon dev # watch -> recompile -> law live locally -> offline proof, on every save`);
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).
package/src/directive.ts CHANGED
@@ -143,7 +143,7 @@ export interface Directive<P = unknown> {
143
143
  readonly declaredRelations: RelationDecl[];
144
144
  /**
145
145
  * The directive's DECLARED required HOST CAPABILITIES — the capability PORTS the HOST must
146
- * PROVIDE for this directive to run (`TARGET_deps.dot` cluster_ports / `TARGET_flow.dot`
146
+ * PROVIDE for this directive to run (`OFFER_KERNEL_ARCHITECTURE.dot` cluster_ports / `OFFER_FLOW.dot`
147
147
  * cluster_ports, invariant 3). This is the HOST/PORT axis — "can this host PHYSICALLY do
148
148
  * this?" — checked ONCE at policy LOAD against what the loading host ADVERTISES, FAIL-CLOSED
149
149
  * (`required ⊆ provided`). It is DISTINCT from the AUTHZ axis {@link requiresCapability}
@@ -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