create-githolon 0.98.3 → 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.
@@ -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
- // A `.births()` directive takes NO parent — the parent IS the session workspace:
67
- final birth = await platformClient.birthProjectWorkspace(payload: order);
68
- // NomosRef is THE cross-workspace primitive — follow it, bind the next client:
69
- final project = await engine.follow(birth.bornRefs.single);
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.