@githolon/dsl 0.1.6 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -0,0 +1,430 @@
1
+ // NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
2
+ // remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
3
+ // wasm32-wasip1 artifact {kernel · projection · embedded
4
+ // QuickJS engine} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /**
9
+ * GENERATED PROOF CODEGEN — `build/<name>.proof.mts`, the runnable e2e SYNTHESIZED
10
+ * FROM THE LAW ITSELF (the field-test-6 ask: reshaping the domain meant rewriting
11
+ * test/e2e.mts by hand; now the proof reshapes itself on every compile).
12
+ *
13
+ * `generateTsProof(modules, opts)` reads the domain's ACTUAL directives / queries /
14
+ * counts (the same composed modules every other emitter sees) and emits a
15
+ * domain-shaped live proof:
16
+ *
17
+ * throwaway workspace → deploy `build/<name>.deploy.json` → connect via the
18
+ * GENERATED typed client → fetch-TRAPPED offline dispatch of the first `.creates`
19
+ * directive (payload synthesized from its zod schema) → first declared query +
20
+ * first count answered LOCALLY → sync (edge admission) → the CLOUD answers →
21
+ * two-client AddWins concurrency (when the law has the shape for it) →
22
+ * convergence → ALL GREEN.
23
+ *
24
+ * Payload SYNTHESIS rules ({@link synthesizePayload}): string → `"sample-<field>"`,
25
+ * enum → its first option, number → 1, boolean → true, array of string →
26
+ * `["sample-a"]`, literal → its value, `.optional()`/`.default()` → omitted,
27
+ * required `…At` strings → omitted (the generated client auto-stamps ISO now() at
28
+ * dispatch — the same {@link autoStampFields} convention codegen_ts types by).
29
+ * NO FALLBACK: a shape the synthesizer can't sample throws, naming the field and
30
+ * the remedy — the compile then SKIPS the proof (the law still ships) and says why.
31
+ *
32
+ * Deterministic codegen: the emitted bytes are a pure function of the modules +
33
+ * package name + domain hash — no timestamps, no randomness (the proof picks its
34
+ * throwaway workspace name at RUN time, not at generation time).
35
+ */
36
+ import type { z } from "zod";
37
+
38
+ import type { AggregateHandle } from "./aggregate.js";
39
+ import type { Field } from "./fields.js";
40
+ import type { Directive } from "./directive.js";
41
+ import type { DomainModule } from "./codegen_dart.js";
42
+ import type { QueryDecl } from "./query.js";
43
+ import { finishCount } from "./count.js";
44
+ import {
45
+ autoStampFields,
46
+ tsClientFactoryName,
47
+ tsHashConstName,
48
+ zodArrayElement,
49
+ zodDef,
50
+ zodEnumValues,
51
+ zodKind,
52
+ zodObjectShape,
53
+ } from "./codegen_ts.js";
54
+
55
+ const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
56
+ const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
57
+
58
+ // ── payload synthesis (the sample-value sibling of codegen_ts's type walker) ──────
59
+
60
+ export interface SynthesizedPayload {
61
+ /** The literal the proof dispatches. Auto-stamped `…At` fields are OMITTED here. */
62
+ readonly value: Record<string, unknown>;
63
+ /** Required `…At` string fields deliberately omitted — the generated client stamps ISO now() at dispatch. */
64
+ readonly autoStamped: readonly string[];
65
+ }
66
+
67
+ /** One sample value for one zod shape. NO FALLBACK: an unhandled shape throws, naming its remedy. */
68
+ function sampleOfZod(zt: z.ZodTypeAny, field: string, where: string): unknown {
69
+ switch (zodKind(zt)) {
70
+ case "string":
71
+ return `sample-${field}`;
72
+ case "number":
73
+ return 1;
74
+ case "boolean":
75
+ return true;
76
+ case "enum":
77
+ return zodEnumValues(zt)[0];
78
+ case "literal": {
79
+ const def = zodDef(zt);
80
+ return def.value !== undefined ? def.value : def.values?.[0];
81
+ }
82
+ case "array": {
83
+ const el = zodArrayElement(zt);
84
+ return zodKind(el) === "string" ? ["sample-a"] : [sampleOfZod(el, field, where)];
85
+ }
86
+ case "object": {
87
+ const out: Record<string, unknown> = {};
88
+ for (const [name, raw] of Object.entries(zodObjectShape(zt))) {
89
+ const k = zodKind(raw as z.ZodTypeAny);
90
+ if (k === "optional" || k === "default") continue; // optional → omit
91
+ out[name] = sampleOfZod(raw as z.ZodTypeAny, name, where);
92
+ }
93
+ return out;
94
+ }
95
+ case "record":
96
+ return {};
97
+ case "nullable":
98
+ case "optional":
99
+ case "default":
100
+ return sampleOfZod(zodDef(zt).innerType!, field, where);
101
+ case "union": {
102
+ const first = zodDef(zt).options?.[0];
103
+ if (first !== undefined) return sampleOfZod(first, field, where);
104
+ throw new Error(`codegen-proof: empty union at field '${field}' of ${where}`);
105
+ }
106
+ default:
107
+ throw new Error(
108
+ `codegen-proof: cannot synthesize a sample for zod shape '${zodKind(zt)}' (field '${field}' of ${where}) — reshape the payload to a sampleable type, or prove that directive by hand in test/e2e.mts`,
109
+ );
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Synthesize a dispatchable sample payload from a directive's zod object schema:
115
+ * optional/defaulted fields omitted, required `…At` strings omitted (auto-stamped
116
+ * by the generated client), everything else sampled by {@link sampleOfZod}.
117
+ */
118
+ export function synthesizePayload(schema: z.ZodTypeAny, where: string): SynthesizedPayload {
119
+ if (zodKind(schema) !== "object") {
120
+ throw new Error(`codegen-proof: ${where} payload is not a zod object — the proof can only synthesize object payloads`);
121
+ }
122
+ const autoStamped = autoStampFields(schema);
123
+ const stamped = new Set(autoStamped);
124
+ const value: Record<string, unknown> = {};
125
+ for (const [name, raw] of Object.entries(zodObjectShape(schema))) {
126
+ const f = raw as z.ZodTypeAny;
127
+ const k = zodKind(f);
128
+ if (k === "optional" || k === "default") continue; // optional → omit
129
+ if (stamped.has(name)) continue; // `…At` → omit; the client stamps it
130
+ value[name] = sampleOfZod(f, name, where);
131
+ }
132
+ return { value, autoStamped };
133
+ }
134
+
135
+ // ── shape discovery (which legs THIS law can prove) ───────────────────────────────
136
+
137
+ interface ConcurrencyLeg {
138
+ readonly directive: Directive<unknown>;
139
+ /** The payload field that carries the target instance id (first required string `…Id`). */
140
+ readonly idField: string;
141
+ /** The AddWins set field the directive adds to (payload array field name === aggregate set field name). */
142
+ readonly setField: string;
143
+ }
144
+
145
+ /**
146
+ * The AddWins two-writer leg needs the law to have the shape for it: a `.mutates`
147
+ * directive on the created aggregate whose payload carries (a) a required string
148
+ * `…Id` field (the instance) and (b) an array-of-string field named exactly like
149
+ * one of the aggregate's AddWins set fields (the `addToSet` signature). Absent
150
+ * that shape, the proof prints a skip note instead of inventing law.
151
+ */
152
+ function findConcurrencyLeg(
153
+ directives: readonly Directive<unknown>[],
154
+ agg: AggregateHandle,
155
+ ): ConcurrencyLeg | undefined {
156
+ const addWinsSets = Object.entries(agg.fields)
157
+ .filter(([, f]) => (f as Field).kind === "set" && ((f as Field).driver as { kind?: string }).kind === "AddWins")
158
+ .map(([name]) => name);
159
+ if (addWinsSets.length === 0) return undefined;
160
+ for (const d of directives) {
161
+ if (d.marker !== "mutates" || d.aggregateId !== agg.id) continue;
162
+ let shape: Record<string, z.ZodTypeAny>;
163
+ try {
164
+ shape = zodObjectShape(d.payloadSchema as z.ZodTypeAny);
165
+ } catch {
166
+ continue;
167
+ }
168
+ const idField = Object.entries(shape).find(
169
+ ([name, f]) => name.length > 2 && name.endsWith("Id") && zodKind(f) === "string",
170
+ )?.[0];
171
+ if (idField === undefined) continue;
172
+ const setField = addWinsSets.find((s) => {
173
+ const f = shape[s];
174
+ return f !== undefined && zodKind(f) === "array" && zodKind(zodArrayElement(f)) === "string";
175
+ });
176
+ if (setField !== undefined) return { directive: d, idField, setField };
177
+ }
178
+ return undefined;
179
+ }
180
+
181
+ // ── the generator ─────────────────────────────────────────────────────────────────
182
+
183
+ export interface TsProofOptions {
184
+ /** The package name (`nomos.package.mjs` `name`) — names the artifacts the proof loads. */
185
+ readonly packageName: string;
186
+ /** sha256 of the built `.package.usda` — checked against the deploy response. */
187
+ readonly domainHash: string;
188
+ }
189
+
190
+ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofOptions): string {
191
+ // The proof drives the FIRST domain that has a creating write — without one
192
+ // there is nothing to author, so there is nothing to prove.
193
+ const mod = modules.find((m) =>
194
+ (m.directives as Directive<unknown>[]).some((d) => d?.marker === "creates" && typeof d.payloadSchema === "object"),
195
+ );
196
+ if (mod === undefined) {
197
+ throw new Error(
198
+ `no .creates directive in any domain — a generated proof needs one creating write; add a directive(...).creates(...) (test/e2e.mts remains the hand-authored lane)`,
199
+ );
200
+ }
201
+ const domain = mod.domain ?? mod.name;
202
+ const factory = tsClientFactoryName(domain);
203
+ const hashConst = tsHashConstName(opts.packageName);
204
+
205
+ const createDir = (mod.directives as Directive<unknown>[]).find((d) => d?.marker === "creates")!;
206
+ const agg = (mod.aggregates as AggregateHandle[]).find((a) => a.id === createDir.aggregateId);
207
+ if (agg === undefined) {
208
+ throw new Error(
209
+ `directive '${createDir.id}' creates '${createDir.aggregateId}' but no such aggregate is composed — export the aggregate from a listed module`,
210
+ );
211
+ }
212
+ const synth = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, `${domain}/${createDir.id}`);
213
+
214
+ // The first declared query that can READ BACK the create: returns the created
215
+ // aggregate and every key field has a synthesized payload value to probe with.
216
+ const query = ((mod.queries ?? []) as QueryDecl[]).find(
217
+ (q) => q.returns === agg.id && q.key.length > 0 && q.key.every((k) => !k.includes(".") && synth.value[k] !== undefined),
218
+ );
219
+
220
+ // The first declared count the create moves: counts the created aggregate,
221
+ // unpredicated (a .where can't be evaluated at codegen time), and — when
222
+ // grouped — grouped by a field the synthesized payload carries.
223
+ const count = (mod.counts ?? [])
224
+ .map((raw) => finishCount(raw))
225
+ .find(
226
+ (c) =>
227
+ c.of === agg.id &&
228
+ (c as { where?: unknown }).where == null &&
229
+ (c.by == null || synth.value[c.by] !== undefined),
230
+ );
231
+
232
+ const concurrency = findConcurrencyLeg(mod.directives as Directive<unknown>[], agg);
233
+
234
+ // Workspace-name slug: any package name → a safe, recognizable ws prefix.
235
+ const slug =
236
+ opts.packageName
237
+ .toLowerCase()
238
+ .replace(/[^a-z0-9]+/g, "-")
239
+ .replace(/^-+|-+$/g, "")
240
+ .slice(0, 24) || "domain";
241
+
242
+ const payloadLit = JSON.stringify(synth.value);
243
+ const queryParams = query ? JSON.stringify(Object.fromEntries(query.key.map((k) => [k, synth.value[k]]))) : "";
244
+ const listAccessor = `list${agg.id}s`;
245
+
246
+ let step = 0;
247
+ const n = () => ++step;
248
+ const out: string[] = [];
249
+
250
+ out.push(
251
+ `// AUTO-GENERATED by nomos-compile — the RUNNABLE PROOF for package "${opts.packageName}",`,
252
+ `// synthesized from the domain's OWN directives/queries/counts. Reshape the law,`,
253
+ `// recompile, and this proof reshapes itself — no test rewriting. Run it:`,
254
+ `//`,
255
+ `// npx githolon proof (or: npx tsx build/${opts.packageName}.proof.mts)`,
256
+ `//`,
257
+ `// The flow mirrors the scaffold's test/e2e.mts (docs/ in a scaffolded app explains`,
258
+ `// every call — start at docs/01-mental-model.md). Regenerated on every compile; do not edit.`,
259
+ `import { readFileSync } from "node:fs";`,
260
+ `import { connect } from "@githolon/client";`,
261
+ `import { ${factory}, ${hashConst} } from "./${opts.packageName}.client.ts";`,
262
+ ``,
263
+ `const CLOUD = (process.env.NOMOS_CLOUD || "https://nomos.captainapp.co.uk").replace(/\\/+$/, "");`,
264
+ `const WS = process.env.NOMOS_WS || ${JSON.stringify(slug + "-proof-")} + Math.random().toString(36).slice(2, 8);`,
265
+ ``,
266
+ `// IDENTITY NOTE: this proof uses the bare x-nomos-principal lane so it is`,
267
+ `// SELF-CONTAINED (a throwaway workspace, no stored credentials). Real apps use`,
268
+ `// \`githolon login --agent\` (a VERIFIED identity) + \`githolon ws create/deploy\`.`,
269
+ `const fail: (m: string) => never = (m) => { console.error("✗ " + m); process.exit(1); };`,
270
+ `const ok = (m: string) => console.log("✓ " + m);`,
271
+ ``,
272
+ `const deploy = JSON.parse(readFileSync(new URL(${JSON.stringify(`./${opts.packageName}.deploy.json`)}, import.meta.url), "utf8"));`,
273
+ ``,
274
+ `// ${n()}. a throwaway workspace (the ONE-TIME secret comes back on create)`,
275
+ `let r = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}\`, { method: "POST", headers: { "x-nomos-principal": "githolon-proof" } });`,
276
+ `let d = await r.json();`,
277
+ `if (!d.ok) fail(\`create workspace: \${JSON.stringify(d)}\`);`,
278
+ `const SECRET: string = d.workspaceSecret;`,
279
+ `if (!SECRET?.startsWith("nws_v1_")) fail(\`no workspaceSecret returned: \${JSON.stringify(d)}\`);`,
280
+ `ok(\`workspace \${WS} created — secret \${SECRET.slice(0, 10)}…\`);`,
281
+ ``,
282
+ `// ${n()}. deploy YOUR compiled law (build/${opts.packageName}.deploy.json) WITH the secret`,
283
+ `r = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/domains\`, { method: "POST", headers: { "content-type": "application/json", authorization: \`Bearer \${SECRET}\` }, body: JSON.stringify(deploy) });`,
284
+ `d = await r.json();`,
285
+ `if (!d.ok || d.installation?.[0]?.data?.["status.phase"] !== "Active") fail(\`deploy: \${JSON.stringify(d)}\`);`,
286
+ `if (d.domainHash !== ${hashConst}) fail(\`deploy hash \${d.domainHash} != the generated client's \${${hashConst}}\`);`,
287
+ `ok(\`law deployed Active — \${${hashConst}.slice(0, 12)}… (the content hash baked into your typed client)\`);`,
288
+ ``,
289
+ `// ${n()}. connect + bind the GENERATED typed client`,
290
+ `const holon = await connect({ cloud: CLOUD, workspace: WS, clientId: "proof-1" });`,
291
+ `const app = ${factory}(holon);`,
292
+ `ok(\`web client connected — ${factory} bound (replica \${holon.replica.slice(0, 6)}…)\`);`,
293
+ ``,
294
+ `// ${n()}. OFFLINE write — the proof is ENFORCED, not narrated: while we author and`,
295
+ `// read LOCALLY, fetch THROWS. The payload below was SYNTHESIZED from the zod`,
296
+ `// schema of \`${domain}/${createDir.id}\`${synth.autoStamped.length ? ` (${synth.autoStamped.map((s) => `\`${s}\``).join("/")} omitted — the client auto-stamps ISO now())` : ``}.`,
297
+ `const realFetch = globalThis.fetch;`,
298
+ `globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: local authoring touched the network"); }) as typeof fetch;`,
299
+ ``,
300
+ `await app.${createDir.id}(${payloadLit});`,
301
+ `ok("dispatch ${domain}/${createDir.id} — offline write under the pulled law (payload synthesized from YOUR schema)");`,
302
+ ``,
303
+ );
304
+
305
+ if (query) {
306
+ out.push(
307
+ `// ${n()}. the first declared query routes LOCALLY (params from the synthesized payload)`,
308
+ `const rows = await app.${lcFirst(camel(query.id))}(${queryParams});`,
309
+ `if (rows.length !== 1) fail(\`local declared query ${query.id}: \${JSON.stringify(rows)}\`);`,
310
+ `const createdId = rows[0]!.id;`,
311
+ `ok(\`declared query ${query.id} answers locally — ${agg.id} \${createdId}\`);`,
312
+ ``,
313
+ );
314
+ } else {
315
+ out.push(
316
+ `// ${n()}. no declared query reads this create back (declare a query(...) keyed on a`,
317
+ `// payload field to see one proved here) — the type-index probe captures the id.`,
318
+ `const rows = await app.${listAccessor}();`,
319
+ `if (rows.length !== 1) fail(\`local ${listAccessor}: \${JSON.stringify(rows)}\`);`,
320
+ `const createdId = rows[0]!.id;`,
321
+ `console.log("– declared-query leg SKIPPED — no query(...) returns ${agg.id} keyed on a synthesized payload field");`,
322
+ `ok(\`local read answers — ${agg.id} \${createdId}\`);`,
323
+ ``,
324
+ );
325
+ }
326
+
327
+ if (count) {
328
+ const accessor = lcFirst(camel(count.id));
329
+ const callArg = count.by != null ? JSON.stringify(String(synth.value[count.by])) : "";
330
+ out.push(
331
+ `// ${n()}. the first declared count — the maintained O(1) tally, locally`,
332
+ `const localCount = await app.${accessor}(${callArg});`,
333
+ `if (localCount !== 1) fail(\`local declared count ${count.id}: \${localCount}\`);`,
334
+ `ok("declared count ${count.id}${count.by != null ? `(${callArg.replace(/"/g, "'")})` : `()`} = 1 locally");`,
335
+ ``,
336
+ );
337
+ }
338
+
339
+ out.push(
340
+ `globalThis.fetch = realFetch;`,
341
+ `ok("offline proof ENFORCED — fetch was trapped: local write/read touched no network");`,
342
+ ``,
343
+ `// ${n()}. sync: push the session branch; edge admission re-verifies and merges to main`,
344
+ `const s = await holon.sync();`,
345
+ `if (!s.admission?.ok) fail(\`sync/admit: \${JSON.stringify(s)}\`);`,
346
+ `const admitted = (s.admission.sessions || []).flatMap((x: { admitted?: unknown[] }) => x.admitted || []);`,
347
+ `if (admitted.length < 1) fail(\`edge admitted \${admitted.length} intent(s): \${JSON.stringify(s.admission)}\`);`,
348
+ `ok(\`synced — edge admission merged \${admitted.length} intent(s) to main\`);`,
349
+ ``,
350
+ );
351
+
352
+ if (query) {
353
+ out.push(
354
+ `// ${n()}. the CLOUD answers the SAME declared query (read-manifest overlay, edge projection)`,
355
+ `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/query?queryId=${query.id}&params=\${encodeURIComponent(${JSON.stringify(queryParams)})}\`)).json();`,
356
+ `if (!(d.ok && d.rows.length === 1 && d.rows[0].id === createdId)) fail(\`cloud declared query: \${JSON.stringify(d)}\`);`,
357
+ `ok("cloud declared query ${query.id} returns the admitted row — full loop closed");`,
358
+ ``,
359
+ );
360
+ } else {
361
+ out.push(
362
+ `// ${n()}. the CLOUD answers the by-id read (edge projection of the admitted write)`,
363
+ `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/aggregates/\${encodeURIComponent(createdId)}\`)).json();`,
364
+ `if (!(d.rows?.length === 1 && d.rows[0].id === createdId)) fail(\`cloud by-id read: \${JSON.stringify(d)}\`);`,
365
+ `ok("cloud read returns the admitted row — full loop closed");`,
366
+ ``,
367
+ );
368
+ }
369
+
370
+ if (count) {
371
+ out.push(
372
+ `// ${n()}. the EDGE maintains the same count (same wasm, same law)`,
373
+ `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/counts/${count.id}${count.by != null ? `?group=\${encodeURIComponent(${JSON.stringify(String(synth.value[count.by]))})}` : ``}\`)).json();`,
374
+ `if (!(d.ok && d.count === 1)) fail(\`cloud count ${count.id}: \${JSON.stringify(d)}\`);`,
375
+ `ok("cloud count ${count.id} = 1 — the O(1) maintained read, at the edge too");`,
376
+ ``,
377
+ );
378
+ }
379
+
380
+ if (concurrency) {
381
+ const { directive: cd, idField, setField } = concurrency;
382
+ const cSynth = synthesizePayload(cd.payloadSchema as z.ZodTypeAny, `${domain}/${cd.id}`);
383
+ const concPayload = (writer: string): string => {
384
+ const entries = Object.entries({ ...cSynth.value, [idField]: 0, [setField]: 0 }).map(([k]) => {
385
+ if (k === idField) return `${JSON.stringify(k)}: createdId`;
386
+ if (k === setField) return `${JSON.stringify(k)}: ${JSON.stringify([`proof-${writer}`])}`;
387
+ return `${JSON.stringify(k)}: ${JSON.stringify(cSynth.value[k])}`;
388
+ });
389
+ return `{ ${entries.join(", ")} }`;
390
+ };
391
+ out.push(
392
+ `// ${n()}. CONCURRENCY — two writers, two clientIds, BLIND to each other: each adds to`,
393
+ `// the AddWins set \`${agg.id}.${setField}\` via \`${domain}/${cd.id}\` offline; admission`,
394
+ `// merges both and the union keeps every add.`,
395
+ `const writerA = await connect({ cloud: CLOUD, workspace: WS, clientId: "proof-a" });`,
396
+ `const writerB = await connect({ cloud: CLOUD, workspace: WS, clientId: "proof-b" });`,
397
+ `await ${factory}(writerA).${cd.id}(${concPayload("a")});`,
398
+ `await ${factory}(writerB).${cd.id}(${concPayload("b")});`,
399
+ `await writerA.sync();`,
400
+ `await writerB.sync();`,
401
+ `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/aggregates/\${encodeURIComponent(createdId)}\`)).json();`,
402
+ `const merged: string[] = d.rows?.[0]?.data?.[${JSON.stringify(setField)}] ?? [];`,
403
+ `if (!(merged.includes("proof-a") && merged.includes("proof-b"))) fail(\`AddWins union lost an add: \${JSON.stringify(merged)}\`);`,
404
+ `ok(\`two blind writers' concurrent adds BOTH survive the merge — ${setField} [\${[...merged].sort().join(", ")}]\`);`,
405
+ ``,
406
+ );
407
+ } else {
408
+ out.push(
409
+ `// ${n()}. concurrency leg: this law has no addToSet-capable .mutates directive on an`,
410
+ `// AddWins set field, so there is nothing honest to race.`,
411
+ `console.log("– concurrency leg SKIPPED — add a .mutates directive carrying an array field named like an AddWins set (the addToSet shape) to see two blind writers merge");`,
412
+ ``,
413
+ );
414
+ }
415
+
416
+ out.push(
417
+ `// ${n()}. CONVERGENCE: the original instance adopts canonical main in place — no reconnect`,
418
+ `await holon.pull();`,
419
+ `const head = await holon.head();`,
420
+ `const refsAdv = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/git/info/refs?service=git-upload-pack\`)).text();`,
421
+ `const remoteMain = (refsAdv.match(/([0-9a-f]{40}) refs\\/heads\\/main/) || [])[1];`,
422
+ `if (head !== remoteMain) fail(\`not converged in place: local \${head?.slice(0, 10)} vs main \${remoteMain?.slice(0, 10)}\`);`,
423
+ `ok("converged in place — local head == canonical main");`,
424
+ ``,
425
+ `console.log(\`\\nALL GREEN — generated proof for "${opts.packageName}": deploy → offline write → local reads → admission → cloud reads${concurrency ? ` → AddWins merge` : ``} → convergence (\${WS})\`);`,
426
+ ``,
427
+ );
428
+
429
+ return out.join("\n");
430
+ }
package/src/codegen_ts.ts CHANGED
@@ -18,7 +18,9 @@
18
18
  * * a PAYLOAD TYPE per directive — derived from the SAME zod schema the sealed
19
19
  * engine validates (recursive zod→TS: enums become literal unions, `.optional()`
20
20
  * becomes `?`, `.int()` stays `number`), so a payload the type system accepts is
21
- * a payload the engine accepts;
21
+ * a payload the engine accepts; required string fields named `…At` are
22
+ * CALLER-STAMPED TIMESTAMPS — optional in the type, auto-filled with ISO now()
23
+ * at dispatch (see {@link autoStampFields});
22
24
  * * a READ-MODEL interface per aggregate — derived from the DSL `Field` kinds the
23
25
  * projection decodes by (set→`T[]`, map→`Record<string, V>` via `mapValueKind`,
24
26
  * `t.jsonObject()`→`Record<string, unknown>`, plain `t.json()`→`unknown`), with
@@ -45,11 +47,24 @@ import type { DerivedDecl } from "./derived.js";
45
47
  import type { CombinedDecl } from "./combined.js";
46
48
 
47
49
  const cap = (s: string) => (s.length ? s[0]!.toUpperCase() + s.slice(1) : s);
48
- const camel = (s: string) => s.replace(/[_-]+(\w)/g, (_m, c: string) => c.toUpperCase());
50
+ const camel = (s: string) => s.replace(/[_\s-]+(\w)/g, (_m, c: string) => c.toUpperCase());
49
51
  const lcFirst = (s: string) => (s.length ? s[0]!.toLowerCase() + s.slice(1) : s);
50
52
  const upperSnake = (s: string) => s.replace(/[-\s]+/g, "_").replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase();
51
53
 
54
+ // ── NAME NORMALIZATION (no naming convention to learn) ────────────────────────────
55
+ // `nomos.package.mjs` names and domain keys may be ANY string — kebab-case,
56
+ // snake_case, space-separated, PascalCase. Artifact FILENAMES keep the raw name
57
+ // verbatim; generated SYMBOLS normalize it:
58
+
59
+ /** `tool-library` / `tool_library` / `Tool Library` → `toolLibraryClient` — the factory symbol. */
60
+ export const tsClientFactoryName = (domain: string): string => `${lcFirst(camel(domain))}Client`;
61
+
62
+ /** `tool-library` / `tool_library` / `Tool Library` → `TOOL_LIBRARY_DOMAIN_HASH` — the hash const. */
63
+ export const tsHashConstName = (packageName: string): string => `${upperSnake(packageName)}_DOMAIN_HASH`;
64
+
52
65
  // ── zod → TS (the same `_def ?? def` convention as codegen_dart's walker) ─────────
66
+ // The walkers are exported: codegen_proof.ts synthesizes SAMPLE payloads from the
67
+ // same zod shapes this file derives TYPES from — one introspection, two emitters.
53
68
 
54
69
  type ZodInternalDef = {
55
70
  type?: string | z.ZodTypeAny;
@@ -63,17 +78,17 @@ type ZodInternalDef = {
63
78
  valueType?: z.ZodTypeAny;
64
79
  };
65
80
 
66
- function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
81
+ export function zodDef(zt: z.ZodTypeAny): ZodInternalDef {
67
82
  const raw = zt as unknown as { _def?: ZodInternalDef; def?: ZodInternalDef };
68
83
  return raw._def ?? raw.def ?? {};
69
84
  }
70
85
 
71
- function zodKind(zt: z.ZodTypeAny): string {
86
+ export function zodKind(zt: z.ZodTypeAny): string {
72
87
  const t = zodDef(zt).type;
73
88
  return typeof t === "string" ? t : "unknown";
74
89
  }
75
90
 
76
- function zodEnumValues(zt: z.ZodTypeAny): string[] {
91
+ export function zodEnumValues(zt: z.ZodTypeAny): string[] {
77
92
  const raw = zt as unknown as { options?: readonly unknown[] };
78
93
  if (raw.options !== undefined) return raw.options.map(String);
79
94
  const entries = zodDef(zt).entries;
@@ -81,7 +96,7 @@ function zodEnumValues(zt: z.ZodTypeAny): string[] {
81
96
  throw new Error("codegen-ts: ZodEnum has no values");
82
97
  }
83
98
 
84
- function zodObjectShape(zt: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
99
+ export function zodObjectShape(zt: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
85
100
  const shape = zodDef(zt).shape;
86
101
  if (typeof shape === "function") return shape();
87
102
  if (shape !== undefined) return shape;
@@ -90,7 +105,7 @@ function zodObjectShape(zt: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
90
105
  throw new Error("codegen-ts: ZodObject has no shape");
91
106
  }
92
107
 
93
- function zodArrayElement(zt: z.ZodTypeAny): z.ZodTypeAny {
108
+ export function zodArrayElement(zt: z.ZodTypeAny): z.ZodTypeAny {
94
109
  const def = zodDef(zt);
95
110
  if (def.element !== undefined) return def.element;
96
111
  if (typeof def.type !== "string" && def.type !== undefined) return def.type;
@@ -151,6 +166,44 @@ function tsTypeOfZod(zt: z.ZodTypeAny, indent: string): string {
151
166
  }
152
167
  }
153
168
 
169
+ // ── caller-side clock sugar (the `…At` payload convention) ────────────────────────
170
+
171
+ /**
172
+ * Top-level REQUIRED string payload fields named `…At` (`signedAt`, `updatedAt`,
173
+ * `stampedAt`, …) are CALLER-STAMPED TIMESTAMPS by convention: the generated client
174
+ * fills an omitted one with `new Date().toISOString()` at dispatch. The stamp rides
175
+ * IN the payload — the plan stays a pure function of `(payload, ports)` and replays
176
+ * byte-identical on every peer — so the domain dev stops thinking about clocks.
177
+ * Passing the field explicitly always wins (scheduling-shaped fields like
178
+ * `expiresAt` should be passed explicitly — "now" is only the DEFAULT).
179
+ */
180
+ export function autoStampFields(schema: z.ZodTypeAny): string[] {
181
+ if (zodKind(schema) !== "object") return [];
182
+ const out: string[] = [];
183
+ for (const [name, f] of Object.entries(zodObjectShape(schema))) {
184
+ // required-only: `.optional()`/`.default()` wrappers change the zod kind, so they skip.
185
+ if (name.length > 2 && name.endsWith("At") && zodKind(f as z.ZodTypeAny) === "string") out.push(name);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ /** The payload type text, with auto-stamped fields surfaced as optional (`?`). */
191
+ function tsTypeOfPayload(schema: z.ZodTypeAny, autoStamped: readonly string[]): string {
192
+ if (zodKind(schema) !== "object" || autoStamped.length === 0) return tsTypeOfZod(schema, "");
193
+ const stamped = new Set(autoStamped);
194
+ const fields = Object.entries(zodObjectShape(schema)).map(([name, raw]) => {
195
+ let f = raw as z.ZodTypeAny;
196
+ let optional = stamped.has(name);
197
+ while (zodKind(f) === "optional" || zodKind(f) === "default") {
198
+ optional = true;
199
+ f = zodDef(f).innerType!;
200
+ }
201
+ const note = stamped.has(name) ? " // auto-stamped with ISO now() at dispatch when omitted" : "";
202
+ return ` ${name}${optional ? "?" : ""}: ${tsTypeOfZod(f, " ")};${note}`;
203
+ });
204
+ return fields.length ? `{\n${fields.join("\n")}\n}` : "Record<string, never>";
205
+ }
206
+
154
207
  // ── DSL Field → read-model TS type (the projection's decode surface) ──────────────
155
208
 
156
209
  function tsTypeOfField(field: Field): string {
@@ -214,7 +267,7 @@ export interface TsClientOptions {
214
267
  }
215
268
 
216
269
  export function generateTsClient(modules: readonly DomainModule[], opts: TsClientOptions): string {
217
- const hashConst = `${upperSnake(opts.packageName)}_DOMAIN_HASH`;
270
+ const hashConst = tsHashConstName(opts.packageName);
218
271
  const out: string[] = [];
219
272
  out.push(
220
273
  `// AUTO-GENERATED by nomos-compile — typed TS client for package "${opts.packageName}".`,
@@ -222,9 +275,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
222
275
  `// exposing the @githolon/client surface (the structural NomosHolon below), e.g.`,
223
276
  `//`,
224
277
  `// import { connect } from "@githolon/client";`,
225
- `// import { ${lcFirst(camel(modules[0]?.domain ?? "domain"))}Client } from "./build/${opts.packageName}.client.js";`,
278
+ `// import { ${tsClientFactoryName(modules[0]?.domain ?? "domain")} } from "./build/${opts.packageName}.client.js";`,
226
279
  `// const holon = await connect({ cloud, workspace, clientId });`,
227
- `// const app = ${lcFirst(camel(modules[0]?.domain ?? "domain"))}Client(holon);`,
280
+ `// const app = ${tsClientFactoryName(modules[0]?.domain ?? "domain")}(holon);`,
228
281
  ``,
229
282
  `/** sha256 of the deployed \`.package.usda\` — the installed law this client dispatches under. */`,
230
283
  `export const ${hashConst} = ${JSON.stringify(opts.domainHash)};`,
@@ -250,6 +303,28 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
250
303
  ``,
251
304
  );
252
305
 
306
+ const anyAutoStamp = modules.some((mod) =>
307
+ (mod.directives as Directive<unknown>[]).some(
308
+ (d) =>
309
+ d &&
310
+ typeof (d as { payloadSchema?: unknown }).payloadSchema === "object" &&
311
+ autoStampFields((d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema).length > 0,
312
+ ),
313
+ );
314
+ if (anyAutoStamp) {
315
+ out.push(
316
+ `/** Caller-side clock sugar: fill omitted \`…At\` timestamp payload fields with ISO now().`,
317
+ ` * The stamp rides IN the payload, so plans stay pure and replay byte-identical. */`,
318
+ `const stampNow = <T extends object>(payload: T, fields: string[]): T => {`,
319
+ ` const now = new Date().toISOString();`,
320
+ ` const out = { ...payload } as Record<string, unknown>;`,
321
+ ` for (const f of fields) if (out[f] === undefined) out[f] = now;`,
322
+ ` return out as T;`,
323
+ `};`,
324
+ ``,
325
+ );
326
+ }
327
+
253
328
  // ── read models: one interface per aggregate wire type (deduped), deriveds/combineds merged ──
254
329
  const aggByType = new Map<string, AggregateHandle>();
255
330
  const extrasByType = new Map<string, string[]>();
@@ -291,14 +366,17 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
291
366
  if (usedPayloadNames.has(payloadName)) payloadName = `${cap(camel(domain))}${cap(d.id)}Payload`;
292
367
  usedPayloadNames.add(payloadName);
293
368
  const schema = (d as unknown as { payloadSchema: z.ZodTypeAny }).payloadSchema;
369
+ const stamps = autoStampFields(schema);
294
370
  out.push(
295
- `/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema). */`,
296
- `export type ${payloadName} = ${tsTypeOfZod(schema, "")};`,
371
+ `/** \`${domain}/${d.id}\` payload (mirrors the engine's zod schema).${stamps.length ? ` Omitted ${stamps.map((s) => `\`${s}\``).join("/")} auto-stamp(s) ISO now() at dispatch.` : ""} */`,
372
+ `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps)};`,
297
373
  ``,
298
374
  );
299
375
  methods.push(
300
376
  ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
301
- ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, { domainHash }),`,
377
+ stamps.length
378
+ ? ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, stampNow(payload, ${JSON.stringify(stamps)}), { domainHash }),`
379
+ : ` ${d.id}: (payload: ${payloadName}) => holon.dispatch(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, payload, { domainHash }),`,
302
380
  );
303
381
  }
304
382
 
@@ -350,7 +428,7 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
350
428
 
351
429
  out.push(
352
430
  `/** Typed client for the \`${domain}\` domain, bound to a connected holon. */`,
353
- `export function ${lcFirst(camel(domain))}Client(holon: NomosHolon, domainHash: string = ${hashConst}) {`,
431
+ `export function ${tsClientFactoryName(domain)}(holon: NomosHolon, domainHash: string = ${hashConst}) {`,
354
432
  ` return {`,
355
433
  ` domain: ${JSON.stringify(domain)} as const,`,
356
434
  ` domainHash,`,
@@ -37,6 +37,10 @@
37
37
  * identityHashes}`: the EXACT body `POST /v1/workspaces/:ws/domains` accepts as
38
38
  * `application/json` on Nomos Cloud (package + per-workspace manifest overlay in
39
39
  * one deploy).
40
+ * * `<name>.proof.mts` — the GENERATED PROOF (codegen_proof.ts): a
41
+ * runnable, domain-shaped live e2e synthesized from the law's own directives /
42
+ * queries / counts. Run it with `githolon proof`. Never fatal: an unsampleable
43
+ * domain still compiles, the skip names its remedy.
40
44
  *
41
45
  * The engine entry is GENERATED (imports + one `registerEngine` call — the machinery
42
46
  * lives in `@githolon/dsl/engine-entry`), bundled with the SAME deterministic esbuild
@@ -69,7 +73,8 @@ import {
69
73
  writeIdentity,
70
74
  type Mod,
71
75
  } from "./build_package.js";
72
- import { generateTsClient } from "./codegen_ts.js";
76
+ import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
77
+ import { generateTsProof } from "./codegen_proof.js";
73
78
 
74
79
  const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
75
80
 
@@ -288,7 +293,12 @@ async function main(): Promise<void> {
288
293
  if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
289
294
  fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
290
295
  }
291
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(cfg.name)) fail(`package name '${cfg.name}' is not a safe basename`);
296
+ // ANY readable name works ("tool-library", "tool_library", "Tool Library"):
297
+ // artifact FILENAMES keep it verbatim; generated SYMBOLS normalize it
298
+ // (toolLibraryClient, TOOL_LIBRARY_DOMAIN_HASH — see codegen_ts.ts).
299
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/.test(cfg.name)) {
300
+ fail(`package name '${cfg.name}' is not a safe basename (letters, digits, spaces, '.', '-', '_')`);
301
+ }
292
302
 
293
303
  const outDir = path.resolve(cfgDir, cfg.outDir ?? "build");
294
304
  mkdirSync(outDir, { recursive: true });
@@ -467,6 +477,21 @@ async function main(): Promise<void> {
467
477
  };
468
478
  const deployPath = path.join(outDir, `${cfg.name}.deploy.json`);
469
479
  writeFileSync(deployPath, JSON.stringify(deployBody) + "\n", "utf8");
480
+
481
+ // ── 4b. the GENERATED PROOF — a runnable, domain-shaped e2e synthesized from the
482
+ // law itself (codegen_proof.ts): reshape the law, recompile, the proof
483
+ // reshapes itself — no test rewriting. NEVER FATAL: a domain the
484
+ // synthesizer can't sample yet still compiles — the skip names its remedy.
485
+ let proofPath: string | undefined;
486
+ let proofSkip: string | undefined;
487
+ try {
488
+ const proofSrc = generateTsProof(domainModules, { packageName: cfg.name, domainHash });
489
+ proofPath = path.join(outDir, `${cfg.name}.proof.mts`);
490
+ writeFileSync(proofPath, proofSrc, "utf8");
491
+ } catch (e) {
492
+ proofSkip = (e as Error).message;
493
+ }
494
+
470
495
  const summaryTxt = [
471
496
  `${cfg.name} — compiled Nomos domain package`,
472
497
  `law (domainHash): ${domainHash} # sha256(${cfg.name}.package.usda) — content-addressed`,
@@ -477,7 +502,10 @@ async function main(): Promise<void> {
477
502
  ` queries: ${d.queries.join(", ") || "—"} counts: ${d.counts.join(", ") || "—"}`,
478
503
  ]),
479
504
  `deploy: POST ${cfg.name}.deploy.json to /v1/workspaces/<ws>/domains (or: githolon deploy <ws>)`,
480
- `typed client: ${cfg.name}.client.ts — ${cfg.name}Client(holon), law hash baked in`,
505
+ `typed client: ${cfg.name}.client.ts — ${tsClientFactoryName(domainModules[0]?.domain ?? domainModules[0]?.name ?? cfg.name)}(holon), law hash baked in`,
506
+ proofPath !== undefined
507
+ ? `generated proof: ${cfg.name}.proof.mts — run: githolon proof (live; ends ALL GREEN or names the jam)`
508
+ : `generated proof: SKIPPED — ${proofSkip}`,
481
509
  ``,
482
510
  ].join("\n");
483
511
  writeFileSync(path.join(outDir, `${cfg.name}.summary.txt`), summaryTxt, "utf8");
@@ -513,6 +541,11 @@ async function main(): Promise<void> {
513
541
  for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);
514
542
  for (const ex of identity.excluded) console.log(` EXCLUDED ${ex.domain}: ${ex.reason}`);
515
543
  console.log(` client ${rel(clientPath)} (typed TS client — payloads, read models, query accessors)`);
544
+ if (proofPath !== undefined) {
545
+ console.log(` proof ${rel(proofPath)} (a runnable e2e GENERATED from your law — run: githolon proof)`);
546
+ } else {
547
+ console.log(` proof SKIPPED — ${proofSkip}`);
548
+ }
516
549
  if (dartOut !== undefined) {
517
550
  console.log(` dart ${rel(dartOut)}${path.sep} (${dartFiles.length} domain file(s) + vendored nomos_dsl support — typed Dart for Flutter)`);
518
551
  }
@@ -522,7 +555,11 @@ async function main(): Promise<void> {
522
555
  console.log(` githolon ws create <ws> && githolon deploy <ws> # the secret is saved + sent for you`);
523
556
  console.log(` # raw lane: curl -X POST -H 'content-type: application/json' -H 'Authorization: Bearer <workspaceSecret>' \\`);
524
557
  console.log(` # --data-binary @${rel(deployPath)} https://nomos.captainapp.co.uk/v1/workspaces/<ws>/domains`);
525
- console.log(`prove it: npm run e2e # offline write -> sync -> admission -> cloud query, live`);
558
+ if (proofPath !== undefined) {
559
+ console.log(`prove it: npx githolon proof # GENERATED from your law — offline write -> sync -> admission -> cloud reads, live`);
560
+ } else {
561
+ console.log(`prove it: npm run e2e # offline write -> sync -> admission -> cloud query, live`);
562
+ }
526
563
  }
527
564
 
528
565
  await main();
package/src/index.ts CHANGED
@@ -6,6 +6,9 @@
6
6
  // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
7
 
8
8
  /** `@githolon/dsl` — the Nomos 2 domain-authoring DSL. */
9
+ // ONE import for a domain file: `z` (payload schemas) rides along with the DSL, so
10
+ // authors never juggle a separate zod dependency (or risk a second zod instance).
11
+ export { z } from "zod";
9
12
  export {
10
13
  aggregate,
11
14
  instance,