@githolon/dsl 0.5.1 → 0.7.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.
@@ -0,0 +1,158 @@
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/encryption_scope.ts — THE GROUP-KEY EVENTS (e2e_encryption.md slice 4).
8
+ * RFC 9420 (MLS) mapped onto the event-sourced ledger, SENDER-KEYS variant (flat
9
+ * per-epoch direct-wrap; TreeKEM dropped — small scopes, §4 of the sheet).
10
+ *
11
+ * A SCOPE is the read cap (`requires("readNote")` ≡ the MLS group). Each scope has
12
+ * EPOCHS; a write encrypts a field with the scope's CURRENT epoch key. The epoch key
13
+ * itself NEVER lands on-chain — only its WRAPS do: per member, the epoch key
14
+ * HPKE-sealed to that member's published X25519 key (`encryption_identity.ts`).
15
+ * Wrapped keys are CIPHERTEXT, safe to publish. A reader replays the WrappedKey
16
+ * events addressed to its principal, unwraps with its device key (host-side, never
17
+ * in the wasm), and decrypts the fields it is authorized for.
18
+ *
19
+ * THE EVENTS (this module's aggregates):
20
+ * - `KeyScope` — the per-scope head: `currentEpoch`. A write's envelope epoch must
21
+ * equal this at the write's position (the gate enforces it — slice 5).
22
+ * - `WrappedKey` — one epoch-key wrap to one member (keyed scope|epoch|principal).
23
+ *
24
+ * THE DIRECTIVE: `advanceKeyEpoch` opens a new epoch and FANS OUT the wraps to every
25
+ * member IN ONE INTENT (the membership change is a single visible, owner-authored
26
+ * event). The wraps are computed CLIENT-SIDE by the committer (who holds the epoch
27
+ * secret + looks up each recipient's pubkey) and ride in the payload as opaque
28
+ * ciphertext; the plan only stores the facts. Forward-only revocation = an advance
29
+ * whose fan-out OMITS the removed member (slice 5 gates the roster + the rotation).
30
+ *
31
+ * NON-DELEGATABLE (critical #2): an encrypted scope's `nomosEncryption` carries
32
+ * `delegatable:false`; the slice-5 fan-out gate derives the roster from DIRECT grants
33
+ * only and refuses a wrap to a delegation-sourced member.
34
+ */
35
+ import { z } from "zod";
36
+ import { aggregate, instance } from "../aggregate.js";
37
+ import { t } from "../fields.js";
38
+ import { Lww } from "../drivers.js";
39
+ import { directive } from "../directive.js";
40
+ import { set, withMarker, type PlannedOp } from "../ops.js";
41
+ import { query } from "../query.js";
42
+
43
+ const CAP_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
44
+ const B64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
45
+
46
+ /** Deterministic ids — the scope head + a per-member wrap, both replay-stable. */
47
+ export function keyScopeInstanceId(scope: string): string {
48
+ return `keyscope-${scope}`;
49
+ }
50
+ export function wrappedKeyInstanceId(scope: string, epoch: number, principal: string): string {
51
+ return `wrapkey-${scope}-${epoch}-${principal}`;
52
+ }
53
+
54
+ /**
55
+ * The per-scope key-schedule HEAD. `currentEpoch` is advisory-read here; the gate
56
+ * (slice 5) enforces that an encrypted-field write's envelope epoch == this value at
57
+ * the write's position, and that an advance only ever increments it.
58
+ */
59
+ export const KeyScope = aggregate("KeyScope", {
60
+ scope: t.string().merge(Lww),
61
+ currentEpoch: t.int().merge(Lww),
62
+ openedAt: t.string().merge(Lww),
63
+ });
64
+ export type KeyScopeHandle = typeof KeyScope;
65
+
66
+ /**
67
+ * ONE wrapped epoch key, addressed to ONE member. `hpkeEpk` is the ephemeral HPKE
68
+ * public key, `ct` the sealed epoch secret — both opaque base64 ciphertext, safe
69
+ * on-chain. `recipientPubKey` pins which device key it was sealed to (audit). Keyed
70
+ * `wrapkey-<scope>-<epoch>-<principal>` so a re-offer is idempotent.
71
+ */
72
+ export const WrappedKey = aggregate("WrappedKey", {
73
+ scope: t.string().merge(Lww),
74
+ epoch: t.int().merge(Lww),
75
+ recipientPrincipal: t.string().merge(Lww),
76
+ recipientPubKey: t.string().merge(Lww), // base64 X25519 the wrap was sealed to
77
+ hpkeEpk: t.string().merge(Lww), // base64 HPKE ephemeral public key
78
+ ct: t.string().merge(Lww), // base64 sealed epoch secret
79
+ });
80
+ export type WrappedKeyHandle = typeof WrappedKey;
81
+
82
+ /** One wrap entry in an `advanceKeyEpoch` payload (computed client-side). */
83
+ const wrapEntrySchema = z.object({
84
+ recipientPrincipal: z.string().min(1),
85
+ recipientPubKey: z.string().regex(B64_RE),
86
+ hpkeEpk: z.string().regex(B64_RE),
87
+ ct: z.string().regex(B64_RE),
88
+ });
89
+ export type WrapEntry = z.infer<typeof wrapEntrySchema>;
90
+
91
+ /** Build the plan ops for one wrap — a deterministic-keyed WrappedKey record. */
92
+ function writeWrap(scope: string, epoch: number, w: WrapEntry): PlannedOp[] {
93
+ const wk = instance(WrappedKey, wrappedKeyInstanceId(scope, epoch, w.recipientPrincipal));
94
+ // The first op MATERIALIZES this new sibling bucket (`ensures` — a re-offer is idempotent);
95
+ // the rest mutate it. Without the marker the referential gate rejects a set on an absent id.
96
+ return [
97
+ withMarker(set(wk, "scope", scope), "ensures"),
98
+ set(wk, "epoch", epoch),
99
+ set(wk, "recipientPrincipal", w.recipientPrincipal),
100
+ set(wk, "recipientPubKey", w.recipientPubKey),
101
+ set(wk, "hpkeEpk", w.hpkeEpk),
102
+ set(wk, "ct", w.ct),
103
+ ];
104
+ }
105
+
106
+ /**
107
+ * `advanceKeyEpoch` — open a new epoch for `scope` and FAN OUT the wraps to every
108
+ * member, in ONE intent. `epoch` is the new epoch number; `wraps` are the
109
+ * client-computed per-member seals of the new epoch secret. The plan bumps the
110
+ * `KeyScope` head and writes each `WrappedKey`. (Slice 5's fan-out gate verifies the
111
+ * roster == the direct-grant members at this position, that the epoch increments, and
112
+ * — for a revoke — that the removed member is omitted.)
113
+ */
114
+ export const advanceKeyEpoch = directive("advanceKeyEpoch")
115
+ .ensures(KeyScope)
116
+ .payload(
117
+ z.object({
118
+ scope: z.string().regex(CAP_RE),
119
+ epoch: z.number().int().nonnegative(),
120
+ openedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
121
+ wraps: z.array(wrapEntrySchema),
122
+ }),
123
+ )
124
+ .plan((p) => {
125
+ const ks = instance(KeyScope, keyScopeInstanceId(p.scope));
126
+ const ops: PlannedOp[] = [
127
+ withMarker(set(ks, "scope", p.scope), "ensures"), // upsert the scope head (first advance creates it)
128
+ set(ks, "currentEpoch", p.epoch),
129
+ set(ks, "openedAt", p.openedAt),
130
+ ];
131
+ for (const w of p.wraps) ops.push(...writeWrap(p.scope, p.epoch, w));
132
+ return ops;
133
+ });
134
+
135
+ /**
136
+ * `wrapKey` — write ONE wrap (the single-member handoff). Beyond its use as a primitive,
137
+ * its `.ensures(WrappedKey)` DECLARATION makes `WrappedKey` an ENSURED type, so the
138
+ * deterministic-keyed `WrappedKey` sibling rows that `advanceKeyEpoch` fans out are
139
+ * admitted by the id-mint gate (the sanctioned upsert-by-deterministic-key category).
140
+ */
141
+ export const wrapKey = directive("wrapKey")
142
+ .ensures(WrappedKey)
143
+ .payload(
144
+ z.object({
145
+ scope: z.string().regex(CAP_RE),
146
+ epoch: z.number().int().nonnegative(),
147
+ recipientPrincipal: z.string().min(1),
148
+ recipientPubKey: z.string().regex(B64_RE),
149
+ hpkeEpk: z.string().regex(B64_RE),
150
+ ct: z.string().regex(B64_RE),
151
+ }),
152
+ )
153
+ .plan((p) => writeWrap(p.scope, p.epoch, p));
154
+
155
+ /** A reader fetches the wraps addressed to it for a scope+epoch (then unwraps host-side). */
156
+ export const wrappedKeysByRecipient = query("wrappedKeysByRecipient").key("recipientPrincipal").returns(WrappedKey);
157
+ /** The current epoch of a scope (the writer reads it to tag its envelope). */
158
+ export const keyScopeByScope = query("keyScopeByScope").key("scope").returns(KeyScope);
@@ -0,0 +1,205 @@
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/relations.ts — NATIVE ZANZIBAR / ReBAC TUPLE EVENTS (the ONE authz spine).
8
+ *
9
+ * A relation tuple `object#relation@subject` is a Zanzibar authorization FACT, and on
10
+ * Nomos it is a LEDGER EVENT folded into the projected `relations` table. This module is
11
+ * the DSL surface that AUTHORS those events — the object-keyed, clean-slate-native shape
12
+ * the F4 reversal ratified (the per-USER `UserPermission` map was the pre-Zanzibar relic;
13
+ * object-keying is what gives per-RECORD sharing + `Expand` for free).
14
+ *
15
+ * ── The aggregate (object-keyed) ─────────────────────────────────────────────
16
+ * `RelationTuple` is keyed `reltuple-<object>-<relation>` — ONE aggregate per
17
+ * (object, relation) pair — carrying a `subjects: MapOf(Lww)` map of `subject → leaf`,
18
+ * where each leaf is a JSON `{ status }` ("active" | "revoked"). The projection folds an
19
+ * ACTIVE subject into a `relations(object, relation, subject)` row; a revoked subject
20
+ * yields no row (Check fails closed).
21
+ *
22
+ * ── Why a status leaf, not a remove-wins set ─────────────────────────────────
23
+ * Kernel `RemoveWins` is palette-only (no kernel driver → excluded from the identity
24
+ * manifest), so a native tuple cannot use a remove-wins member set and stay deployable.
25
+ * We replicate `UserPermission`'s sound trick: revoke flips the SAME map entry to
26
+ * `status:"revoked"` (an Lww write over the per-subject key). The gate supplies
27
+ * REMOVE-WINS by refusing to re-grant a subject that was ever revoked in the prefix
28
+ * (slice 4); the projection already drops a revoked leaf. Only Lww/MapOf drivers → the
29
+ * aggregate is identity-manifest-eligible.
30
+ *
31
+ * ── Determinism ──────────────────────────────────────────────────────────────
32
+ * The aggregate id is a pure function of (object, relation); the subject is the map KEY.
33
+ * A re-offered grant is therefore idempotent (upsert-by-deterministic-key — the
34
+ * sanctioned `.ensures` category). Plans carry no clock/random — provenance timestamps
35
+ * ride in the payload as ISO strings.
36
+ *
37
+ * ── Reserved tokens ──────────────────────────────────────────────────────────
38
+ * `object` is `"<type>:<id>"`; `subject` is `user:<uid>`, the public wildcard `user:*`,
39
+ * or a userset `<object>#<relation>`. A raw uid carrying the `:` delimiter or being the
40
+ * bare `*` is refused at the payload boundary so it can never forge a wildcard/userset.
41
+ */
42
+ import { z } from "zod";
43
+ import { aggregate, instance } from "../aggregate.js";
44
+ import { t } from "../fields.js";
45
+ import { Lww, MapOf } from "../drivers.js";
46
+ import { directive } from "../directive.js";
47
+ import { set, setEntry, withMarker, type PlannedOp } from "../ops.js";
48
+ import { query } from "../query.js";
49
+
50
+ /** Tuple subject lifecycle — the same vocabulary the kernel read drops a non-active leaf by. */
51
+ export const RELATION_STATUSES = ["active", "revoked"] as const;
52
+ export type RelationStatus = (typeof RELATION_STATUSES)[number];
53
+
54
+ /**
55
+ * An OBJECT address: `"<type>:<id>"` (per-record grain) or a workspace-scope object such
56
+ * as `"workspace:<ws>"`. The `:` delimiter is structural — a single colon separating the
57
+ * type from the id — so the projection can split it back into the `relations` row's
58
+ * (object) column verbatim. We allow at most the one delimiter the address shape needs.
59
+ */
60
+ const OBJECT_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]*:[A-Za-z0-9][A-Za-z0-9_.:/-]*$/;
61
+ /** A relation name (the edge label) — a bare identifier, like a directive/cap name. */
62
+ const RELATION_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
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.
67
+ */
68
+ const USER_SUBJECT_RE = /^user:([A-Za-z0-9][A-Za-z0-9_.-]*|\*)$/;
69
+
70
+ /** Deterministic per-(object, relation) aggregate instance id — replay-stable. */
71
+ export function relationTupleInstanceId(object: string, relation: string): string {
72
+ return `reltuple-${object}-${relation}`;
73
+ }
74
+
75
+ /**
76
+ * The native relation-tuple aggregate, OBJECT-KEYED. One instance per (object, relation);
77
+ * `subjects` is the per-subject status map. A read of the projected `relations` table is
78
+ * the Check leaf — this aggregate is the WRITE shape that fold produces those rows.
79
+ *
80
+ * - object Lww (the address, materialised on first relate so the aggregate exists)
81
+ * - relation Lww (the edge label)
82
+ * - subjects MapOf(Lww) — subject → JSON `{status}` leaf (active drops to a tuple row)
83
+ */
84
+ export const RelationTuple = aggregate("RelationTuple", {
85
+ object: t.string().merge(Lww),
86
+ relation: t.string().merge(Lww),
87
+ subjects: t.map(t.json()).merge(MapOf(Lww)),
88
+ });
89
+ export type RelationTupleHandle = typeof RelationTuple;
90
+
91
+ /** The per-subject leaf the `subjects` map stores (JSON-encoded). Frozen wire shape. */
92
+ export interface RelationSubjectLeaf {
93
+ status: RelationStatus;
94
+ grantedBy: string;
95
+ grantedAt: string;
96
+ revokedBy?: string;
97
+ revokedAt?: string;
98
+ }
99
+
100
+ /** Strip undefined keys so the JSON leaf is byte-stable (no dropped-slot `"k":undefined`). */
101
+ function compact(obj: Record<string, unknown>): RelationSubjectLeaf {
102
+ const out: Record<string, unknown> = {};
103
+ for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = v;
104
+ return out as unknown as RelationSubjectLeaf;
105
+ }
106
+
107
+ interface WriteTupleInput {
108
+ object: string;
109
+ relation: string;
110
+ subject: string;
111
+ grantedBy: string;
112
+ grantedAt: string;
113
+ }
114
+
115
+ /** Lower a tuple write to plan ops. The first op MATERIALIZES the (object, relation)
116
+ * aggregate (`ensures` — a re-offer is idempotent); the subject entry rides the map. */
117
+ function lowerTuple(
118
+ p: WriteTupleInput & {
119
+ status: RelationStatus;
120
+ revokedBy?: string | undefined;
121
+ revokedAt?: string | undefined;
122
+ },
123
+ ): PlannedOp[] {
124
+ const rt = instance(RelationTuple, relationTupleInstanceId(p.object, p.relation));
125
+ const leaf = compact({
126
+ status: p.status,
127
+ grantedBy: p.grantedBy,
128
+ grantedAt: p.grantedAt,
129
+ revokedBy: p.revokedBy,
130
+ revokedAt: p.revokedAt,
131
+ });
132
+ return [
133
+ // upsert the aggregate head (first relate creates it); without this the referential
134
+ // gate would reject a setEntry on an absent id.
135
+ withMarker(set(rt, "object", p.object), "ensures"),
136
+ set(rt, "relation", p.relation),
137
+ setEntry(rt, "subjects", p.subject, JSON.stringify(leaf)),
138
+ ];
139
+ }
140
+
141
+ /**
142
+ * writeTuple — the framework PRIMITIVE: write one ACTIVE tuple `object#relation@subject`.
143
+ * The collapse target of a relate-family directive. Ensures the (object, relation) head.
144
+ */
145
+ export function writeTuple(p: WriteTupleInput): PlannedOp[] {
146
+ return lowerTuple({ ...p, status: "active" });
147
+ }
148
+
149
+ /**
150
+ * removeTuple — the framework PRIMITIVE: flip the subject's leaf to `status:revoked`
151
+ * (an Lww write over the SAME map entry — the projection then drops the tuple row). The
152
+ * gate's remove-wins check (slice 4) refuses a later re-grant of an ever-revoked subject.
153
+ */
154
+ export function removeTuple(
155
+ p: WriteTupleInput & { revokedBy: string; revokedAt: string },
156
+ ): PlannedOp[] {
157
+ return lowerTuple({
158
+ ...p,
159
+ status: "revoked",
160
+ revokedBy: p.revokedBy,
161
+ revokedAt: p.revokedAt,
162
+ });
163
+ }
164
+
165
+ /** The shared payload boundary — validates the address + subject reserved-token rules. */
166
+ const relatePayload = z.object({
167
+ object: z.string().regex(OBJECT_RE, "object must be <type>:<id>"),
168
+ relation: z.string().regex(RELATION_RE),
169
+ subject: z.string().regex(USER_SUBJECT_RE, "subject must be user:<uid> or user:*"),
170
+ grantedBy: z.string().min(1),
171
+ grantedAt: z.string().min(1), // ISO-8601, caller-stamped (determinism)
172
+ });
173
+
174
+ /**
175
+ * `relateSubject` — author one ACTIVE tuple. The `.ensures(RelationTuple)` DECLARATION
176
+ * makes `RelationTuple` an ensured type so the deterministic-keyed (object, relation)
177
+ * aggregate is admitted by the id-mint gate (upsert-by-deterministic-key). The relation
178
+ * itself is the cap the gate will require to author it (slice 4 wires `.requires`).
179
+ */
180
+ export const relateSubject = directive("relateSubject")
181
+ .ensures(RelationTuple)
182
+ .payload(relatePayload)
183
+ .plan((p) => writeTuple(p));
184
+
185
+ /**
186
+ * `unrelateSubject` — revoke a tuple (flip the subject leaf to revoked). Same ensured
187
+ * type; the aggregate already exists, but `ensures` keeps a revoke-before-grant ordering
188
+ * race idempotent (it materialises the head if somehow absent).
189
+ */
190
+ export const unrelateSubject = directive("unrelateSubject")
191
+ .ensures(RelationTuple)
192
+ .payload(
193
+ relatePayload.extend({
194
+ revokedBy: z.string().min(1),
195
+ revokedAt: z.string().min(1),
196
+ }),
197
+ )
198
+ .plan((p) => removeTuple(p));
199
+
200
+ /** Enumerate the tuples for one object (the subjects map per relation). The Check/Expand
201
+ * read serves off the projected `relations` table; this declared query exposes the raw
202
+ * tuple aggregate for tooling/audit. */
203
+ export const relationTuplesByObject = query("relationTuplesByObject")
204
+ .key("object")
205
+ .returns(RelationTuple);
@@ -81,9 +81,18 @@ export const WORKSPACE_TIERS = ["edge", "heavy"] as const;
81
81
  /**
82
82
  * The workspace lifecycle: `requested` (law fact only — nothing physical exists)
83
83
  * → `provisioned` (the worker built the machinery and recorded the owner hash)
84
- * → `retired` (the law has withdrawn it; the worker refuses its bearers).
84
+ * → `retired` (the law has withdrawn it; the worker refuses its bearers)
85
+ * → `reclaimed` (the LAW has authorized the host to DISCARD the custody — the git
86
+ * ledger and the DO state are dropped; the custody-reclaimed FACT remains here
87
+ * forever. The ONE status from which there is no return: retirement is a status,
88
+ * reclamation is the lawful discard of the bytes. See {@link reclaimWorkspace}).
85
89
  */
86
- export const WORKSPACE_STATUSES = ["requested", "provisioned", "retired"] as const;
90
+ export const WORKSPACE_STATUSES = [
91
+ "requested",
92
+ "provisioned",
93
+ "retired",
94
+ "reclaimed",
95
+ ] as const;
87
96
 
88
97
  /** A grant is `active` until the law revokes it — revocation is permanent. */
89
98
  export const GRANT_STATUSES = ["active", "revoked"] as const;
@@ -454,6 +463,36 @@ export const retireWorkspace = directive("retireWorkspace")
454
463
  return [set(ws, "status", "retired" as const)];
455
464
  });
456
465
 
466
+ /**
467
+ * reclaimWorkspace — THE LAWFUL CUSTODY DISCARD. Where {@link retireWorkspace}
468
+ * withdraws the law (a status, the bytes untouched), reclaim is the law AUTHORIZING
469
+ * the host to drop the custody: the worker bailiff, on seeing THIS fact folded,
470
+ * `ARTIFACTS.delete`s the workspace's git ledger and `deleteAll`s its DO storage.
471
+ *
472
+ * THE TWO-PHASE RULE (enforced worker-side, since a plan cannot read prior state):
473
+ * only a `retired` workspace may be reclaimed — retire withdraws, reclaim discards.
474
+ *
475
+ * THE FIXED POINT: this is authored for CHILDREN only. The worker refuses to reclaim
476
+ * ROOT — the genesis workspace is the seat of the law that authorizes reclaim, so the
477
+ * law can reap everything it governs EXCEPT itself. Dropping root is the one act Nomos
478
+ * cannot perform in-law; the custodian does it through Cloudflare directly. The
479
+ * custody-reclaimed fact (this status flip) is append-only and outlives the bytes:
480
+ * Nomos never erases the FACT that a workspace existed and was reclaimed.
481
+ */
482
+ export const reclaimWorkspace = directive("reclaimWorkspace")
483
+ .mutates(CloudWorkspace)
484
+ .payload(
485
+ z.object({
486
+ workspaceId: z.string().min(1),
487
+ reclaimedBy: z.string().min(1),
488
+ reclaimedAt: epochMs,
489
+ }),
490
+ )
491
+ .plan((p) => {
492
+ const ws = instance(CloudWorkspace, p.workspaceId);
493
+ return [set(ws, "status", "reclaimed" as const)];
494
+ });
495
+
457
496
  /**
458
497
  * birthPlatform — A PLATFORM IS BORN: the cloud, one level down. Records the
459
498
  * platform-kind workspace (parented to root, owned by the invited principal,
package/src/index.ts CHANGED
@@ -13,12 +13,17 @@ export {
13
13
  aggregate,
14
14
  instance,
15
15
  forwardRef,
16
+ requires,
17
+ OWNER_FIELD,
16
18
  type AggregateHandle,
19
+ type AudienceMethods,
20
+ type Audience,
17
21
  type BoundAggregate,
18
22
  type StoredFields,
19
23
  type HasManyFields,
20
24
  type AggregateInvariantFn,
21
25
  type AggregateInvariantVerdict,
26
+ type ReadVisibility,
22
27
  } from "./aggregate.js";
23
28
  export {
24
29
  t,
@@ -30,6 +35,8 @@ export {
30
35
  } from "./fields.js";
31
36
  export {
32
37
  Lww,
38
+ LwwCausal,
39
+ LwwBy,
33
40
  AddWins,
34
41
  Conflict,
35
42
  MapOf,
@@ -62,7 +69,7 @@ export { directive, type Directive, type ReferentialMarker } from "./directive.j
62
69
  // a DDD-fluent `AggregateRef`; the dev `.set`/`.add`(collection)/`.setEntry`(map)/`.relate`(reference)
63
70
  // — naming no id. A directive's `.plan` calls these and returns `[]` (the recorded graph is the write
64
71
  // surface). The dev never sees a "prim" — that is the GitHolon's internal OpenUSD IR.
65
- export { create, existing, type AggregateRef } from "./authoring.js";
72
+ export { create, existing, ensure, type AggregateRef } from "./authoring.js";
66
73
  // THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
67
74
  // the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
68
75
  // queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).
@@ -202,6 +209,45 @@ export {
202
209
  type Scope,
203
210
  type RoleCatalogue,
204
211
  } from "./framework/rbac.js";
212
+ // NATIVE ZANZIBAR / ReBAC (architecture/zanzibar_native_plan.md, slice 1) — object-keyed
213
+ // relation-tuple events `object#relation@subject`, the clean-slate-native authz spine.
214
+ export {
215
+ RELATION_STATUSES,
216
+ RelationTuple,
217
+ relationTupleInstanceId,
218
+ writeTuple,
219
+ removeTuple,
220
+ relateSubject,
221
+ unrelateSubject,
222
+ relationTuplesByObject,
223
+ type RelationStatus,
224
+ type RelationSubjectLeaf,
225
+ type RelationTupleHandle,
226
+ } from "./framework/relations.js";
227
+ // E2E ENCRYPTION (e2e_encryption.md slice 1) — the published per-principal X25519
228
+ // encryption-key registry; the wrap-target lookup the key-handoff fan-out resolves.
229
+ export {
230
+ EncKey,
231
+ encKeyInstanceId,
232
+ writeEncKey,
233
+ publishEncKey,
234
+ encKeyByPrincipal,
235
+ type EncKeyHandle,
236
+ } from "./framework/encryption_identity.js";
237
+ // E2E ENCRYPTION (slice 4) — the group-key events (RFC 9420 sender-keys on the ledger).
238
+ export {
239
+ KeyScope,
240
+ WrappedKey,
241
+ advanceKeyEpoch,
242
+ wrapKey,
243
+ wrappedKeysByRecipient,
244
+ keyScopeByScope,
245
+ keyScopeInstanceId,
246
+ wrappedKeyInstanceId,
247
+ type KeyScopeHandle,
248
+ type WrappedKeyHandle,
249
+ type WrapEntry,
250
+ } from "./framework/encryption_scope.js";
205
251
  export {
206
252
  IMPURE_CAPABILITY_STATUSES,
207
253
  IMPURE_CAPABILITY_ENVELOPE,
package/src/manifest.ts CHANGED
@@ -89,6 +89,22 @@ export interface CanonicalAggregate {
89
89
  readonly hasInvariant?: true;
90
90
  /** The LAW-DECLARED instance cap; omitted when uncapped (hash-stable). */
91
91
  readonly cap?: number;
92
+ /**
93
+ * READ VISIBILITY (slice 5.1) — the capability a read TOUCHING this type requires.
94
+ * PRESENT only when the type is declared `visibility: requires(cap)` (PRIVATE);
95
+ * OMITTED ENTIRELY for the default `public`, so a public-only domain is
96
+ * byte-identical in the manifest to before this key existed (golden hashes
97
+ * UNCHANGED — mirrors `cap`/`hasInvariant`). It IS law (the githolon refuses an
98
+ * unauthorized read on it), so adding/changing it MOVES the hash.
99
+ */
100
+ readonly requiresRead?: string;
101
+ /**
102
+ * E2E ENCRYPTION (slice 2) — the STORED field names whose values are `.encrypted()`,
103
+ * SORTED. PRESENT only when ≥1 field is encrypted; OMITTED otherwise (a plaintext
104
+ * aggregate is byte-identical). It IS law (the gate enforces the envelope + the scope),
105
+ * so adding/changing it MOVES the hash. The scope is the aggregate's `requiresRead`.
106
+ */
107
+ readonly encryptedFields?: string[];
92
108
  }
93
109
 
94
110
  /** One captured directive contract — NO `plan`/`scopeFrom` behaviour. */
@@ -994,7 +1010,19 @@ function stableIdsForModule(
994
1010
  */
995
1011
  export function domainManifest(mod: DomainModule): CanonicalManifest {
996
1012
  const aggregates: CanonicalAggregate[] = mod.aggregates
997
- .map((agg) => ({
1013
+ .map((agg) => {
1014
+ // SECURE-BY-DEFAULT — `.owned()` is declared in the audience API, but its owner-SCOPED reads
1015
+ // (the projection row-filter) + per-record E2E are not yet enforced. Refuse to compile owned law
1016
+ // FAIL-CLOSED rather than ship a type whose reads aren't gated (architecture/secure_by_default_dsl.md
1017
+ // build order). Use `.public()` or `.readableBy(cap)` until the `.owned()` slice lands.
1018
+ if (agg.audience?.kind === "owned") {
1019
+ throw new Error(
1020
+ `aggregate '${agg.id}': .owned() is declared but its read-scoping + E2E are not yet enforced — ` +
1021
+ `refusing to compile owned law (fail-closed). Use .public() or .readableBy("<cap>") for now ` +
1022
+ `(the .owned() slice is next — see architecture/secure_by_default_dsl.md).`,
1023
+ );
1024
+ }
1025
+ return {
998
1026
  id: agg.id,
999
1027
  schema: sortedSchema(encodeKernelSchema(agg)),
1000
1028
  // OMIT-WHEN-ABSENT: only emit `hasInvariant` when the aggregate declares one,
@@ -1003,7 +1031,14 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1003
1031
  ...(agg.hasInvariant === true ? { hasInvariant: true as const } : {}),
1004
1032
  // The LAW-DECLARED instance cap — hash-bearing (it IS law), omit-when-absent.
1005
1033
  ...(agg.cap !== undefined ? { cap: agg.cap } : {}),
1006
- }))
1034
+ // READ VISIBILITY (slice 5.1) — the capability a read of this type requires.
1035
+ // Hash-bearing (it IS law); omit-when-public so all-public domains are byte-stable.
1036
+ ...(agg.visibility !== undefined ? { requiresRead: agg.visibility.requires } : {}),
1037
+ // E2E ENCRYPTION (slice 2) — the encrypted field names. Hash-bearing law;
1038
+ // omit-when-none so plaintext aggregates are byte-stable.
1039
+ ...(agg.encryptedFields !== undefined ? { encryptedFields: [...agg.encryptedFields] } : {}),
1040
+ };
1041
+ })
1007
1042
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1008
1043
 
1009
1044
  const directives: CanonicalDirective[] = mod.directives
@@ -1026,6 +1061,23 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1026
1061
  }))
1027
1062
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1028
1063
 
1064
+ // E2E ENCRYPTION — critical #5 (e2e_encryption.md §5), the captured-read seal, COMPILE
1065
+ // half. A captured read (`read()` on the write path) of a PRIVATE query would record its
1066
+ // plaintext result onto the intent as PUBLIC, signed, replay-verified chain data —
1067
+ // bypassing the read gate AND the encryption envelope. Forbid it: a `requires(cap)`
1068
+ // query can NEVER be a captured read. (The one gate re-asserts this at admission/replay
1069
+ // via `cap_for_query`, so a hand-crafted raw intent is refused on every peer too.)
1070
+ const privateTypes = new Set(aggregates.filter((a) => a.requiresRead !== undefined).map((a) => a.id));
1071
+ for (const d of directives) {
1072
+ for (const cr of d.capturedReads ?? []) {
1073
+ if (privateTypes.has(cr.returns)) {
1074
+ throw new Error(
1075
+ `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.`,
1076
+ );
1077
+ }
1078
+ }
1079
+ }
1080
+
1029
1081
  // exists_ entries are COUNT declarations in the IR — merge them into `counts` so the
1030
1082
  // Rust engine sees them as ordinary counts (the marker lives only in the DSL/codegen
1031
1083
  // layer). Combine mod.counts + normalized exists_ before canonicalizing counts.
package/src/usd.ts CHANGED
@@ -213,6 +213,37 @@ export interface UsdLayer {
213
213
  /** directive id → the SORTED query ids its plan may read. */
214
214
  readonly directives: Record<string, string[]>;
215
215
  };
216
+ /**
217
+ * THE READ-VISIBILITY GATE DECLARATION (slice 5.1, `host_routes_only.md` §5.1).
218
+ * Present IFF ≥1 aggregate of this layer is declared PRIVATE (`visibility:
219
+ * requires(cap)`): the law's EXPLICIT opt-in to the githolon gating its own
220
+ * reads. The one wasm read boundary serves a read touching a private type ONLY to
221
+ * a principal that effectively holds the required `cap` in this workspace's own
222
+ * folded role-bindings (the kernel `identity::authz` path). OMITTED for all-public
223
+ * law (byte-identical hash — the EXACT `nomosReadGate`/`nomosInvariantGate` era
224
+ * discipline; every pre-slice-5.1 chain serves reads exactly as before, and a
225
+ * public type is never gated). Keyed by aggregate TYPE name → required capability.
226
+ */
227
+ readonly nomosReadVisibility?: {
228
+ readonly v: 1;
229
+ /** Aggregate TYPE name → the capability a read touching it requires. SORTED by type name. */
230
+ readonly aggregates: Record<string, { requires: string }>;
231
+ };
232
+ /**
233
+ * THE ENCRYPTION GATE DECLARATION (e2e_encryption.md slice 2). Present IFF ≥1 field of
234
+ * this layer is `.encrypted()`: the law's EXPLICIT opt-in to at-rest field ciphertext.
235
+ * The gate enforces the envelope structure + `scope == the aggregate's read cap` +
236
+ * `epoch ≤ active` on every encrypted-field write (key-free, every peer); a key-holder
237
+ * decrypts at read time. OMITTED for plaintext law (byte-identical hash — the EXACT
238
+ * `nomosReadVisibility` era discipline; every pre-encryption chain replays as before).
239
+ * `scope` is the aggregate's `requires(cap)`; `delegatable:false` — an encrypted scope
240
+ * is NON-DELEGATABLE (the adversarial-review fix: a delegated read cap must NOT mint a
241
+ * key), so the key roster is direct grants only. Keyed by aggregate TYPE, SORTED.
242
+ */
243
+ readonly nomosEncryption?: {
244
+ readonly v: 1;
245
+ readonly aggregates: Record<string, { scope: string; fields: string[]; delegatable: false }>;
246
+ };
216
247
  }
217
248
 
218
249
  /** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
@@ -310,6 +341,19 @@ export function emitUsd(
310
341
  for (const k of [...rgQueries.keys()].sort()) readGateQueries[k] = rgQueries.get(k)!;
311
342
  const readGateDirectives: Record<string, string[]> = {};
312
343
  for (const k of [...rgDirectives.keys()].sort()) readGateDirectives[k] = rgDirectives.get(k)!;
344
+ // THE READ-VISIBILITY GATE (slice 5.1): every aggregate the manifest carries a
345
+ // `requiresRead` for, keyed by TYPE name → required cap. SORTED keys
346
+ // (byte-determinism); omit-when-empty so an all-public layer is byte-identical.
347
+ const visAggregates: Record<string, { requires: string }> = {};
348
+ const encAggregates: Record<string, { scope: string; fields: string[]; delegatable: false }> = {};
349
+ for (const a of [...manifest.aggregates].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0))) {
350
+ if (a.requiresRead !== undefined) visAggregates[a.id] = { requires: a.requiresRead };
351
+ // THE ENCRYPTION GATE (slice 2): an aggregate with encrypted field(s) — its scope
352
+ // is its read cap (aggregate()/the gate guarantee they coincide). delegatable:false.
353
+ if (a.encryptedFields !== undefined && a.encryptedFields.length > 0 && a.requiresRead !== undefined) {
354
+ encAggregates[a.id] = { scope: a.requiresRead, fields: [...a.encryptedFields], delegatable: false };
355
+ }
356
+ }
313
357
  return {
314
358
  path: l.path,
315
359
  prims: encodeModuleToPrims(l.path, l.module),
@@ -336,6 +380,12 @@ export function emitUsd(
336
380
  },
337
381
  }
338
382
  : {}),
383
+ ...(Object.keys(visAggregates).length > 0
384
+ ? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
385
+ : {}),
386
+ ...(Object.keys(encAggregates).length > 0
387
+ ? { nomosEncryption: { v: 1 as const, aggregates: encAggregates } }
388
+ : {}),
339
389
  };
340
390
  })
341
391
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));