@githolon/dsl 0.7.0 → 0.8.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/aggregate.ts CHANGED
@@ -26,25 +26,28 @@ export type AggregateInvariantVerdict = { accept: true } | { reject: string };
26
26
  export type AggregateInvariantFn = (snapshot: Record<string, unknown>) => AggregateInvariantVerdict;
27
27
 
28
28
  /**
29
- * READ VISIBILITY (slice 5.1, `host_routes_only.md` §5.1) — the DECLARED posture
30
- * of an aggregate's data on the read side. `public` (the DEFAULT, and the only
31
- * value before this existed) means any peer may read the type; `requires(cap)`
32
- * makes the type PRIVATE: a read that touches it (a `query` returning it, a
33
- * `count`/`sum` over it, a by-id read of one of its instances) is served ONLY to a
34
- * principal that effectively holds `cap` in THIS workspace's own folded
35
- * role-bindings (the kernel `identity::authz` path, lifted from the write gate).
29
+ * READ VISIBILITY (the native-relations read gate) — the DECLARED posture of an
30
+ * aggregate's data on the read side. `public` (the DEFAULT, and the only value
31
+ * before this existed) means any peer may read the type; `requires(cap)` makes the
32
+ * type PRIVATE: a read that touches it (a `query` returning it, a `count`/`sum`
33
+ * over it, a by-id read of one of its instances) is served ONLY to a principal that
34
+ * holds the read RELATION `cap` on the workspace-scope object — decided by the ONE
35
+ * native Zanzibar `RewriteEngine.check` over THIS workspace's own folded relation
36
+ * tuples (`nomos_identity::relations`, the SAME evaluator the write gate uses).
36
37
  *
37
38
  * THE LAW DECIDES, NOT THE HOST: the visibility rides IN the law package (the
38
- * `nomosReadVisibility` USD era key — OMITTED when every aggregate is public, so
39
- * an all-public domain hashes byte-identically to before this existed) and the
40
- * githolon enforces it at the one wasm read boundary. The host only STAMPS the
41
- * presented principal (an impure identity read) and relays the githolon's verdict.
39
+ * `nomosReadVisibility` USD era key — type → required cap — OMITTED when every
40
+ * aggregate is public, so an all-public domain hashes byte-identically to before
41
+ * this existed), and the compiler ALSO synthesizes a `workspace#<cap>` relation into
42
+ * `nomosRelations` so the cap is a checkable native relation. The githolon enforces
43
+ * it at the one wasm read boundary; the host only STAMPS the presented principal (an
44
+ * impure identity read) and relays the githolon's verdict.
42
45
  *
43
- * `cap` is a CAPABILITY NAME (the same vocabulary as a role's caps / a directive
44
- * name — see `framework/rbac.ts`): the workspace's owner authors a `grant` of a
45
- * role carrying `cap` to the reader's principal (e.g. at birth, over the
46
- * secret-gated author lane). A degenerate identity catalogue (role ≡ cap) means
47
- * "holds an active, unexpired, non-revoked binding whose role grants `cap`".
46
+ * `cap` is the read RELATION NAME (the same vocabulary as a directive's required
47
+ * relation): the workspace's owner authors a `relateSubject` tuple
48
+ * `workspace:self#<cap>@user:<principal>` to the reader (e.g. at birth, over the
49
+ * secret-gated author lane). Holding the relation (an active, non-revoked tuple) =
50
+ * may read.
48
51
  */
49
52
  export type ReadVisibility = { readonly requires: string };
50
53
 
@@ -52,6 +52,7 @@ import type { CombinedDecl } from "./combined.js";
52
52
  import type { ImpureCapabilityDecl } from "./codegen_provider_dart.js";
53
53
  import type { WorkspaceTypeDecl } from "./workspace_type.js";
54
54
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
55
+ import type { RelationTypeDecl } from "./relation_schema.js";
55
56
  import { domainHash, emitManifestBytes } from "./manifest.js";
56
57
  import { emitUsd } from "./usd.js";
57
58
  import { expandCapabilityExports } from "./capability_exports.js";
@@ -121,6 +122,8 @@ export interface DomainModuleSpec {
121
122
  readonly workspaceTypes?: readonly WorkspaceTypeDecl[];
122
123
  /** Declared workspace invariants (#266) — presence is hashed; bodies ship in the lump. */
123
124
  readonly workspaceInvariants?: readonly WorkspaceInvariantDecl[];
125
+ /** Native Zanzibar relation schema (P1) — pure-data rewrites → the `nomosRelations` era key. */
126
+ readonly relationTypes?: readonly RelationTypeDecl[];
124
127
  readonly dartImports?: readonly DartImport[];
125
128
  readonly dartRefImports?: DomainModule["dartRefImports"];
126
129
  readonly permissionVocabulary?: PermissionVocabulary;
@@ -176,6 +179,7 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
176
179
  const maxes = [...(spec.maxes ?? [])];
177
180
  const workspaceTypes = [...(spec.workspaceTypes ?? [])];
178
181
  const workspaceInvariants = [...(spec.workspaceInvariants ?? [])];
182
+ const relationTypes = [...(spec.relationTypes ?? [])];
179
183
  const dartImports = [...(spec.dartImports ?? [])];
180
184
 
181
185
  return {
@@ -200,6 +204,9 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
200
204
  // Omit-when-empty — presence-only hashed law (#266): an invariant-free domain's
201
205
  // canonical manifest must stay byte-identical (adding/removing an invariant moves it).
202
206
  ...(workspaceInvariants.length > 0 ? { workspaceInvariants } : {}),
207
+ // Omit-when-empty — the relation schema is hashed law (P1); a relation-free domain's
208
+ // canonical manifest/USD stays byte-identical (the nomosRelations era rule).
209
+ ...(relationTypes.length > 0 ? { relationTypes } : {}),
203
210
  ...(dartImports.length > 0 ? { dartImports } : {}),
204
211
  ...(spec.dartRefImports !== undefined && spec.dartRefImports.length > 0
205
212
  ? { dartRefImports: [...spec.dartRefImports] }
@@ -31,6 +31,7 @@
31
31
  import { z } from "zod";
32
32
  import type { AggregateHandle } from "./aggregate.js";
33
33
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
34
+ import type { RelationTypeDecl } from "./relation_schema.js";
34
35
  import type { Field, FieldKind } from "./fields.js";
35
36
  import type { Directive } from "./directive.js";
36
37
  import { executeDirectiveToIntent } from "./wire_encode.js";
@@ -2427,6 +2428,14 @@ export interface DomainModule {
2427
2428
  * aggregate `hasInvariant` / a directive `plan`). See `docs/workspace_invariant.md`.
2428
2429
  */
2429
2430
  workspaceInvariants?: WorkspaceInvariantDecl[];
2431
+ /**
2432
+ * The domain's NATIVE ZANZIBAR RELATION SCHEMA (`relation_schema.ts` — the userset-rewrite law,
2433
+ * P1). Pure-data per-type relation rewrites lowered to the `nomosRelations` USD era key (NO JS on
2434
+ * the schema path). ADDITIVE + OPTIONAL: a domain that declares none omits this entirely and is
2435
+ * byte-identical in the canonical manifest to before the key existed (era rule). PRESENCE + the
2436
+ * AST is hashed; the recursive Check/Expand EVALUATOR is the Rust engine substrate, never authored.
2437
+ */
2438
+ relationTypes?: RelationTypeDecl[];
2430
2439
  /**
2431
2440
  * The domain's FIRST-CLASS WORKSPACE TYPES (`workspace_type.ts`; sharding §10
2432
2441
  * RATIFIED, slice 1) — the law-declared workspace taxonomy
package/src/codegen_ts.ts CHANGED
@@ -451,8 +451,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
451
451
  ` authToken?: string;`,
452
452
  ` /** Transitional bare principal uid, sent as \`x-nomos-principal\` (no token). */`,
453
453
  ` principal?: string;`,
454
- ` /** Bearer credential when the lane is gated (platform admin key / invite key). */`,
454
+ ` /** Bearer credential when the lane is gated (an invite/creation key). */`,
455
455
  ` bearer?: string;`,
456
+ ` /** Platform births: the founding admin key YOU choose (sent as \`x-nomos-platform-admin-key\`). The`,
457
+ ` * host never mints or returns one — keep this; it is the AdminGrant of the platform's governance. */`,
458
+ ` platformAdminKey?: string;`,
456
459
  `}`,
457
460
  ``,
458
461
  `/** A birth lane's response — \`workspace\` is the born holon's addressable name. */`,
@@ -462,8 +465,6 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
462
465
  ` workspace?: string;`,
463
466
  ` /** Returned ONCE at birth — the workspace's deploy credential. SAVE IT. */`,
464
467
  ` workspaceSecret?: string;`,
465
- ` /** Platform births only — the platform's own governance credential, ONCE. */`,
466
- ` platformAdminKey?: string;`,
467
468
  ` error?: string;`,
468
469
  ` [k: string]: unknown;`,
469
470
  `}`,
@@ -473,6 +474,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
473
474
  ` if (auth.authToken !== undefined) headers["x-nomos-auth"] = auth.authToken;`,
474
475
  ` if (auth.principal !== undefined) headers["x-nomos-principal"] = auth.principal;`,
475
476
  ` if (auth.bearer !== undefined) headers["authorization"] = \`Bearer \${auth.bearer}\`;`,
477
+ ` if (auth.platformAdminKey !== undefined) headers["x-nomos-platform-admin-key"] = auth.platformAdminKey;`,
476
478
  ` const res = await fetch(\`\${cloud.replace(/\\/$/, "")}\${path}\`, { method: "POST", headers });`,
477
479
  ` return (await res.json()) as NomosBirthResult;`,
478
480
  `};`,
@@ -507,11 +509,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
507
509
  // A TOP-LEVEL type: births directly under the cloud.
508
510
  emit(
509
511
  fnName(t.id),
510
- `Birth a '${t.id}' workspace${isPlatform ? " (a platform-kind coordinator: hosts its own subtree)" : ""} — derived from the taxonomy over the existing ${isPlatform ? "platform" : "workspace"} lane.`,
511
- isPlatform ? [`name: string`] : [`name: string`, `tier?: "edge" | "heavy"`],
512
+ `Birth a '${t.id}' workspace${isPlatform ? " (a platform-kind coordinator: hosts its own subtree; PRESENT your chosen platformAdminKey)" : ""} — derived from the taxonomy over the existing ${isPlatform ? "platform" : "workspace"} lane. Placement is automatic (measured shape) — no tier to pick.`,
513
+ isPlatform ? [`name: string`, `platformAdminKey: string`] : [`name: string`],
512
514
  isPlatform
513
515
  ? `\`/v1/platforms/\${encodeURIComponent(opts.name)}\``
514
- : `\`/v1/workspaces/\${encodeURIComponent(opts.name)}\${opts.tier === "heavy" ? "?tier=heavy" : ""}\``,
516
+ : `\`/v1/workspaces/\${encodeURIComponent(opts.name)}\``,
515
517
  );
516
518
  }
517
519
  for (const childId of t.childIds) {
@@ -521,13 +523,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
521
523
  const childIsPlatform = child.childIds.length > 0;
522
524
  emit(
523
525
  fnName(child.id),
524
- `Birth a '${child.id}' under a '${t.id}' — the taxonomy's hasMany edge over the existing platform lane (lineage + pool enforced by the parent's law).`,
526
+ `Birth a '${child.id}' under a '${t.id}' — the taxonomy's hasMany edge over the existing platform lane (lineage + pool enforced by the parent's law).${childIsPlatform ? " PRESENT your chosen platformAdminKey." : ""} Placement is automatic (measured shape).`,
525
527
  childIsPlatform
526
- ? [`${parentParam}: string`, `name: string`]
527
- : [`${parentParam}: string`, `name: string`, `tier?: "edge" | "heavy"`],
528
+ ? [`${parentParam}: string`, `name: string`, `platformAdminKey: string`]
529
+ : [`${parentParam}: string`, `name: string`],
528
530
  childIsPlatform
529
531
  ? `\`/v1/platforms/\${encodeURIComponent(opts.${parentParam})}/platforms/\${encodeURIComponent(opts.name)}\``
530
- : `\`/v1/platforms/\${encodeURIComponent(opts.${parentParam})}/workspaces/\${encodeURIComponent(opts.name)}\${opts.tier === "heavy" ? "?tier=heavy" : ""}\``,
532
+ : `\`/v1/platforms/\${encodeURIComponent(opts.${parentParam})}/workspaces/\${encodeURIComponent(opts.name)}\``,
531
533
  );
532
534
  }
533
535
  }
@@ -88,6 +88,7 @@ import { canonicalTaxonomyFragment, validateWorkspaceTaxonomyAndRoutes } from ".
88
88
  import { shardingLawModule, type ShardingLawSpec } from "./workspace_sharding.js";
89
89
  import type { AnyExtremum } from "./extremum.js";
90
90
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
91
+ import { type RelationTypeDecl, isRelationTypeDecl, validateRelationType } from "./relation_schema.js";
91
92
  import {
92
93
  emitLayeredDomain,
93
94
  flattenLayeredUsd,
@@ -126,6 +127,8 @@ interface DomainConfig {
126
127
  readonly workspaceTypes?: readonly string[];
127
128
  /** Export-name escape hatch for workspace invariants (auto-discovered by shape otherwise). */
128
129
  readonly workspaceInvariants?: readonly string[];
130
+ /** Export-name escape hatch for relation types (auto-discovered by brand otherwise). */
131
+ readonly relationTypes?: readonly string[];
129
132
  readonly impureCapabilities?: readonly string[];
130
133
  readonly extraAggregates?: readonly string[];
131
134
  readonly readModelAggregates?: readonly string[];
@@ -691,6 +694,12 @@ async function main(): Promise<void> {
691
694
  ...discover<WorkspaceInvariantDecl>(merged, isWorkspaceInvariantDecl),
692
695
  ...resolveNamed<WorkspaceInvariantDecl>(merged, d.workspaceInvariants, "workspaceInvariants"),
693
696
  ];
697
+ // Native Zanzibar relation schema (P1) — auto-discovered by brand, like workspaceInvariants.
698
+ const relationTypes = [
699
+ ...discover<RelationTypeDecl>(merged, isRelationTypeDecl),
700
+ ...resolveNamed<RelationTypeDecl>(merged, d.relationTypes, "relationTypes"),
701
+ ];
702
+ for (const rt of relationTypes) validateRelationType(rt); // DX fast-fail (Rust resolve is authoritative)
694
703
  // Sums are AUTO-DISCOVERED like queries/counts (#57 — an exported SumDecl used to be
695
704
  // SILENTLY DROPPED unless config-named; the config lane stays as the escape hatch).
696
705
  // Reference-dedupe: a sum that is BOTH exported and config-named is the same object.
@@ -758,6 +767,7 @@ async function main(): Promise<void> {
758
767
  ...(maxes.length > 0 ? { maxes } : {}),
759
768
  ...(workspaceTypes.length > 0 ? { workspaceTypes } : {}),
760
769
  ...(workspaceInvariants.length > 0 ? { workspaceInvariants } : {}),
770
+ ...(relationTypes.length > 0 ? { relationTypes } : {}),
761
771
  ...(spatials.length > 0 ? { spatials } : {}),
762
772
  ...(d.dartImports !== undefined && d.dartImports.length > 0
763
773
  ? { dartImports: d.dartImports }
package/src/directive.ts CHANGED
@@ -32,7 +32,6 @@ export type ReferentialMarker = "creates" | "mutates" | "ensures" | "archives";
32
32
  * replay, and `admit`'s authz gate folds the permission projection strictly-before
33
33
  * the intent's HLC against exactly this scope.
34
34
  */
35
- export type ScopeFrom<P> = (payload: P) => string[];
36
35
 
37
36
  export interface Directive<P = unknown> {
38
37
  readonly id: string;
@@ -50,20 +49,15 @@ export interface Directive<P = unknown> {
50
49
  */
51
50
  readonly description?: string;
52
51
  /**
53
- * The capability an actor must hold to author this directive (the DECLARATIVE
54
- * authz pre-condition). `undefined` ⇒ the directive declares no requirement and
55
- * the gate's authz slot stays inert for it. When set, the kernel threads this
56
- * into `Authored.required_caps` so `admit`'s authz step flips to `Enforce` once a
57
- * `RoleCatalogue` is supplied. Defaults to the directive `id` (a directive's name
58
- * IS its capability — the same convention the `RoleCatalogue` keys caps by).
52
+ * NATIVE ZANZIBAR write-authz (P4): the RELATION the captured author must HOLD to author this
53
+ * directive, and the OBJECT it is checked on. The ONE gate runs `engine.check(schema, object,
54
+ * relation, principal)` over the pre-apply folded tuple store — refusing an author who does not
55
+ * hold it (the side-door fix made real). `object` is SELF-RELATIVE by default (`"workspace:self"`,
56
+ * the governance convention); a payload-derived per-record object is the follow-on. DISTINCT from
57
+ * the legacy `requiresCapability` (RBAC) and `declaredRelations` (cross-workspace) axes. Emitted
58
+ * into the `nomosRelationGate` USD era key; enforced IFF the law also declares a relation schema.
59
59
  */
60
- readonly requiresCapability?: string;
61
- /**
62
- * Pure payload→scope derivation feeding `admit`'s `target` (see {@link ScopeFrom}).
63
- * `undefined` ⇒ root scope. Authz checks the actor holds `requiresCapability` at
64
- * a scope COVERING this target.
65
- */
66
- readonly scopeFrom?: ScopeFrom<P>;
60
+ readonly requiresRelationGate?: { readonly relation: string; readonly object: string };
67
61
  /**
68
62
  * The directive's DECLARED read boundary: the ref types its `plan` may READ (the
69
63
  * "IR for law boundaries" decision — the boundary is part of the domain identity).
@@ -141,7 +135,7 @@ export interface RequirableDirective<P> extends Directive<P> {
141
135
  * to the directive id) at the scope `scopeFrom(payload)` derives (defaults to root).
142
136
  * Returns a new `Directive` carrying `requiresCapability` + `scopeFrom`.
143
137
  */
144
- requires(capability?: string, scopeFrom?: ScopeFrom<P>): RequirableDirective<P>;
138
+ requires(relation: string, opts?: { object?: string }): RequirableDirective<P>;
145
139
  /**
146
140
  * Declare the directive's READ boundary. TWO argument kinds, one declaration site:
147
141
  * * a STRING declares a ref TYPE its `plan` may read (the original boundary —
@@ -275,11 +269,14 @@ class DirectivePlanStep<Id extends string, P> {
275
269
  function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
276
270
  return {
277
271
  ...base,
278
- requires(capability?: string, scopeFrom?: ScopeFrom<P>): RequirableDirective<P> {
272
+ requires(relation: string, opts?: { object?: string }): RequirableDirective<P> {
273
+ // THE ONE write-authz declaration (full-native Zanzibar, no mixed systems): the captured author
274
+ // must HOLD `relation` on `object` (self-relative `"workspace:self"` by default) for the one gate
275
+ // to admit. Lowers to the `nomosRelationGate` era key; `admit` runs `engine.check`. (Replaces the
276
+ // deleted RBAC capability axis — a capability WAS just a relation.)
279
277
  return makeRequirable({
280
278
  ...base,
281
- requiresCapability: capability ?? base.id,
282
- ...(scopeFrom !== undefined ? { scopeFrom } : {}),
279
+ requiresRelationGate: { relation, object: opts?.object ?? "workspace:self" },
283
280
  });
284
281
  },
285
282
  reads(...reads: (string | QueryDecl)[]): RequirableDirective<P> {
@@ -536,6 +536,7 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
536
536
  /** `{workspaceInvariantReads:{id}, payload}` → the bounded ref-set `[{name,aggregate,id},…]`. */
537
537
  function planWorkspaceInvariantReads(job: {
538
538
  intent?: { workspaceInvariantReads?: { id?: string }; payload?: unknown };
539
+ capturedPorts?: { principal?: string };
539
540
  }): string {
540
541
  const id = job.intent?.workspaceInvariantReads?.id;
541
542
  if (typeof id !== "string") {
@@ -548,13 +549,17 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
548
549
  throw new Error(`engine workspaceInvariantReads: no workspace invariant registered for "${id}"`);
549
550
  }
550
551
  const intent = (job.intent?.payload ?? {}) as Record<string, unknown>;
551
- return JSON.stringify(decl.reads({ intent }));
552
+ // LAW-GATED AUTHZ: the captured author identity (a top-level sibling of `intent`, set by the wasm
553
+ // author leg + replayed verbatim) — the governance invariant reads it to gate by the AUTHOR.
554
+ const principal = (job.capturedPorts?.principal ?? "") as string;
555
+ return JSON.stringify(decl.reads({ intent, principal }));
552
556
  }
553
557
 
554
558
  /** `{workspaceInvariant:{id,on}, payload?}` + priorState (named snapshots) → {accept}|{reject}. */
555
559
  function planWorkspaceInvariantAssert(job: {
556
560
  intent?: { workspaceInvariant?: { id?: string }; payload?: unknown };
557
561
  priorState?: Record<string, unknown>;
562
+ capturedPorts?: { principal?: string };
558
563
  }): string {
559
564
  const id = job.intent?.workspaceInvariant?.id;
560
565
  if (typeof id !== "string") {
@@ -568,7 +573,8 @@ export function registerEngine(config: EngineEntryConfig): RegisteredEngine {
568
573
  }
569
574
  const snapshots = (job.priorState ?? {}) as Record<string, Record<string, unknown>>;
570
575
  const intent = (job.intent?.payload ?? {}) as Record<string, unknown>;
571
- return JSON.stringify(decl.assert(snapshots, { intent }));
576
+ const principal = (job.capturedPorts?.principal ?? "") as string;
577
+ return JSON.stringify(decl.assert(snapshots, { intent, principal }));
572
578
  }
573
579
 
574
580
  function planDerive(job: {
@@ -6,7 +6,7 @@
6
6
  /**
7
7
  * framework/encryption_identity.ts — the E2E ENCRYPTION KEY REGISTRY (slice 1 of the
8
8
  * `architecture/e2e_encryption.md` arc). Tenant-agnostic framework MECHANISM, the
9
- * encryption sibling of `framework/rbac.ts`'s authority registry.
9
+ * encryption sibling of the native authority registry (`framework/relations.ts`).
10
10
  *
11
11
  * THE ROLE: the key-handoff lane (slice 4) wraps a scope's epoch secret TO a grant
12
12
  * recipient's X25519 PUBLIC key. To do that it must look up "principal → encryption
@@ -15,7 +15,7 @@
15
15
  * / ~/.holon, never on any chain), and `grant`/the fan-out gate resolve it by principal.
16
16
  *
17
17
  * PRINCIPAL-KEYED, NOT device-keyed — deliberately. The read-visibility grant
18
- * (`rbac.ts`) targets a `principal` (the auth-provider uid), and the launch platforms use
18
+ * (a native `relateSubject` tuple) targets a `principal` (the auth-provider uid), and the launch platforms use
19
19
  * bare auth-provider principals with no full Actor/DeviceKey model. So the encryption key
20
20
  * is keyed by the SAME principal the grant targets — one decoupled registry a tenant
21
21
  * composes WITHOUT the full `identity.ts` domain. (Multi-device per principal, and the
@@ -42,7 +42,7 @@ const X25519_PUB_B64_RE = /^[A-Za-z0-9+/]{42,44}={0,2}$/;
42
42
 
43
43
  /**
44
44
  * The per-principal encryption-key record. Multi-instance, keyed
45
- * `enc-key-<principal>` (mirrors `rbac.ts`'s `user-permissions-<userId>`). `encPublicKey`
45
+ * `enc-key-<principal>` (mirrors the native `reltuple-<object>-<relation>` keying). `encPublicKey`
46
46
  * is the published X25519 public key; a re-publish (Lww) is a client-driven key rotation
47
47
  * (which the owner should follow with a scope epoch rotation so new wraps target it).
48
48
  */
@@ -0,0 +1,88 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/genesis.ts — THE GENESIS WARRANT (the bootstrap base case;
8
+ * `architecture/identity_keys.md` step 0, `home_holons_and_cross_custody.md` §2).
9
+ *
10
+ * THE BOOTSTRAP PARADOX: the relation gate refuses a founding control-plane grant
11
+ * on a VIRGIN chain (an empty `#admin`/`#creator`/… roster) — there is no admin yet
12
+ * to gate the first admin. Resolved LAWFULLY (not by a host bypass): at birth the
13
+ * host verifies the `x-nomos-auth` token ONCE and records the verified principal as
14
+ * an in-history fact — this `GenesisWarrant`. The gate's GENESIS CLAUSE
15
+ * (`executor::genesis_admits`) then admits a founding grant on a virgin object IFF
16
+ * the warrant binds the chain to the captured principal. Strictly additive (only an
17
+ * otherwise-`Unauthorized` virgin grant), so every sealed chain replays unchanged.
18
+ *
19
+ * CONTROL-PLANE: `seedGenesis` is authored by the BIRTH DOOR (the host, after the
20
+ * one-time token verification), NOT by the open session lane — the session lane
21
+ * structurally refuses control-plane intents, so a tenant cannot forge a warrant for
22
+ * a principal it does not own. The warrant is the host's recorded verification, not
23
+ * a host authority decision: every replaying peer re-reads the chain fact.
24
+ *
25
+ * SINGLETON per chain: one founding principal per home/workspace, so the warrant is
26
+ * a singleton keyed `genesis-warrant:self`. `seedGenesis` is create-once — a second
27
+ * seed is a dup-create refusal, so the birth principal is fixed forever-on-chain.
28
+ */
29
+ import { z } from "zod";
30
+ import { aggregate, instance } from "../aggregate.js";
31
+ import { t } from "../fields.js";
32
+ import { Lww } from "../drivers.js";
33
+ import { directive } from "../directive.js";
34
+ import { set, type PlannedOp } from "../ops.js";
35
+
36
+ /**
37
+ * Deterministic singleton id of the founding warrant — MUST equal
38
+ * `executor::GENESIS_WARRANT_ID` (the gate reads this exact key). One per chain.
39
+ */
40
+ export const GENESIS_WARRANT_ID = "genesis-warrant:self";
41
+
42
+ /**
43
+ * The founding-warrant record. It does THREE jobs at once:
44
+ * - `principal` — the x-nomos-auth-verified OWNER (the genesis clause binds the founding grant to it);
45
+ * - `workspaceId` — this workspace's UNIQUE SELF-ID (the source binding the author signature covers,
46
+ * re-derived locally by the gate — NOT the shared/deterministic genesis commit, which is identical
47
+ * across all workspaces and so bound nothing);
48
+ * - its mere PRESENCE — the SIGNED-AUTHOR REGIME signal (the "era"): a workspace with a warrant
49
+ * REQUIRES a verified author block (forge closed); one without falls back to the legacy
50
+ * host-stamped principal (pre-feature chains replay green, per-position).
51
+ */
52
+ export const GenesisWarrant = aggregate("GenesisWarrant", {
53
+ principal: t.string().merge(Lww), // the x-nomos-auth-verified owner, recorded at birth
54
+ workspaceId: t.string().merge(Lww), // this workspace's unique self-id (the author-signature source binding)
55
+ bornAt: t.string().merge(Lww), // ISO-8601, caller-stamped (determinism)
56
+ });
57
+ export type GenesisWarrantHandle = typeof GenesisWarrant;
58
+
59
+ /** The framework PRIMITIVE plan: record the founding warrant (singleton). */
60
+ export function writeGenesisWarrant(p: { principal: string; workspaceId: string; bornAt: string }): PlannedOp[] {
61
+ const w = instance(GenesisWarrant, GENESIS_WARRANT_ID);
62
+ return [
63
+ set(w, "principal", p.principal),
64
+ set(w, "workspaceId", p.workspaceId),
65
+ set(w, "bornAt", p.bornAt),
66
+ ];
67
+ }
68
+
69
+ /**
70
+ * `seedGenesis` — the birth-door directive that records the verified founding principal + this
71
+ * workspace's unique self-id at the FIXED singleton id `genesis-warrant:self`. `.ensures` (NOT
72
+ * `.creates`): the warrant is a deterministic-KEYED singleton, not a minted record — `.creates` would
73
+ * demand a runtime-minted `<Type>_<uuid>` id and the id-mint gate refuses a hand-written one. The
74
+ * gate reads the warrant at THIS exact key, so the id is law, not minted. A second seed UPSERTs the
75
+ * same leaf rather than minting a duplicate; re-seeding is itself prevented out-of-band — seedGenesis
76
+ * is a CONTROL-PLANE birth-door write (the open session lane refuses it) and post-genesis bootstrap
77
+ * intents are refused once the lifecycle controller is active.
78
+ */
79
+ export const seedGenesis = directive("seedGenesis")
80
+ .ensures(GenesisWarrant)
81
+ .payload(
82
+ z.object({
83
+ principal: z.string().min(1),
84
+ workspaceId: z.string().min(1),
85
+ bornAt: z.string().min(1),
86
+ }),
87
+ )
88
+ .plan((p) => writeGenesisWarrant(p));
@@ -41,7 +41,7 @@
41
41
  * response-mutation). This is exactly the proven `ExportJobAggregate` shape
42
42
  * (the tenant domains package `thing_structures`) generalised into a reusable framework pattern so a
43
43
  * domain dev does NOT hand-roll the {aggregate + order + complete + fail} quartet each
44
- * time — they DECLARE the task's params and get the quartet for free, like `rbac.ts`
44
+ * time — they DECLARE the task's params and get the quartet for free, like `relations.ts`
45
45
  * gives grant/revoke for free.
46
46
  *
47
47
  * ── What is framework vs tenant ──────────────────────────────────────────────
@@ -168,7 +168,7 @@ export function impureCapabilityAggregate<
168
168
  * captured + committed — so the `order` create is idempotent on replay by capture, and the
169
169
  * `complete`/`fail` receipt addresses the SAME instance by reading the committed minted id.
170
170
  * Trivial today (identity), but centralised here so the keying rule has ONE home (the
171
- * symmetry with `rbac.ts`'s `userPermissionsInstanceId`).
171
+ * symmetry with `relations.ts`'s `relationTupleInstanceId`).
172
172
  */
173
173
  export function impureCapabilityInstanceId(requestId: string): string {
174
174
  return requestId;
@@ -215,7 +215,7 @@ const deadLetterSchema = z.object({
215
215
  * The three plan builders. Each takes the task aggregate handle + the (already
216
216
  * Zod-validated) payload and returns the `PlannedOp[]` writing the envelope. A tenant
217
217
  * routes its directive `.plan` through these so the envelope shape can never drift —
218
- * exactly how `rbac.ts`'s grant/revoke route through `buildBinding`/`writeBinding`.
218
+ * exactly how `relations.ts`'s relate/unrelate route through `writeTuple`/`removeTuple`.
219
219
  */
220
220
 
221
221
  /**
@@ -61,11 +61,30 @@ const OBJECT_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*:[A-Za-z0-9][A-Za-z0-9_.:/-]*$/;
61
61
  /** A relation name (the edge label) — a bare identifier, like a directive/cap name. */
62
62
  const RELATION_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
63
63
  /**
64
- * A SUBJECT: `user:<uid>`, the public wildcard `user:*`, or a userset `<object>#<relation>`.
65
- * v1 admits user-subjects + the wildcard; usersets are reserved for slice 3 (the rewrite
66
- * layer) and refused here so a raw uid can never smuggle one in.
64
+ * A SUBJECT: `user:<uid>`, the public wildcard `user:*`, a KEY-ADDRESSED `key:<64hex>`, or a
65
+ * USERSET `<type>:<id>#<relation>` (group membership / indirection — P2). A raw uid carrying the
66
+ * `:` delimiter or being the bare `*` is refused so it can never forge a wildcard/userset.
67
67
  */
68
68
  const USER_SUBJECT_RE = /^user:([A-Za-z0-9][A-Za-z0-9_.-]*|\*)$/;
69
+ /**
70
+ * A KEY-ADDRESSED subject: `key:<64 lowercase hex>` — a sha256 keyHash, the CONTROL-PLANE authority
71
+ * subject (an AdminGrant/CreationGrant/CapabilityBinding key becomes `…@key:<keyHash>`). Pinned to
72
+ * EXACTLY 64 hex with NO wildcard: the Check wildcard arm is `user:*`-only, so a `key:*` would be a
73
+ * forge surface (any key claiming admin). Distinct from `user:` so a keyHash is never read as a uid.
74
+ */
75
+ const KEY_SUBJECT_RE = /^key:[0-9a-f]{64}$/;
76
+ /**
77
+ * A USERSET subject `<type>:<id>#<relation>` — group membership / indirection (P2, the native-Zanzibar
78
+ * recursion EDGE the `RewriteEngine` resolves: `Doc#viewer@group:eng#member`). The body is OBJECT_RE
79
+ * (`<type>:<id>`) + `#` + RELATION_RE (`#` is never in either part, so the split is unambiguous). The
80
+ * CONTROL-PLANE CARVE-OUT (an admin/creator relation must NOT accept a userset — else admin-via-group)
81
+ * is enforced at the WRITE GATE by the relation's declared `AllowedType` (P4), not here: this generic
82
+ * boundary only checks the subject is well-FORMED. A relation that declares no userset allowed-type
83
+ * refuses one fail-closed at the gate. (Governance does not use the relation schema until P5, and P4
84
+ * lands the AllowedType gate before it — so no control-plane userset is admissible in the interim.)
85
+ */
86
+ const USERSET_SUBJECT_RE =
87
+ /^[A-Za-z0-9][A-Za-z0-9_.-]*:[A-Za-z0-9][A-Za-z0-9_.:/-]*#[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
69
88
 
70
89
  /** Deterministic per-(object, relation) aggregate instance id — replay-stable. */
71
90
  export function relationTupleInstanceId(object: string, relation: string): string {
@@ -166,7 +185,12 @@ export function removeTuple(
166
185
  const relatePayload = z.object({
167
186
  object: z.string().regex(OBJECT_RE, "object must be <type>:<id>"),
168
187
  relation: z.string().regex(RELATION_RE),
169
- subject: z.string().regex(USER_SUBJECT_RE, "subject must be user:<uid> or user:*"),
188
+ subject: z
189
+ .string()
190
+ .refine(
191
+ (s) => USER_SUBJECT_RE.test(s) || KEY_SUBJECT_RE.test(s) || USERSET_SUBJECT_RE.test(s),
192
+ "subject must be user:<uid>, user:*, key:<64hex>, or a userset <type>:<id>#<relation>",
193
+ ),
170
194
  grantedBy: z.string().min(1),
171
195
  grantedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
172
196
  });
@@ -0,0 +1,97 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * framework/signing_identity.ts — THE `signer` RELATION (the key↔identity binding,
8
+ * `architecture/identity_keys.md`). NATIVE ZANZIBAR, not a bespoke roster.
9
+ *
10
+ * THE CLEAN SPLIT. Proving WHO authored an intent has exactly two parts:
11
+ * 1. The SIGNATURE — "this ed25519 device key signed these bytes." That is the ONE
12
+ * irreducibly-crypto primitive (the attestation layer beneath Zanzibar; relations
13
+ * can't prove a signature). It is the kernel/provenance `verify` over the
14
+ * source-bound author bytes — NOT modelled here.
15
+ * 2. WHICH KEY BELONGS TO WHICH IDENTITY — a RELATION:
16
+ * `identity:<principal>#signer@key:<sha256hex(pubkey)>`
17
+ * "this device key may AUTHOR as this principal." ENROLL with `relateSubject`,
18
+ * REVOKE with `unrelateSubject` (remove-wins — already native), CHECK with the
19
+ * relation engine (`RewriteEngine.check`). No aggregate, no roster fold; the pubkey
20
+ * rides the signature and its hash is just the subject. The gate reads THIS
21
+ * workspace's own signer tuples (local — each workspace holds its own guest list).
22
+ *
23
+ * So: the gate derives a PROVEN principal by `verify(sig)` (1) then
24
+ * `Check(identity:P # signer @ key:<hash>)` (2) — and only THEN runs the directive's
25
+ * own `.requires(...)` relation gate. Two of the three steps are relations; only the
26
+ * signature is crypto.
27
+ *
28
+ * BOOTSTRAP: the FOUNDING signer tuple of a principal (on a virgin `identity:P#signer`)
29
+ * is admitted by the genesis clause (`framework/genesis.ts`, token-bound), exactly like
30
+ * the founding admin grant; every later device is enrolled by proof-of-possession (an
31
+ * intent signed by an already-enrolled key).
32
+ */
33
+ import { z } from "zod";
34
+ import { directive } from "../directive.js";
35
+ import { relationType, self, key } from "../relation_schema.js";
36
+ import { writeTuple, RelationTuple } from "./relations.js";
37
+
38
+ /**
39
+ * The framework `identity` relation schema — the `signer` relation, LEAF-ONLY
40
+ * (`allow: [key()]`, never a userset/wildcard: a `key:*` would let any key claim to author
41
+ * as anyone). A workspace that admits signed authorship composes this into its law.
42
+ */
43
+ export const identityRelations = relationType("identity").relation("signer", {
44
+ rewrite: self(),
45
+ allow: [key()],
46
+ });
47
+
48
+ /** The Zanzibar OBJECT for a principal's signer relation — `identity:<principal>` (the gate mirrors
49
+ * this as `executor::signer_object`; the subject is `key:<sha256hex(pubkey)>`). */
50
+ export function signerObject(principal: string): string {
51
+ return `identity:${principal}`;
52
+ }
53
+
54
+ /** The `signer` relation name — the relation a device key holds on its `identity:<principal>` object. */
55
+ export const SIGNER_RELATION = "signer";
56
+
57
+ const sha256Hex = z.string().regex(/^[0-9a-f]{64}$/, "expected lowercase sha256 hex (the device pubkey hash)");
58
+
59
+ /**
60
+ * `enrollSigner` — author the founding `identity:<principal>#signer@key:<keyHash>` tuple (the bootstrap
61
+ * E of the warrant-flip, `architecture/identity_keys.md`). A `relateSubject` SPECIALISATION:
62
+ * - `.ensures(RelationTuple)` so the deterministic-keyed `(identity:<principal>, signer)` head is
63
+ * admitted by the id-mint gate (upsert-by-key) — exactly like `grantAdmin`;
64
+ * - **NO `.requires`** — DELIBERATE. At genesis the founding signer enrolls on a still-WARRANT-FREE
65
+ * prefix (it precedes `seedGenesis`), so `verify_captured_principal` is in its legacy fast path and
66
+ * admits it UNSIGNED; there is no admin/signer yet to gate it, so a `.requires` would re-create the
67
+ * bootstrap chicken-and-egg. The forge surface a bare `relateSubject` would open is closed by
68
+ * DOMAIN PLACEMENT: this directive is composed into the CONTROL-PLANE governance law (domain
69
+ * `workspaces`/`nomos`), which the open session lane structurally refuses
70
+ * (`cloud/holon-host/src/engine.mjs` — only the birth door + the secret-gated `/author` + admin
71
+ * lanes author it), so no tenant can forge a signer for another principal.
72
+ *
73
+ * THE ORDER IS THE CONTRACT (verified, zero gate change): `enrollSigner` (E, unsigned/pre-warrant) →
74
+ * `seedGenesis` (W, unsigned/pre-warrant, the warrant lands in its output) → `grantAdmin` (A, the FIRST
75
+ * SIGNED intent — its prefix now carries the warrant, the E-tuple proves the key, the virgin `#admin` is
76
+ * admitted by the genesis clause). NB: a GENERAL tenant-callable enrolment (additional devices) needs a
77
+ * self-binding (captured principal == payload principal) or admin-gating — NOT this founding lane.
78
+ */
79
+ export const enrollSigner = directive("enrollSigner")
80
+ .ensures(RelationTuple)
81
+ .payload(
82
+ z.object({
83
+ principal: z.string().min(1), // the auth-provider uid the key may author as
84
+ keyHash: sha256Hex, // sha256hex of the ed25519 device PUBLIC key
85
+ grantedBy: z.string().min(1),
86
+ grantedAt: z.number().int().nonnegative(), // epoch ms, caller-stamped (determinism)
87
+ }),
88
+ )
89
+ .plan((p) =>
90
+ writeTuple({
91
+ object: signerObject(p.principal),
92
+ relation: SIGNER_RELATION,
93
+ subject: "key:" + p.keyHash,
94
+ grantedBy: p.grantedBy,
95
+ grantedAt: String(p.grantedAt),
96
+ }),
97
+ );
@@ -69,8 +69,10 @@ export function refAs(name: string, aggregateType: string, id: string): Invarian
69
69
  return { name, aggregate: aggregateType, id };
70
70
  }
71
71
 
72
- /** Derive the BOUNDED declared read-set from the intent payload. Pure — no IO, no live read. */
73
- export type WorkspaceInvariantReads = (ctx: { intent: Record<string, unknown> }) => InvariantRef[];
72
+ /** Derive the BOUNDED declared read-set from the intent payload. Pure — no IO, no live read.
73
+ * `ctx.principal` is the captured, authenticated AUTHOR identity (e.g. `key:<keyHash>` / a verified uid)
74
+ * — the same value on every replaying peer — so an invariant can gate by WHO authored (law-gated authz). */
75
+ export type WorkspaceInvariantReads = (ctx: { intent: Record<string, unknown>; principal?: string }) => InvariantRef[];
74
76
 
75
77
  /**
76
78
  * The post-apply predicate over the resolved snapshots, keyed by each ref's binding `name`.
@@ -84,7 +86,7 @@ export type WorkspaceInvariantReads = (ctx: { intent: Record<string, unknown> })
84
86
  */
85
87
  export type WorkspaceInvariantAssert = (
86
88
  snapshots: Record<string, Record<string, unknown>>,
87
- ctx?: { intent: Record<string, unknown> },
89
+ ctx?: { intent: Record<string, unknown>; principal?: string },
88
90
  ) => WorkspaceInvariantVerdict;
89
91
 
90
92
  /** A complete, registered workspace-invariant declaration (the `.assert` terminal yields this). */