@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
@@ -73,8 +73,17 @@ import { t } from "../fields.js";
73
73
  import { set, withMarker } from "../ops.js";
74
74
  import { query } from "../query.js";
75
75
  import { workspaceInvariant, refAs } from "./workspace_invariant.js";
76
- import { writeTuple, removeTuple, RelationTuple } from "./relations.js";
76
+ import { writeTuple, removeTuple, RelationTuple, relationTupleInstanceId } from "./relations.js";
77
77
  import { relationType, self, user, key } from "../relation_schema.js";
78
+ import { GENESIS_WARRANT_ID } from "./genesis.js";
79
+ import { signerObject, SIGNER_RELATION } from "./signing_identity.js";
80
+ // THE GENESIS BOOTSTRAP (platform-birth genesis bootstrap, Approach A): governance carries the
81
+ // warrant-flip primitives so the birth door can lawfully bootstrap a virgin platform's founding admin —
82
+ // E `enrollSigner` (the founding signer tuple) → W `seedGenesis` (the GenesisWarrant) → A signed
83
+ // `grantAdmin` (admitted by the kernel genesis clause). Re-exported so the package's shape-discovery picks
84
+ // them up as part of the `workspaces` domain. (architecture/platform_birth_genesis_bootstrap.md)
85
+ export { GenesisWarrant, seedGenesis } from "./genesis.js";
86
+ export { identityRelations, enrollSigner } from "./signing_identity.js";
78
87
 
79
88
  // ── WORKSPACE SELF-IDENTITY (the law reads its OWN install-context) ──────────────────────────────────
80
89
  // The governance bundle runs on root AND every born platform. A workspace knows its ROLE via this
@@ -204,6 +213,10 @@ export const CloudWorkspace = aggregate("CloudWorkspace", {
204
213
  createdAt: t.int().merge(Lww),
205
214
  /** Epoch-ms the worker completed physical provisioning; absent until then. */
206
215
  provisionedAt: t.int().merge(Lww).optional(),
216
+ /** sha256 hex of the founding-admin key for a kind:platform birth — DATA the birth capability provider
217
+ * grants admin to (never the key). The record (status:requested, kind:platform) IS the birth obligation;
218
+ * the provider drains it, materializes, and authors provisionWorkspace (the receipt). Absent for plain ws. */
219
+ foundingAdminHash: t.string().merge(Lww).optional(),
207
220
  /**
208
221
  * Per-workspace cumulative-ledger byte cap OVERRIDE (bytes); absent = the
209
222
  * cloud-wide {@link CloudPolicy} default applies. Raised on the record via
@@ -374,6 +387,8 @@ export const createWorkspace = directive("createWorkspace")
374
387
  .set("ownerGrantHash", "") // no owner credential until provisioned
375
388
  .set("createdBy", p.requestedBy)
376
389
  .set("createdAt", p.requestedAt);
390
+ // A plain workspace record (no kind:platform) — boot materializes it directly; the birth capability
391
+ // provider only drains kind:platform records, so this is a lineage record, not a genesis order.
377
392
  return [];
378
393
  })
379
394
  .requires("creator"); // posture-gated creation: only a #creator holder may request a workspace
@@ -462,7 +477,8 @@ export const rotateWorkspaceOwner = directive("rotateWorkspaceOwner")
462
477
  .plan((p) => {
463
478
  const ws = instance(CloudWorkspace, p.workspaceId);
464
479
  return [set(ws, "ownerGrantHash", p.ownerGrantHash)];
465
- });
480
+ })
481
+ .requires("admin"); // lifecycle authority is law-state, not a host allowlist
466
482
 
467
483
  /**
468
484
  * setWorkspaceTier — move a workspace between `edge` and `heavy` ON THE RECORD;
@@ -482,7 +498,8 @@ export const setWorkspaceTier = directive("setWorkspaceTier")
482
498
  .plan((p) => {
483
499
  const ws = instance(CloudWorkspace, p.workspaceId);
484
500
  return [set(ws, "tier", p.tier)];
485
- });
501
+ })
502
+ .requires("admin");
486
503
 
487
504
  /**
488
505
  * retireWorkspace — the law withdraws the workspace. The worker thereafter
@@ -501,7 +518,8 @@ export const retireWorkspace = directive("retireWorkspace")
501
518
  .plan((p) => {
502
519
  const ws = instance(CloudWorkspace, p.workspaceId);
503
520
  return [set(ws, "status", "retired" as const)];
504
- });
521
+ })
522
+ .requires("admin");
505
523
 
506
524
  /**
507
525
  * reclaimWorkspace — THE LAWFUL CUSTODY DISCARD. Where {@link retireWorkspace}
@@ -531,7 +549,8 @@ export const reclaimWorkspace = directive("reclaimWorkspace")
531
549
  .plan((p) => {
532
550
  const ws = instance(CloudWorkspace, p.workspaceId);
533
551
  return [set(ws, "status", "reclaimed" as const)];
534
- });
552
+ })
553
+ .requires("admin");
535
554
 
536
555
  /**
537
556
  * birthPlatform — A PLATFORM IS BORN: the cloud, one level down. Records the
@@ -552,7 +571,10 @@ export const birthPlatform = directive("birthPlatform")
552
571
  parent: z.string().default(""),
553
572
  /** The pool: how many live children the platform may parent. */
554
573
  pool: z.number().int().positive().default(50),
555
- tier: z.enum(WORKSPACE_TIERS).default("edge"),
574
+ tier: z.enum(WORKSPACE_TIERS).default("heavy"),
575
+ /** sha256 of the founding-admin key the requester PRESENTED (the provider grants admin to the hash;
576
+ * the requester keeps the key — it is the platform's admin credential, used to govern + deploy). */
577
+ foundingAdminHash: sha256Hex,
556
578
  requestedBy: z.string().min(1),
557
579
  requestedAt: epochMs,
558
580
  }),
@@ -567,12 +589,32 @@ export const birthPlatform = directive("birthPlatform")
567
589
  .set("tier", p.tier)
568
590
  .set("status", "requested" as const)
569
591
  .set("ownerGrantHash", "")
592
+ .set("foundingAdminHash", p.foundingAdminHash) // the provider grants admin to this hash at materialize
570
593
  .set("createdBy", p.requestedBy)
571
594
  .set("createdAt", p.requestedAt);
595
+ // THE RECORD IS THE OBLIGATION: a kind:platform CloudWorkspace at status:requested with no ownerGrantHash
596
+ // is an order the birth capability provider drains (materialize + install the parent's law + seed genesis
597
+ // + grant the founding admin + birthCloud), closing it with provisionWorkspace (the receipt). No separate
598
+ // obligation aggregate — the minted CloudWorkspace IS it (sidesteps the id-mint gate; reuses the lifecycle).
572
599
  return [];
573
600
  })
574
601
  .requires("platformCreator"); // invite-only: an active #platformCreator tuple admits the birth
575
602
 
603
+ // ─────────────────────────── BIRTH AS A NOMOS PRIMITIVE ──────────────────────────────────────────────
604
+ // THE RECORD IS THE OBLIGATION (architecture/githolon_host_contract.md "Birth is a capability"). Birth is
605
+ // an IMPURE capability: the parent ORDERS (the law-gated birthPlatform mints a CloudWorkspace at
606
+ // status:requested, kind:platform, carrying foundingAdminHash — gated by .requires("platformCreator")),
607
+ // the BIRTH CAPABILITY PROVIDER fulfils it (materialize: install the PARENT's law + seed genesis + grant
608
+ // the founding admin + birthCloud — the child's OWN kernel admitting each), and authors provisionWorkspace
609
+ // (the receipt) the parent TRUSTS. No separate obligation aggregate — the minted CloudWorkspace IS the
610
+ // order (it sidesteps the id-mint gate and reuses the requested→provisioned lifecycle). The provider knows
611
+ // HOW (the Capability Exception sanctions a declared effect); the law owns WHO/WHAT; the parent trusts the
612
+ // DID-IT-HAPPEN. A child born this way runs THIS SAME law and births its children identically.
613
+
614
+ /** workspacesByStatus — the birth provider drains `requested` CloudWorkspaces (kind:platform, no
615
+ * ownerGrantHash) → materialize → provisionWorkspace. The law's order queue; the host polls, never decides. */
616
+ export const workspacesByStatus = query("workspacesByStatus").key("status").returns(CloudWorkspace);
617
+
576
618
  /** setPlatformPool — the pool edition of a granted quota request. */
577
619
  export const setPlatformPool = directive("setPlatformPool")
578
620
  .mutates(CloudWorkspace)
@@ -729,7 +771,8 @@ export const claimHostname = directive("claimHostname")
729
771
  .set("claimedAt", p.claimedAt)
730
772
  .set("status", "active" as const);
731
773
  return [];
732
- });
774
+ })
775
+ .requires("admin"); // hostname authority is law-state
733
776
 
734
777
  /** releaseHostname — free the name (the record stays — audit, never erasure). */
735
778
  export const releaseHostname = directive("releaseHostname")
@@ -744,7 +787,8 @@ export const releaseHostname = directive("releaseHostname")
744
787
  .plan((p) => {
745
788
  const c = instance(HostnameClaim, p.claimId);
746
789
  return [set(c, "status", "released" as const)];
747
- });
790
+ })
791
+ .requires("admin");
748
792
 
749
793
  /**
750
794
  * bindCapability — record the FACT that a workspace holds a capability
@@ -782,7 +826,8 @@ export const bindCapability = directive("bindCapability")
782
826
  if (p.scope !== undefined) b.set("scope", p.scope);
783
827
  if (p.expiresAt !== undefined) b.set("expiresAt", p.expiresAt);
784
828
  return [];
785
- });
829
+ })
830
+ .requires("admin"); // capability-bind authority is law-state
786
831
 
787
832
  /**
788
833
  * revokeCapability — flip a CapabilityBinding to `revoked`. The credential
@@ -804,7 +849,8 @@ export const revokeCapability = directive("revokeCapability")
804
849
  .plan((p) => {
805
850
  const b = instance(CapabilityBinding, p.bindingId);
806
851
  return [set(b, "status", "revoked" as const)];
807
- });
852
+ })
853
+ .requires("admin");
808
854
 
809
855
  /**
810
856
  * setCloudPolicy — set (or amend) THE QUOTA LAW. An Ensure on the law-state
@@ -1025,11 +1071,20 @@ export const capabilityBindingByKeyHash = query("capabilityBindingByKeyHash")
1025
1071
  // UserPermission): admin=`workspace:self#admin@key:<keyHash>`, creator=`…#creator@key:…`,
1026
1072
  // platformCreator=`workspace:self#platformCreator@user:<principal>`. grantAdmin/grantCreation/
1027
1073
  // grantPlatformCreation write them via `writeTuple`; the bespoke AdminGrant/CreationGrant aggregates +
1028
- // their queries are GONE. Re-export the framework tuple surface so the COMPILED governance package mints
1029
- // RelationTuple's stable id into the lockfile + identity manifest, the engine registers relateSubject/
1030
- // unrelateSubject for dispatch, and the projected relationTuplesByObject read routes. (QuotaGrant stays
1031
- // an aggregate — a numeric allowance, not a membership.)
1032
- export { relateSubject, unrelateSubject, relationTuplesByObject } from "./relations.js";
1074
+ // their queries are GONE. RelationTuple's stable id rides into the lockfile + identity manifest via the
1075
+ // `export { RelationTuple }` below (grantAdmin/grantCreation/… `.ensures(RelationTuple)`); the projected
1076
+ // relationTuplesByObject read routes via its own re-export. (QuotaGrant stays an aggregate — a numeric
1077
+ // allowance, not a membership.)
1078
+ //
1079
+ // THE RAW TUPLE WRITERS ARE NOT GOVERNANCE OFFERS (the security pass). `relateSubject`/`unrelateSubject`
1080
+ // are the framework PRIMITIVE (still the DSL public API for domains that knowingly compose their own
1081
+ // schema); governance never authored through them — every governance tuple write is a GATED wrapper
1082
+ // (grantAdmin/grantCreation/grantPlatformCreation → `writeTuple`, each `.requires("admin")`). Re-exporting
1083
+ // the bare directives only registered an UNGATED `workspace:self#admin@…` writer for dispatch — a
1084
+ // self-grant-admin hole the moment the /v2 gateway routes every offer. Dropping the re-export removes them
1085
+ // from governance's law/dispatch/spec ENTIRELY (not hidden — genuinely not governance's API; the true
1086
+ // generic "you may only grant a relation you hold" rule is a future `relationFrom` kernel arc).
1087
+ export { relationTuplesByObject } from "./relations.js";
1033
1088
  export { RelationTuple };
1034
1089
 
1035
1090
  // ── GOVERNANCE WRITE-AUTHZ IS NATIVE ZANZIBAR — the ONE gate, no bespoke predicates ───────────────────
@@ -1045,3 +1100,80 @@ export { RelationTuple };
1045
1100
  // in the pre-apply state). SELF-RELATIVE: custody guarantees a workspace only folds its OWN tuples, so the
1046
1101
  // SAME law governs root and every born platform. The host's `_adminOk`/`_creationLawOk`/`_platformCheck`
1047
1102
  // pre-checks are GONE too (a pure bailiff) — the native gate refuses a non-holder at admission.
1103
+
1104
+ // ── THE SECURITY PASS: genesis + receipt gating as LAW (nomos-improving-nomos / the spec audit) ────────
1105
+ // The /v2 gateway routes EVERY offer (nothing hidden — Jack's "make the surface attack-proof"). An offer
1106
+ // with neither a `.requires` gate nor an invariant is a RED FLAG: a raw ungated primitive. The genesis
1107
+ // directives (seedGenesis/enrollSigner) + the self-birth (birthCloud) + the birth receipt
1108
+ // (provisionWorkspace) CANNOT use `.requires` — they run pre-admin / pre-warrant or carry no actor — so
1109
+ // the protection that USED to live in the HOST session-lane refusal of control-plane intents moves here,
1110
+ // onto the ledger, as workspaceInvariants: pure predicates over pre-apply state + the payload, fired at the
1111
+ // ONE gate (author / edge admission / verify_chain replay), era-keyed (nomosInvariantGate). No kernel
1112
+ // change, no host gate — exactly the "evolve security THROUGH nomos" the north star demands.
1113
+ //
1114
+ // WRITE-ONCE-BY-VALUE, not strict create-once: an IDENTICAL re-offer (root's by-hash idempotent genesis
1115
+ // re-entry) passes; only a DIFFERENT binding is refused. So idempotence survives while a forged re-seed
1116
+ // (regime-flip / owner-rebind / signer-graft) becomes a typed refusal on every door.
1117
+
1118
+ /** seedGenesis: the founding GenesisWarrant is write-once-by-value. A virgin chain may seed; re-seeding
1119
+ * the SAME (principal, workspaceId) is idempotent; a DIFFERENT binding — the signed-author regime-flip /
1120
+ * owner-rebind the host session-lane used to refuse — is rejected. */
1121
+ export const genesisWarrantWriteOnce = workspaceInvariant("GenesisWarrantWriteOnce")
1122
+ .on("seedGenesis")
1123
+ .reads(() => [refAs("warrant", "GenesisWarrant", "pre:" + GENESIS_WARRANT_ID)])
1124
+ .assert(({ warrant }, ctx) => {
1125
+ const cur = warrant as { principal?: string; workspaceId?: string } | undefined;
1126
+ if (!cur?.principal) return { accept: true }; // virgin — genesis may seed the founding warrant
1127
+ const p = (ctx?.intent ?? {}) as { principal?: string; workspaceId?: string };
1128
+ return cur.principal === p.principal && (cur.workspaceId ?? "") === (p.workspaceId ?? "")
1129
+ ? { accept: true } // idempotent re-seed (the by-hash genesis re-entry)
1130
+ : { reject: "genesis-warrant-already-bound" };
1131
+ });
1132
+
1133
+ /** enrollSigner: the FOUNDING signer is write-once per principal. A virgin `identity:<principal>#signer`
1134
+ * may enroll its founding key; re-offering the SAME key is idempotent; grafting ANY other key (the
1135
+ * author-as-someone-else impersonation the host session-lane used to refuse) is rejected — a later device
1136
+ * needs a self-bound / admin-gated enrolment lane, never this founding one. */
1137
+ export const founderSignerWriteOnce = workspaceInvariant("FounderSignerWriteOnce")
1138
+ .on("enrollSigner")
1139
+ .reads((ctx) => [
1140
+ refAs(
1141
+ "tuple",
1142
+ "RelationTuple",
1143
+ "pre:" + relationTupleInstanceId(signerObject(String((ctx.intent ?? {}).principal ?? "")), SIGNER_RELATION),
1144
+ ),
1145
+ ])
1146
+ .assert(({ tuple }, ctx) => {
1147
+ const subjects = (tuple as { subjects?: Record<string, unknown> })?.subjects ?? {};
1148
+ const want = "key:" + String((ctx?.intent ?? {}).keyHash ?? "");
1149
+ const others = Object.keys(subjects).filter((k) => k !== want);
1150
+ return others.length === 0 ? { accept: true } : { reject: "founding-signer-already-enrolled" };
1151
+ });
1152
+
1153
+ /** birthCloud: a chain is born ONCE. A second birthCloud would mint a duplicate root-ish CloudWorkspace
1154
+ * AND could flip the self-role via its ensure(WorkspaceSelf) — the governance-freeze attack. Refuse once
1155
+ * WorkspaceSelf.role exists. Genesis births on a role-less chain; materialize births each child once. */
1156
+ export const birthCloudOnce = workspaceInvariant("BirthCloudOnce")
1157
+ .on("birthCloud")
1158
+ .reads(() => [refAs("self", "WorkspaceSelf", "pre:" + WORKSPACE_SELF_ID)])
1159
+ .assert(({ self }) =>
1160
+ (self as { role?: string })?.role ? { reject: "cloud-already-born" } : { accept: true },
1161
+ );
1162
+
1163
+ /** provisionWorkspace: the birth RECEIPT may only close a `requested` record (no hijacking a live
1164
+ * workspace's owner credential), and for a kind:platform birth the receipt's ownerGrantHash MUST equal
1165
+ * the pre-committed foundingAdminHash (the provider grants admin to the key the parent ORDERED, never an
1166
+ * arbitrary one). A plain workspace carries no foundingAdminHash, so its fresh-secret mint stays the
1167
+ * trusted impure capability — birth honesty: the law gates what it can prove, trusts the provider for the
1168
+ * rest. (Worker authors ownerGrantHash=foundingAdminHash for platforms, sha256(freshSecret) for plain.) */
1169
+ export const provisionReceiptGuard = workspaceInvariant("ProvisionReceiptGuard")
1170
+ .on("provisionWorkspace")
1171
+ .reads((ctx) => [refAs("ws", "CloudWorkspace", "pre:" + String((ctx.intent ?? {}).workspaceId ?? ""))])
1172
+ .assert(({ ws }, ctx) => {
1173
+ const rec = ws as { status?: string; foundingAdminHash?: string } | undefined;
1174
+ if (!rec || rec.status !== "requested") return { reject: "provision-requires-requested-workspace" };
1175
+ const p = (ctx?.intent ?? {}) as { ownerGrantHash?: string };
1176
+ if (rec.foundingAdminHash && rec.foundingAdminHash !== p.ownerGrantHash)
1177
+ return { reject: "provision-founding-hash-mismatch" };
1178
+ return { accept: true };
1179
+ });
package/src/index.ts CHANGED
@@ -32,7 +32,21 @@ export {
32
32
  type FieldKind,
33
33
  type EvidenceRefValue,
34
34
  evidenceRefSchema,
35
+ // FIELD FACETS (the endorse model — security_model.md): authority/standing/attribution gate
36
+ // set() acceptance of PrincipalEvidence; secret/public are the confidentiality lattice.
37
+ authorityField,
38
+ standingField,
39
+ attributionField,
40
+ secretField,
41
+ publicField,
35
42
  } from "./fields.js";
43
+ export {
44
+ type PrincipalEvidence,
45
+ type AttributionPrincipal,
46
+ type EndorsedAuthority,
47
+ type EndorsedStanding,
48
+ type FieldFacet,
49
+ } from "./principal_evidence.js";
36
50
  export {
37
51
  Lww,
38
52
  LwwCausal,
@@ -410,6 +424,16 @@ export {
410
424
  type InvariantVerdict,
411
425
  } from "./relation.js";
412
426
  export { generateDartDomain, type DomainModule } from "./codegen_dart.js";
427
+ // SHAPE COMPATIBILITY (#72) — the structural contract a client negotiates with a workspace
428
+ // by, instead of the law hash. Pure + deterministic; the editor/CI mirror imports these.
429
+ export {
430
+ domainInterface,
431
+ compatible,
432
+ type DomainInterface,
433
+ type CompatVerdict,
434
+ type DirectivePayloadShape,
435
+ type AggregateReadShape,
436
+ } from "./shape_compat.js";
413
437
  // The OPTIONAL Graphviz `.dot` ER-diagram emitter (off-by-default presentation projection,
414
438
  // turned on by the `--emit-dot` compiler flag). Pure `DomainModule -> string`, deterministic;
415
439
  // NOT part of the domain identity. Barrel-safe (no node:* imports), so the engine bundle can
package/src/manifest.ts CHANGED
@@ -39,6 +39,7 @@ import { finishExtremum, type AnyExtremum } from "./extremum.js";
39
39
  import { existsAsCount, type AnyExists } from "./exists.js";
40
40
  import type { OrderedReadDecl } from "./ordered_read.js";
41
41
  import type { CanonicalPred } from "./predicate.js";
42
+ import { assertFlowGate } from "./flow_gate.js";
42
43
  import type { DerivedDecl } from "./derived.js";
43
44
  import type { CombinedDecl } from "./combined.js";
44
45
  import type { CertifiedReadDecl } from "./certified_read.js";
@@ -156,7 +157,7 @@ export interface CanonicalDirective {
156
157
  */
157
158
  readonly capturedReads?: CanonicalCapturedRead[];
158
159
  /**
159
- * The directive's DECLARED required HOST CAPABILITY ports (`TARGET_deps.dot` cluster_ports;
160
+ * The directive's DECLARED required HOST CAPABILITY ports (`OFFER_KERNEL_ARCHITECTURE.dot` cluster_ports;
160
161
  * the HOST/PORT axis, invariant 3): the capability ports the host must PROVIDE for the
161
162
  * directive to run, checked FAIL-CLOSED (`required ⊆ provided`) at policy LOAD. SORTED (the
162
163
  * SET is the contract; order is incidental). DISTINCT from {@link requiresCapability} (the
@@ -1055,22 +1056,10 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1055
1056
  }))
1056
1057
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1057
1058
 
1058
- // E2E ENCRYPTION — critical #5 (e2e_encryption.md §5), the captured-read seal, COMPILE
1059
- // half. A captured read (`read()` on the write path) of a PRIVATE query would record its
1060
- // plaintext result onto the intent as PUBLIC, signed, replay-verified chain data —
1061
- // bypassing the read gate AND the encryption envelope. Forbid it: a `requires(cap)`
1062
- // query can NEVER be a captured read. (The one gate re-asserts this at admission/replay
1063
- // via `cap_for_query`, so a hand-crafted raw intent is refused on every peer too.)
1064
- const privateTypes = new Set(aggregates.filter((a) => a.requiresRead !== undefined).map((a) => a.id));
1065
- for (const d of directives) {
1066
- for (const cr of d.capturedReads ?? []) {
1067
- if (privateTypes.has(cr.returns)) {
1068
- throw new Error(
1069
- `directive '${d.id}': captured read '${cr.queryId}' returns the PRIVATE type '${cr.returns}' — a .reads() of a requires(cap) query would write its plaintext onto the chain as public data. Private data may only leave via the gated read path (query/qbyid), never a captured read.`,
1070
- );
1071
- }
1072
- }
1073
- }
1059
+ // THE INFORMATION-FLOW GATE (architecture/security_model.md): rule 2 (no private
1060
+ // captured read on the chain) + rule 1 (no encrypt-then-index) are enforced as ONE
1061
+ // pass over the assembled canonical manifest, just before return (see assertFlowGate).
1062
+ // E2E ENCRYPTION — critical #5 (e2e_encryption.md §5): the captured-read seal is rule 2.
1074
1063
 
1075
1064
  // exists_ entries are COUNT declarations in the IR — merge them into `counts` so the
1076
1065
  // Rust engine sees them as ordinary counts (the marker lives only in the DSL/codegen
@@ -1081,7 +1070,7 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1081
1070
  // Omit-when-empty: a domain declaring NO query / NO count / NO sum contributes neither
1082
1071
  // key, so its canonical manifest is byte-identical to before the read-closure layer
1083
1072
  // existed. `sums` follows the same omit-when-empty discipline as `counts`.
1084
- return {
1073
+ const manifest: CanonicalManifest = {
1085
1074
  domain: mod.name,
1086
1075
  aggregates,
1087
1076
  directives,
@@ -1109,6 +1098,11 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1109
1098
  ...(mod.workspaceTypes !== undefined ? { workspaceTypes: mod.workspaceTypes } : {}),
1110
1099
  }),
1111
1100
  };
1101
+ // THE STATIC INFORMATION-FLOW GATE — leaky law is unrepresentable (a typed compile
1102
+ // error naming the field, the sink, and the rule). Rules 1/2/5 read the manifest IR;
1103
+ // rule 4 also needs the raw module (the executable invariant reads + declassify waivers).
1104
+ assertFlowGate(manifest, mod as never);
1105
+ return manifest;
1112
1106
  }
1113
1107
 
1114
1108
  /** Recursively sort object keys; arrays are already in sorted/stable order. */
package/src/ops.ts CHANGED
@@ -17,6 +17,13 @@
17
17
  */
18
18
  import type { AggregateHandle, BoundAggregate } from "./aggregate.js";
19
19
  import type { Field, FieldValue } from "./fields.js";
20
+ import {
21
+ endorsementOf,
22
+ type AttributionPrincipal,
23
+ type EndorsedAuthority,
24
+ type EndorsedStanding,
25
+ type PrincipalFacet,
26
+ } from "./principal_evidence.js";
20
27
 
21
28
  /**
22
29
  * A FIELD op, retaining the source aggregate id + field for deterministic wire encoding.
@@ -41,6 +48,13 @@ export interface FieldOp {
41
48
  readonly value?: unknown;
42
49
  readonly items?: readonly string[];
43
50
  readonly entryKey?: string;
51
+ /**
52
+ * THE PRINCIPAL-FLOW ENDORSEMENT (security_model.md — the endorse model): present iff this op's
53
+ * value came from a PrincipalEvidence conversion. The raw principal is materialised into `value`;
54
+ * this records the facet + endorsement reason so the discovery harvest can lift it into the
55
+ * manifest `principalFlows`, and the runtime witness can emit the observed flow. Internal.
56
+ */
57
+ readonly endorsement?: { readonly facet: PrincipalFacet; readonly reason: string | null };
44
58
  }
45
59
 
46
60
  export type PlannedEventMarker = "creates" | "mutates" | "ensures" | "archives";
@@ -84,6 +98,20 @@ type Target<Id extends string = string, F extends Record<string, Field> = Record
84
98
 
85
99
  type Fields<A> = A extends Target<string, infer F> ? F : never;
86
100
 
101
+ /**
102
+ * What `set()` ACCEPTS for a field, given its facet (security_model.md — the endorse model).
103
+ * A plain (un-faceted) field accepts its ordinary value. A faceted field ALSO accepts the matching
104
+ * PrincipalEvidence conversion — and ONLY that one, so writing un-endorsed identity (or the wrong
105
+ * endorsement) into an authority/standing sink does not typecheck. The read/wire type is unchanged.
106
+ */
107
+ type AcceptedValue<F> = F extends { readonly facet: "authority" }
108
+ ? FieldValue<F> | EndorsedAuthority<FieldValue<F>>
109
+ : F extends { readonly facet: "standing" }
110
+ ? FieldValue<F> | EndorsedStanding<FieldValue<F>>
111
+ : F extends { readonly facet: "attribution" }
112
+ ? FieldValue<F> | AttributionPrincipal
113
+ : FieldValue<F>;
114
+
87
115
  /** Resolve a target's `{aggregateId, aggregateType}` — bound → instance id + type. */
88
116
  function addressOf(agg: Target): { aggregateId: string; aggregateType?: string } {
89
117
  if ((agg as BoundAggregate).__isBoundAggregate === true) {
@@ -110,7 +138,7 @@ export function set<
110
138
  (Fields<A>[K] extends { kind: "set" }
111
139
  ? `TYPE ERROR: '${K}' is a set field — set() would OVERWRITE the merge; use addToSet(handle, "${K}", values)`
112
140
  : K),
113
- value: FieldValue<Fields<A>[K]>,
141
+ value: AcceptedValue<Fields<A>[K]>,
114
142
  ): FieldOp {
115
143
  const f = (agg as { fields?: Record<string, { kind?: string }> }).fields?.[field];
116
144
  if (f?.kind === "set") {
@@ -119,6 +147,19 @@ export function set<
119
147
  `(concurrent adds would be lost). Use addToSet(handle, '${field}', values) — the additive write.`,
120
148
  );
121
149
  }
150
+ // MATERIALISE PrincipalEvidence: the typed value may be an evidence conversion (an opaque token).
151
+ // Store the RAW principal in the op, and record the endorsement so the harvest + runtime witness
152
+ // see the flow (security_model.md). A plain value passes through untouched.
153
+ const endorsement = endorsementOf(value);
154
+ if (endorsement) {
155
+ return {
156
+ ...addressOf(agg),
157
+ field,
158
+ kind: "set",
159
+ value: endorsement.principal,
160
+ endorsement: { facet: endorsement.facet, reason: endorsement.reason },
161
+ };
162
+ }
122
163
  return { ...addressOf(agg), field, kind: "set", value };
123
164
  }
124
165
 
@@ -0,0 +1,112 @@
1
+ // NOMOS — the integrity dual of the information-flow gate (architecture/security_model.md).
2
+ // "Identity may attribute freely. Identity may authorise only when endorsed by declared law."
3
+ //
4
+ // `ctx.principal()` returns opaque PrincipalEvidence — NOT a string. It cannot be compared,
5
+ // concatenated, branched on, or smuggled into a payload (the raw value is held in a module-
6
+ // private registry, unreadable by authors). Its ONLY exits are three typed conversions:
7
+ //
8
+ // .asAttribution() → AttributionPrincipal (free — createdBy/grantedBy/...)
9
+ // .asAuthoritySubject(reason) → EndorsedAuthority<T> (endorsed — a write-up into authority)
10
+ // .asStandingParty(reason) → EndorsedStanding<T> (endorsed — accountability: owedBy/...)
11
+ //
12
+ // Field facets gate `set()` acceptance (authorityField accepts only EndorsedAuthority, etc.), so an
13
+ // un-endorsed flow into an authority sink does not COMPILE — the confused deputy is closed by
14
+ // construction, not by plan-body taint analysis. `requires`/`assert` remain the only place an
15
+ // authority decision may be MADE (evidence can't branch, so `if (principal() === …)` won't typecheck).
16
+ //
17
+ // The `reason` is part of the LAW, not the data: a non-empty human string, harvested into the
18
+ // manifest `principalFlows` + the endorse era key (the audit surface), and witnessed fail-closed at
19
+ // runtime/replay (Slice B).
20
+
21
+ /** The endorsement facet — which kind of sink the evidence has been converted for. */
22
+ export type PrincipalFacet = "attribution" | "authority" | "standing";
23
+
24
+ /**
25
+ * A field's FACET — the integrity/confidentiality role of the sink. The integrity facets
26
+ * (authority/attribution/standing) gate `set()` acceptance of PrincipalEvidence conversions;
27
+ * the confidentiality facets (secret/public) mark the read-down lattice. Default (no facet) =
28
+ * an ordinary field that accepts a plain value.
29
+ */
30
+ export type FieldFacet = "authority" | "attribution" | "standing" | "secret" | "public";
31
+
32
+ /** The record behind an evidence-derived value, readable ONLY by the framework (never the author). */
33
+ export interface EndorsementRecord {
34
+ readonly facet: PrincipalFacet;
35
+ /** The verified authoring principal (the captured `ctx.principal()` value). */
36
+ readonly principal: string;
37
+ /** The static endorsement reason (LAW) — null for free attribution. */
38
+ readonly reason: string | null;
39
+ }
40
+
41
+ // The raw principal + endorsement live HERE, module-private — an author holding a converted value
42
+ // has no accessor to them (the brand below is phantom). The framework reads via `endorsementOf`.
43
+ const REGISTRY = new WeakMap<object, EndorsementRecord>();
44
+
45
+ declare const FACET_BRAND: unique symbol;
46
+ /** A principal converted for ATTRIBUTION — accepted by `attributionField` sinks. Opaque. */
47
+ export interface AttributionPrincipal {
48
+ readonly [FACET_BRAND]: "attribution";
49
+ }
50
+ /** A principal ENDORSED for an AUTHORITY sink (relation subject/relation/object, token, policy…). Opaque. */
51
+ export interface EndorsedAuthority<T = string> {
52
+ readonly [FACET_BRAND]: "authority";
53
+ /** Phantom — carries the sink's value type for `set()` inference; never materialised. */
54
+ readonly __t?: T;
55
+ }
56
+ /** A principal ENDORSED for a STANDING sink (owedBy/responsible/issuer…). Opaque. */
57
+ export interface EndorsedStanding<T = string> {
58
+ readonly [FACET_BRAND]: "standing";
59
+ readonly __t?: T;
60
+ }
61
+
62
+ /** The opaque identity capability `ctx.principal()` returns. No raw exit — only typed conversions. */
63
+ export interface PrincipalEvidence {
64
+ /** Free: record WHO acted, into an `attributionField` (createdBy/grantedBy/submittedBy). */
65
+ asAttribution(): AttributionPrincipal;
66
+ /** Endorsed write-up into an `authorityField` — `reason` is LAW (a static, non-empty string). */
67
+ asAuthoritySubject<T = string>(reason: string): EndorsedAuthority<T>;
68
+ /** Endorsed write-up into a `standingField` (accountability) — `reason` is LAW. */
69
+ asStandingParty<T = string>(reason: string): EndorsedStanding<T>;
70
+ }
71
+
72
+ function requireReason(kind: string, reason: unknown): string {
73
+ if (typeof reason !== "string" || reason.trim().length === 0) {
74
+ throw new Error(
75
+ `${kind}(reason) requires a non-empty reason — endorsing identity into an authority sink is a ` +
76
+ `declared act of law, not data. Pass a static string explaining why this flow is intended.`,
77
+ );
78
+ }
79
+ return reason;
80
+ }
81
+
82
+ function mint(facet: PrincipalFacet, principal: string, reason: string | null): object {
83
+ const token = {};
84
+ REGISTRY.set(token, { facet, principal, reason });
85
+ return token;
86
+ }
87
+
88
+ /** Build the opaque evidence capability over a verified principal (the engine wires this into ctx). */
89
+ export function principalEvidence(principal: string): PrincipalEvidence {
90
+ return {
91
+ asAttribution: () => mint("attribution", principal, null) as AttributionPrincipal,
92
+ asAuthoritySubject: <T = string>(reason: string) =>
93
+ mint("authority", principal, requireReason("asAuthoritySubject", reason)) as EndorsedAuthority<T>,
94
+ asStandingParty: <T = string>(reason: string) =>
95
+ mint("standing", principal, requireReason("asStandingParty", reason)) as EndorsedStanding<T>,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * FRAMEWORK-INTERNAL: read the endorsement behind an evidence-derived value — used by the write
101
+ * path (to materialise the raw principal into the op), the discovery harvest (to record the flow),
102
+ * and the runtime/replay witness (to emit the observed flow). Returns null for a plain value (a
103
+ * non-evidence write). Authors cannot call this usefully — the REGISTRY is module-private.
104
+ */
105
+ export function endorsementOf(value: unknown): EndorsementRecord | null {
106
+ return typeof value === "object" && value !== null && REGISTRY.has(value) ? (REGISTRY.get(value) ?? null) : null;
107
+ }
108
+
109
+ /** Is this value an evidence conversion (any facet)? */
110
+ export function isPrincipalEvidenceValue(value: unknown): boolean {
111
+ return endorsementOf(value) !== null;
112
+ }
@@ -210,10 +210,16 @@ export interface SugaredRelationTypeBuilder extends RelationTypeBuilder {
210
210
  * builder; we re-wrap on every chain step so the audience methods survive interleaving with `.relation`.
211
211
  */
212
212
  export function withAudiences(builder: RelationTypeBuilder): SugaredRelationTypeBuilder {
213
- // The builder object is mutated in place by `.relation(...)` (it returns itself), so we can safely
214
- // attach methods that delegate to it and re-wrap. We attach to a fresh proxy-like object that forwards
215
- // the decl fields by reference (the Map is shared, so reads stay live).
216
- const sugared = Object.create(builder) as SugaredRelationTypeBuilder;
213
+ // Carry the decl's data as OWN enumerable properties (NOT `Object.create(builder)` — a prototype-only
214
+ // object serializes to `{}`, which collapses the compiler's `discover()` JSON dedup so every sugared
215
+ // type but the first is dropped). `relations` is the SAME Map reference the underlying builder mutates,
216
+ // so reads stay live as `.relation(...)`/the audience methods add to it.
217
+ const sugared = {
218
+ type: builder.type,
219
+ relations: builder.relations,
220
+ ...(builder.caps !== undefined ? { caps: builder.caps } : {}),
221
+ __isRelationType: true as const,
222
+ } as SugaredRelationTypeBuilder;
217
223
 
218
224
  sugared.relation = function (name, opts) {
219
225
  builder.relation(name, opts);