@githolon/dsl 0.6.0 → 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.
- package/package.json +1 -1
- package/src/aggregate.ts +79 -4
- package/src/authoring.ts +18 -6
- package/src/build_package.ts +10 -1
- package/src/codegen_dart.ts +10 -0
- package/src/codegen_ts.ts +2 -0
- package/src/drivers.ts +48 -2
- package/src/engine_entry.ts +7 -0
- package/src/fields.ts +24 -0
- package/src/framework/relations.ts +205 -0
- package/src/framework/workspaces.ts +41 -2
- package/src/index.ts +21 -1
- package/src/manifest.ts +15 -2
- package/src/usd_layers.ts +14 -3
- package/src/wire.ts +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
package/src/aggregate.ts
CHANGED
|
@@ -51,6 +51,26 @@ export type ReadVisibility = { readonly requires: string };
|
|
|
51
51
|
/** Capability/role names: a non-empty token, no whitespace or quotes (it rides a USD string + a JSON leaf). */
|
|
52
52
|
const READ_CAP_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* THE READ AUDIENCE (secure-by-default rework — `architecture/secure_by_default_dsl.md`). Every
|
|
56
|
+
* aggregate DECLARES who may read it; there is no silent default (the compiler refuses an undeclared
|
|
57
|
+
* aggregate). Three choices:
|
|
58
|
+
* • `public` — anyone reads (plaintext). The deliberate exception.
|
|
59
|
+
* • `readableBy` — holders of `cap` read (gate-enforced). Sugar over the shipped read-visibility
|
|
60
|
+
* (`requires(cap)`); per-field `.secret()` escalates to team-E2E.
|
|
61
|
+
* • `owned` — the owner (+ per-record grantees) read; owner = the verified authoring principal,
|
|
62
|
+
* stamped into the `ownerField` (default `__owner`); operator-blind by definition.
|
|
63
|
+
* `.public()`/`.readableBy(cap)`/`.owned()` are the fluent declarations on the aggregate handle.
|
|
64
|
+
*/
|
|
65
|
+
export type Audience =
|
|
66
|
+
| { readonly kind: "public" }
|
|
67
|
+
| { readonly kind: "readableBy"; readonly cap: string }
|
|
68
|
+
| { readonly kind: "owned"; readonly ownerField: string };
|
|
69
|
+
|
|
70
|
+
/** The framework-reserved OWNERSHIP field (`.owned()`) — the verified authoring principal, kernel-style
|
|
71
|
+
* like `__type`/`__id`. The admission gate enforces it is never set to a non-author (slice 0). */
|
|
72
|
+
export const OWNER_FIELD = "__owner";
|
|
73
|
+
|
|
54
74
|
/**
|
|
55
75
|
* Declare an aggregate PRIVATE behind a capability — the value for
|
|
56
76
|
* `aggregate(id, fields, { visibility: requires("readEntry") })`. The only
|
|
@@ -136,6 +156,13 @@ export interface AggregateHandle<
|
|
|
136
156
|
* `nomosReadVisibility` era key is likewise omitted). See {@link ReadVisibility}.
|
|
137
157
|
*/
|
|
138
158
|
readonly visibility?: ReadVisibility;
|
|
159
|
+
/**
|
|
160
|
+
* THE DECLARED READ AUDIENCE (secure-by-default rework). PRESENT once the aggregate declares
|
|
161
|
+
* `.public()`/`.readableBy(cap)`/`.owned()` (or the legacy `{ visibility: requires(cap) }`, which is
|
|
162
|
+
* `readableBy` sugar). UNDEFINED ⇒ no audience declared — `nomos-compile` flags it (the mandatory
|
|
163
|
+
* rule); silence can never ship data. See {@link Audience}.
|
|
164
|
+
*/
|
|
165
|
+
readonly audience?: Audience;
|
|
139
166
|
/**
|
|
140
167
|
* E2E ENCRYPTION (e2e_encryption.md slice 2) — the STORED field names whose values
|
|
141
168
|
* are `.encrypted()`, SORTED. PRESENT only when ≥1 field is encrypted; OMITTED
|
|
@@ -144,13 +171,31 @@ export interface AggregateHandle<
|
|
|
144
171
|
* aggregate is `visibility: requires(cap)` (the scope) and its driver is Lww-class.
|
|
145
172
|
*/
|
|
146
173
|
readonly encryptedFields?: string[];
|
|
174
|
+
/**
|
|
175
|
+
* THE `.meta()` FIELDS (secure-by-default) — the STORED field names marked `.meta()` (plaintext
|
|
176
|
+
* metadata, the indexable opt-out on an owned aggregate), SORTED. PRESENT only when ≥1; OMITTED
|
|
177
|
+
* otherwise (era-safe). Indexing/grouping/summing is restricted to these on an owned/secret aggregate.
|
|
178
|
+
*/
|
|
179
|
+
readonly metaFields?: string[];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The fluent AUDIENCE declarations returned by {@link aggregate} (secure-by-default). Each returns a
|
|
183
|
+
* copy of the handle with the read audience set; you call exactly one at the declaration site. */
|
|
184
|
+
export interface AudienceMethods<Id extends string, F extends Record<string, Field>> {
|
|
185
|
+
/** Anyone may read (plaintext) — the deliberate public exception. */
|
|
186
|
+
public(): AggregateHandle<Id, F>;
|
|
187
|
+
/** Holders of `capability` may read (gate-enforced) — sugar over `requires(cap)`. */
|
|
188
|
+
readableBy(capability: string): AggregateHandle<Id, F>;
|
|
189
|
+
/** The owner (+ per-record grantees) read; owner = the verified author, stamped into `ownerField`
|
|
190
|
+
* (default `__owner`); operator-blind. NOTE: full owned read-scoping + E2E land in the next slice. */
|
|
191
|
+
owned(ownerField?: string): AggregateHandle<Id, F>;
|
|
147
192
|
}
|
|
148
193
|
|
|
149
194
|
export function aggregate<const Id extends string, F extends Record<string, Field>>(
|
|
150
195
|
id: Id,
|
|
151
196
|
fields: F,
|
|
152
197
|
opts?: { invariant?: AggregateInvariantFn; cap?: number; visibility?: ReadVisibility },
|
|
153
|
-
): AggregateHandle<Id, F> {
|
|
198
|
+
): AggregateHandle<Id, F> & AudienceMethods<Id, F> {
|
|
154
199
|
const { stored, hasMany } = partitionFields(fields);
|
|
155
200
|
if (opts?.cap !== undefined && (!Number.isInteger(opts.cap) || opts.cap < 1)) {
|
|
156
201
|
throw new Error(`aggregate '${id}': cap must be a positive integer (got ${opts.cap})`);
|
|
@@ -180,18 +225,48 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
180
225
|
}
|
|
181
226
|
}
|
|
182
227
|
}
|
|
183
|
-
|
|
228
|
+
// The `.meta()` fields (plaintext-metadata opt-out on owned aggregates) — recorded for the compiler.
|
|
229
|
+
const metaFields = Object.entries(stored)
|
|
230
|
+
.filter(([, f]) => (f as Field).isMeta === true)
|
|
231
|
+
.map(([name]) => name)
|
|
232
|
+
.sort();
|
|
233
|
+
|
|
234
|
+
// The audience-free DATA shape; the audience is attached below (from the legacy `{visibility}` opt or a
|
|
235
|
+
// fluent `.public()`/`.readableBy()`/`.owned()` call).
|
|
236
|
+
const base = {
|
|
184
237
|
id,
|
|
185
238
|
fields: stored as StoredFields<F>,
|
|
186
239
|
hasMany: hasMany as HasManyFields<F>,
|
|
187
|
-
__isAggregateHandle: true,
|
|
240
|
+
__isAggregateHandle: true as const,
|
|
188
241
|
...(opts?.invariant !== undefined
|
|
189
242
|
? { hasInvariant: true as const, invariant: opts.invariant }
|
|
190
243
|
: {}),
|
|
191
244
|
...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
|
|
192
|
-
...(opts?.visibility !== undefined ? { visibility: opts.visibility } : {}),
|
|
193
245
|
...(encryptedFields.length > 0 ? { encryptedFields } : {}),
|
|
246
|
+
...(metaFields.length > 0 ? { metaFields } : {}),
|
|
194
247
|
};
|
|
248
|
+
|
|
249
|
+
// Build a handle with a given audience + the fluent declarations re-attached (so a re-declaration
|
|
250
|
+
// is harmless and the handle the directives reference is always audience-bearing).
|
|
251
|
+
const build = (
|
|
252
|
+
audience: Audience | undefined,
|
|
253
|
+
visibility: ReadVisibility | undefined,
|
|
254
|
+
): AggregateHandle<Id, F> & AudienceMethods<Id, F> => ({
|
|
255
|
+
...base,
|
|
256
|
+
...(audience !== undefined ? { audience } : {}),
|
|
257
|
+
...(visibility !== undefined ? { visibility } : {}),
|
|
258
|
+
public: () => build({ kind: "public" }, undefined),
|
|
259
|
+
readableBy: (cap: string) => {
|
|
260
|
+
const v = requires(cap); // validates the capability name
|
|
261
|
+
return build({ kind: "readableBy", cap: v.requires }, v);
|
|
262
|
+
},
|
|
263
|
+
owned: (ownerField: string = OWNER_FIELD) => build({ kind: "owned", ownerField }, undefined),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Legacy `{ visibility: requires(cap) }` IS `readableBy(cap)` — carry both so existing law is unchanged.
|
|
267
|
+
const initialAudience: Audience | undefined =
|
|
268
|
+
opts?.visibility !== undefined ? { kind: "readableBy", cap: opts.visibility.requires } : undefined;
|
|
269
|
+
return build(initialAudience, opts?.visibility);
|
|
195
270
|
}
|
|
196
271
|
|
|
197
272
|
/** Lww-class driver: a wholesale-replace merge (`Lww`, or `MapOf` wrapping an Lww-class inner). */
|
package/src/authoring.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import type { AggregateHandle } from "./aggregate.js";
|
|
27
27
|
import type { Field } from "./fields.js";
|
|
28
|
-
import type { FieldOp, PlannedOp } from "./ops.js";
|
|
28
|
+
import type { FieldOp, PlannedOp, PlannedEventMarker } from "./ops.js";
|
|
29
29
|
|
|
30
30
|
// ── the per-dispatch authoring sink — library state the engine never sees (NOT a side channel out of
|
|
31
31
|
// the sandbox; it is internal to the directive runtime, drained synchronously by `executeDirectiveToIntent`). ──
|
|
@@ -67,15 +67,17 @@ class Ref implements AggregateRef {
|
|
|
67
67
|
constructor(
|
|
68
68
|
private readonly handle: AggregateHandle,
|
|
69
69
|
readonly __id: string,
|
|
70
|
-
|
|
70
|
+
// The referential marker stamped on EVERY op this ref emits: `creates` (minted birth),
|
|
71
|
+
// `ensures` (deterministic birth-or-update), or null (rides the directive's own marker).
|
|
72
|
+
private readonly marker: PlannedEventMarker | null,
|
|
71
73
|
) {}
|
|
72
74
|
get __type(): string {
|
|
73
75
|
return this.handle.id;
|
|
74
76
|
}
|
|
75
|
-
/** Emit a field op TARGETING THIS aggregate,
|
|
77
|
+
/** Emit a field op TARGETING THIS aggregate, stamped with this ref's marker (if any). */
|
|
76
78
|
private emit(field: string, kind: FieldOp["kind"], extra: Partial<FieldOp>): void {
|
|
77
79
|
const op: FieldOp = { aggregateId: this.__id, aggregateType: this.__type, field, kind, ...extra };
|
|
78
|
-
sink.push(this.
|
|
80
|
+
sink.push(this.marker ? { ...op, marker: this.marker } : op);
|
|
79
81
|
}
|
|
80
82
|
set(field: string, value: unknown): this {
|
|
81
83
|
this.emit(field, "set", { value });
|
|
@@ -110,10 +112,20 @@ class Ref implements AggregateRef {
|
|
|
110
112
|
|
|
111
113
|
/** Ask Nomos to CREATE a new aggregate of the handle's type → a fluent reference (id minted, no choice). */
|
|
112
114
|
export function create(agg: AggregateHandle): AggregateRef {
|
|
113
|
-
return new Ref(agg, mint(agg.id),
|
|
115
|
+
return new Ref(agg, mint(agg.id), "creates");
|
|
114
116
|
}
|
|
115
117
|
|
|
116
118
|
/** A reference to an EXISTING aggregate the dev already holds an id for (e.g. from the payload / a read). */
|
|
117
119
|
export function existing(agg: AggregateHandle, id: string): AggregateRef {
|
|
118
|
-
return new Ref(agg, id,
|
|
120
|
+
return new Ref(agg, id, null);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* ENSURE a sibling aggregate at a caller-chosen DETERMINISTIC id (born-or-updated) — every op it emits is
|
|
125
|
+
* stamped `ensures`, so a multi-aggregate plan needs NO per-op `withMarker(set(...), "ensures")`. A directive
|
|
126
|
+
* carries ONE referential marker for its primary aggregate; the SECOND+ aggregate it touches declares its
|
|
127
|
+
* own birth through this (the fluent sibling of the typed `withMarker(set(instance(Agg, id), …), "ensures")`).
|
|
128
|
+
*/
|
|
129
|
+
export function ensure(agg: AggregateHandle, id: string): AggregateRef {
|
|
130
|
+
return new Ref(agg, id, "ensures");
|
|
119
131
|
}
|
package/src/build_package.ts
CHANGED
|
@@ -55,6 +55,7 @@ import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js"
|
|
|
55
55
|
import { domainHash, emitManifestBytes } from "./manifest.js";
|
|
56
56
|
import { emitUsd } from "./usd.js";
|
|
57
57
|
import { expandCapabilityExports } from "./capability_exports.js";
|
|
58
|
+
import { RelationTuple } from "./framework/relations.js";
|
|
58
59
|
|
|
59
60
|
// ─────────────────────────────────────────────────────────────────────────────────
|
|
60
61
|
// composeDomainModule — the named-keys `module(...)`
|
|
@@ -141,8 +142,16 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
|
|
|
141
142
|
|
|
142
143
|
const byId = new Map<string, AggregateHandle>();
|
|
143
144
|
for (const a of [...aggregatesOf(merged), ...(spec.extraAggregates ?? [])]) byId.set(a.id, a);
|
|
144
|
-
const aggregates = [...byId.values()];
|
|
145
145
|
const directives = directivesOf(merged);
|
|
146
|
+
// RELATIONS AUTO-ENSURE: a directive that `.ensures(RelationTuple)` (the relate-family shape — its plan
|
|
147
|
+
// `writeTuple`s) needs the framework RelationTuple aggregate IN the schema, or the deploy gate never
|
|
148
|
+
// learns the type and the tuple write silently fails admission. Inject it so the dev needn't re-export
|
|
149
|
+
// the framework handle — the same "discoverable without re-exports" courtesy a `capability()` bundle
|
|
150
|
+
// gets. Era-safe: only a domain that authors tuples gains the aggregate, so no existing manifest hash drifts.
|
|
151
|
+
if (!byId.has(RelationTuple.id) && directives.some((d) => d.aggregateId === RelationTuple.id)) {
|
|
152
|
+
byId.set(RelationTuple.id, RelationTuple);
|
|
153
|
+
}
|
|
154
|
+
const aggregates = [...byId.values()];
|
|
146
155
|
const directiveIds = new Set(directives.map((d) => d.id));
|
|
147
156
|
for (const task of spec.impureCapabilities ?? []) {
|
|
148
157
|
for (const directive of [task.order, task.complete, task.fail, task.block, task.deadLetter]) {
|
package/src/codegen_dart.ts
CHANGED
|
@@ -611,6 +611,8 @@ function aggFieldSpec(
|
|
|
611
611
|
}
|
|
612
612
|
case "int":
|
|
613
613
|
return { name, wireName, kind: field.kind, dartType: "int", optional };
|
|
614
|
+
case "bool":
|
|
615
|
+
return { name, wireName, kind: field.kind, dartType: "bool", optional };
|
|
614
616
|
case "enum": {
|
|
615
617
|
// Pull the enum values out of the Zod schema (unwrapping `.optional()`).
|
|
616
618
|
const values = zodEnumValues(unwrapZod(field.zod));
|
|
@@ -1210,6 +1212,10 @@ function parseField(s: AggFieldSpec, aggregate: string): string {
|
|
|
1210
1212
|
return opt
|
|
1211
1213
|
? `readJsonStrOrNull(data, '${s.wireName}')`
|
|
1212
1214
|
: `readJsonStr(data, '${aggregate}', '${s.wireName}')`;
|
|
1215
|
+
case "bool":
|
|
1216
|
+
// The projection decodes a bool field to a real JSON boolean — read it directly (no runtime
|
|
1217
|
+
// helper). Optional → nullable cast; required → a plain cast (absent ⇒ throws, like readInt).
|
|
1218
|
+
return opt ? `(data['${s.wireName}'] as bool?)` : `(data['${s.wireName}'] as bool)`;
|
|
1213
1219
|
case "int":
|
|
1214
1220
|
return opt
|
|
1215
1221
|
? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
|
|
@@ -1274,6 +1280,8 @@ function readModelDartType(s: AggFieldSpec): string {
|
|
|
1274
1280
|
return s.valueClass ?? "String";
|
|
1275
1281
|
case "int":
|
|
1276
1282
|
return "int";
|
|
1283
|
+
case "bool":
|
|
1284
|
+
return "bool";
|
|
1277
1285
|
case "enum":
|
|
1278
1286
|
return s.dartType; // the generated enum type
|
|
1279
1287
|
case "evidence":
|
|
@@ -1344,6 +1352,8 @@ function parseReadModelField(s: AggFieldSpec, aggregate: string): string {
|
|
|
1344
1352
|
return opt
|
|
1345
1353
|
? `readJsonObjectOrNull(data, '${aggregate}', '${s.wireName}')`
|
|
1346
1354
|
: `readJsonObject(data, '${aggregate}', '${s.wireName}')`;
|
|
1355
|
+
case "bool":
|
|
1356
|
+
return opt ? `(data['${s.wireName}'] as bool?)` : `(data['${s.wireName}'] as bool)`;
|
|
1347
1357
|
case "int":
|
|
1348
1358
|
return opt
|
|
1349
1359
|
? `readIntOrNull(data, '${aggregate}', '${s.wireName}')`
|
package/src/codegen_ts.ts
CHANGED
|
@@ -229,6 +229,8 @@ function tsTypeOfField(field: Field): string {
|
|
|
229
229
|
return "string";
|
|
230
230
|
case "int":
|
|
231
231
|
return "number";
|
|
232
|
+
case "bool":
|
|
233
|
+
return "boolean";
|
|
232
234
|
case "enum":
|
|
233
235
|
try {
|
|
234
236
|
return union(zodEnumValues(field.zod as z.ZodTypeAny).map((v) => JSON.stringify(v)));
|
package/src/drivers.ts
CHANGED
|
@@ -23,6 +23,14 @@ import type { WireDriver } from "./wire.js";
|
|
|
23
23
|
*/
|
|
24
24
|
export type KernelDriverKind =
|
|
25
25
|
| "Lww"
|
|
26
|
+
// ── EXPLICIT TIME-AUTHORITY Lww drivers (the "whose clock?" axis) ──
|
|
27
|
+
// Nomos has no servers, so "do we trust the clock?" is the WRONG question — the real
|
|
28
|
+
// one is WHOSE clock orders THIS field, and a dev assigns it per field. `Lww` trusts
|
|
29
|
+
// the WRITER's wall clock; `LwwCausal` trusts NO clock (causal order only, skew-proof);
|
|
30
|
+
// `LwwBy(principal)` names ONE principal as the field's SOLE time-authority. All three
|
|
31
|
+
// are real `merge_field` arms in `nomos2/kernel/src/lib.rs`.
|
|
32
|
+
| "LwwCausal"
|
|
33
|
+
| "LwwBy"
|
|
26
34
|
| "AddWins"
|
|
27
35
|
| "MapOf"
|
|
28
36
|
| "Conflict"
|
|
@@ -42,6 +50,8 @@ export interface Driver {
|
|
|
42
50
|
readonly kind: DriverKind;
|
|
43
51
|
/** Only present for MapOf. */
|
|
44
52
|
readonly inner?: Driver;
|
|
53
|
+
/** Only present for `LwwBy`: the named time-authority principal (a verified userId). */
|
|
54
|
+
readonly principal?: string;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
export const Lww: Driver = { kind: "Lww" };
|
|
@@ -49,6 +59,34 @@ export const AddWins: Driver = { kind: "AddWins" };
|
|
|
49
59
|
export const Conflict: Driver = { kind: "Conflict" };
|
|
50
60
|
export const MapOf = (inner: Driver): Driver => ({ kind: "MapOf", inner });
|
|
51
61
|
|
|
62
|
+
// ── EXPLICIT TIME-AUTHORITY Lww drivers ──
|
|
63
|
+
// One-liner field tags answering "whose clock orders this field?" — each a real kernel
|
|
64
|
+
// `merge_field` arm (`hlc_lww_cmp` / `lww_authority_admits` in `kernel/src/lib.rs`).
|
|
65
|
+
/**
|
|
66
|
+
* `merge(LwwCausal)` — trust NO wall clock. The field resolves by CAUSAL order (the HLC's
|
|
67
|
+
* `(logical, replica)`, IGNORING `physical`), so a writer with a skewed / fast / lying
|
|
68
|
+
* physical clock can never win merely by stamping a large time. Deterministic + convergent
|
|
69
|
+
* (a total order); clock-skew-proof. Same value space as `Lww` (a wholesale-replace scalar).
|
|
70
|
+
*/
|
|
71
|
+
export const LwwCausal: Driver = { kind: "LwwCausal" };
|
|
72
|
+
/**
|
|
73
|
+
* `merge(LwwBy("coordinator"))` — name ONE principal as the field's time-authority. Only
|
|
74
|
+
* writes AUTHORED BY that principal are honoured (a write by anyone else is ignored), so the
|
|
75
|
+
* named principal is the field's sole writer and therefore the sole clock that orders it.
|
|
76
|
+
* Time-trust is a declared RELATIONSHIP — you say WHOSE clock, per field. The principal is a
|
|
77
|
+
* verified `userId` (the engine's `principal()` port / the intent's captured principal);
|
|
78
|
+
* an empty string is rejected (a time-authority must be named). Same value space as `Lww`.
|
|
79
|
+
*/
|
|
80
|
+
export const LwwBy = (principal: string): Driver => {
|
|
81
|
+
if (typeof principal !== "string" || principal.length === 0) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`LwwBy(principal) needs a non-empty principal — it NAMES the field's sole ` +
|
|
84
|
+
`time-authority. An empty authority can never be matched (fail-closed).`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
return { kind: "LwwBy", principal };
|
|
88
|
+
};
|
|
89
|
+
|
|
52
90
|
// Structural CRDT drivers — each encodes to its kernel `merge_field` arm
|
|
53
91
|
// (`kernel/src/lib.rs`): LastPosition = LWW-by-HLC over a position value,
|
|
54
92
|
// RemoveWins = 2P-set union(adds)−union(removes), Counter = PN-counter
|
|
@@ -67,6 +105,14 @@ export function encodeDriver(d: Driver): WireDriver {
|
|
|
67
105
|
switch (d.kind) {
|
|
68
106
|
case "Lww":
|
|
69
107
|
return "Lww";
|
|
108
|
+
// ── EXPLICIT TIME-AUTHORITY Lww drivers (kernel `merge_field` arms) ──
|
|
109
|
+
case "LwwCausal":
|
|
110
|
+
return "LwwCausal";
|
|
111
|
+
case "LwwBy":
|
|
112
|
+
// serde newtype variant `Driver::LwwBy(String)` → `{ "LwwBy": "<principal>" }`.
|
|
113
|
+
if (!d.principal)
|
|
114
|
+
throw new Error("LwwBy driver is missing its time-authority principal");
|
|
115
|
+
return { LwwBy: d.principal };
|
|
70
116
|
case "AddWins":
|
|
71
117
|
return "AddWins";
|
|
72
118
|
case "Conflict":
|
|
@@ -86,8 +132,8 @@ export function encodeDriver(d: Driver): WireDriver {
|
|
|
86
132
|
throw new Error(
|
|
87
133
|
`Driver '${d.kind}' is in the Nomos palette but not yet in the kernel — ` +
|
|
88
134
|
`cannot encode. Implement its merge_field arm in nomos2/kernel first, ` +
|
|
89
|
-
`or pick one of: Lww,
|
|
90
|
-
`LastPosition.`,
|
|
135
|
+
`or pick one of: Lww, LwwCausal, LwwBy, AddWins, MapOf, Conflict, ` +
|
|
136
|
+
`RemoveWins, Counter, LastPosition.`,
|
|
91
137
|
);
|
|
92
138
|
}
|
|
93
139
|
throw new Error(`Unknown driver kind '${(d as Driver).kind}'`);
|
package/src/engine_entry.ts
CHANGED
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
*/
|
|
54
54
|
import { executeDirectiveToIntent } from "./wire_encode.js";
|
|
55
55
|
import { expandCapabilityExports } from "./capability_exports.js";
|
|
56
|
+
import { RelationTuple } from "./framework/relations.js";
|
|
56
57
|
import type { Directive } from "./directive.js";
|
|
57
58
|
import type { AggregateHandle, AggregateInvariantFn, AggregateInvariantVerdict } from "./aggregate.js";
|
|
58
59
|
import type { Ports } from "./ctx.js";
|
|
@@ -233,6 +234,12 @@ function buildDomainSlice(mod: DomainModuleExports): DomainSlice {
|
|
|
233
234
|
const registry = new Map<string, RegistryEntry>();
|
|
234
235
|
const aggs = aggregatesOf(mod);
|
|
235
236
|
const dirs = directivesOf(mod);
|
|
237
|
+
// RELATIONS AUTO-ENSURE (mirror of composeDomainModule): a relate-family directive `.ensures(RelationTuple)`
|
|
238
|
+
// needs the framework aggregate registered for DISPATCH too — without it the directive is silently dropped
|
|
239
|
+
// below. Inject iff a directive targets it and the dev didn't already export the handle.
|
|
240
|
+
if (!aggs.has(RelationTuple.id) && [...dirs.values()].some((d) => d.aggregateId === RelationTuple.id)) {
|
|
241
|
+
aggs.set(RelationTuple.id, RelationTuple);
|
|
242
|
+
}
|
|
236
243
|
for (const [dirId, directive] of dirs) {
|
|
237
244
|
const agg = aggs.get(directive.aggregateId);
|
|
238
245
|
if (agg === undefined) {
|
package/src/fields.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type { AggregateHandle } from "./aggregate.js";
|
|
|
21
21
|
export type FieldKind =
|
|
22
22
|
| "string"
|
|
23
23
|
| "int"
|
|
24
|
+
| "bool"
|
|
24
25
|
| "enum"
|
|
25
26
|
| "set"
|
|
26
27
|
| "map"
|
|
@@ -127,10 +128,20 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
127
128
|
* the `encrypted()` METHOD sets it, mirroring `optional()`/`isOptional`.)
|
|
128
129
|
*/
|
|
129
130
|
readonly isEncrypted?: true;
|
|
131
|
+
/**
|
|
132
|
+
* METADATA (secure-by-default rework — `secure_by_default_dsl.md`): on an `.owned()` aggregate the
|
|
133
|
+
* default is that content fields are E2E (operator-blind); `.meta()` opts THIS field OUT to plaintext,
|
|
134
|
+
* operator-visible metadata — and metadata fields are the ONLY ones you may index / group / sum BY
|
|
135
|
+
* (indexing an encrypted field would leak the very plaintext you sealed). A deliberate, declared leak.
|
|
136
|
+
* Flag named `isMeta` — the `meta()` METHOD sets it, mirroring `encrypted()`/`optional()`.
|
|
137
|
+
*/
|
|
138
|
+
readonly isMeta?: true;
|
|
130
139
|
/** Returns a copy with the chosen driver (kind literal preserved). */
|
|
131
140
|
merge(driver: Driver): Field<T, K>;
|
|
132
141
|
/** Mark this field's value ENCRYPTED (sets {@link Field.isEncrypted}). Chainable + non-destructive. */
|
|
133
142
|
encrypted(): Field<T, K>;
|
|
143
|
+
/** Mark this field plaintext METADATA (sets {@link Field.isMeta}) — the indexable opt-out on an owned aggregate. */
|
|
144
|
+
meta(): Field<T, K>;
|
|
134
145
|
/**
|
|
135
146
|
* Mark this (scalar) field OPTIONAL: a folded aggregate may lack it, so the
|
|
136
147
|
* generated Dart type is nullable + tolerant. Chainable + non-destructive (kind
|
|
@@ -154,6 +165,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
154
165
|
mapValueKind?: FieldKind;
|
|
155
166
|
jsonShape?: "object";
|
|
156
167
|
encrypted?: true;
|
|
168
|
+
meta?: true;
|
|
157
169
|
} = {},
|
|
158
170
|
): Field<T, K> {
|
|
159
171
|
const isOptional = extra.isOptional ?? false;
|
|
@@ -168,6 +180,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
168
180
|
...(extra.mapValueKind !== undefined ? { mapValueKind: extra.mapValueKind } : {}),
|
|
169
181
|
...(extra.jsonShape !== undefined ? { jsonShape: extra.jsonShape } : {}),
|
|
170
182
|
...(extra.encrypted ? { isEncrypted: true as const } : {}),
|
|
183
|
+
...(extra.meta ? { isMeta: true as const } : {}),
|
|
171
184
|
merge(d: Driver) {
|
|
172
185
|
return makeField<T, K>(kind, zod, d, extra);
|
|
173
186
|
},
|
|
@@ -186,6 +199,11 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
186
199
|
// `nomosEncryption` era key, built by aggregate()/manifest, is what carries the fact.
|
|
187
200
|
return makeField<T, K>(kind, zod, driver, { ...extra, encrypted: true });
|
|
188
201
|
},
|
|
202
|
+
meta(): Field<T, K> {
|
|
203
|
+
// Plaintext-metadata opt-out (owned aggregates) — threaded through merge/optional like `encrypted`.
|
|
204
|
+
// Driver/schema unchanged; the fact rides the aggregate's audience surface, not the wire kind.
|
|
205
|
+
return makeField<T, K>(kind, zod, driver, { ...extra, meta: true });
|
|
206
|
+
},
|
|
189
207
|
};
|
|
190
208
|
return f;
|
|
191
209
|
}
|
|
@@ -200,6 +218,12 @@ export const t = {
|
|
|
200
218
|
int(): Field<number, "int"> {
|
|
201
219
|
return makeField("int", z.number().int(), Lww);
|
|
202
220
|
},
|
|
221
|
+
/** A boolean flag (Lww). Stored as the kernel scalar `"true"`/`"false"` and decoded back to a real
|
|
222
|
+
* JSON boolean on read — so a flag is `t.bool()`, not a 2-value `t.enum`. Counts/filters key on the
|
|
223
|
+
* same string form. (Not encryptable — only Lww-class scalars are, and bool already is.) */
|
|
224
|
+
bool(): Field<boolean, "bool"> {
|
|
225
|
+
return makeField("bool", z.boolean(), Lww);
|
|
226
|
+
},
|
|
203
227
|
/** Enum via `as const` literal union — value typos squeal at compile time. */
|
|
204
228
|
enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
|
|
205
229
|
return makeField<E[number], "enum">(
|
|
@@ -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 = [
|
|
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
|
@@ -14,7 +14,10 @@ export {
|
|
|
14
14
|
instance,
|
|
15
15
|
forwardRef,
|
|
16
16
|
requires,
|
|
17
|
+
OWNER_FIELD,
|
|
17
18
|
type AggregateHandle,
|
|
19
|
+
type AudienceMethods,
|
|
20
|
+
type Audience,
|
|
18
21
|
type BoundAggregate,
|
|
19
22
|
type StoredFields,
|
|
20
23
|
type HasManyFields,
|
|
@@ -32,6 +35,8 @@ export {
|
|
|
32
35
|
} from "./fields.js";
|
|
33
36
|
export {
|
|
34
37
|
Lww,
|
|
38
|
+
LwwCausal,
|
|
39
|
+
LwwBy,
|
|
35
40
|
AddWins,
|
|
36
41
|
Conflict,
|
|
37
42
|
MapOf,
|
|
@@ -64,7 +69,7 @@ export { directive, type Directive, type ReferentialMarker } from "./directive.j
|
|
|
64
69
|
// a DDD-fluent `AggregateRef`; the dev `.set`/`.add`(collection)/`.setEntry`(map)/`.relate`(reference)
|
|
65
70
|
// — naming no id. A directive's `.plan` calls these and returns `[]` (the recorded graph is the write
|
|
66
71
|
// surface). The dev never sees a "prim" — that is the GitHolon's internal OpenUSD IR.
|
|
67
|
-
export { create, existing, type AggregateRef } from "./authoring.js";
|
|
72
|
+
export { create, existing, ensure, type AggregateRef } from "./authoring.js";
|
|
68
73
|
// THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
|
|
69
74
|
// the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
|
|
70
75
|
// queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).
|
|
@@ -204,6 +209,21 @@ export {
|
|
|
204
209
|
type Scope,
|
|
205
210
|
type RoleCatalogue,
|
|
206
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";
|
|
207
227
|
// E2E ENCRYPTION (e2e_encryption.md slice 1) — the published per-principal X25519
|
|
208
228
|
// encryption-key registry; the wrap-target lookup the key-handoff fan-out resolves.
|
|
209
229
|
export {
|
package/src/manifest.ts
CHANGED
|
@@ -1010,7 +1010,19 @@ function stableIdsForModule(
|
|
|
1010
1010
|
*/
|
|
1011
1011
|
export function domainManifest(mod: DomainModule): CanonicalManifest {
|
|
1012
1012
|
const aggregates: CanonicalAggregate[] = mod.aggregates
|
|
1013
|
-
.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 {
|
|
1014
1026
|
id: agg.id,
|
|
1015
1027
|
schema: sortedSchema(encodeKernelSchema(agg)),
|
|
1016
1028
|
// OMIT-WHEN-ABSENT: only emit `hasInvariant` when the aggregate declares one,
|
|
@@ -1025,7 +1037,8 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
|
|
|
1025
1037
|
// E2E ENCRYPTION (slice 2) — the encrypted field names. Hash-bearing law;
|
|
1026
1038
|
// omit-when-none so plaintext aggregates are byte-stable.
|
|
1027
1039
|
...(agg.encryptedFields !== undefined ? { encryptedFields: [...agg.encryptedFields] } : {}),
|
|
1028
|
-
|
|
1040
|
+
};
|
|
1041
|
+
})
|
|
1029
1042
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
1030
1043
|
|
|
1031
1044
|
const directives: CanonicalDirective[] = mod.directives
|
package/src/usd_layers.ts
CHANGED
|
@@ -99,12 +99,14 @@ export function usdaStringArray(items: readonly string[]): string {
|
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
101
|
* A `WireDriver` as a READABLE usda token: unit drivers are their bare serde
|
|
102
|
-
* string (`"Lww"`, `"AddWins"`, …); `MapOf` nests as `MapOf(<inner>)
|
|
103
|
-
*
|
|
104
|
-
*
|
|
102
|
+
* string (`"Lww"`, `"AddWins"`, `"LwwCausal"`, …); `MapOf` nests as `MapOf(<inner>)`;
|
|
103
|
+
* the EXPLICIT TIME-AUTHORITY `LwwBy` carries its principal as `LwwBy(<principal>)`.
|
|
104
|
+
* Readable on purpose — the usdcat/usdview tree is the visual proof, so the merge law
|
|
105
|
+
* (and the time-authority) of every field must be legible in it (hex would bury the point).
|
|
105
106
|
*/
|
|
106
107
|
export function driverToken(driver: WireDriver): string {
|
|
107
108
|
if (typeof driver === "string") return driver;
|
|
109
|
+
if ("LwwBy" in driver) return `LwwBy(${driver.LwwBy})`;
|
|
108
110
|
return `MapOf(${driverToken(driver.MapOf)})`;
|
|
109
111
|
}
|
|
110
112
|
|
|
@@ -113,6 +115,15 @@ export function parseDriverToken(token: string): WireDriver {
|
|
|
113
115
|
if (token.startsWith("MapOf(") && token.endsWith(")")) {
|
|
114
116
|
return { MapOf: parseDriverToken(token.slice("MapOf(".length, -1)) };
|
|
115
117
|
}
|
|
118
|
+
// `LwwBy(<principal>)` — the principal is an opaque non-empty string (uids carry
|
|
119
|
+
// hyphens, so it is NOT the bare-identifier shape the unit-driver branch validates).
|
|
120
|
+
if (token.startsWith("LwwBy(") && token.endsWith(")")) {
|
|
121
|
+
const principal = token.slice("LwwBy(".length, -1);
|
|
122
|
+
if (principal.length === 0) {
|
|
123
|
+
throw new Error(`usd-layers: LwwBy driver token names no time-authority principal`);
|
|
124
|
+
}
|
|
125
|
+
return { LwwBy: principal };
|
|
126
|
+
}
|
|
116
127
|
if (!/^[A-Za-z][A-Za-z0-9]*$/.test(token)) {
|
|
117
128
|
throw new Error(`usd-layers: malformed driver token '${token}'`);
|
|
118
129
|
}
|
package/src/wire.ts
CHANGED
|
@@ -130,19 +130,23 @@ export interface WireRead {
|
|
|
130
130
|
|
|
131
131
|
/**
|
|
132
132
|
* `Driver` — the kernel's externally-tagged serde encoding (`nomos2/kernel/src/lib.rs`).
|
|
133
|
-
* Unit variants → bare strings; `MapOf` → `{ MapOf: <inner> }
|
|
134
|
-
*
|
|
135
|
-
* drivers `RemoveWins` / `Counter` /
|
|
133
|
+
* Unit variants → bare strings; `MapOf` → `{ MapOf: <inner> }`; the newtype `LwwBy(String)`
|
|
134
|
+
* → `{ LwwBy: <principal> }`. This mirrors the kernel variants the DSL `encodeDriver` emits:
|
|
135
|
+
* the four originals, the structural CRDT unit drivers `RemoveWins` / `Counter` /
|
|
136
|
+
* `LastPosition`, and the EXPLICIT TIME-AUTHORITY drivers `LwwCausal` (causal-only LWW) /
|
|
137
|
+
* `LwwBy` (principal-as-time-authority LWW) — each a real `merge_field` arm.
|
|
136
138
|
* (`NumericMax`/`NumericMin`/`ImmutableAfterCreate`/`OrderedList` are kernel variants no
|
|
137
139
|
* DSL driver encodes to yet — added here only as the DSL gains a constant for them.)
|
|
138
140
|
*/
|
|
139
141
|
export type WireDriver =
|
|
140
142
|
| "Lww"
|
|
143
|
+
| "LwwCausal"
|
|
141
144
|
| "AddWins"
|
|
142
145
|
| "Conflict"
|
|
143
146
|
| "RemoveWins"
|
|
144
147
|
| "Counter"
|
|
145
148
|
| "LastPosition"
|
|
149
|
+
| { LwwBy: string }
|
|
146
150
|
| { MapOf: WireDriver };
|
|
147
151
|
|
|
148
152
|
/** `Schema = BTreeMap<FieldId, Driver>` — a plain object, field -> Driver. */
|