@githolon/dsl 0.7.1 → 0.9.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.1",
3
+ "version": "0.9.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";
@@ -611,6 +612,13 @@ function aggFieldSpec(
611
612
  }
612
613
  case "int":
613
614
  return { name, wireName, kind: field.kind, dartType: "int", optional };
615
+ // A PN-COUNTER reads as its net count and a POSITION as its order-key — the read model collapses
616
+ // them (kernel `observed_value`) to a scalar Int / Str, so codegen treats them AS `int` / `string`
617
+ // (the spec carries the NORMALIZED kind so every downstream reader path is unchanged).
618
+ case "counter":
619
+ return { name, wireName, kind: "int", dartType: "int", optional };
620
+ case "position":
621
+ return { name, wireName, kind: "string", dartType: valueClass ?? "String", optional, ...(valueClass !== undefined ? { valueClass } : {}) };
614
622
  case "bool":
615
623
  return { name, wireName, kind: field.kind, dartType: "bool", optional };
616
624
  case "enum": {
@@ -1194,6 +1202,8 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1194
1202
  ? `() { final v = ${reader}; return v == null ? null : ${s.refIdClass}.fromRow(v); }()`
1195
1203
  : `${s.refIdClass}.fromRow(${reader})`;
1196
1204
  }
1205
+ // `position` normalizes to `string`, `counter` to `int` in `aggFieldSpec` — unreachable here.
1206
+ case "position":
1197
1207
  case "string":
1198
1208
  if (s.valueClass !== undefined) {
1199
1209
  const reader = opt
@@ -1216,6 +1226,7 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
1216
1226
  // The projection decodes a bool field to a real JSON boolean — read it directly (no runtime
1217
1227
  // helper). Optional → nullable cast; required → a plain cast (absent ⇒ throws, like readInt).
1218
1228
  return opt ? `(data['${s.wireName}'] as bool?)` : `(data['${s.wireName}'] as bool)`;
1229
+ case "counter":
1219
1230
  case "int":
1220
1231
  return opt
1221
1232
  ? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
@@ -1276,8 +1287,12 @@ function readModelDartType(s: AggFieldSpec): string {
1276
1287
  // leaf — the kernel `Value` is Str|Int only), so it is surfaced as the permissive
1277
1288
  // `Object?` (the read engine's decoded value as-is — never coerced/thrown).
1278
1289
  return isJsonObjectLeaf(s) ? "Map<String, Object?>" : "Object?";
1290
+ // `position`/`counter` are normalized to `string`/`int` in `aggFieldSpec` (read-collapsed scalars),
1291
+ // so these labels are unreachable at runtime — present for FieldKind exhaustiveness.
1292
+ case "position":
1279
1293
  case "string":
1280
1294
  return s.valueClass ?? "String";
1295
+ case "counter":
1281
1296
  case "int":
1282
1297
  return "int";
1283
1298
  case "bool":
@@ -1326,6 +1341,7 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
1326
1341
  ? `() { final v = ${reader}; return v == null ? null : ${s.refIdClass}.fromRow(v); }()`
1327
1342
  : `${s.refIdClass}.fromRow(${reader})`;
1328
1343
  }
1344
+ case "position":
1329
1345
  case "string":
1330
1346
  if (s.valueClass !== undefined) {
1331
1347
  const reader = opt
@@ -1354,6 +1370,7 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
1354
1370
  : `readJsonObject(data, '${aggregate}', '${s.wireName}')`;
1355
1371
  case "bool":
1356
1372
  return opt ? `(data['${s.wireName}'] as bool?)` : `(data['${s.wireName}'] as bool)`;
1373
+ case "counter":
1357
1374
  case "int":
1358
1375
  return opt
1359
1376
  ? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
@@ -2427,6 +2444,14 @@ export interface DomainModule {
2427
2444
  * aggregate `hasInvariant` / a directive `plan`). See `docs/workspace_invariant.md`.
2428
2445
  */
2429
2446
  workspaceInvariants?: WorkspaceInvariantDecl[];
2447
+ /**
2448
+ * The domain's NATIVE ZANZIBAR RELATION SCHEMA (`relation_schema.ts` — the userset-rewrite law,
2449
+ * P1). Pure-data per-type relation rewrites lowered to the `nomosRelations` USD era key (NO JS on
2450
+ * the schema path). ADDITIVE + OPTIONAL: a domain that declares none omits this entirely and is
2451
+ * byte-identical in the canonical manifest to before the key existed (era rule). PRESENCE + the
2452
+ * AST is hashed; the recursive Check/Expand EVALUATOR is the Rust engine substrate, never authored.
2453
+ */
2454
+ relationTypes?: RelationTypeDecl[];
2430
2455
  /**
2431
2456
  * The domain's FIRST-CLASS WORKSPACE TYPES (`workspace_type.ts`; sharding §10
2432
2457
  * RATIFIED, slice 1) — the law-declared workspace taxonomy
package/src/codegen_ts.ts CHANGED
@@ -229,6 +229,12 @@ function tsTypeOfField(field: Field): string {
229
229
  return "string";
230
230
  case "int":
231
231
  return "number";
232
+ // STRUCTURAL CRDT scalars (read-collapsed): a counter reads as its net number, a position as its
233
+ // order-key string (kernel `observed_value` → the projection surfaces a plain scalar).
234
+ case "counter":
235
+ return "number";
236
+ case "position":
237
+ return "string";
232
238
  case "bool":
233
239
  return "boolean";
234
240
  case "enum":
@@ -451,8 +457,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
451
457
  ` authToken?: string;`,
452
458
  ` /** Transitional bare principal uid, sent as \`x-nomos-principal\` (no token). */`,
453
459
  ` principal?: string;`,
454
- ` /** Bearer credential when the lane is gated (platform admin key / invite key). */`,
460
+ ` /** Bearer credential when the lane is gated (an invite/creation key). */`,
455
461
  ` bearer?: string;`,
462
+ ` /** Platform births: the founding admin key YOU choose (sent as \`x-nomos-platform-admin-key\`). The`,
463
+ ` * host never mints or returns one — keep this; it is the AdminGrant of the platform's governance. */`,
464
+ ` platformAdminKey?: string;`,
456
465
  `}`,
457
466
  ``,
458
467
  `/** A birth lane's response — \`workspace\` is the born holon's addressable name. */`,
@@ -462,8 +471,6 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
462
471
  ` workspace?: string;`,
463
472
  ` /** Returned ONCE at birth — the workspace's deploy credential. SAVE IT. */`,
464
473
  ` workspaceSecret?: string;`,
465
- ` /** Platform births only — the platform's own governance credential, ONCE. */`,
466
- ` platformAdminKey?: string;`,
467
474
  ` error?: string;`,
468
475
  ` [k: string]: unknown;`,
469
476
  `}`,
@@ -473,6 +480,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
473
480
  ` if (auth.authToken !== undefined) headers["x-nomos-auth"] = auth.authToken;`,
474
481
  ` if (auth.principal !== undefined) headers["x-nomos-principal"] = auth.principal;`,
475
482
  ` if (auth.bearer !== undefined) headers["authorization"] = \`Bearer \${auth.bearer}\`;`,
483
+ ` if (auth.platformAdminKey !== undefined) headers["x-nomos-platform-admin-key"] = auth.platformAdminKey;`,
476
484
  ` const res = await fetch(\`\${cloud.replace(/\\/$/, "")}\${path}\`, { method: "POST", headers });`,
477
485
  ` return (await res.json()) as NomosBirthResult;`,
478
486
  `};`,
@@ -507,11 +515,11 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
507
515
  // A TOP-LEVEL type: births directly under the cloud.
508
516
  emit(
509
517
  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"`],
518
+ `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.`,
519
+ isPlatform ? [`name: string`, `platformAdminKey: string`] : [`name: string`],
512
520
  isPlatform
513
521
  ? `\`/v1/platforms/\${encodeURIComponent(opts.name)}\``
514
- : `\`/v1/workspaces/\${encodeURIComponent(opts.name)}\${opts.tier === "heavy" ? "?tier=heavy" : ""}\``,
522
+ : `\`/v1/workspaces/\${encodeURIComponent(opts.name)}\``,
515
523
  );
516
524
  }
517
525
  for (const childId of t.childIds) {
@@ -521,13 +529,13 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
521
529
  const childIsPlatform = child.childIds.length > 0;
522
530
  emit(
523
531
  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).`,
532
+ `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
533
  childIsPlatform
526
- ? [`${parentParam}: string`, `name: string`]
527
- : [`${parentParam}: string`, `name: string`, `tier?: "edge" | "heavy"`],
534
+ ? [`${parentParam}: string`, `name: string`, `platformAdminKey: string`]
535
+ : [`${parentParam}: string`, `name: string`],
528
536
  childIsPlatform
529
537
  ? `\`/v1/platforms/\${encodeURIComponent(opts.${parentParam})}/platforms/\${encodeURIComponent(opts.name)}\``
530
- : `\`/v1/platforms/\${encodeURIComponent(opts.${parentParam})}/workspaces/\${encodeURIComponent(opts.name)}\${opts.tier === "heavy" ? "?tier=heavy" : ""}\``,
538
+ : `\`/v1/platforms/\${encodeURIComponent(opts.${parentParam})}/workspaces/\${encodeURIComponent(opts.name)}\``,
531
539
  );
532
540
  }
533
541
  }
@@ -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,49 @@ 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 PER-RECORD object (home_holons §6) is set via `objectFrom`: the gate
57
+ * then checks the relation on `<recordType>:<validated_payload[idField]>` — the specific record this
58
+ * directive writes, not the whole workspace (`record ⊂ object`; the engine is already object-generic).
59
+ * DISTINCT from the legacy `requiresCapability` (RBAC) and `declaredRelations` (cross-workspace) axes.
60
+ * Emitted into the `nomosRelationGate` USD era key; enforced IFF the law also declares a relation schema.
59
61
  */
60
- readonly requiresCapability?: string;
62
+ readonly requiresRelationGate?: {
63
+ readonly relation: string;
64
+ readonly object: string;
65
+ /** PER-RECORD (§6): when set, the gate object is `<recordType>:<payload[idField]>` (the static
66
+ * `object` is the fallback). `undefined` ⇒ the static object — era-ruled, byte-identical replay. */
67
+ readonly objectFrom?: { readonly recordType: string; readonly idField: string };
68
+ };
61
69
  /**
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.
70
+ * CROSS-CUSTODY ATTESTATION-AUTHORIZED (home_holons §3/§5). When `true`, this directive's authorization
71
+ * is a carried §3 SIGNED QUOTE (the asserter's pubkey + a signature over the edge), NOT an author block —
72
+ * so a key-less bailiff can deliver it host-free to an OFFLINE subject. Lowered into the
73
+ * `nomosAttestationGate` USD era key: the one gate then re-verifies the quote (bound to the home's warrant
74
+ * owner) AND pins the captured principal to the proven, unprivileged attestation key, and the signed-author
75
+ * doors exempt it. The directive's payload MUST carry the framework-convention fields the gate reads
76
+ * (`asserterWorkspace` / `object` / `relation` / `attestedAt` / `publicKey` / `signature` — the
77
+ * `recordShare` shape). FRAMEWORK-ONLY today (the home law's `recordShare`); a true E2E authority-free fact.
65
78
  */
66
- readonly scopeFrom?: ScopeFrom<P>;
79
+ readonly attestationAuthorizedGate?: {
80
+ /** The singleton aggregate INSTANCE id whose field holds the home owner (e.g. `home-owner:self`). */
81
+ readonly ownerObject: string;
82
+ /** The field on that singleton holding the owner principal (e.g. `principal`). */
83
+ readonly ownerField: string;
84
+ /** The payload field names the §3 quote rides in — the law's declared wire shape (defaulted to the
85
+ * framework `recordShare` convention by `.attestationAuthorized()`, so the kernel hardcodes none). */
86
+ readonly fields: {
87
+ readonly asserterWorkspace: string;
88
+ readonly object: string;
89
+ readonly relation: string;
90
+ readonly attestedAt: string;
91
+ readonly publicKey: string;
92
+ readonly signature: string;
93
+ };
94
+ };
67
95
  /**
68
96
  * The directive's DECLARED read boundary: the ref types its `plan` may READ (the
69
97
  * "IR for law boundaries" decision — the boundary is part of the domain identity).
@@ -141,7 +169,17 @@ export interface RequirableDirective<P> extends Directive<P> {
141
169
  * to the directive id) at the scope `scopeFrom(payload)` derives (defaults to root).
142
170
  * Returns a new `Directive` carrying `requiresCapability` + `scopeFrom`.
143
171
  */
144
- requires(capability?: string, scopeFrom?: ScopeFrom<P>): RequirableDirective<P>;
172
+ requires(
173
+ relation: string,
174
+ opts?: { object?: string; objectFrom?: { recordType: string; idField: string } },
175
+ ): RequirableDirective<P>;
176
+ /**
177
+ * Declare this directive ATTESTATION-AUTHORIZED (home_holons §3/§5): its authorization is a carried §3
178
+ * signed quote rather than an author block — lowered into the `nomosAttestationGate` era key so the one
179
+ * gate verifies the quote + pins the principal to the attestation key, and the bailiff delivers it
180
+ * host-free. Framework-only (the home law's `recordShare`). Returns a NEW requirable directive.
181
+ */
182
+ attestationAuthorized(owner: { ownerObject: string; ownerField: string }): RequirableDirective<P>;
145
183
  /**
146
184
  * Declare the directive's READ boundary. TWO argument kinds, one declaration site:
147
185
  * * a STRING declares a ref TYPE its `plan` may read (the original boundary —
@@ -266,6 +304,19 @@ class DirectivePlanStep<Id extends string, P> {
266
304
  }
267
305
  }
268
306
 
307
+ /**
308
+ * Build a PER-RECORD authz object template (home_holons §6) for `.requires(rel, { objectFrom })`. The
309
+ * gate then checks the relation on `<Agg>:<validated_payload[idField]>` — the specific record this
310
+ * directive writes (e.g. `recordObject(Entry, "entryId")` ⇒ `Entry:<payload.entryId>`), not the whole
311
+ * workspace. `record ⊂ object`: the same engine, pointed at one record.
312
+ */
313
+ export function recordObject(
314
+ agg: AggregateHandle,
315
+ idField: string,
316
+ ): { recordType: string; idField: string } {
317
+ return { recordType: agg.id, idField };
318
+ }
319
+
269
320
  /**
270
321
  * Wrap a `Directive` with the chainable `.requires(...)` declaration. `.requires`
271
322
  * is non-destructive: it returns a NEW requirable directive carrying the resolved
@@ -275,11 +326,46 @@ class DirectivePlanStep<Id extends string, P> {
275
326
  function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
276
327
  return {
277
328
  ...base,
278
- requires(capability?: string, scopeFrom?: ScopeFrom<P>): RequirableDirective<P> {
329
+ requires(
330
+ relation: string,
331
+ opts?: { object?: string; objectFrom?: { recordType: string; idField: string } },
332
+ ): RequirableDirective<P> {
333
+ // THE ONE write-authz declaration (full-native Zanzibar, no mixed systems): the captured author
334
+ // must HOLD `relation` on the object for the one gate to admit. Default object is self-relative
335
+ // (`"workspace:self"`, the governance convention); `objectFrom` (home_holons §6) gates on the
336
+ // PER-RECORD object the payload names (`recordObject(Agg, "idField")`) instead. Lowers to the
337
+ // `nomosRelationGate` era key; `admit` runs `engine.check`. (Replaces the deleted RBAC capability
338
+ // axis — a capability WAS just a relation.)
339
+ return makeRequirable({
340
+ ...base,
341
+ requiresRelationGate: {
342
+ relation,
343
+ object: opts?.object ?? "workspace:self",
344
+ ...(opts?.objectFrom !== undefined ? { objectFrom: opts.objectFrom } : {}),
345
+ },
346
+ });
347
+ },
348
+ attestationAuthorized(owner: { ownerObject: string; ownerField: string }): RequirableDirective<P> {
349
+ // CROSS-CUSTODY ATTESTATION-AUTHORIZED (home_holons §3/§5): this directive's authorization is a
350
+ // carried §3 signed quote, not an author block — lowered into the `nomosAttestationGate` era key, so
351
+ // the one gate verifies the quote (binding its subject to the LAW-DECLARED `owner`), pins the principal
352
+ // to the attestation key, and the signed-author doors exempt it. `owner` names the singleton + field
353
+ // holding the home owner; the QUOTE FIELD NAMES default to the framework `recordShare` convention HERE
354
+ // (in the DSL — the kernel reads them from the law's declaration, hardcoding nothing).
279
355
  return makeRequirable({
280
356
  ...base,
281
- requiresCapability: capability ?? base.id,
282
- ...(scopeFrom !== undefined ? { scopeFrom } : {}),
357
+ attestationAuthorizedGate: {
358
+ ownerObject: owner.ownerObject,
359
+ ownerField: owner.ownerField,
360
+ fields: {
361
+ asserterWorkspace: "asserterWorkspace",
362
+ object: "object",
363
+ relation: "relation",
364
+ attestedAt: "attestedAt",
365
+ publicKey: "publicKey",
366
+ signature: "signature",
367
+ },
368
+ },
283
369
  });
284
370
  },
285
371
  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: {
package/src/fields.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * the value type (via Zod) is preserved.
16
16
  */
17
17
  import { z } from "zod";
18
- import { Conflict, Lww, type Driver } from "./drivers.js";
18
+ import { Conflict, Counter, LastPosition, Lww, type Driver } from "./drivers.js";
19
19
  import type { AggregateHandle } from "./aggregate.js";
20
20
 
21
21
  export type FieldKind =
@@ -35,7 +35,14 @@ export type FieldKind =
35
35
  // evidence.md §6/§9.2 — a content-addressed EvidenceRef. The migration target of the
36
36
  // hand-wired `blobRef` `t.jsonObject()` string (thing_structures.ts). The authored
37
37
  // value is the typed EvidenceRef shape; it encodes to a kernel `Value::Evidence`.
38
- | "evidence";
38
+ | "evidence"
39
+ // ── STRUCTURAL CRDT scalar kinds (the read-collapsible drivers) ──
40
+ // `t.counter()` — a PN-counter (`Driver::Counter`): driven by `increment`/`decrement`, read as the
41
+ // net `Int` (Σpos − Σneg). `t.position()` — an LWW order-key (`Driver::LastPosition`): driven by
42
+ // `set`, read as the `String` key. Both collapse to a scalar at read time (kernel `observed_value`),
43
+ // so the read model + codegen treat them as `number` / `string`.
44
+ | "counter"
45
+ | "position";
39
46
 
40
47
  /**
41
48
  * The authored shape of an `EvidenceRef` (`evidence.md` §1) — a content-addressed
@@ -224,6 +231,21 @@ export const t = {
224
231
  bool(): Field<boolean, "bool"> {
225
232
  return makeField("bool", z.boolean(), Lww);
226
233
  },
234
+ /** A PN-COUNTER (`Driver::Counter`) — a number that CONVERGES under concurrent edits: two replicas
235
+ * that each `increment` while offline both land (the merge is the per-replica sum), so nobody's count
236
+ * is lost. Drive it with `increment(handle, "field", n)` / `decrement(...)`, NOT `set()` (set would
237
+ * clobber the convergent state). Reads back as the net `number` (Σincrements − Σdecrements; may be
238
+ * negative). The kernel owns the CRDT; the field tag picks it. */
239
+ counter(): Field<number, "counter"> {
240
+ return makeField<number, "counter">("counter", z.number().int(), Counter);
241
+ },
242
+ /** An LWW ORDER-KEY POSITION (`Driver::LastPosition`) — an opaque fractional/index string (e.g. `"a0"`,
243
+ * `"a0V"`) that orders an item among siblings, resolved last-writer-wins by HLC. Drive it with
244
+ * `set(handle, "pos", key)`; concurrent moves converge to one position (the later HLC). Reads back as
245
+ * the `string` key. (Compute the key client-side — fractional indexing — and ride it in the payload.) */
246
+ position(): Field<string, "position"> {
247
+ return makeField<string, "position">("position", z.string(), LastPosition);
248
+ },
227
249
  /** Enum via `as const` literal union — value typos squeal at compile time. */
228
250
  enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
229
251
  return makeField<E[number], "enum">(
@@ -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
  */