create-githolon 0.6.0 → 0.7.1

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 CHANGED
@@ -55,9 +55,16 @@ cpSync(TEMPLATE, targetDir, { recursive: true });
55
55
  // and the scaffold restores the dot.
56
56
  renameSync(path.join(targetDir, "gitignore"), path.join(targetDir, ".gitignore"));
57
57
 
58
- for (const f of ["package.json", "README.md"]) {
58
+ // Parameterize the scaffold to the app name. The generated typed client + its baked
59
+ // hash const are NAMED off the package name (`__APP_NAME__`): the artifact filename
60
+ // keeps the raw name (build/<app>.client.ts), the hash SYMBOL normalizes it to
61
+ // UPPER_SNAKE (the same transform `githolon compile` uses — see codegen_ts.ts). The
62
+ // client FACTORY (`todoClient`) is named off the DOMAIN key, which stays `todo`.
63
+ const appHash = `${appName.replace(/[-\s]+/g, "_").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase()}_DOMAIN_HASH`;
64
+ const subst = (s) => s.replaceAll("__APP_NAME__", appName).replaceAll("__APP_HASH__", appHash);
65
+ for (const f of ["package.json", "README.md", "nomos.package.mjs", "domains/todo.ts", "test/e2e.mts"]) {
59
66
  const p = path.join(targetDir, f);
60
- writeFileSync(p, readFileSync(p, "utf8").replaceAll("__APP_NAME__", appName), "utf8");
67
+ if (existsSync(p)) writeFileSync(p, subst(readFileSync(p, "utf8")), "utf8");
61
68
  }
62
69
 
63
70
  const git = (cmd) => spawnSync("git", cmd, { cwd: targetDir, stdio: "ignore" }).status === 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-githolon",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Scaffold a Nomos domain package: the starter domain + compile config + live e2e. `npm create githolon my-app`.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -24,7 +24,7 @@ real workspace afterwards.
24
24
 
25
25
  ```bash
26
26
  npx githolon ws create <ws> # births a fresh workspace on your identity; SAVES its one-time secret to ~/.holon
27
- npx githolon deploy <ws> # POSTs build/guestbook.deploy.json with the stored secret
27
+ npx githolon deploy <ws> # POSTs build/__APP_NAME__.deploy.json with the stored secret
28
28
  npm run e2e # the full loop, live: offline write → sync → admission → cloud query
29
29
  ```
30
30
 
@@ -55,14 +55,14 @@ git just works against the cloud.)
55
55
 
56
56
  ## Make it YOURS — the five-minute walkthrough
57
57
 
58
- The starter is the tutorial; this is the exact path from guestbook to your own law.
58
+ The starter is the tutorial; this is the exact path from the todo list to your own law.
59
59
 
60
60
  1. **Rename the law file** and reshape it:
61
- `git mv domains/guestbook.ts domains/<yours>.ts` — keep the patterns
62
- (aggregate fields each tagged a merge driver; an optional DECLARED INVARIANT
63
- on the aggregate the guestbook's max-10-tags rule shows the shape, judged
64
- post-fold at the one gate with a typed refusal; directives = zod payload
65
- pure plan; `addToSet` for AddWins sets `set()` on a set field is refused).
61
+ `git mv domains/todo.ts domains/<yours>.ts` — keep the patterns
62
+ (aggregate fields each tagged a merge driver `Lww` scalar, `AddWins` set;
63
+ directives = zod payload pure plan; `addToSet` for AddWins sets — `set()` on
64
+ a set field is refused; `done` is a 2-value Lww enum because there is no
65
+ `t.bool()`; sharing is one native Zanzibar tuple via `writeTuple`).
66
66
  2. **Point the compile config at it** — `nomos.package.mjs`:
67
67
  ```js
68
68
  export default { name: "<pkg>", domains: [{ key: "<pkg>", modules: ["./domains/<yours>.ts"] }] };
@@ -83,13 +83,15 @@ The starter is the tutorial; this is the exact path from guestbook to your own l
83
83
  reference until it's clean.
84
84
  5. **Prove it live:** `npm run e2e` — your law deploys to a throwaway workspace,
85
85
  an offline write syncs through admission, the cloud answers your declared
86
- query and count, and two blind clients prove the AddWins merge.
86
+ query and count, and one native sharing tuple is recorded. (Prove it OFFLINE
87
+ first with `npx githolon proof` — the generated proof on a local holon, no
88
+ cloud.)
87
89
 
88
90
  Then deploy it for real: `npx githolon login --agent && npx githolon ws create <ws> && npx githolon deploy <ws>`.
89
91
 
90
92
  ## Docs — in the box
91
93
 
92
- `docs/` travels with you — offline, like everything else here. Six short
94
+ `docs/` travels with you — offline, like everything else here. Seven short
93
95
  pages, each under a screen and a half:
94
96
 
95
97
  1. [The mental model](./docs/01-mental-model.md) — holons, content-addressed
@@ -107,18 +109,22 @@ pages, each under a screen and a half:
107
109
  6. [Evolving law](./docs/06-law-evolution.md) — what's safe to change once
108
110
  real data exists (add freely; rename/retype honestly), the era rule, and
109
111
  the one trap: old clients after a breaking upgrade.
112
+ 7. [Security](./docs/07-security.md) — private aggregates (`requires(cap)` +
113
+ grant/revoke, the holon gates its own reads) and operator-blind E2E
114
+ encrypted fields (`.encrypted()`, the key handshake, `githolon decrypt`).
110
115
 
111
116
  ## What's here
112
117
 
113
- - `docs/` — the six pages above; the offline reference.
114
- - `domains/guestbook.ts` — the starter domain (the law): one aggregate, two
115
- directives, a declared query, a derived field, a count. Rename it, reshape
116
- it, or add domains with `npx githolon generate domain <name>`.
118
+ - `docs/` — the seven pages above; the offline reference.
119
+ - `domains/todo.ts` — the starter domain (the law): a shared, offline-first todo
120
+ list — two aggregates, the directives, a declared query, a where-filtered count,
121
+ and one native Zanzibar sharing tuple. Rename it, reshape it, or add domains
122
+ with `npx githolon generate domain <name>`.
117
123
  - `nomos.package.mjs` — the compile config (module list per domain key; reads
118
124
  are auto-discovered from exports).
119
- - `build/guestbook.client.ts` — GENERATED typed TS client (payload types from
125
+ - `build/__APP_NAME__.client.ts` — GENERATED typed TS client (payload types from
120
126
  the engine's zod, read models from the field kinds, the law hash baked in).
121
- - `build/guestbook.proof.mts` — GENERATED runnable proof, synthesized from your
127
+ - `build/__APP_NAME__.proof.mts` — GENERATED runnable proof, synthesized from your
122
128
  own directives/queries/counts on every compile; `npx githolon proof` runs it.
123
129
  - `test/e2e.mts` — proves compile → deploy → typed offline write/query →
124
130
  edge admission → cloud declared query, against the live cloud.
@@ -4,7 +4,7 @@ You write exactly TWO things: aggregates and directives. Never apply/fold/
4
4
  merge code — the kernel owns folding; your directive PLANS ops and the sealed
5
5
  engine replays them deterministically on every peer. Declared reads (query,
6
6
  count, derived, sum) are auto-discovered from your module's exports by shape.
7
- `domains/guestbook.ts` demonstrates the core patterns below; reshape it.
7
+ `domains/todo.ts` demonstrates the core patterns below; reshape it.
8
8
 
9
9
  ## Aggregates: typed fields, each tagged a merge driver
10
10
 
@@ -7,10 +7,10 @@ is the map.
7
7
 
8
8
  ```ts
9
9
  import { connect } from "@githolon/client";
10
- import { guestbookClient } from "../build/guestbook.client.ts";
10
+ import { todoClient } from "../build/<app>.client.ts";
11
11
 
12
12
  const holon = await connect({ cloud, workspace, clientId });
13
- const app = guestbookClient(holon);
13
+ const app = todoClient(holon);
14
14
  ```
15
15
 
16
16
  `connect` pulls the wasm, the workspace manifests, and the ledger, then runs
@@ -63,4 +63,4 @@ is for reconciliation only.
63
63
  is the complete, verifiable history of the workspace.
64
64
 
65
65
  Back to the [README](../README.md) — or start reshaping
66
- `domains/guestbook.ts` ([02-authoring.md](./02-authoring.md)).
66
+ `domains/todo.ts` ([02-authoring.md](./02-authoring.md)).
@@ -0,0 +1,176 @@
1
+ # Security: private reads & operator-blind encryption
2
+
3
+ Two opt-in layers, both e2e-proven, both **era-ruled** — a domain that uses
4
+ neither pays nothing, and a chain sealed before these features replays
5
+ byte-identically forever. You add them field by field; nothing else changes.
6
+
7
+ The throughline: **the githolon enforces its own access**. Not the host, not a
8
+ server's `if (user.canRead)` — the same byte-identical wasm gate every peer runs.
9
+ A read you aren't entitled to is refused by the holon itself, on every replica.
10
+
11
+ ## Layer 1 — private aggregates: the holon gates its own reads
12
+
13
+ Make a type private and a read touching it is served only to a principal the
14
+ workspace **granted** the capability. `role ≡ cap` in v1 — the role you grant is
15
+ the cap a read requires.
16
+
17
+ ```ts
18
+ import {
19
+ aggregate, t, Lww, requires, userPermissionAggregate, grant, revoke, directive, query, z,
20
+ } from "@githolon/dsl";
21
+
22
+ // PUBLIC — anyone reads (the control: privacy must not change how public serves).
23
+ export const Announcement = aggregate("Announcement", { text: t.string().merge(Lww) });
24
+
25
+ // PRIVATE — a read of Note needs the `readNote` cap.
26
+ export const Note = aggregate("Note",
27
+ { owner: t.string().merge(Lww), body: t.string().merge(Lww) },
28
+ { visibility: requires("readNote") });
29
+
30
+ // The framework permission aggregate — EXPORT it, so grants fold into THIS chain
31
+ // (the bindings the read gate reads). Forgetting this is the #1 mistake.
32
+ export const UserPermission = userPermissionAggregate;
33
+
34
+ // The owner bestows the cap on a reader. Authored through the secret-gated
35
+ // `/author` lane (or `githolon author`) — only the owner can grant.
36
+ export const grantReader = directive("grantReader").ensures(UserPermission)
37
+ .payload(z.object({ principal: z.string(), grantedBy: z.string(), grantedAt: z.string() }))
38
+ .plan((p) => grant({
39
+ userId: p.principal, scope: ["workspace", "notes"], role: "readNote",
40
+ grantedBy: p.grantedBy, grantedAt: p.grantedAt,
41
+ }));
42
+
43
+ // Revoke flips that same binding to status:revoked (forward-only, remove-wins).
44
+ export const revokeReader = directive("revokeReader").ensures(UserPermission)
45
+ .payload(z.object({
46
+ principal: z.string(), grantedBy: z.string(), grantedAt: z.string(),
47
+ revokedBy: z.string(), revokedAt: z.string(),
48
+ }))
49
+ .plan((p) => revoke({
50
+ userId: p.principal, scope: ["workspace", "notes"], role: "readNote",
51
+ grantedBy: p.grantedBy, grantedAt: p.grantedAt, revokedBy: p.revokedBy, revokedAt: p.revokedAt,
52
+ }));
53
+
54
+ export const notesByOwner = query("notesByOwner").key("owner").returns(Note); // inherits the gate
55
+ ```
56
+
57
+ How it behaves:
58
+
59
+ - **Who decides.** The read gate runs in the wasm against the workspace's own
60
+ folded role-bindings — never the host. Every peer (the cloud edge included)
61
+ re-derives the same verdict.
62
+ - **Two grains.** *Granular* reads (`query`, `queryById`) need the **specific**
63
+ cap. *Bulk* reads — cloning `main`, `snapshot`, `count`, `sum` — need **any**
64
+ declared cap and reveal everything (pulling `main` hands over the whole ledger;
65
+ that's the deal). A fully public workspace clones openly, as before.
66
+ - **Who you are.** A read carries the principal (an `x-nomos-auth` token, verified;
67
+ or the transitional `x-nomos-principal`). No principal ⇒ a private read is
68
+ refused, fail-closed.
69
+ - **Revocation is honest.** Remove-wins: a revoked principal can never be
70
+ re-granted by a replayed grant. The post-revoke read re-derives authority from
71
+ the maintained projection — cost scales with the number of *identities*, not
72
+ your data volume (revoking one reader over 10k private records stays fast).
73
+
74
+ The owner grants through the secret-gated lane; your end-user app carries **no
75
+ secret** — its writes are judged by edge admission, and it simply can't author a
76
+ grant. Authority enters only through the owner.
77
+
78
+ ## Layer 2 — encrypted fields: operator-blind, end-to-end
79
+
80
+ Mark a field `.encrypted()` and its value is sealed **on the client** before it
81
+ ever reaches the cloud. The operator stores and serves ciphertext; only a
82
+ key-holder can read it. This is RFC 9420-style group keys mapped onto the ledger:
83
+ per-scope keys, distributed as on-chain wrapped-key events, revocation as
84
+ forward-only key rotation.
85
+
86
+ ```ts
87
+ import {
88
+ aggregate, t, Lww, requires, directive, create, query, z,
89
+ userPermissionAggregate, grant,
90
+ KeyScope, WrappedKey, advanceKeyEpoch, wrapKey, wrappedKeysByRecipient,
91
+ } from "@githolon/dsl";
92
+
93
+ export const Note = aggregate("Note",
94
+ { owner: t.string().merge(Lww), body: t.string().merge(Lww).encrypted() }, // body is E2E-encrypted
95
+ { visibility: requires("readNote") }); // .encrypted() REQUIRES a private aggregate (the cap is the scope)
96
+
97
+ export const UserPermission = userPermissionAggregate;
98
+ export const KS = KeyScope; // the per-scope key head — export so it folds
99
+ export const WK = WrappedKey; // the on-chain wrapped keys — export so it folds
100
+ export const advanceEpoch = advanceKeyEpoch; // the key rotation — export so it routes
101
+ export const wrap1 = wrapKey;
102
+ export const wraps = wrappedKeysByRecipient;
103
+
104
+ export const writeNote = directive("writeNote").creates(Note)
105
+ .payload(z.object({ owner: z.string(), body: z.string() })) // `body` is the PRE-SEALED envelope
106
+ .plan((p) => { create(Note).set("owner", p.owner).set("body", p.body); return []; });
107
+
108
+ export const grantReader = directive("grantReader").ensures(UserPermission)
109
+ .payload(z.object({ principal: z.string(), grantedBy: z.string(), grantedAt: z.string() }))
110
+ .plan((p) => grant({ userId: p.principal, scope: ["workspace", "w"], role: "readNote",
111
+ grantedBy: p.grantedBy, grantedAt: p.grantedAt }));
112
+
113
+ export const notesByOwner = query("notesByOwner").key("owner").returns(Note);
114
+ ```
115
+
116
+ `.encrypted()` is only legal on a **private** (`requires(cap)`) aggregate — the
117
+ cap *is* the encryption scope — and only on an **Lww-class** field (the value is
118
+ a ciphertext envelope replaced wholesale). `githolon compile` refuses otherwise,
119
+ naming the fix.
120
+
121
+ ### The handshake (operator-blind)
122
+
123
+ The cloud never sees a key or a plaintext. The dance:
124
+
125
+ 1. **The reader makes a device key.** An X25519 keypair (`cryptoKeygen`); the
126
+ secret stays on the device, the public key is published.
127
+ 2. **The owner grants the cap** (`grantReader`) — opens the read gate *and* adds
128
+ the reader to the scope's key roster.
129
+ 3. **The owner advances the epoch** (`advanceKeyEpoch`), wrapping the scope key
130
+ to every member's public key (computed client-side, `cryptoWrapKey`). The
131
+ fan-out gate verifies the wraps cover exactly the surviving roster — no missing
132
+ member (lost access), no extra (a leak).
133
+ 4. **The author seals the field** — resolve the `(type, field)` stable ids from
134
+ the law, `crypto_seal_field` with the scope key → an envelope, write the
135
+ envelope as the field value.
136
+ 5. **The reader unwraps and reads** — `cryptoUnwrapKey` with the device secret
137
+ recovers the scope key; inject it on the read and the field comes back
138
+ decrypted. Without the key it stays an opaque envelope.
139
+
140
+ A standalone proof of the whole loop ships as a dev tool:
141
+
142
+ ```bash
143
+ githolon decrypt <ws> --as <principal> --secret <b64> --query notesByOwner
144
+ ```
145
+
146
+ It clones the ledger, unwraps that principal's scope keys from the on-chain
147
+ wrapped keys, and runs the query **decrypted** — the operator-blind ledger is
148
+ plaintext only to a key-holder, and this proves it as that holder.
149
+
150
+ ### What to keep in mind
151
+
152
+ - **Revocation is forward-only.** Removing a reader advances the epoch and
153
+ re-wraps to the survivors; old ciphertext is *not* re-encrypted. A removed
154
+ member can still read what they were already shown — "no new reads" is the only
155
+ honest semantic over an append-only log.
156
+ - **Metadata is public.** Who is a member, how many, ciphertext sizes, write
157
+ timing, and any plaintext field you `count` / `sum` / index *by* — all visible.
158
+ Encrypt the secret value, not the thing you group by.
159
+ - **Renames are safe.** The envelope carries the field's stable id, so renaming
160
+ an encrypted field (or its aggregate) keeps old ciphertext decryptable under the
161
+ new label. A true removal or retype still needs the evolve-gate dispositions
162
+ (see `06-law-evolution.md`).
163
+ - **Never write plaintext into an encrypted field.** The gate refuses anything
164
+ that isn't a valid envelope sealed to the declared slot.
165
+ - **Ergonomics today.** The generated client types the payloads but does not yet
166
+ seal/unwrap for you — drive the seal and the key injection through the holon's
167
+ crypto ops (the reference flow is small; ask if you want a helper wired into the
168
+ generated client).
169
+
170
+ ## Defense in depth, not a panic button
171
+
172
+ Per-scope keys mean a client only ever *holds* keys for scopes it may read — so
173
+ leaking your own authorized view is harmless, and holding a key for data you were
174
+ never granted simply can't happen. A device keystore (non-extractable WebCrypto on
175
+ web, the OS keystore on native) is real hardening on top, but the load-bearing
176
+ guarantee is the key scoping, not the enclave.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * __APP_NAME__ — a shared, offline-first todo list. The smallest domain that shows the
3
+ * magic: two people edit the SAME list while BOTH offline, blind to each other, and
4
+ * converge byte-identically when they sync — because the merge is the LAW (kernel-folded
5
+ * CRDT), not the app's job. You write only aggregates + directives; the engine does the
6
+ * rest. Rename it, add fields, add directives — it's yours from the first byte.
7
+ */
8
+ import {
9
+ z, aggregate, t, Lww, AddWins, directive, create, instance, set, addToSet,
10
+ query, count, writeTuple, RelationTuple, relationTuplesByObject,
11
+ } from "@githolon/dsl";
12
+
13
+ // ── what we store ─────────────────────────────────────────────────────────────
14
+ export const TodoList = aggregate("TodoList", {
15
+ title: t.string().merge(Lww), // last writer wins
16
+ });
17
+
18
+ export const Todo = aggregate("Todo", {
19
+ listId: t.string().merge(Lww), // which list this todo is on (the query key)
20
+ text: t.string().merge(Lww), // last writer wins
21
+ done: t.bool(), // a flag — Lww by default; reads back a real boolean
22
+ tags: t.set(t.string()).merge(AddWins), // concurrent taggers UNION — nobody's tag is lost
23
+ });
24
+
25
+ // ── what people do (directives PLAN ops; the sealed engine replays them) ────────
26
+ export const createList = directive("createList")
27
+ .creates(TodoList) // Nomos MINTS the id — callers never supply one
28
+ .payload(z.object({ title: z.string().min(1) }))
29
+ .plan((p) => { create(TodoList).set("title", p.title); return []; });
30
+
31
+ export const addTodo = directive("addTodo")
32
+ .creates(Todo)
33
+ .payload(z.object({ listId: z.string().min(1), text: z.string().min(1) }))
34
+ .plan((p) => {
35
+ create(Todo).set("listId", p.listId).set("text", p.text).set("done", false);
36
+ return [];
37
+ });
38
+
39
+ export const renameTodo = directive("renameTodo")
40
+ .mutates(Todo)
41
+ .payload(z.object({ todoId: z.string(), text: z.string().min(1) }))
42
+ .plan((p) => [set(instance(Todo, p.todoId), "text", p.text)]);
43
+
44
+ // The caller decides done/not-done and rides it IN the payload (a plan is pure — no clock,
45
+ // no random). Concurrent toggles are Lww: the later HLC wins, and both peers converge.
46
+ export const toggleTodo = directive("toggleTodo")
47
+ .mutates(Todo)
48
+ .payload(z.object({ todoId: z.string(), done: z.boolean() }))
49
+ .plan((p) => [set(instance(Todo, p.todoId), "done", p.done)]);
50
+
51
+ // addToSet is the ONLY additive write to an AddWins set (set() would overwrite).
52
+ export const tagTodo = directive("tagTodo")
53
+ .mutates(Todo)
54
+ .payload(z.object({ todoId: z.string(), tags: z.array(z.string()).min(1) }))
55
+ .plan((p) => [addToSet(instance(Todo, p.todoId), "tags", p.tags)]);
56
+
57
+ // Sharing = ONE native Zanzibar tuple: TodoList:<id>#<role>@user:<user>.
58
+ export const shareList = directive("shareList")
59
+ .ensures(RelationTuple)
60
+ .payload(z.object({
61
+ listId: z.string().min(1), userId: z.string().min(1),
62
+ role: z.enum(["editor", "viewer"]), sharedBy: z.string().min(1), sharedAt: z.string(),
63
+ }))
64
+ .plan((p) => writeTuple({
65
+ object: `TodoList:${p.listId}`, relation: p.role, subject: `user:${p.userId}`,
66
+ grantedBy: p.sharedBy, grantedAt: p.sharedAt,
67
+ }));
68
+
69
+ // ── what people read (auto-discovered by shape; routed to every peer) ───────────
70
+ export const todosByList = query("todosByList").key("listId").returns(Todo);
71
+
72
+ // An O(1) maintained tally: how many todos are still open (done = false), per list.
73
+ export const openTodosByList = count("openTodosByList")
74
+ .of(Todo).where((p) => p.field("done").eq(false)).by("listId");
75
+
76
+ // The raw sharing tuples for a list — the audit surface (re-exported so it routes here).
77
+ export { relationTuplesByObject };
@@ -1,9 +1,11 @@
1
- // The holon-compile config: one package, one domain, one module file.
2
- // Read-side declarations (queries/counts/...) are auto-discovered from the
3
- // module's exports by shape — nothing to wire here.
1
+ // The githolon-compile config: one package, one domain, one module file.
2
+ // Read-side declarations (the query + the count) are auto-discovered from the
3
+ // module's exports by shape — nothing to wire here. The framework RelationTuple
4
+ // aggregate is auto-injected by shareList's `.ensures(RelationTuple)`; todo.ts only
5
+ // re-exports the relationTuplesByObject query (the read side).
4
6
  // Building a Flutter app too? Add `dart: true` for the typed Dart frontend
5
7
  // (build/dart/) alongside the TS client.
6
8
  export default {
7
- name: "guestbook",
8
- domains: [{ key: "guestbook", modules: ["./domains/guestbook.ts"] }],
9
+ name: "__APP_NAME__",
10
+ domains: [{ key: "todo", modules: ["./domains/todo.ts"] }],
9
11
  };
@@ -1,23 +1,25 @@
1
- // E2E: the FULL friend loop against a LIVE Nomos Cloud, driven through the
2
- // GENERATED TYPED CLIENT (`build/guestbook.client.ts` — emitted by nomos-compile):
1
+ // E2E: the FULL loop against a LIVE Nomos Cloud, driven through the GENERATED TYPED
2
+ // CLIENT (`build/__APP_NAME__.client.ts` — emitted by githolon compile):
3
3
  //
4
4
  // compile → create workspace → deploy (package + manifest overlay, ONE POST) →
5
- // web client connects → TYPED dispatch signGuestbook LOCALLY (offline write) →
6
- // TYPED declared query routes LOCALLY → sync + edge admission → the CLOUD's
7
- // declared query returns the entry.
5
+ // web client connects → TYPED createList + addTodo LOCALLY (offline writes) →
6
+ // sync + edge admission → the CLOUD's declared query + count return the work.
8
7
  //
9
8
  // The docs travel with you, offline: docs/01-mental-model.md first, then
10
9
  // docs/03-client.md explains every call here. This file is the NARRATED
11
- // walkthrough you reshape by hand; its GENERATED sibling — build/<name>.proof.mts,
10
+ // walkthrough you reshape by hand; its GENERATED sibling — build/__APP_NAME__.proof.mts,
12
11
  // emitted by every compile from your own law — needs no rewriting: `githolon proof`.
13
12
  //
14
- // Run from this directory AFTER `npx nomos-compile`: npx tsx test/e2e.mts
13
+ // The OFFLINE convergence proof (two peers, blind edits, byte-identical sync, no cloud)
14
+ // lives in test/convergence.local.mjs: node test/convergence.local.mjs
15
+ //
16
+ // Run from this directory AFTER `npx githolon compile`: npx tsx test/e2e.mts
15
17
  import { readFileSync } from "node:fs";
16
18
  import { connect } from "@githolon/client";
17
- import { guestbookClient, GUESTBOOK_DOMAIN_HASH } from "../build/guestbook.client.ts";
19
+ import { todoClient, __APP_HASH__ } from "../build/__APP_NAME__.client.ts";
18
20
 
19
21
  const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\/+$/, "");
20
- const WS = process.env.NOMOS_WS || "gb-e2e-" + Math.random().toString(36).slice(2, 8);
22
+ const WS = process.env.NOMOS_WS || "todo-e2e-" + Math.random().toString(36).slice(2, 8);
21
23
 
22
24
  // explicit annotation on the const so TS's never-call narrowing applies after fail(...)
23
25
  // IDENTITY NOTE: this proof uses the bare x-nomos-principal lane so the test is
@@ -26,169 +28,87 @@ const WS = process.env.NOMOS_WS || "gb-e2e-" + Math.random().toString(36).slice(
26
28
  const fail: (m: string) => never = (m) => { console.error("✗ " + m); process.exit(1); };
27
29
  const ok = (m: string) => console.log("✓ " + m);
28
30
 
29
- const deploy = JSON.parse(readFileSync(new URL("../build/guestbook.deploy.json", import.meta.url), "utf8"));
31
+ const deploy = JSON.parse(readFileSync(new URL("../build/__APP_NAME__.deploy.json", import.meta.url), "utf8"));
30
32
 
31
- // 1. create the workspace (capture the ONE-TIME secret) + deploy WITH the secret
33
+ // 1. create the workspace (capture the ONE-TIME secret)
32
34
  let r = await fetch(`${CLOUD}/v1/workspaces/${WS}`, { method: "POST", headers: { "x-nomos-principal": "nomos-e2e-suite" } });
33
35
  let d = await r.json();
34
36
  if (!d.ok) fail(`create workspace: ${JSON.stringify(d)}`);
35
37
  const SECRET: string = d.workspaceSecret;
36
38
  if (!SECRET?.startsWith("nws_v1_")) fail(`no workspaceSecret returned: ${JSON.stringify(d)}`);
37
- ok(`workspace ${WS} created — controller ${d.controller.domainHash.slice(0, 12)}…, secret ${SECRET.slice(0, 10)}…`);
39
+ ok(`workspace ${WS} created — controller ${d.controller.domainHash.slice(0, 12)}…`);
38
40
 
39
41
  // ownership: a deploy WITHOUT the secret must be refused
40
42
  r = await fetch(`${CLOUD}/v1/workspaces/${WS}/domains`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(deploy) });
41
43
  if (r.status !== 401) fail(`unauthenticated deploy was not refused (got ${r.status})`);
42
44
  ok("ownership — deploy without the workspace secret refused (401)");
43
45
 
46
+ // deploy WITH the secret. The generated client's baked hash IS the deployed law's hash.
44
47
  r = await fetch(`${CLOUD}/v1/workspaces/${WS}/domains`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${SECRET}` }, body: JSON.stringify(deploy) });
45
48
  d = await r.json();
46
49
  if (!d.ok || d.installation?.[0]?.data?.["status.phase"] !== "Active") fail(`deploy: ${JSON.stringify(d)}`);
47
- // The generated client's baked hash IS the deployed law's content hash — same compile.
48
- if (d.domainHash !== GUESTBOOK_DOMAIN_HASH) fail(`deploy hash ${d.domainHash} != generated client's ${GUESTBOOK_DOMAIN_HASH}`);
49
- ok(`guestbook deployed Active — ${GUESTBOOK_DOMAIN_HASH.slice(0, 12)}… (baked into the typed client)`);
50
+ if (d.domainHash !== __APP_HASH__) fail(`deploy hash ${d.domainHash} != generated client's ${__APP_HASH__}`);
51
+ ok(`law deployed Active ${__APP_HASH__.slice(0, 12)} (baked into the typed client)`);
50
52
 
51
- // 2. connect + bind the TYPED client (zero-import generated file, structural holon)
52
- const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "guest-1" });
53
- const app = guestbookClient(holon);
53
+ // 2. connect a web client (pulls wasm + manifests + ledger) and bind the typed surface
54
+ const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "owner-1" });
55
+ const app = todoClient(holon);
54
56
  ok(`web client connected — typed client bound (replica ${holon.replica.slice(0, 6)}…)`);
55
57
 
56
- let rows = await holon.queryById(`domain-installation:${GUESTBOOK_DOMAIN_HASH}`);
57
- if (!(rows.length && rows[0].data["status.phase"] === "Active")) fail("local replay: guestbook not Active locally");
58
- ok("local replay — guestbook law Active in the local projection");
59
-
60
- // 3. LOCAL-FIRST write through the TYPED surface: payload shape + enum are compile-checked
61
- const signedAt = new Date().toISOString();
62
- // THE OFFLINE PROOF IS ENFORCED, not narrated: while we author/query/count
63
- // LOCALLY, fetch THROWS — if any "local" path touched the network, this test fails.
58
+ // 3. LOCAL-FIRST writes — ENFORCED offline (fetch is trapped; any network touch fails).
64
59
  const realFetch = globalThis.fetch;
65
60
  globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: local authoring touched the network"); }) as typeof fetch;
66
61
 
67
- await app.signGuestbook({ author: "ada", message: "hello from the edge of the network", mood: "delighted", signedAt, tags: ["first-entry"] });
68
- ok("typed dispatch signGuestbook offline write under the pulled law");
69
-
70
- // 4. the TYPED declared query routes LOCALLY; rows come back as Row<GuestbookEntryData>[]
71
- const entries = await app.entriesByAuthor({ author: "ada" });
72
- if (!(entries.length === 1 && entries[0]!.data.message === "hello from the edge of the network")) {
73
- fail(`local typed query: ${JSON.stringify(entries)}`);
74
- }
75
- const entryId = entries[0]!.id;
76
- ok(`typed declared query routes — entriesByAuthor → ${entryId} (mood: ${entries[0]!.data.mood})`);
77
-
78
- // 5. typed mutate + typed by-id read (SetEntry map-put folds locally).
79
- // `reactedAt` is OMITTED the generated client auto-stamps `…At` payload fields
80
- // with ISO now() at dispatch (caller-side, so the plan stays pure).
81
- await app.reactToEntry({ entryId, reactor: "bob", reaction: "love" });
82
- const after = await app.guestbookEntryById(entryId);
83
- if (after[0]!.data.reactions?.bob !== "love") fail(`typed mutate/read: ${JSON.stringify(after)}`);
84
- if (!/^\d{4}-\d{2}-\d{2}T/.test(after[0]!.data.updatedAt ?? "")) fail(`auto-stamped reactedAt did not fold into updatedAt: ${JSON.stringify(after[0]!.data)}`);
85
- ok("typed mutate folds reactions map shows bob → love locally (reactedAt auto-stamped by the client)");
86
-
87
- // 5b. DERIVED read field: the engine projects moodEmoji from the law's pure fn
88
- if (after[0]!.data.moodEmoji !== "🤩") fail(`derived moodEmoji not projected locally: ${JSON.stringify(after[0]!.data)}`);
89
- ok("derived field projects locally — moodEmoji 🤩 (engine-computed, never in the ledger)");
90
-
91
- // 5c. DECLARED COUNT: the maintained O(1) tally, typed accessor, local projection
92
- const localCount = await app.entriesPerMood("delighted");
93
- if (localCount !== 1) fail(`local declared count: ${localCount}`);
94
- ok("declared count maintained locally — entriesPerMood('delighted') = 1");
62
+ // A directive returns the new local HEAD (a string). Aggregate ids are MINTED by the
63
+ // engineread them back from the declared query. `addTodo.listId` is a plain string
64
+ // the app chooses (the grouping key), so we don't need createList's minted id to wire
65
+ // todos to a list we just key both on the same `listId`.
66
+ const listId = "list-launch-plan";
67
+ await app.createList({ title: "Launch plan" });
68
+ await app.addTodo({ listId, text: "Draft the deck" });
69
+ await app.addTodo({ listId, text: "Book the venue" });
70
+ ok("typed offline writes — createList + 2 addTodo, under the pulled law");
71
+
72
+ // the TYPED declared query routes LOCALLY; minted Todo ids come back on the rows
73
+ const seeded = await app.todosByList({ listId });
74
+ if (seeded.length !== 2) fail(`local query: expected 2 todos, got ${seeded.length}`);
75
+ const t1 = seeded.find((row) => row.data.text === "Draft the deck")!.id;
76
+ await app.toggleTodo({ todoId: t1, done: true }); // Lww boolean (t.bool)
77
+ await app.tagTodo({ todoId: t1, tags: ["urgent", "q3"] }); // AddWins set
78
+ ok(`read back a minted Todo id (${t1.slice(0, 16)}…) → toggled done + tagged`);
79
+
80
+ const localOpen = await app.openTodosByList(listId);
81
+ if (localOpen !== 1) fail(`local count: expected 1 open, got ${localOpen}`);
82
+ ok("local reads route todosByList = 2, openTodosByList = 1 (2 todos 1 done), no network");
95
83
 
96
84
  globalThis.fetch = realFetch;
97
- ok("offline proof ENFORCED — fetch was trapped: local write/query/mutate/count touched no network");
85
+ ok("offline proof ENFORCED — fetch was trapped: local write/query/count touched no network");
98
86
 
99
- // 6. sync: push the session branch + edge admission merges to main.
100
- // Bare sync() IS the whole loop — admit defaults to true.
87
+ // 4. sync: push the session branch edge admission merges to canonical main
101
88
  const s = await holon.sync();
102
89
  if (!s.admission?.ok) fail(`sync/admit: ${JSON.stringify(s)}`);
103
- const admitted = (s.admission.sessions || []).flatMap((x: { admitted?: unknown[] }) => x.admitted || []);
104
- if (admitted.length < 2) fail(`edge admitted ${admitted.length} intent(s), expected 2: ${JSON.stringify(s.admission)}`);
105
- ok(`synced edge admission merged ${admitted.length} intents to main`);
106
-
107
- // 7. the CLOUD's declared query (read-manifest overlay, edge projection) returns the entry
108
- d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/query?queryId=entriesByAuthor&params=${encodeURIComponent(JSON.stringify({ author: "ada" }))}`)).json();
109
- if (!(d.ok && d.rows.length === 1 && d.rows[0].data.reactions?.bob === "love")) {
110
- fail(`cloud declared query: ${JSON.stringify(d)}`);
111
- }
112
- ok("cloud declared query returns the admitted entry WITH the admitted reaction — full loop closed");
113
-
114
- // 8. the EDGE projects the derived field + maintains the count too (same wasm, same law)
115
- d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/aggregates/${encodeURIComponent(entryId)}`)).json();
116
- if (d.rows?.[0]?.data?.moodEmoji !== "🤩") fail(`edge derived projection: ${JSON.stringify(d.rows?.[0]?.data)}`);
117
- ok("derived field projects at the edge — moodEmoji 🤩");
118
- d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/counts/entriesPerMood?group=delighted`)).json();
90
+ ok("synced session branch admitted onto canonical main");
91
+
92
+ // 5. the CLOUD answers the declared query + the maintained count from canonical main
93
+ d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/query?queryId=todosByList&params=${encodeURIComponent(JSON.stringify({ listId }))}`)).json();
94
+ if (!(d.ok && d.rows.length === 2)) fail(`cloud query: ${JSON.stringify(d)}`);
95
+ ok("cloud query todosByList returned both todos from canonical main");
96
+ d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/counts/openTodosByList?group=${encodeURIComponent(listId)}`)).json();
119
97
  if (!(d.ok && d.count === 1)) fail(`cloud count: ${JSON.stringify(d)}`);
120
- ok("cloud count endpoint entriesPerMood[delighted] = 1 (O(1) maintained read)");
121
-
122
- // 9. CONCURRENCYthe canonical AddWins demo: two writers = two connect()s with
123
- // DIFFERENT clientIds; each tags the same entry OFFLINE, blind to the other;
124
- // admission merges both and the AddWins union keeps every add.
125
- const taggerA = await connect({ cloud: CLOUD, workspace: WS, clientId: "tagger-a" });
126
- const taggerB = await connect({ cloud: CLOUD, workspace: WS, clientId: "tagger-b" });
127
- await guestbookClient(taggerA).tagEntry({ entryId, tags: ["vegan"] });
128
- await guestbookClient(taggerB).tagEntry({ entryId, tags: ["gluten-free"] });
129
- await taggerA.sync();
130
- await taggerB.sync();
131
- d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/aggregates/${encodeURIComponent(entryId)}`)).json();
132
- const mergedTags: string[] = d.rows?.[0]?.data?.tags ?? [];
133
- if (!(mergedTags.includes("vegan") && mergedTags.includes("gluten-free"))) fail(`AddWins union lost an add: ${JSON.stringify(mergedTags)}`);
134
- ok(`concurrent tags from two offline clients ALL survive the merge — [${[...mergedTags].sort().join(", ")}]`);
135
-
136
- // 10. CONVERGENCE: the ORIGINAL instance adopts canonical main including the two
137
- // taggers' admitted writes — inside a pull; no reconnect, no rebuild.
138
- await holon.pull();
139
- const head = await holon.head();
140
- const refsAdv = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/git/info/refs?service=git-upload-pack`)).text();
141
- const remoteMain = (refsAdv.match(/([0-9a-f]{40}) refs\/heads\/main/) || [])[1];
142
- if (head !== remoteMain) fail(`not converged in place: local ${head?.slice(0, 10)} vs main ${remoteMain?.slice(0, 10)}`);
143
- ok("converged in place — local head == canonical main, no reconnect");
144
-
145
- // 11. WATCH — the local reactive read: watchById polls the LOCAL projection and fires
146
- // the callback whenever the row changes (purely local — no network, no server push
147
- // to wait on). A typed mutate lands; the watcher sees it.
148
- const watched: Record<string, unknown>[] = [];
149
- const stopWatch = holon.watchById(entryId, (rows) => { watched.push(rows[0]?.data ?? {}); }, { intervalMs: 50 });
150
- await app.reactToEntry({ entryId, reactor: "carol", reaction: "wow" });
151
- const sawReaction = async () => {
152
- for (let i = 0; i < 100; i++) {
153
- if (watched.some((dd) => (dd.reactions as Record<string, unknown> | undefined)?.carol === "wow")) return true;
154
- await new Promise((res) => setTimeout(res, 50));
155
- }
156
- return false;
157
- };
158
- if (!(await sawReaction())) fail(`watchById never observed carol's reaction: ${JSON.stringify(watched.at(-1))}`);
159
- stopWatch(); // the returned stop() ends the polling — no leaked timers
160
- ok("watchById fires on change — typed mutate observed reactively (carol → wow), watcher stopped");
161
- await holon.sync(); // ride carol's reaction to main so the ledger ends converged
162
-
163
- // 12. DEAD-LETTER QUEUE — refused work is NEVER lost. The honest jam: a user's client
164
- // on a FRESH workspace installs a domain LOCALLY (offline-first — the edge has no
165
- // such law) and does real work under it. On sync the edge cannot certify that law:
166
- // a DOMAIN rejection PARKS the intent — full provenance, durable, rides
167
- // export()/restore — it does not vanish. (A directive whose domain isn't in YOUR
168
- // law refuses at dispatch time, loudly — only committed-then-refused work parks.)
169
- const WS2 = WS + "-dlq";
170
- d = await (await fetch(`${CLOUD}/v1/workspaces/${WS2}`, { method: "POST", headers: { "x-nomos-principal": "nomos-e2e-suite" } })).json();
171
- if (!d.ok) fail(`create DLQ workspace: ${JSON.stringify(d)}`);
172
- const jammed = await connect({ cloud: CLOUD, workspace: WS2, clientId: "jammed-user" });
173
- await jammed.dispatch("nomos", "installDomain", {
174
- domainHash: GUESTBOOK_DOMAIN_HASH, packageUsda: deploy.packageUsda, installedBy: "local-user",
175
- authorityScope: "workspace/root", dependencies: [], finalizers: [],
176
- }, { domainHash: d.controller.domainHash });
177
- await guestbookClient(jammed).signGuestbook({ author: "marta", message: "work I refuse to lose", mood: "happy", signedAt: new Date().toISOString() });
178
- const js = await jammed.sync();
179
- if (!(js.converged?.deadLettered ?? []).length) fail(`tenant write did not dead-letter: ${JSON.stringify(js.converged)}`);
180
- const dlq = await jammed.deadLetters();
181
- if (!(dlq.length === 1 && dlq[0]!.domain === "guestbook" && dlq[0]!.directiveId === "signGuestbook" && dlq[0]!.source === "edge")) {
182
- fail(`DLQ provenance wrong: ${JSON.stringify(dlq)}`);
183
- }
184
- ok(`dead letter parked with provenance — ${dlq[0]!.domain}/${dlq[0]!.directiveId} refused by ${dlq[0]!.source}, work NOT lost`);
185
- // THE RETRY LANE (not run here — see test/dlq.e2e.mjs in the nomos2 repo for the full
186
- // unjam): the domain dev deploys the law fix (`POST /v1/workspaces/:ws/domains`, secret-
187
- // gated), then `retryDeadLetter(id)` — or the owner's `POST /dead-letters/retry` — lands
188
- // the parked write on main. Here the app makes the OTHER explicit choice:
189
- const dropped = await jammed.discardDeadLetter(dlq[0]!.id ?? dlq[0]!.oid);
190
- if (!(dropped.ok && dropped.removed === 1)) fail(`discard: ${JSON.stringify(dropped)}`);
191
- if ((await jammed.deadLetters()).length !== 0) fail("DLQ not drained after discard");
192
- ok("discardDeadLetter drains the queue — the APP's explicit choice, never the runtime's");
193
-
194
- console.log(`\nALL GREEN — typed client: compile → deploy → connect → typed write/query/mutate/derive/count → admit → converge → watch → dead-letter custody → cloud reads (${WS})`);
98
+ ok("cloud count — openTodosByList = 1 (the where-filtered O(1) tally, maintained)");
99
+
100
+ // 6. SHARINGone native Zanzibar tuple. Author it through the owner's secret-gated
101
+ // /author lane, then read it back from the cloud's audit query.
102
+ r = await fetch(`${CLOUD}/v1/workspaces/${WS}/author`, {
103
+ method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${SECRET}` },
104
+ body: JSON.stringify({ domain: "todo", directiveId: "shareList", payload: {
105
+ listId, userId: "bob", role: "editor", sharedBy: "owner-1", sharedAt: new Date().toISOString() } }),
106
+ });
107
+ d = await r.json();
108
+ if (!d.ok) fail(`shareList author: ${JSON.stringify(d)}`);
109
+ d = await (await fetch(`${CLOUD}/v1/workspaces/${WS}/query?queryId=relationTuplesByObject&params=${encodeURIComponent(JSON.stringify({ object: `TodoList:${listId}` }))}`)).json();
110
+ const editor = (d.rows ?? []).find((u: { data: { relation: string } }) => u.data.relation === "editor");
111
+ if (editor?.data?.subjects?.["user:bob"]?.status !== "active") fail(`sharing tuple not recorded: ${JSON.stringify(d)}`);
112
+ ok("sharing TodoList#editor@user:bob recorded as a native relation tuple (active)");
113
+
114
+ console.log(`\nALL GREEN — typed client: compile deploy connect typed offline writes → query/count → admit → cloud reads → native sharing (${WS})`);
@@ -1,164 +0,0 @@
1
- /**
2
- * guestbook — the smallest complete Nomos tenant domain, written the only way a
3
- * domain is ever written: aggregates + directives on `@githolon/dsl`. No apply/fold/
4
- * merge code — the kernel owns folding; the directive plans ops, the sealed engine
5
- * replays them deterministically on every peer.
6
- *
7
- * What it shows:
8
- * * an aggregate with Lww scalars, an AddWins set, and a MapOf(Lww) map;
9
- * * a `.creates` directive (Nomos mints the aggregate id — never the caller);
10
- * * a `.mutates` directive bound to an existing instance;
11
- * * a declared, indexed read query (auto-discovered by `nomos-compile`);
12
- * * a DERIVED read field (engine-projected, PURE fn of the folded fields);
13
- * * a COUNT (a maintained tally, grouped by a field).
14
- */
15
- import {
16
- z,
17
- aggregate,
18
- t,
19
- Lww,
20
- AddWins,
21
- MapOf,
22
- directive,
23
- create,
24
- instance,
25
- set,
26
- setEntry,
27
- addToSet,
28
- query,
29
- derived,
30
- count,
31
- type DerivedDecl,
32
- type CountDecl,
33
- } from "@githolon/dsl";
34
-
35
- // --- vocabulary ---------------------------------------------------------------
36
-
37
- export const ENTRY_MOODS = ["delighted", "happy", "neutral", "grumpy"] as const;
38
-
39
- // --- aggregate ----------------------------------------------------------------
40
-
41
- /** An entry may carry at most this many tags — the declared invariant's honest rule. */
42
- export const MAX_TAGS = 10;
43
-
44
- /**
45
- * One guestbook entry. The wire type `GuestbookEntry` is what readers key on.
46
- *
47
- * THE DECLARED AGGREGATE INVARIANT (#41 — declared invariants are REAL law): an
48
- * entry holds AT MOST {@link MAX_TAGS} tags. The kernel judges the POST-FOLD
49
- * snapshot at the ONE admission gate on every peer — a write that would leave
50
- * an entry over the limit refuses TYPED (`tags-exceed-limit:10`), offline and
51
- * on the edge alike. AddWins still merges concurrent adds; the invariant
52
- * bounds what any single admitted write may leave behind.
53
- */
54
- export const GuestbookEntry = aggregate(
55
- "GuestbookEntry",
56
- {
57
- author: t.string().merge(Lww),
58
- message: t.string().merge(Lww),
59
- mood: t.enum(ENTRY_MOODS).merge(Lww),
60
- signedAt: t.string().merge(Lww), // ISO-8601, caller-stamped
61
- updatedAt: t.string().merge(Lww), // ISO-8601
62
- /** Free-form additive tags — concurrent adds commute (AddWins). */
63
- tags: t.set(t.string()).merge(AddWins),
64
- /** Reactions keyed by reactor — per-key Lww, concurrent reactors commute. */
65
- reactions: t.map(t.string()).merge(MapOf(Lww)),
66
- },
67
- {
68
- invariant: (snap) => {
69
- const tags = snap["tags"];
70
- const n = Array.isArray(tags) ? tags.length : 0;
71
- return n > MAX_TAGS ? { reject: `tags-exceed-limit:${MAX_TAGS}` } : { accept: true };
72
- },
73
- },
74
- );
75
-
76
- // --- directives ---------------------------------------------------------------
77
-
78
- /** Sign the guestbook: creates an entry (Nomos mints the id). */
79
- export const signGuestbook = directive("signGuestbook")
80
- .creates(GuestbookEntry)
81
- .payload(
82
- z.object({
83
- author: z.string().min(1),
84
- message: z.string().min(1),
85
- mood: z.enum(ENTRY_MOODS),
86
- signedAt: z.string(), // ISO-8601
87
- tags: z.array(z.string()).optional(),
88
- }),
89
- )
90
- .plan((p) => {
91
- const entry = create(GuestbookEntry);
92
- entry
93
- .set("author", p.author)
94
- .set("message", p.message)
95
- .set("mood", p.mood)
96
- .set("signedAt", p.signedAt)
97
- .set("updatedAt", p.signedAt);
98
- for (const tag of p.tags ?? []) entry.add("tags", tag);
99
- return [];
100
- });
101
-
102
- /**
103
- * Tag an entry — the ADDITIVE set write. Two people tagging the SAME entry at
104
- * the SAME time both win: AddWins unions the adds, nothing is lost. (addToSet
105
- * is the ONLY additive write to an AddWins set — set() would overwrite, and is
106
- * refused at both the type level and runtime.)
107
- */
108
- export const tagEntry = directive("tagEntry")
109
- .mutates(GuestbookEntry)
110
- .payload(z.object({ entryId: z.string(), tags: z.array(z.string()).min(1) }))
111
- .plan((p) => [addToSet(instance(GuestbookEntry, p.entryId), "tags", p.tags)]);
112
-
113
- /** React to an entry: a map-PUT at the reactor's key (concurrent reactors commute). */
114
- export const reactToEntry = directive("reactToEntry")
115
- .mutates(GuestbookEntry)
116
- .payload(
117
- z.object({
118
- entryId: z.string(),
119
- reactor: z.string().min(1),
120
- reaction: z.string().min(1),
121
- reactedAt: z.string(), // ISO-8601
122
- }),
123
- )
124
- .plan((p) => {
125
- const entry = instance(GuestbookEntry, p.entryId);
126
- return [
127
- setEntry(entry, "reactions", p.reactor, p.reaction),
128
- set(entry, "updatedAt", p.reactedAt),
129
- ];
130
- });
131
-
132
- // --- declared read queries (auto-discovered by nomos-compile) -------------------
133
-
134
- /** All entries by one author — an indexed probe, not a scan. */
135
- export const entriesByAuthor = query("entriesByAuthor").key("author").returns(GuestbookEntry);
136
-
137
- // --- derived read fields (engine-projected, PURE; NEVER stamped into the ledger) ---
138
-
139
- /** The mood → emoji rendering table — verbatim the {@link ENTRY_MOODS} vocabulary. */
140
- export const MOOD_EMOJI: Record<(typeof ENTRY_MOODS)[number], string> = {
141
- delighted: "🤩",
142
- happy: "😊",
143
- neutral: "🙂",
144
- grumpy: "😠",
145
- };
146
-
147
- /**
148
- * `GuestbookEntry.moodEmoji` — an ENGINE-PROJECTED DERIVED read field: a PURE fn of the
149
- * entry's folded `mood` (the {@link MOOD_EMOJI} table lookup; `"❔"` while `mood` is
150
- * still unfolded — partial folds are normal). Computed BY THE ENGINE during the
151
- * read-model projection and stored ONLY in the read model — never stamped into a kernel
152
- * op/event, so the ledger stays pure user-intents and the value is always re-derivable.
153
- * Exported INDIVIDUALLY at top level: both the engine bundle and `nomos-compile`
154
- * auto-discover it from the module exports by shape ({id, of, returns, fn}).
155
- */
156
- export const moodEmoji: DerivedDecl = derived("moodEmoji")
157
- .of(GuestbookEntry)
158
- .returns(z.string())
159
- .as((a) => MOOD_EMOJI[a.mood as (typeof ENTRY_MOODS)[number]] ?? "❔");
160
-
161
- // --- counts (maintained tallies — O(1) reads, never a scan) ----------------------
162
-
163
- /** How many entries per mood — one maintained counter per distinct `mood` value. */
164
- export const entriesPerMood: CountDecl = count("entriesPerMood").of(GuestbookEntry).by("mood");