@githolon/dsl 0.1.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.
Files changed (53) hide show
  1. package/LICENSE.md +36 -0
  2. package/compile_package.mjs +50 -0
  3. package/package.json +59 -0
  4. package/src/aggregate.ts +167 -0
  5. package/src/authoring.ts +119 -0
  6. package/src/build_package.ts +636 -0
  7. package/src/certified_read.ts +313 -0
  8. package/src/codegen_dart.ts +2732 -0
  9. package/src/codegen_dot.ts +466 -0
  10. package/src/codegen_provider_dart.ts +358 -0
  11. package/src/codegen_ts.ts +365 -0
  12. package/src/codegen_usda.ts +388 -0
  13. package/src/combined.ts +195 -0
  14. package/src/compile_engine.ts +567 -0
  15. package/src/compile_package_main.ts +496 -0
  16. package/src/compose.ts +317 -0
  17. package/src/count.ts +218 -0
  18. package/src/ctx.ts +57 -0
  19. package/src/derived.ts +138 -0
  20. package/src/directive.ts +306 -0
  21. package/src/drivers.ts +95 -0
  22. package/src/emits_guard.ts +123 -0
  23. package/src/engine_entry.ts +449 -0
  24. package/src/exists.ts +170 -0
  25. package/src/extremum.ts +227 -0
  26. package/src/fields.ts +291 -0
  27. package/src/framework/bootstrap.ts +22 -0
  28. package/src/framework/disclosure.ts +108 -0
  29. package/src/framework/domain_lifecycle.ts +108 -0
  30. package/src/framework/identity.ts +537 -0
  31. package/src/framework/impure_capability.ts +643 -0
  32. package/src/framework/rbac.ts +418 -0
  33. package/src/framework/repair.ts +150 -0
  34. package/src/framework/sync_lifecycle.ts +125 -0
  35. package/src/framework/workspace_invariant.ts +128 -0
  36. package/src/framework/workspaces.ts +817 -0
  37. package/src/index.ts +317 -0
  38. package/src/manifest.ts +947 -0
  39. package/src/ops.ts +145 -0
  40. package/src/ordered_read.ts +228 -0
  41. package/src/predicate.ts +203 -0
  42. package/src/query/compile.ts +0 -0
  43. package/src/query/relations.ts +144 -0
  44. package/src/query.ts +151 -0
  45. package/src/read.ts +54 -0
  46. package/src/relation.ts +189 -0
  47. package/src/report/csv.ts +54 -0
  48. package/src/report.ts +401 -0
  49. package/src/spatial.ts +115 -0
  50. package/src/sum.ts +194 -0
  51. package/src/usd.ts +563 -0
  52. package/src/wire.ts +149 -0
  53. package/src/wire_encode.ts +250 -0
@@ -0,0 +1,108 @@
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
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * The `nomos` lifecycle/controller surface.
10
+ *
11
+ * This is the Kubernetes CRD/API-server split in Nomos terms:
12
+ * * `PolicyBundle` is the content-addressed package object (`policy:{hash}.bundle`);
13
+ * * `DomainInstallation` is the workspace resource with spec/status/phase;
14
+ * * `installDomain` writes BOTH in one intent and declares the `installDomain`
15
+ * capability. This is the richer controller API; bootstrap exposes a thinner
16
+ * intent with the same name and payload shape for stage-zero install.
17
+ */
18
+ import { z } from "zod";
19
+ import { aggregate, instance } from "../aggregate.js";
20
+ import { Lww, MapOf } from "../drivers.js";
21
+ import { t } from "../fields.js";
22
+ import { directive } from "../directive.js";
23
+ import { set, withMarker, addToSet, type PlannedOp } from "../ops.js";
24
+
25
+ export const DOMAIN_INSTALLATION_PHASES = [
26
+ "Pending",
27
+ "Active",
28
+ "Disabled",
29
+ "Superseded",
30
+ "Retiring",
31
+ "Failed",
32
+ ] as const;
33
+ export type DomainInstallationPhase = (typeof DOMAIN_INSTALLATION_PHASES)[number];
34
+
35
+ export const PolicyBundle = aggregate("PolicyBundle", {
36
+ bundle: t.string().merge(Lww),
37
+ });
38
+
39
+ export const DomainInstallation = aggregate("DomainInstallation", {
40
+ "spec.domainHash": t.string().merge(Lww),
41
+ "spec.packageHash": t.string().merge(Lww),
42
+ "spec.policyAggregate": t.string().merge(Lww),
43
+ "spec.authorityScope": t.string().merge(Lww),
44
+ "spec.installedBy": t.string().merge(Lww),
45
+ "spec.dependencies": t.set(t.string()),
46
+ "spec.supersedes": t.string().merge(Lww).optional(),
47
+ "spec.finalizers": t.set(t.string()),
48
+ "metadata.generation": t.int().merge(Lww),
49
+ "status.phase": t.enum(DOMAIN_INSTALLATION_PHASES).merge(Lww),
50
+ "status.observedGeneration": t.int().merge(Lww),
51
+ "status.conditions": t.map(t.json()).merge(MapOf(Lww)),
52
+ });
53
+
54
+ export function policyAggregateId(domainHash: string): string {
55
+ return `policy:${domainHash}`;
56
+ }
57
+
58
+ export function domainInstallationAggregateId(domainHash: string): string {
59
+ return `domain-installation:${domainHash}`;
60
+ }
61
+
62
+ function policyBundleInstance(domainHash: string) {
63
+ return instance(PolicyBundle, policyAggregateId(domainHash));
64
+ }
65
+
66
+ function domainInstallationInstance(domainHash: string) {
67
+ return instance(DomainInstallation, domainInstallationAggregateId(domainHash));
68
+ }
69
+
70
+ export const installDomain = directive("installDomain")
71
+ .ensures(DomainInstallation)
72
+ .payload(
73
+ z.object({
74
+ domainHash: z.string(),
75
+ packageUsda: z.string(),
76
+ installedBy: z.string(),
77
+ authorityScope: z.string().default("workspace/root"),
78
+ dependencies: z.array(z.string()).default([]),
79
+ supersedes: z.string().optional(),
80
+ finalizers: z.array(z.string()).default([]),
81
+ }),
82
+ )
83
+ .plan((p): PlannedOp[] => {
84
+ const policy = policyBundleInstance(p.domainHash);
85
+ const install = domainInstallationInstance(p.domainHash);
86
+ const ops: PlannedOp[] = [
87
+ withMarker(set(policy, "bundle", p.packageUsda), "ensures"),
88
+ set(install, "spec.domainHash", p.domainHash),
89
+ set(install, "spec.packageHash", p.domainHash),
90
+ set(install, "spec.policyAggregate", policyAggregateId(p.domainHash)),
91
+ set(install, "spec.authorityScope", p.authorityScope),
92
+ set(install, "spec.installedBy", p.installedBy),
93
+ set(install, "metadata.generation", 1),
94
+ set(install, "status.phase", "Active"),
95
+ set(install, "status.observedGeneration", 1),
96
+ ];
97
+ if (p.dependencies.length > 0) {
98
+ ops.push(addToSet(install, "spec.dependencies", p.dependencies));
99
+ }
100
+ if (p.supersedes !== undefined) {
101
+ ops.push(set(install, "spec.supersedes", p.supersedes));
102
+ }
103
+ if (p.finalizers.length > 0) {
104
+ ops.push(addToSet(install, "spec.finalizers", p.finalizers));
105
+ }
106
+ return ops;
107
+ })
108
+ .requires("installDomain");
@@ -0,0 +1,537 @@
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
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * The `identity` domain — actors, users, and device keys.
10
+ *
11
+ * Authored against the STABLE DSL only (no kernel changes). A domain dev writes
12
+ * ONLY aggregates (typed fields + a merge Driver each) and directives (payload
13
+ * schema + plan). They NEVER write apply/fold/merge — the kernel owns the algebra.
14
+ *
15
+ * Design source: `nomos2/identity-access.md` (the actor / device-key model) and
16
+ * the Dart `identity_v1` domain (UserAggregate field shapes). This file models
17
+ * the DATA SHAPE designed in identity-access.md; it deliberately does NOT model
18
+ * kernel enforcement (signature verification, point-in-time authz, the
19
+ * partial-replication read filter) — those are later kernel slices.
20
+ *
21
+ * ─────────────────────────────────────────────────────────────────────────────
22
+ * NOTES / GAPS (surface these to the kernel + design owners):
23
+ *
24
+ * 1. DRIVER PALETTE GAPS. Today the kernel ships only Lww / AddWins / MapOf /
25
+ * Conflict. Several identity fields want richer semantics and are modelled
26
+ * with the nearest *safe* kernel driver, flagged here:
27
+ *
28
+ * a. IMMUTABLE-AFTER-CREATE provenance fields (actorId, userId, deviceKeyId,
29
+ * publicKey, enrolledAt, registeredAt). identity-access.md: "identity =
30
+ * the public key + an actor record" and a key, once enrolled, never
31
+ * changes its public key. There is no `immutableAfterCreate` driver.
32
+ * Modelled as `Lww` (a second write merely converges, it does not corrupt)
33
+ * but the REAL semantic is create-only; a re-write should be a *rejected*
34
+ * intent (C-ref, mirrors the dup-create disease in the findings). Flag:
35
+ * kernel wants an immutable/create-only driver.
36
+ *
37
+ * b. REVOCATION is append-only and POINT-IN-TIME. identity-access.md /
38
+ * AC-revoke: "revocation affects FUTURE authorship only — historical
39
+ * intents signed by a since-revoked key remain valid." A revoked key must
40
+ * NEVER come back to life via a concurrent merge, so the device-key
41
+ * lifecycle status is modelled `RemoveWins`-in-spirit. RemoveWins is NOT
42
+ * in the kernel yet, so `status` is modelled as `Lww` and the revocation
43
+ * is *also* recorded as an append-only set (`revokedDeviceKeyIds` AddWins
44
+ * on the Actor) — the set union is monotone and revocation-wins by
45
+ * construction (you can add to it, never remove from it). The Lww `status`
46
+ * is the convenience read; the AddWins set is the authoritative,
47
+ * revocation-wins record. Flag: kernel wants `RemoveWins` so a single
48
+ * `status` field can carry revoke-wins directly.
49
+ *
50
+ * c. lastAuthoredAt / lastSeenAt would want `numericMax` (monotonic, most
51
+ * recent wins). No such driver — modelled `Lww`. Benign (these are hints,
52
+ * not provenance), flagged for completeness.
53
+ *
54
+ * 2. KERNEL-ENFORCEMENT, NOT MODELLED HERE. The signature on each intent, the
55
+ * committer stamp (`nomos@vX`), point-in-time authz (C-auth / AC-authz), and
56
+ * the read-access replication filter (AC-read) are intent-boundary / sync
57
+ * concerns the kernel owns. This domain only stores the *data* those checks
58
+ * read: which keys exist, which are revoked, and as-of when. The publicKey is
59
+ * stored as an opaque string leaf (base64/PEM); the kernel verifies, the DSL
60
+ * does not.
61
+ *
62
+ * 3. RBAC (role-bindings) is its OWN aggregate per identity-access.md ("a
63
+ * permissions aggregate"). It is event-sourced state but lives closer to the
64
+ * existing `permissions_v1` domain than to identity-as-keys; it is OUT OF
65
+ * SCOPE for this file (which covers actor / user / device-key only) and is
66
+ * left to a permissions domain authoring pass. Flagged so the boundary is
67
+ * explicit, not forgotten.
68
+ *
69
+ * 4. Enrollment is cross-actor (an admin invites → a device enrols its key).
70
+ * The kernel's authz decides whether the *enroller* may enrol; here we model
71
+ * the resulting data: an enrollment directive `.creates` the DeviceKey and
72
+ * `.mutates` the owning Actor's key set in ONE atomic intent (the kernel
73
+ * fans this out to two events, #56). The enroller's authority is a C-auth
74
+ * pre-condition, not a field.
75
+ * ─────────────────────────────────────────────────────────────────────────────
76
+ */
77
+ import { z } from "zod";
78
+ import { aggregate, instance } from "../aggregate.js";
79
+ import { t } from "../fields.js";
80
+ import { AddWins, Lww, MapOf } from "../drivers.js";
81
+ import { directive } from "../directive.js";
82
+ import { addToSet, set, setEntry } from "../ops.js";
83
+
84
+ /** A user/actor's lifecycle status (mirrors Dart `UserStatus`). */
85
+ export const USER_STATUSES = ["active", "inactive", "deactivated"] as const;
86
+
87
+ /**
88
+ * A device key's lifecycle status. A key is `enrolled` when first provisioned and
89
+ * `revoked` once a rotation/revocation intent retires it. Revocation is one-way
90
+ * (see NOTE 1b); the authoritative revoke record is the Actor's `revokedDeviceKeyIds`
91
+ * AddWins set — this scalar is the convenient point-read.
92
+ */
93
+ export const DEVICE_KEY_STATUSES = ["enrolled", "revoked"] as const;
94
+
95
+ /**
96
+ * The Actor aggregate — "who you are". Per identity-access.md an actor (human,
97
+ * service, or the kernel itself) holds keypairs; identity = the public key + an
98
+ * actor record. `deviceKeyIds` is the add-wins set of keys enrolled to this actor;
99
+ * `revokedDeviceKeyIds` is the monotone, revocation-wins record (NOTE 1b).
100
+ *
101
+ * NOTE: actorId / kind / createdAt are immutable-after-create provenance modelled
102
+ * as Lww (NOTE 1a). `kind` distinguishes human / service / kernel actors
103
+ * (identity-access.md "open: service (non-human) identities" — modelled, not
104
+ * enforced).
105
+ */
106
+ export const ACTOR_KINDS = ["human", "service", "kernel"] as const;
107
+
108
+ export const Actor = aggregate("Actor", {
109
+ actorId: t.string().merge(Lww), // immutable-after-create (NOTE 1a)
110
+ kind: t.enum(ACTOR_KINDS).merge(Lww), // immutable-after-create (NOTE 1a)
111
+ displayName: t.string().merge(Lww),
112
+ // The keys enrolled to this actor. Add-wins: a new device enrolling concurrently
113
+ // with another must not lose its key.
114
+ deviceKeyIds: t.set(t.string()).merge(AddWins),
115
+ // Monotone revocation record — the authoritative "revoke-wins" set (NOTE 1b).
116
+ // You only ever ADD to it; a revoked id can never be merged away.
117
+ revokedDeviceKeyIds: t.set(t.string()).merge(AddWins),
118
+ createdAt: t.string().merge(Lww), // ISO-8601; immutable-after-create (NOTE 1a)
119
+ updatedAt: t.string().merge(Lww),
120
+ });
121
+
122
+ /**
123
+ * The User aggregate — an actor's human-facing profile. Field shapes mirror the
124
+ * Dart `UserAggregate` (userId/email/status/registeredAt/updatedAt + profile).
125
+ * Profile is a map-of-Lww so concurrent edits to *different* profile keys (first
126
+ * name vs picture) both survive; same key → Lww.
127
+ *
128
+ * `actorId` links a user to its Actor (the keypair holder). A user MAY have an
129
+ * actor; service actors have no user. Stored as a plain id string (same-workspace
130
+ * link); cross-workspace edges are the kernel's routing job (not used here).
131
+ */
132
+ export const User = aggregate("User", {
133
+ userId: t.string().merge(Lww), // immutable-after-create (NOTE 1a)
134
+ actorId: t.string().merge(Lww), // link to the owning Actor
135
+ email: t.string().merge(Lww),
136
+ status: t.enum(USER_STATUSES).merge(Lww),
137
+ // first/last name, picture url, etc. — each key merges independently by Lww.
138
+ profile: t.map(t.string()).merge(MapOf(Lww)),
139
+ registeredAt: t.string().merge(Lww), // immutable-after-create (NOTE 1a)
140
+ updatedAt: t.string().merge(Lww),
141
+ });
142
+
143
+ /**
144
+ * The DeviceKey aggregate — a device-scoped key bound to an actor. "Each device
145
+ * enrols its own key bound to an actor. This is what makes offline real ... and
146
+ * revocation fine-grained" (identity-access.md).
147
+ *
148
+ * publicKey is an opaque string leaf (base64/PEM) the KERNEL verifies; the DSL
149
+ * never parses it (NOTE 2). status is Lww as a convenience point-read; the
150
+ * authoritative revoke record is Actor.revokedDeviceKeyIds (NOTE 1b). revokedAt /
151
+ * revocationReason are append-only fields written once at revocation.
152
+ */
153
+ export const DeviceKey = aggregate("DeviceKey", {
154
+ deviceKeyId: t.string().merge(Lww), // immutable-after-create (NOTE 1a)
155
+ actorId: t.string().merge(Lww), // immutable-after-create (NOTE 1a) — binds key to actor
156
+ deviceLabel: t.string().merge(Lww), // human label ("Jack's MacBook")
157
+ publicKey: t.string().merge(Lww), // immutable-after-create opaque leaf (NOTE 1a/2)
158
+ status: t.enum(DEVICE_KEY_STATUSES).merge(Lww), // convenience read (NOTE 1b)
159
+ enrolledAt: t.string().merge(Lww), // immutable-after-create (NOTE 1a)
160
+ // Set once at revocation; absent while enrolled. Neither create (enroll/rotate)
161
+ // folds these, so a freshly-enrolled key lacks them — OPTIONAL on the read type.
162
+ revokedAt: t.string().merge(Lww).optional(),
163
+ revocationReason: t.string().merge(Lww).optional(),
164
+ // Optional hint of last authorship by this key; NOT provenance (NOTE 1c). Not
165
+ // folded at create — OPTIONAL.
166
+ lastAuthoredAt: t.string().merge(Lww).optional(),
167
+ });
168
+
169
+ /**
170
+ * The Organization aggregate — mirrors the Dart `CreateOrganization` shape. Org
171
+ * metadata is a map-of-Lww; the structured PostalAddress is a JSON leaf.
172
+ */
173
+ export const ORGANIZATION_TYPES = [
174
+ "owner",
175
+ "supplier",
176
+ "contractor",
177
+ "partner",
178
+ "other",
179
+ ] as const;
180
+
181
+ export const Organization = aggregate("Organization", {
182
+ organizationId: t.string().merge(Lww), // immutable-after-create (NOTE 1a)
183
+ name: t.string().merge(Lww),
184
+ orgType: t.enum(ORGANIZATION_TYPES).merge(Lww),
185
+ // `createOrganization` folds these contact/registration scalars only when
186
+ // supplied — a freshly-created org may lack them — OPTIONAL on the read type.
187
+ description: t.string().merge(Lww).optional(),
188
+ website: t.string().merge(Lww).optional(),
189
+ primaryContactEmail: t.string().merge(Lww).optional(),
190
+ primaryContactPhone: t.string().merge(Lww).optional(),
191
+ registrationNumber: t.string().merge(Lww).optional(),
192
+ taxId: t.string().merge(Lww).optional(),
193
+ address: t.jsonObject().merge(Lww).optional(), // whole PostalAddress value object
194
+ organizationData: t.map(t.string()).merge(MapOf(Lww)),
195
+ createdBy: t.string().merge(Lww),
196
+ createdAt: t.string().merge(Lww),
197
+ updatedAt: t.string().merge(Lww),
198
+ });
199
+
200
+ /**
201
+ * The PublicUser aggregate — the lightweight public-facing profile index (display
202
+ * name, email, photo) that the old `public_user_directives` maintains, distinct
203
+ * from the full `User`. `email` doubles as the public index key.
204
+ */
205
+ export const PublicUser = aggregate("PublicUser", {
206
+ userId: t.string().merge(Lww), // immutable-after-create
207
+ email: t.string().merge(Lww),
208
+ displayName: t.string().merge(Lww),
209
+ // `createPublicUser` folds photoUrl only when supplied — OPTIONAL (read-decode gap).
210
+ photoUrl: t.string().merge(Lww).optional(),
211
+ updatedAt: t.string().merge(Lww),
212
+ });
213
+
214
+ // ───────────────────────────── Directives ──────────────────────────────────
215
+
216
+ /** registerUser — .creates the User; seeds identity + status (mirrors Dart RegisterUser). */
217
+ export const registerUser = directive("registerUser")
218
+ .creates(User)
219
+ .payload(
220
+ z.object({
221
+ userId: z.string(),
222
+ actorId: z.string(),
223
+ email: z.string().email(),
224
+ firstName: z.string(),
225
+ lastName: z.string(),
226
+ registeredAt: z.string(),
227
+ }),
228
+ )
229
+ .plan((p) => {
230
+ // #105: address THIS user's concrete id (multi-instance, keyed by userId).
231
+ const usr = instance(User, p.userId);
232
+ return [
233
+ set(usr, "userId", p.userId),
234
+ set(usr, "actorId", p.actorId),
235
+ set(usr, "email", p.email),
236
+ set(usr, "status", "active"),
237
+ setEntry(usr, "profile", "firstName", p.firstName),
238
+ setEntry(usr, "profile", "lastName", p.lastName),
239
+ set(usr, "registeredAt", p.registeredAt),
240
+ set(usr, "updatedAt", p.registeredAt),
241
+ ];
242
+ });
243
+
244
+ /** createActor — .creates the Actor (the keypair holder); seeds id/kind/displayName. */
245
+ export const createActor = directive("createActor")
246
+ .creates(Actor)
247
+ .payload(
248
+ z.object({
249
+ actorId: z.string(),
250
+ kind: z.enum(ACTOR_KINDS),
251
+ displayName: z.string(),
252
+ createdAt: z.string(),
253
+ }),
254
+ )
255
+ .plan((p) => {
256
+ // #105: address THIS actor's concrete id (multi-instance, keyed by actorId).
257
+ const act = instance(Actor, p.actorId);
258
+ return [
259
+ set(act, "actorId", p.actorId),
260
+ set(act, "kind", p.kind),
261
+ set(act, "displayName", p.displayName),
262
+ set(act, "createdAt", p.createdAt),
263
+ set(act, "updatedAt", p.createdAt),
264
+ ];
265
+ });
266
+
267
+ /**
268
+ * A profile value mirrors the v1 `UserProfile.toJson()` value space: a plain
269
+ * string (firstName/jobTitle/…), a bool (onboardingComplete), a NESTED MAP
270
+ * (notifications → 7 bools; preloadedThingIds → thingId→bool), or a list. The
271
+ * profile map stores Str leaves, so a NON-string value is JSON-encoded into one
272
+ * leaf (deterministic `JSON.stringify`); strings pass through verbatim so the
273
+ * existing flat-string patches lower identically. The Dart side already models a
274
+ * `z.record(...)` payload as `Map<String, Object?>` (codegen_dart), so this widening
275
+ * needs no generated-type change — only the runtime value space + lowering.
276
+ */
277
+ const profileValue = z.union([
278
+ z.string(),
279
+ z.boolean(),
280
+ z.record(z.string(), z.unknown()), // nested map (notifications / preloadedThingIds)
281
+ z.array(z.unknown()), // list-valued profile entry
282
+ ]);
283
+
284
+ /** Encode one profile value to the Str leaf its map key stores. Strings pass
285
+ * through unchanged (flat-patch fidelity); everything else is canonical JSON. */
286
+ function encodeProfileValue(v: z.infer<typeof profileValue>): string {
287
+ return typeof v === "string" ? v : JSON.stringify(v);
288
+ }
289
+
290
+ /** updateUserProfile — .mutates the User's profile map (per-key Lww). */
291
+ export const updateUserProfile = directive("updateUserProfile")
292
+ .mutates(User)
293
+ .payload(
294
+ z.object({
295
+ userId: z.string(), // #105: target the concrete user instance.
296
+ updatedAt: z.string(),
297
+ // Partial profile patch carrying the FULL v1 UserProfile value space
298
+ // (string / bool / nested map / list — match `UserProfile.toJson()`); each
299
+ // key set independently into the Lww map (non-string values JSON-encoded).
300
+ profile: z.record(z.string(), profileValue),
301
+ }),
302
+ )
303
+ .plan((p) => {
304
+ const usr = instance(User, p.userId);
305
+ return [
306
+ ...Object.entries(p.profile).map(([k, v]) =>
307
+ setEntry(usr, "profile", k, encodeProfileValue(v)),
308
+ ),
309
+ set(usr, "updatedAt", p.updatedAt),
310
+ ];
311
+ });
312
+
313
+ /** deactivateUser — .mutates the User's status (mirrors Dart DeactivateUser). */
314
+ export const deactivateUser = directive("deactivateUser")
315
+ .mutates(User)
316
+ .payload(z.object({ userId: z.string(), updatedAt: z.string() }))
317
+ .plan((p) => {
318
+ const usr = instance(User, p.userId);
319
+ return [set(usr, "status", "deactivated"), set(usr, "updatedAt", p.updatedAt)];
320
+ });
321
+
322
+ /**
323
+ * enrollDeviceKey — the enrollment intent (identity-access.md: "a device key is
324
+ * created by an enrollment intent"). It is a multi-aggregate, atomic write
325
+ * (#56):
326
+ * - .creates the DeviceKey (status=enrolled, the opaque publicKey),
327
+ * - .mutates the owning Actor's `deviceKeyIds` add-wins set.
328
+ * Marker is `.creates(DeviceKey)` — the new key is the lifecycle subject. The
329
+ * kernel lowers this to two events sharing the intent's HLC. The enroller's
330
+ * authority is a C-auth pre-condition, not modelled here (NOTE 4).
331
+ */
332
+ export const enrollDeviceKey = directive("enrollDeviceKey")
333
+ .creates(DeviceKey)
334
+ .payload(
335
+ z.object({
336
+ deviceKeyId: z.string(),
337
+ actorId: z.string(),
338
+ deviceLabel: z.string(),
339
+ publicKey: z.string(),
340
+ enrolledAt: z.string(),
341
+ }),
342
+ )
343
+ .plan((p) => {
344
+ // #105: bind BOTH the new key instance (deviceKeyId) and the actor update onto the
345
+ // owning actor instance (actorId).
346
+ const key = instance(DeviceKey, p.deviceKeyId);
347
+ const act = instance(Actor, p.actorId);
348
+ return [
349
+ // The new key.
350
+ set(key, "deviceKeyId", p.deviceKeyId),
351
+ set(key, "actorId", p.actorId),
352
+ set(key, "deviceLabel", p.deviceLabel),
353
+ set(key, "publicKey", p.publicKey),
354
+ set(key, "status", "enrolled"),
355
+ set(key, "enrolledAt", p.enrolledAt),
356
+ // Add the key to its actor's add-wins set (a second event in the same intent).
357
+ addToSet(act, "deviceKeyIds", [p.deviceKeyId]),
358
+ ];
359
+ });
360
+
361
+ /**
362
+ * revokeDeviceKey — append-only revocation. identity-access.md / AC-revoke:
363
+ * "revoking a key blocks FUTURE authorship by it; historical intents it signed
364
+ * remain valid." We model only the data shape (kernel enforces point-in-time):
365
+ * - .mutates the DeviceKey: status→revoked + revokedAt/revocationReason (the
366
+ * convenience point-read),
367
+ * - .mutates the owning Actor's `revokedDeviceKeyIds` AddWins set — the
368
+ * AUTHORITATIVE, revocation-wins record (monotone; can never be un-revoked
369
+ * by a concurrent merge) (NOTE 1b).
370
+ * Marker is `.mutates(DeviceKey)` — revocation is a lifecycle change, not a
371
+ * tombstone (the key record stays, historically valid).
372
+ */
373
+ export const revokeDeviceKey = directive("revokeDeviceKey")
374
+ .mutates(DeviceKey)
375
+ .payload(
376
+ z.object({
377
+ deviceKeyId: z.string(),
378
+ actorId: z.string(),
379
+ revokedAt: z.string(),
380
+ reason: z.string(),
381
+ }),
382
+ )
383
+ .plan((p) => {
384
+ const key = instance(DeviceKey, p.deviceKeyId);
385
+ const act = instance(Actor, p.actorId);
386
+ return [
387
+ set(key, "status", "revoked"),
388
+ set(key, "revokedAt", p.revokedAt),
389
+ set(key, "revocationReason", p.reason),
390
+ // Authoritative revoke-wins record on the Actor (a second event in the same intent).
391
+ addToSet(act, "revokedDeviceKeyIds", [p.deviceKeyId]),
392
+ ];
393
+ });
394
+
395
+ /**
396
+ * rotateDeviceKey — rotation = enrol a NEW key + revoke the OLD one, atomically.
397
+ * identity-access.md groups "rotation / revocation" as the one append-only
398
+ * lifecycle intent. This is the widest multi-aggregate identity write: it touches the new DeviceKey, the
399
+ * old DeviceKey, and the Actor (both its add-wins enrolled set and its monotone
400
+ * revoked set) in ONE intent (#56). Marker `.creates(DeviceKey)` — the new key is
401
+ * the subject.
402
+ */
403
+ export const rotateDeviceKey = directive("rotateDeviceKey")
404
+ .creates(DeviceKey)
405
+ .payload(
406
+ z.object({
407
+ actorId: z.string(),
408
+ oldDeviceKeyId: z.string(),
409
+ newDeviceKeyId: z.string(),
410
+ deviceLabel: z.string(),
411
+ newPublicKey: z.string(),
412
+ rotatedAt: z.string(),
413
+ reason: z.string(),
414
+ }),
415
+ )
416
+ .plan((p) => {
417
+ // #105: the NEW key is the subject (bound to newDeviceKeyId); the companion write
418
+ // targets the owning actor instance (actorId).
419
+ const key = instance(DeviceKey, p.newDeviceKeyId);
420
+ const act = instance(Actor, p.actorId);
421
+ return [
422
+ // Enrol the replacement key.
423
+ set(key, "deviceKeyId", p.newDeviceKeyId),
424
+ set(key, "actorId", p.actorId),
425
+ set(key, "deviceLabel", p.deviceLabel),
426
+ set(key, "publicKey", p.newPublicKey),
427
+ set(key, "status", "enrolled"),
428
+ set(key, "enrolledAt", p.rotatedAt),
429
+ // Actor: add the new key, record the old key as revoked (both add-wins/monotone).
430
+ addToSet(act, "deviceKeyIds", [p.newDeviceKeyId]),
431
+ addToSet(act, "revokedDeviceKeyIds", [p.oldDeviceKeyId]),
432
+ ];
433
+ });
434
+
435
+ // ── organization ─────────────────────────────────────────────────────────────
436
+
437
+ /** createOrganization — .creates the Organization (mirrors Dart CreateOrganization). */
438
+ export const createOrganization = directive("createOrganization")
439
+ .creates(Organization)
440
+ .payload(
441
+ z.object({
442
+ organizationId: z.string(),
443
+ name: z.string(),
444
+ orgType: z.enum(ORGANIZATION_TYPES),
445
+ description: z.string().optional(),
446
+ website: z.string().optional(),
447
+ primaryContactEmail: z.string().optional(),
448
+ primaryContactPhone: z.string().optional(),
449
+ registrationNumber: z.string().optional(),
450
+ taxId: z.string().optional(),
451
+ address: z.string().optional(), // JSON-encoded PostalAddress
452
+ createdBy: z.string(),
453
+ createdAt: z.string(),
454
+ }),
455
+ )
456
+ .plan((p) => {
457
+ // #105: address THIS organization's concrete id (multi-instance).
458
+ const org = instance(Organization, p.organizationId);
459
+ const ops = [
460
+ set(org, "organizationId", p.organizationId),
461
+ set(org, "name", p.name),
462
+ set(org, "orgType", p.orgType),
463
+ set(org, "createdBy", p.createdBy),
464
+ set(org, "createdAt", p.createdAt),
465
+ set(org, "updatedAt", p.createdAt),
466
+ ];
467
+ if (p.description !== undefined) ops.push(set(org, "description", p.description));
468
+ if (p.website !== undefined) ops.push(set(org, "website", p.website));
469
+ if (p.primaryContactEmail !== undefined)
470
+ ops.push(set(org, "primaryContactEmail", p.primaryContactEmail));
471
+ if (p.primaryContactPhone !== undefined)
472
+ ops.push(set(org, "primaryContactPhone", p.primaryContactPhone));
473
+ if (p.registrationNumber !== undefined)
474
+ ops.push(set(org, "registrationNumber", p.registrationNumber));
475
+ if (p.taxId !== undefined) ops.push(set(org, "taxId", p.taxId));
476
+ if (p.address !== undefined) ops.push(set(org, "address", p.address));
477
+ return ops;
478
+ });
479
+
480
+ // ── public user (profile index) ──────────────────────────────────────────────
481
+
482
+ /** createPublicUser — .creates the PublicUser profile index entry. */
483
+ export const createPublicUser = directive("createPublicUser")
484
+ .creates(PublicUser)
485
+ .payload(
486
+ z.object({
487
+ userId: z.string(),
488
+ email: z.string().email(),
489
+ displayName: z.string(),
490
+ photoUrl: z.string().optional(),
491
+ updatedAt: z.string(),
492
+ }),
493
+ )
494
+ .plan((p) => {
495
+ // #105: PublicUser is keyed by userId (v1 entityId = userId).
496
+ const pu = instance(PublicUser, p.userId);
497
+ const ops = [
498
+ set(pu, "userId", p.userId),
499
+ set(pu, "email", p.email),
500
+ set(pu, "displayName", p.displayName),
501
+ set(pu, "updatedAt", p.updatedAt),
502
+ ];
503
+ if (p.photoUrl !== undefined) ops.push(set(pu, "photoUrl", p.photoUrl));
504
+ return ops;
505
+ });
506
+
507
+ /** updatePublicUserProfile — .mutates the PublicUser profile scalars. */
508
+ export const updatePublicUserProfile = directive("updatePublicUserProfile")
509
+ .mutates(PublicUser)
510
+ .payload(
511
+ z.object({
512
+ userId: z.string(), // #105: target the concrete public-user instance (v1 keys on userId).
513
+ displayName: z.string().optional(),
514
+ photoUrl: z.string().optional(),
515
+ email: z.string().email().optional(),
516
+ updatedAt: z.string(),
517
+ }),
518
+ )
519
+ .plan((p) => {
520
+ const pu = instance(PublicUser, p.userId);
521
+ const ops = [] as ReturnType<typeof set>[];
522
+ if (p.displayName !== undefined) ops.push(set(pu, "displayName", p.displayName));
523
+ if (p.photoUrl !== undefined) ops.push(set(pu, "photoUrl", p.photoUrl));
524
+ if (p.email !== undefined) ops.push(set(pu, "email", p.email));
525
+ ops.push(set(pu, "updatedAt", p.updatedAt));
526
+ return ops;
527
+ });
528
+
529
+ // ─────────────────────── FRAMEWORK / TENANT boundary ────────────────────────
530
+ //
531
+ // This module is now FRAMEWORK-ONLY: it carries the identity CORE aggregates
532
+ // (Actor, User, DeviceKey, Organization, PublicUser) and their directives — no
533
+ // tenant content. The tenant-specific identity aggregates that used to live here
534
+ // have moved to the tenant package (its `the tenant identity module` module). The tenant engine
535
+ // bundle MERGES this framework `identity` module with the tenant's identity module
536
+ // under the SAME `identity` domain key, so the runtime dispatch is byte-for-byte
537
+ // unchanged — the boundary moved, the wire contract did not.