@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.
- package/LICENSE.md +36 -0
- package/compile_package.mjs +50 -0
- package/package.json +59 -0
- package/src/aggregate.ts +167 -0
- package/src/authoring.ts +119 -0
- package/src/build_package.ts +636 -0
- package/src/certified_read.ts +313 -0
- package/src/codegen_dart.ts +2732 -0
- package/src/codegen_dot.ts +466 -0
- package/src/codegen_provider_dart.ts +358 -0
- package/src/codegen_ts.ts +365 -0
- package/src/codegen_usda.ts +388 -0
- package/src/combined.ts +195 -0
- package/src/compile_engine.ts +567 -0
- package/src/compile_package_main.ts +496 -0
- package/src/compose.ts +317 -0
- package/src/count.ts +218 -0
- package/src/ctx.ts +57 -0
- package/src/derived.ts +138 -0
- package/src/directive.ts +306 -0
- package/src/drivers.ts +95 -0
- package/src/emits_guard.ts +123 -0
- package/src/engine_entry.ts +449 -0
- package/src/exists.ts +170 -0
- package/src/extremum.ts +227 -0
- package/src/fields.ts +291 -0
- package/src/framework/bootstrap.ts +22 -0
- package/src/framework/disclosure.ts +108 -0
- package/src/framework/domain_lifecycle.ts +108 -0
- package/src/framework/identity.ts +537 -0
- package/src/framework/impure_capability.ts +643 -0
- package/src/framework/rbac.ts +418 -0
- package/src/framework/repair.ts +150 -0
- package/src/framework/sync_lifecycle.ts +125 -0
- package/src/framework/workspace_invariant.ts +128 -0
- package/src/framework/workspaces.ts +817 -0
- package/src/index.ts +317 -0
- package/src/manifest.ts +947 -0
- package/src/ops.ts +145 -0
- package/src/ordered_read.ts +228 -0
- package/src/predicate.ts +203 -0
- package/src/query/compile.ts +0 -0
- package/src/query/relations.ts +144 -0
- package/src/query.ts +151 -0
- package/src/read.ts +54 -0
- package/src/relation.ts +189 -0
- package/src/report/csv.ts +54 -0
- package/src/report.ts +401 -0
- package/src/spatial.ts +115 -0
- package/src/sum.ts +194 -0
- package/src/usd.ts +563 -0
- package/src/wire.ts +149 -0
- 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.
|