create-githolon 0.99.0 → 0.100.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/index.mjs +7 -11
- package/package.json +1 -1
- package/template/CLAUDE.md +9 -32
- package/template/README.md +51 -132
- package/template/domains/todo.ts +18 -70
- package/template/nomos.package.mjs +1 -7
- package/template/package.json +1 -2
- package/template/docs/01-mental-model.md +0 -57
- package/template/docs/02-authoring.md +0 -437
- package/template/docs/03-client.md +0 -166
- package/template/docs/04-cloud.md +0 -78
- package/template/docs/05-superpowers.md +0 -69
- package/template/docs/06-law-evolution.md +0 -166
- package/template/docs/07-security.md +0 -195
- package/template/docs/08-births.md +0 -66
- package/template/docs/09-attested-reads.md +0 -87
- package/template/docs/10-flutter-layering.md +0 -79
- package/template/docs/11-governance-postures.md +0 -89
- package/template/test/e2e.mts +0 -114
|
@@ -1,437 +0,0 @@
|
|
|
1
|
-
# Authoring law
|
|
2
|
-
|
|
3
|
-
You write exactly TWO things: aggregates and directives. Never apply/fold/
|
|
4
|
-
merge code — the kernel owns folding; your directive PLANS ops and the sealed
|
|
5
|
-
engine replays them deterministically on every peer. Declared reads (query,
|
|
6
|
-
count, derived, sum) are auto-discovered from your module's exports by shape.
|
|
7
|
-
`domains/todo.ts` demonstrates the core patterns below; reshape it.
|
|
8
|
-
|
|
9
|
-
## Aggregates: typed fields, each tagged a merge driver
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
export const Book = aggregate("Book", {
|
|
13
|
-
title: t.string().merge(Lww),
|
|
14
|
-
tags: t.set(t.string()).merge(AddWins),
|
|
15
|
-
notes: t.map(t.string()).merge(MapOf(Lww)),
|
|
16
|
-
});
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
Field kinds: `t.string()` · `t.int()` · `t.bool()` (a true/false flag —
|
|
20
|
-
count/filter key on it; NOT a 2-value `t.enum`) · `t.enum([...] as const)` (typos squeal
|
|
21
|
-
at compile time) · `t.set(t.string())` · `t.map(inner)` · `t.json()` /
|
|
22
|
-
`t.jsonObject()` (JSON-string leaf; `jsonObject` asserts an object and reads
|
|
23
|
-
back structured) · `t.ref(Agg)` (an id-valued reference) ·
|
|
24
|
-
`t.hasMany(Child).via("parent")` (the virtual read side of a child's ref —
|
|
25
|
-
nothing stored on the parent). Add `.optional()` where absence is legal.
|
|
26
|
-
|
|
27
|
-
**Cross-workspace links are TYPED — never a bare string.** A reference into ANOTHER
|
|
28
|
-
workspace (its own app-specific ledger — a Task pointing at a Space, a Project at a
|
|
29
|
-
catalogue) is `t.foreignRef({ workspace: "<sibling field holding the foreign workspace
|
|
30
|
-
id>", target: SpaceOwner })`. It stores the foreign aggregate id, types the edge as
|
|
31
|
-
`ForeignRef<SpaceOwnerData>`, and the generated client emits `.resolve(nomos)` /
|
|
32
|
-
`.watch(nomos)` to open the other holon on demand. Cross-domain imports are first-class
|
|
33
|
-
(`import { SpaceOwner } from "./space.ts"`). It is a typed LOCATOR, not a gate-enforced
|
|
34
|
-
foreign key (separate ledgers = no cross-workspace referential integrity at the gate);
|
|
35
|
-
law that must depend on the foreign FACT opts into attested evidence via `trustSource`
|
|
36
|
-
+ `.readsFrom(...)`. Worked four-layer example (home → Space + Project with a typed
|
|
37
|
-
cross-ref): `bench/scale/fixtures/four-layer-nested-topology`. **Do NOT store a foreign
|
|
38
|
-
workspace name in a plain `t.string()` — that throws away the type and the resolver.**
|
|
39
|
-
|
|
40
|
-
## Relationships: reference aggregates, don't hand-roll composite ids
|
|
41
|
-
|
|
42
|
-
When one aggregate points at another — a listing's primary image, its many
|
|
43
|
-
images, a membership row — reach for a REFERENCE, not a `primary:<listingId>` /
|
|
44
|
-
`media:<listingId>:<attId>` string convention. A `t.ref` stores the target's id
|
|
45
|
-
(the same bytes a string would), but it tells the compiler, the generated
|
|
46
|
-
client, and the next reader *what that id means* — so queries can traverse it,
|
|
47
|
-
the client types it as a link, and the ER graph renders it.
|
|
48
|
-
|
|
49
|
-
```ts
|
|
50
|
-
export const Attachment = aggregate("Attachment", { url: t.string().merge(Lww) });
|
|
51
|
-
|
|
52
|
-
export const Listing = aggregate("Listing", {
|
|
53
|
-
title: t.string().merge(Lww),
|
|
54
|
-
primaryImage: t.ref(Attachment), // 1:1 — "exactly one" is one field (re-set to change)
|
|
55
|
-
media: t.hasMany(() => ListingMedia).via("listing"), // 1:N — the virtual read side of the child's ref
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
// M:N WITH EDGE DATA — a real join aggregate whose FKs are refs (typed + traversable), carrying role/order.
|
|
59
|
-
export const ListingMedia = aggregate("ListingMedia", {
|
|
60
|
-
listing: t.ref(Listing),
|
|
61
|
-
attachment: t.ref(Attachment),
|
|
62
|
-
role: t.enum(["gallery", "datasheet", "manual"] as const).merge(Lww),
|
|
63
|
-
order: t.int().merge(Lww),
|
|
64
|
-
});
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Pick the shape by cardinality: **1:1** is a `t.ref` field ("one" is structural,
|
|
68
|
-
no invariant needed); **1:N** is `t.hasMany(...).via("backref")` on the parent
|
|
69
|
-
(virtual — the child's `t.ref` back-edge is the only thing stored, and
|
|
70
|
-
`parent.add("children", child)` writes ONE event on the child); **M:N** is a
|
|
71
|
-
join aggregate with `t.ref` foreign keys (a real row when the edge carries data
|
|
72
|
-
like role/order). Traverse in a query by keying on the ref: `query("mediaByListing").key("listing").returns(ListingMedia)`.
|
|
73
|
-
|
|
74
|
-
A bidirectional pair (`Listing.hasMany(ListingMedia)` ⇄ `ListingMedia.ref(Listing)`)
|
|
75
|
-
is a declaration-order cycle — pass a THUNK on either side (`t.hasMany(() =>
|
|
76
|
-
ListingMedia)` / `t.ref(() => Listing)`) and it resolves lazily; the emitted law
|
|
77
|
-
is byte-identical to the eager form. Worked example: `examples/relations`.
|
|
78
|
-
|
|
79
|
-
## Value objects: named, composable structured values
|
|
80
|
-
|
|
81
|
-
When a field is a STRUCTURE (an address, a location, a money amount), don't
|
|
82
|
-
hand-roll `t.jsonObject()` plus a private zod — declare a NAMED value object
|
|
83
|
-
once and reuse it everywhere:
|
|
84
|
-
|
|
85
|
-
```ts
|
|
86
|
-
import { valueObject, geo, z } from "@githolon/dsl";
|
|
87
|
-
|
|
88
|
-
const PostalAddress = valueObject("PostalAddress", {
|
|
89
|
-
streetAddress: z.string(), city: z.string(), countryCode: z.string(),
|
|
90
|
-
});
|
|
91
|
-
const GeoPoint = geo.point("GeoPoint"); // RFC 7946 Point
|
|
92
|
-
const TaskLocation = valueObject("TaskLocation", {
|
|
93
|
-
address: PostalAddress.optional(), // VOs nest in VOs
|
|
94
|
-
point: GeoPoint.optional(),
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
export const ProjectTask = aggregate("ProjectTask", {
|
|
98
|
-
name: t.string().merge(Lww),
|
|
99
|
-
location: TaskLocation.field().optional(), // ONE json-object leaf
|
|
100
|
-
point: GeoPoint.field().optional(), // give a geometry its OWN field…
|
|
101
|
-
});
|
|
102
|
-
export const tasksByBounds = spatial("tasksByBounds").of(ProjectTask).on("point"); // …to index it
|
|
103
|
-
export const recordTask = directive("recordTask").creates(ProjectTask)
|
|
104
|
-
.payload(z.object({ name: z.string(), location: TaskLocation.zod })) // the SAME zod validates
|
|
105
|
-
.plan((p) => { create(ProjectTask).set("name", p.name).set("location", p.location); return []; });
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
ONE definition yields the zod (payload validation — a malformed VO is the
|
|
109
|
-
normal typed refusal), the TS type + a named interface in the generated
|
|
110
|
-
`.client.ts`, a typed Dart class (`fromProjected`/`toJson`), and a canonical
|
|
111
|
-
descriptor in the package IR (`nomosValueObjects`) so tooling can see the
|
|
112
|
-
structure. A domain using no VOs is byte-identical to before they existed.
|
|
113
|
-
|
|
114
|
-
**The merge rule (know it):** a VO field is ONE `Lww`-class value leaf,
|
|
115
|
-
replaced WHOLESALE on write — there is NO per-subfield merge. Two offline
|
|
116
|
-
writers touching different subfields of the same VO converge to one writer's
|
|
117
|
-
whole value. When subfields must merge independently, model them as aggregate
|
|
118
|
-
fields or a `t.map(...)` — that is what aggregates and maps are for.
|
|
119
|
-
`.optional()`/`.encrypted()` compose under the usual field rules.
|
|
120
|
-
|
|
121
|
-
**Spatial pairing:** `geo.point()` is the blessed spatial-indexable shape.
|
|
122
|
-
The R*Tree indexes a TOP-LEVEL geometry field — so give the geometry its own
|
|
123
|
-
field (`point: GeoPoint.field()`) and declare `spatial(...).on("point")`. A
|
|
124
|
-
geometry nested inside another VO (`location.point`) is data, not an
|
|
125
|
-
indexable path (a spatial over a non-geo VO field refuses at compile).
|
|
126
|
-
`geo.lineString()` / `geo.polygon()` exist as typed VOs too.
|
|
127
|
-
|
|
128
|
-
VO fields come from a deliberately small set: `z.string()` · `z.number()` ·
|
|
129
|
-
`z.boolean()` · `z.enum([...])` · `z.array(<of these>)` · a nested value
|
|
130
|
-
object · `.optional()` on any. Anything richer refuses at compile — model it
|
|
131
|
-
as an aggregate. A `z.enum` field generates a REAL Dart enum
|
|
132
|
-
(`<VoName><Field>Enum`, `.wire` = the exact wire string) with a TOLERANT
|
|
133
|
-
decode: a wire value this client's law snapshot doesn't know decodes to the
|
|
134
|
-
`unknownWire` sentinel — law can add enum values without crashing old clients.
|
|
135
|
-
|
|
136
|
-
## Contracts: public DTOs for cross-domain references
|
|
137
|
-
|
|
138
|
-
When one domain references another's aggregate, reference a **contract** — a named
|
|
139
|
-
public shape (a DTO) — not the concrete aggregate. This keeps domains from importing
|
|
140
|
-
each other's private types (which tangles the package graph). A contract is fields,
|
|
141
|
-
no behavior:
|
|
142
|
-
|
|
143
|
-
```ts
|
|
144
|
-
// contracts.ts — the shared public shapes
|
|
145
|
-
export const SpaceLabelContract = contract("SpaceLabel", {
|
|
146
|
-
labelId: t.string(),
|
|
147
|
-
name: t.string(),
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
// space.ts — the concrete owner declares it satisfies the shape (compile-checked;
|
|
151
|
-
// a missing/mismatched field fails the build naming it). Private fields stay private.
|
|
152
|
-
export const SpaceLabel = aggregate("SpaceLabel", {
|
|
153
|
-
labelId: t.string().merge(Lww),
|
|
154
|
-
name: t.string().merge(Lww),
|
|
155
|
-
internalNotes: t.string().merge(Lww), // not in the contract — behind the boundary
|
|
156
|
-
}).implements(SpaceLabelContract);
|
|
157
|
-
|
|
158
|
-
// project.ts — reference the CONTRACT, never the concrete aggregate
|
|
159
|
-
export const ProjectTask = aggregate("ProjectTask", {
|
|
160
|
-
spaceWorkspaceName: t.string().merge(Lww),
|
|
161
|
-
label: t.foreignRef({ workspace: "spaceWorkspaceName", target: SpaceLabelContract, domain: "space" }),
|
|
162
|
-
});
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
The generated client types the reference as `ForeignRef<SpaceLabelContract>`;
|
|
166
|
-
the Dart DTO class lives in a shared `contracts.dart` every domain imports, so `project`
|
|
167
|
-
never imports `space`'s implementation. If two domains ever import each other's
|
|
168
|
-
concrete types, the compiler fails with a cycle diagnostic pointing you here. Contracts
|
|
169
|
-
are pure typing metadata — they never change an aggregate's law hash.
|
|
170
|
-
|
|
171
|
-
## Naming: the four generated tiers (and how to rename them)
|
|
172
|
-
|
|
173
|
-
One aggregate spawns several generated identities. Know the four tiers so you can name
|
|
174
|
-
each deliberately (the names below are the DEFAULTS, derived from your ids):
|
|
175
|
-
|
|
176
|
-
| tier | what it is | generated name |
|
|
177
|
-
|---|---|---|
|
|
178
|
-
| **Command payloads** | the typed input to a directive (`toPayloadJson()`) | `<Cap(directiveId)>Payload` — e.g. `RecordTaskPayload` |
|
|
179
|
-
| **Aggregate state** | the concrete decoded aggregate class | `<Cap(aggId)>` — e.g. `ProjectTask` |
|
|
180
|
-
| **Read models / projections** | the typed projection row (incl. derived/combined fields) | `<stem(aggId)>ReadModel` — e.g. `ProjectTaskReadModel` |
|
|
181
|
-
| **Value objects** | a named structured value (`valueObject`/`geo.*`) | the VO's own declared name, unprefixed |
|
|
182
|
-
|
|
183
|
-
The wire identity (aggregate id, stable ids, projection type tag) is separate from ALL of
|
|
184
|
-
these — names are labels, identity is minted. Renaming below never touches the law.
|
|
185
|
-
|
|
186
|
-
### `.dartName()` — declare the product name (Dart codegen only)
|
|
187
|
-
|
|
188
|
-
An aggregate id is often long for uniqueness (`ProjectTask`); the Dart product name you want
|
|
189
|
-
is short (`Task`). `.dartName("Task")` makes `Task` the PRIMARY generated Dart identity —
|
|
190
|
-
not a post-hoc alias:
|
|
191
|
-
|
|
192
|
-
```ts
|
|
193
|
-
export const ProjectTask = aggregate("ProjectTask", {
|
|
194
|
-
owner: t.string().merge(Lww),
|
|
195
|
-
name: t.string().merge(Lww),
|
|
196
|
-
}).public().dartName("Task");
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
Now EVERY Dart site renames consistently: the state class `Task`, the read model
|
|
200
|
-
`TaskReadModel`, the typed id `TaskId`, the by-id accessors `readTaskById`/`watchTaskById`,
|
|
201
|
-
`listTasks`, and any `ForeignRef<Task>`. The WIRE id stays `ProjectTask` (unchanged law,
|
|
202
|
-
unchanged stable ids — the minted-id tag is still `ProjectTask_<uuid>`). It is validated
|
|
203
|
-
at author time (must be a Dart type identifier `^[A-Z][A-Za-z0-9_]*$`; a collision with
|
|
204
|
-
another aggregate's name fails the compile). **TS codegen is deliberately unaffected** —
|
|
205
|
-
the generated `.client.ts` stays id-derived (`ProjectTaskData`), so there is no
|
|
206
|
-
cross-language identity confusion; `.dartName()` is a Dart-only presentation choice.
|
|
207
|
-
|
|
208
|
-
### Namespaces, not prefixes (multi-domain packages)
|
|
209
|
-
|
|
210
|
-
Each domain compiles to its OWN generated `.dart` library (`project.dart`, `space.dart`).
|
|
211
|
-
That IS Dart's namespace mechanism: two domains can BOTH declare a `Comment` and stay
|
|
212
|
-
unambiguous — `import 'package:x/src/generated/project.dart' as project;` then `project.Comment`.
|
|
213
|
-
The compiler never smashes a domain word into your class name. The top-level package barrel
|
|
214
|
-
flat-re-exports every domain's classes by default (`dart.packages.barrelExports: 'all'`);
|
|
215
|
-
for a multi-domain package where you'd rather force prefixed imports (so two `Comment`s never
|
|
216
|
-
silently share one flat scope), set `barrelExports: 'explicit'` — the barrel then exports
|
|
217
|
-
only the shared core and you import each domain with an `as` prefix. A single-domain package
|
|
218
|
-
is unaffected either way.
|
|
219
|
-
|
|
220
|
-
### The migration compat package
|
|
221
|
-
|
|
222
|
-
Renaming a name callers already import is a breaking change. `dart.packages.compat` gives
|
|
223
|
-
you a soft landing: a standalone, clearly-labeled package of pure `typedef`s over the real
|
|
224
|
-
types, deletable once callers have migrated. Map the OLD name each caller used to the new
|
|
225
|
-
generated name via `dart.names`, and route it to the compat package:
|
|
226
|
-
|
|
227
|
-
```js
|
|
228
|
-
// nomos.package.mjs
|
|
229
|
-
dart: {
|
|
230
|
-
support: "package",
|
|
231
|
-
packages: {
|
|
232
|
-
types: "task_tracker_types",
|
|
233
|
-
client: "task_tracker_client",
|
|
234
|
-
test: "task_tracker_test",
|
|
235
|
-
compat: "task_tracker_compat_names", // ← the migration-only shim
|
|
236
|
-
},
|
|
237
|
-
// ProjectTask.dartName("Task") → the read model is now `TaskReadModel`. Callers that
|
|
238
|
-
// imported the old `TaskRootAggregate` name keep compiling via the compat typedef.
|
|
239
|
-
names: { TaskReadModel: "TaskRootAggregate" },
|
|
240
|
-
}
|
|
241
|
-
```
|
|
242
|
-
|
|
243
|
-
The `task_tracker_compat_names` package then contains exactly:
|
|
244
|
-
|
|
245
|
-
```dart
|
|
246
|
-
// MIGRATION-ONLY — delete this package once callers have moved to the real names in
|
|
247
|
-
// `task_tracker_types`. …
|
|
248
|
-
import 'package:task_tracker_types/task_tracker_types.dart';
|
|
249
|
-
typedef TaskRootAggregate = TaskReadModel;
|
|
250
|
-
```
|
|
251
|
-
|
|
252
|
-
When `compat` is UNSET, the typedefs land in the types barrel exactly as before.
|
|
253
|
-
|
|
254
|
-
## Merge drivers ARE the conflict policy
|
|
255
|
-
|
|
256
|
-
| driver | concurrent writes to the same field… |
|
|
257
|
-
|---|---|
|
|
258
|
-
| `Lww` | last write wins (by the intent's HLC timestamp) |
|
|
259
|
-
| `AddWins` | sets union — concurrent adds ALL survive |
|
|
260
|
-
| `MapOf(Lww)` | per-key LWW — concurrent writers to different keys commute |
|
|
261
|
-
| `Conflict` | refuse to merge (fail-closed; the default if you tag nothing) |
|
|
262
|
-
|
|
263
|
-
You choose the policy per field, at authoring time. Nobody writes merge code
|
|
264
|
-
at 2am during an incident — the kernel applies the declared driver, the same
|
|
265
|
-
way on every peer.
|
|
266
|
-
|
|
267
|
-
## Directives: a zod payload → a pure plan
|
|
268
|
-
|
|
269
|
-
```ts
|
|
270
|
-
export const addBook = directive("addBook").creates(Book)
|
|
271
|
-
.payload(z.object({ title: z.string(), addedAt: z.string() }))
|
|
272
|
-
.plan((p) => { create(Book).set("title", p.title); return []; });
|
|
273
|
-
```
|
|
274
|
-
|
|
275
|
-
Plan ops: `set` (scalars/refs) · `addToSet` (the ONLY additive write to an
|
|
276
|
-
AddWins set — `set()` on a set field would overwrite the union and is REFUSED
|
|
277
|
-
at the type level and at runtime) · `setEntry` (one map key) · `strike`
|
|
278
|
-
(retract). `.creates(Agg)` mints the id — payloads never carry one;
|
|
279
|
-
`.mutates(Agg)` takes the instance id in the payload; `.ensures(Agg)` upserts
|
|
280
|
-
at a DETERMINISTIC id (next section).
|
|
281
|
-
|
|
282
|
-
## Upserts: `.ensures` — create-or-amend at a deterministic id
|
|
283
|
-
|
|
284
|
-
When the CALLER owns the identity (a reading keyed `probe:day`, a config row
|
|
285
|
-
keyed by name), mark the directive `.ensures(Agg)` and address the instance
|
|
286
|
-
with an id DERIVED FROM THE PAYLOAD — never minted, never guessed:
|
|
287
|
-
|
|
288
|
-
```ts
|
|
289
|
-
export const recordReading = directive("recordReading").ensures(Reading)
|
|
290
|
-
.payload(z.object({ probe: z.string(), day: z.string(), value: z.number().int() }))
|
|
291
|
-
.plan((p) => {
|
|
292
|
-
const r = instance(Reading, `reading:${p.probe}:${p.day}`);
|
|
293
|
-
return [set(r, "probe", p.probe), set(r, "day", p.day), set(r, "value", p.value)];
|
|
294
|
-
});
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
The first dispatch creates the row; a re-dispatch folds onto it IN PLACE under
|
|
298
|
-
the field merge drivers (Lww: a re-submission of the same value is a no-op, a
|
|
299
|
-
corrected value replaces it). That makes the write IDEMPOTENT — any host can
|
|
300
|
-
re-submit without double-counting. Collectors, crons, and retry loops live on
|
|
301
|
-
this.
|
|
302
|
-
|
|
303
|
-
## "Singletons" — minted record + natural-key query, NEVER a fixed row id
|
|
304
|
-
|
|
305
|
-
A common instinct is to model a one-of-a-kind record (a platform, a config, a
|
|
306
|
-
tenant) with a FIXED aggregate id — `create(Platform)` then address it forever
|
|
307
|
-
at `"the-platform"`. **The gate refuses this.** Every `create()` mints a
|
|
308
|
-
kernel id (`<TypeTag>_<uuidv7>`) and `check_create_ids` rejects a hand-written
|
|
309
|
-
id typed (`NotMinted`). Identity is minted; names are data.
|
|
310
|
-
|
|
311
|
-
So a singleton is **a minted record carrying its natural key as a FIELD, plus an
|
|
312
|
-
indexed query to find it by that key:**
|
|
313
|
-
|
|
314
|
-
```ts
|
|
315
|
-
export const Platform = aggregate("Platform", {
|
|
316
|
-
platformId: t.string().merge(Lww), // the natural key — a FIELD, not the row id
|
|
317
|
-
namespace: t.string().merge(Lww),
|
|
318
|
-
// … the rest of the platform's state
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
export const registerPlatform = directive("registerPlatform").creates(Platform)
|
|
322
|
-
.payload(z.object({ platformId: z.string().min(1), namespace: z.string().min(1) }))
|
|
323
|
-
.plan((p) => { create(Platform).set("platformId", p.platformId).set("namespace", p.namespace); return []; });
|
|
324
|
-
// ^ NO id in the payload — Nomos MINTS it. The typed client mints-when-omitted.
|
|
325
|
-
|
|
326
|
-
export const platformByPlatformId = query("platformByPlatformId").key("platformId").returns(Platform);
|
|
327
|
-
// ^ "the singleton" is read by its natural key, O(1) — never by a guessed row id.
|
|
328
|
-
```
|
|
329
|
-
|
|
330
|
-
The same pattern is how you model `namespaceId`, `providerId`, a config record
|
|
331
|
-
keyed by `name` — each a minted row + a `…By<NaturalKey>` query. If you truly
|
|
332
|
-
want create-or-amend semantics at a caller-owned key (and the natural key IS the
|
|
333
|
-
identity), use `.ensures` above instead — that lane DELIBERATELY addresses a
|
|
334
|
-
derived id and is the ONLY way a non-minted id is lawful.
|
|
335
|
-
|
|
336
|
-
### Multi-aggregate ensures: `withMarker`
|
|
337
|
-
|
|
338
|
-
A directive's marker covers its OWN target aggregate only. When the same plan
|
|
339
|
-
also upserts a SECOND aggregate (a sample updating its monthly meter in the
|
|
340
|
-
same intent), tag that aggregate's write explicitly with
|
|
341
|
-
`withMarker(op, "ensures")` — untagged fan-out rides as a mutate of a row
|
|
342
|
-
that may not exist yet:
|
|
343
|
-
|
|
344
|
-
```ts
|
|
345
|
-
export const recordSample = directive("recordSample").ensures(Sample)
|
|
346
|
-
.payload(/* … */)
|
|
347
|
-
.plan((p) => {
|
|
348
|
-
const s = instance(Sample, sampleId(p)); // the directive's .ensures target
|
|
349
|
-
const m = instance(Meter, meterId(p)); // the sibling upsert
|
|
350
|
-
return [
|
|
351
|
-
set(s, "value", p.value),
|
|
352
|
-
withMarker(set(m, "month", monthOf(p)), "ensures"), // ONE tagged op marks the whole aggregate's event
|
|
353
|
-
set(m, "monthToDate", p.monthToDate),
|
|
354
|
-
];
|
|
355
|
-
});
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
One tagged op per sibling aggregate is enough (conflicting markers for the
|
|
359
|
-
same aggregate refuse at encode, fail-closed). This is exactly how Nomos
|
|
360
|
-
Cloud's own usage tenant folds a reading AND its budget meter in ONE intent —
|
|
361
|
-
so the budget invariant judges both together: a reading that would leave the
|
|
362
|
-
meter over an unacked threshold refuses, and the sample refuses WITH it.
|
|
363
|
-
|
|
364
|
-
## Roles — sugar over relations (the kernel is the authority)
|
|
365
|
-
|
|
366
|
-
A `role(...)` is the highest-level authz surface: it LOWERS onto the ONE ReBAC
|
|
367
|
-
spine (relation tuples + the kernel gate). It is pure sugar — there is NO
|
|
368
|
-
role-array membership check anywhere; every capability is a relation tuple the
|
|
369
|
-
kernel judges.
|
|
370
|
-
|
|
371
|
-
```ts
|
|
372
|
-
import { role, aggregate, t, Lww, requires, directive, create } from "@githolon/dsl";
|
|
373
|
-
|
|
374
|
-
export const editor = role("editor"); // .edit / .view refs available immediately
|
|
375
|
-
|
|
376
|
-
export const Note = aggregate("Note",
|
|
377
|
-
{ author: t.string().merge(Lww), body: t.string().merge(Lww) },
|
|
378
|
-
{ visibility: requires(editor.view) }); // Note is private behind the role's VIEW cap
|
|
379
|
-
|
|
380
|
-
// EMIT the role's grant/revoke/seed directives + relation schema (auto-discovered by shape):
|
|
381
|
-
export const noteRoles = editor.canEdit(Note).canView(Note).grantableBy("admin").emit();
|
|
382
|
-
|
|
383
|
-
// Wire the role's EDIT ref onto a mutating directive — only an editor may write:
|
|
384
|
-
export const editNote = directive("editNote")
|
|
385
|
-
.creates(Note)
|
|
386
|
-
.payload(z.object({ author: z.string().min(1), body: z.string().min(1) }))
|
|
387
|
-
.plan((p) => { create(Note).set("author", p.author).set("body", p.body); return []; })
|
|
388
|
-
.requires(editor.edit);
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
What `editor…emit()` gives you:
|
|
392
|
-
|
|
393
|
-
- **Relation schema with TEETH** — the `editorEdit` write relation is authored
|
|
394
|
-
into the law, so `.requires(editor.edit)` never fails open (the fail-open
|
|
395
|
-
footgun is closed); `editorView` is a read-visibility cap (wire it as an
|
|
396
|
-
aggregate `requires(editor.view)`).
|
|
397
|
-
- **Grant / revoke / seed directives** — `grantEditor` / `revokeEditor` seat or
|
|
398
|
-
remove the role's relation tuple, gated `.requires("admin")` (the kernel judges
|
|
399
|
-
the granter). `seedEditor` seats the `admin` grant-authority on a founding
|
|
400
|
-
principal at genesis/birth (ungated seed lane).
|
|
401
|
-
- **Typed policy refs** — `editor.edit` / `editor.view` (never strings) for
|
|
402
|
-
`.requires(...)` and aggregate visibility.
|
|
403
|
-
- **`canEdit<Agg>` / `canView<Agg>` codegen** (TS + Dart) — UI-hint helpers that
|
|
404
|
-
mirror the kernel's check over the folded tuples. **INFORMATIONAL only — the one
|
|
405
|
-
wasm gate is the authority and refuses regardless of what the helper returns.**
|
|
406
|
-
Use them to grey out a button, never to decide access.
|
|
407
|
-
|
|
408
|
-
Roles are workspace-scoped (the read-visibility gate is workspace-scoped in the
|
|
409
|
-
kernel). Per-record grants stay available via the lower-level
|
|
410
|
-
`.requires(rel, { objectFrom })` axis. `grantableBy` defaults to `admin`.
|
|
411
|
-
|
|
412
|
-
## Determinism or death
|
|
413
|
-
|
|
414
|
-
A plan is a PURE function of its payload. No `Date.now()`, no
|
|
415
|
-
`Math.random()`, no I/O — the sandbox traps them. Timestamps ride IN the
|
|
416
|
-
payload as ISO strings, stamped by the caller. Why so strict: every peer
|
|
417
|
-
re-runs your plan and byte-compares the result; one nondeterministic call and
|
|
418
|
-
admission fails everywhere, forever.
|
|
419
|
-
|
|
420
|
-
## Declared reads — name them, never scan
|
|
421
|
-
|
|
422
|
-
- `query("booksByShelf").key("shelf").returns(Book)` — an indexed probe.
|
|
423
|
-
- `count("booksPerShelf").of(Book).by("shelf")` — a maintained O(1) tally.
|
|
424
|
-
- `sum("stockValue", "price").of(Book).by("shelf")` — count's numeric sibling:
|
|
425
|
-
a maintained running total of an int field; `.where(p => …)` filters, `.by`
|
|
426
|
-
groups. Never a `SUM(*)` scan.
|
|
427
|
-
- `derived("isLong").of(Book).returns(z.boolean()).as((b) => …)` — a PURE
|
|
428
|
-
engine-projected read field. Lives only in the read model, never in the
|
|
429
|
-
ledger, so it is always re-derivable.
|
|
430
|
-
|
|
431
|
-
Export each at top level; `githolon compile` auto-discovers them by shape and
|
|
432
|
-
routes them into the read manifest so they work at the edge AND in clients.
|
|
433
|
-
After a compile, read `build/<pkg>.summary.txt` — it lists every aggregate,
|
|
434
|
-
directive, query, count, and sum you actually built; if a declared read is
|
|
435
|
-
missing there, it is missing everywhere.
|
|
436
|
-
|
|
437
|
-
Next: [03-client.md](./03-client.md) — driving it from an app.
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
# The client
|
|
2
|
-
|
|
3
|
-
`test/e2e.mts` IS the tutorial — every claim below runs live in it. This page
|
|
4
|
-
is the map.
|
|
5
|
-
|
|
6
|
-
## connect()
|
|
7
|
-
|
|
8
|
-
```ts
|
|
9
|
-
import { connect } from "@githolon/client";
|
|
10
|
-
import { todoClient } from "../build/<app>.client.ts";
|
|
11
|
-
|
|
12
|
-
const holon = await connect({ cloud, workspace, clientId });
|
|
13
|
-
const app = todoClient(holon);
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
`connect` pulls the wasm, the workspace manifests, and the ledger, then runs
|
|
17
|
-
the byte-identical holon LOCALLY. `clientId` names your session branch
|
|
18
|
-
(`session/<clientId>`) — one per writer. Everything after connect works
|
|
19
|
-
offline; the e2e proves it by trapping `fetch` while it authors and queries.
|
|
20
|
-
|
|
21
|
-
## The generated client contract
|
|
22
|
-
|
|
23
|
-
`githolon compile` emits `build/<pkg>.client.ts`: a typed method per directive
|
|
24
|
-
(payload types from the SAME zod the engine validates), a read-model interface
|
|
25
|
-
per aggregate (every field optional — partial folds are normal), typed
|
|
26
|
-
accessors per declared query/count/sum, by-id read and watch helpers, and the
|
|
27
|
-
deployed law's content hash baked in. Zero imports — it binds structurally to
|
|
28
|
-
`connect()`'s holon. If the cloud's law hash differs from the client's, you
|
|
29
|
-
compiled different bytes; recompile rather than wonder.
|
|
30
|
-
|
|
31
|
-
The raw surface stays underneath: `holon.dispatch(...)`, `holon.query(id,
|
|
32
|
-
params)`, `holon.queryById(id)`, `holon.count(id, group)`, `holon.sum(id,
|
|
33
|
-
group)`. Hover anything in your editor — `@githolon/client` ships full types
|
|
34
|
-
and the semantics live in the doc comments.
|
|
35
|
-
|
|
36
|
-
## Many workspaces at once (the normal shape)
|
|
37
|
-
|
|
38
|
-
Everything about Nomos is multi-workspace: a real app holds a dozen+ live
|
|
39
|
-
local workspaces (the user's home, each space, each project). Don't build
|
|
40
|
-
on a single-connection assumption — hold one PLANE and many handles on it.
|
|
41
|
-
The four-layer split (the blessed shape):
|
|
42
|
-
|
|
43
|
-
- **Engine** (`NomosEngine`, the bridge root / `openRealm`) — ONLY the
|
|
44
|
-
runtime: mount/connect, the session registry, sync, custody, `follow(ref)`.
|
|
45
|
-
Never the surface for domain writes.
|
|
46
|
-
- **Session** — exactly one workspace, stateful + inspectable.
|
|
47
|
-
- **Generated client** — exactly one session + one domain interface + one
|
|
48
|
-
actor context. No workspace/domain/directive strings at call sites, ever.
|
|
49
|
-
- **Stateless values** — payloads, VOs, `NomosRef`s, read rows, outcomes.
|
|
50
|
-
|
|
51
|
-
- **TS/JS**: `openRealm` mounts N workspaces in ONE engine; give each
|
|
52
|
-
generated client its own handle:
|
|
53
|
-
`const realm = await openRealm({ cloud }); const project = projectClient(await realm.workspace("project-…"));`
|
|
54
|
-
(`connect()` remains the one-workspace sugar over the same machinery.)
|
|
55
|
-
- **Flutter/Dart** — session-bound clients, preflighted at construction:
|
|
56
|
-
|
|
57
|
-
```dart
|
|
58
|
-
final engine = NomosBridge(transport); // the device's local plane
|
|
59
|
-
await engine.ready;
|
|
60
|
-
final home = await engine.open(cloud: cloud, workspace: homeWs);
|
|
61
|
-
final homeClient = await HomeClient.bind(home); // ASYNC preflight (see below)
|
|
62
|
-
|
|
63
|
-
final platform = await engine.open(cloud: cloud, workspace: platformWs);
|
|
64
|
-
final platformClient = await PlatformClient.bind(platform);
|
|
65
|
-
|
|
66
|
-
// One business birth; the parent is the bound session and Nomos owns the ceremony:
|
|
67
|
-
final projectRef = await platformClient.birth(project);
|
|
68
|
-
// NomosRef is THE cross-workspace primitive — follow it, bind the next client:
|
|
69
|
-
final project = await engine.follow(projectRef);
|
|
70
|
-
final projectClient = await ProjectClient.bind(project);
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
`XClient.bind(session)` verifies the session's workspace is reachable, holds
|
|
74
|
-
the client's domain, and serves a structurally compatible interface (#72
|
|
75
|
-
shape-compat) — an incompatible session throws a typed
|
|
76
|
-
`DomainClientSessionMismatch` (session, expected/active domain keys +
|
|
77
|
-
hashes, the classified cause, a fix hint) AT CONSTRUCTION, not two clicks
|
|
78
|
-
into the app. The synchronous `XClient(session)` constructor remains for
|
|
79
|
-
codepaths that can't await; it runs the SAME preflight lazily on the first
|
|
80
|
-
dispatch. `session.assertCompatible(XClient.interface)` is the same check as
|
|
81
|
-
a bare assertion (handy in tests). A dispatch that names a domain the
|
|
82
|
-
workspace doesn't run surfaces as a typed `DomainNotInstalledForSession`
|
|
83
|
-
naming what the workspace ACTUALLY runs and the likely causes — never the
|
|
84
|
-
bare kernel string.
|
|
85
|
-
|
|
86
|
-
The engine-level generic writes (`bridge.offerDirective(...)`,
|
|
87
|
-
`bridge.createWorkspace(parent: ...)`) are `@Deprecated` — CLI/admin tooling
|
|
88
|
-
lanes only. App code dispatches through a bound client.
|
|
89
|
-
|
|
90
|
-
A workspace opened with no `cloud` is LOCAL-ONLY (never converges upstream);
|
|
91
|
-
cloud sync is a per-workspace posture, and one plane freely mixes both.
|
|
92
|
-
|
|
93
|
-
## sync()
|
|
94
|
-
|
|
95
|
-
```ts
|
|
96
|
-
const s = await holon.sync({ admit: true });
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
One call: push unpushed intents to your session branch, ask the edge to judge
|
|
100
|
-
now, pull canonical main, rebase-replay anything still unacked (intent-id
|
|
101
|
-
deduped — nothing ever double-folds). `s.admission` is `null` when there was
|
|
102
|
-
nothing to push — normal, not an error. Without `{admit: true}` a push still
|
|
103
|
-
lands within ~2s: the edge self-schedules admission. `holon.pull()` converges
|
|
104
|
-
in place on demand; no reconnects, ever.
|
|
105
|
-
|
|
106
|
-
## watch
|
|
107
|
-
|
|
108
|
-
`app.watch<Agg>ById(id, (rows) => …)` is a LOCAL reactive read — it fires on
|
|
109
|
-
local folds and on pulls. Render from watches; treat sync as background
|
|
110
|
-
reconciliation, not as the read path.
|
|
111
|
-
|
|
112
|
-
## Dead letters — refused work is never lost
|
|
113
|
-
|
|
114
|
-
A DOMAIN rejection (the law couldn't admit it — e.g. its domain isn't deployed
|
|
115
|
-
yet) parks the FULL intent on both sides: `holon.deadLetters()` locally
|
|
116
|
-
(durable — it rides `holon.export()`/restore) and the workspace DLQ in the
|
|
117
|
-
cloud. Ship a law fix through the deploy lane, then `holon.retryDeadLetter(id)`
|
|
118
|
-
— the user's work lands on main. `discardDeadLetter(id)` is the app's explicit
|
|
119
|
-
choice; nothing is silently dropped. Obvious attacks (session-lane law
|
|
120
|
-
intents) are dropped, never queued.
|
|
121
|
-
|
|
122
|
-
## Two writers, no coordination
|
|
123
|
-
|
|
124
|
-
The canonical concurrency demo (e2e step 9): two `connect()`s with DIFFERENT
|
|
125
|
-
clientIds tag the same entry offline, blind to each other. Both sync; the
|
|
126
|
-
AddWins union keeps every add. No locks, no "last writer wins the whole
|
|
127
|
-
record", no merge code — the field's declared driver did it
|
|
128
|
-
(see [02-authoring.md](./02-authoring.md)).
|
|
129
|
-
|
|
130
|
-
## Typed-client patterns (the three gotchas)
|
|
131
|
-
|
|
132
|
-
Building with the generated typed client (`XClient.bind(session)` — no string
|
|
133
|
-
directive ids), three things trip people up once and never again:
|
|
134
|
-
|
|
135
|
-
- **Success = `outcome.ok`.** A directive method returns a `NomosOfferOutcome`;
|
|
136
|
-
`outcome.ok` is true when it committed (a refusal *throws* / surfaces its typed
|
|
137
|
-
law error, it doesn't return a false outcome). For a `.creates` you also get the
|
|
138
|
-
minted id back with no query round-trip; for a `.births` you get `outcome.born`.
|
|
139
|
-
- **Timestamps are caller-stamped — pass them.** Payload fields like `createdAt` /
|
|
140
|
-
`bornAt` are `required` BY DESIGN, not an oversight: a plan is a pure function of
|
|
141
|
-
its payload and reads no clock (determinism — every peer replays the same bytes).
|
|
142
|
-
Stamp `DateTime.now().toUtc().toIso8601String()` at the call site. This is the
|
|
143
|
-
timestamp doctrine, not friction to remove.
|
|
144
|
-
- **Apps hold MANY workspaces open at once — that is the model, not an edge case.**
|
|
145
|
-
`bridge.session(ws)` returns a live session from the REALM (one engine, N mounted
|
|
146
|
-
workspaces, LRU park/remount above a budget). A Space AND a Project AND the home
|
|
147
|
-
are all open concurrently; there is no singleton "current workspace" (that surface
|
|
148
|
-
would be the bug). See [10-flutter-layering.md](./10-flutter-layering.md).
|
|
149
|
-
- **A cross-workspace `ForeignRef` has TWO read paths — don't confuse them.**
|
|
150
|
-
`task.space` (a `ForeignRef<SpaceOwnerData>`) is a typed LOCATOR.
|
|
151
|
-
- **App/presentation read:** `await task.space.resolve(nomos)` / `.watch(nomos)`
|
|
152
|
-
reads the target THROUGH the open realm — instant when that Space is already a
|
|
153
|
-
live session (it usually is), a mount otherwise. This is live UI data, not law.
|
|
154
|
-
- **Law read (a plan's DECISION depends on the foreign fact):** never a live
|
|
155
|
-
resolve — declare `trustSource` + `.readsFrom(...)` so the fact is CAPTURED onto
|
|
156
|
-
the intent and re-verified on every replay (the captured-ports / attested-read
|
|
157
|
-
doctrine, deterministic). `foreignRef` also generates `.attest(nomos)` for that
|
|
158
|
-
evidence. Separate ledgers mean readability is policy, not a gate-checked FK —
|
|
159
|
-
see [09-attested-reads.md](./09-attested-reads.md).
|
|
160
|
-
|
|
161
|
-
## Persistence
|
|
162
|
-
|
|
163
|
-
`holon.export()` → bytes; `connect({ restoreFrom: bytes })` restores. Pending
|
|
164
|
-
un-synced writes survive an app reload and still sync.
|
|
165
|
-
|
|
166
|
-
Next: [04-cloud.md](./04-cloud.md) — workspaces, identity, quotas.
|