@githolon/dsl 0.4.0 → 0.5.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.
Files changed (32) hide show
  1. package/package.json +2 -1
  2. package/src/build_package.ts +4 -0
  3. package/src/capability_exports.ts +55 -0
  4. package/src/codegen_dart.ts +9 -0
  5. package/src/codegen_proof.ts +72 -11
  6. package/src/compile_package_main.ts +241 -13
  7. package/src/directive.ts +35 -10
  8. package/src/engine_entry.ts +5 -1
  9. package/src/framework/capability.ts +215 -0
  10. package/src/framework/impure_capability.ts +25 -3
  11. package/src/framework/workspaces.ts +129 -0
  12. package/src/index.ts +9 -0
  13. package/src/manifest.ts +103 -0
  14. package/src/read.ts +29 -0
  15. package/src/stable_ids.ts +226 -0
  16. package/src/stable_ids_types.ts +40 -0
  17. package/src/usd.ts +54 -0
  18. package/src/usd_layers.ts +65 -1
  19. package/src/wire_encode.ts +15 -0
  20. package/dart/.dart_tool/package_config.json +0 -328
  21. package/dart/.dart_tool/package_graph.json +0 -485
  22. package/dart/.dart_tool/pub/bin/test/test.dart-3.11.5.snapshot +0 -0
  23. package/dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjU= +0 -0
  24. package/dart/.dart_tool/version +0 -1
  25. package/dart/build/native_assets/macos/native_assets.json +0 -1
  26. package/dart/build/test_cache/build/89a6598c8854ed031dfc25d83c80860e.cache.dill.track.dill +0 -0
  27. package/dart/build/unit_test_assets/AssetManifest.bin +0 -0
  28. package/dart/build/unit_test_assets/FontManifest.json +0 -1
  29. package/dart/build/unit_test_assets/NOTICES.Z +0 -0
  30. package/dart/build/unit_test_assets/NativeAssetsManifest.json +0 -1
  31. package/dart/build/unit_test_assets/shaders/ink_sparkle.frag +0 -0
  32. package/dart/build/unit_test_assets/shaders/stretch_effect.frag +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
@@ -17,6 +17,7 @@
17
17
  "exports": {
18
18
  ".": "./src/index.ts",
19
19
  "./manifest": "./src/manifest.ts",
20
+ "./stable-ids": "./src/stable_ids.ts",
20
21
  "./compose": "./src/compose.ts",
21
22
  "./usd": "./src/usd.ts",
22
23
  "./usd-layers": "./src/usd_layers.ts",
@@ -54,6 +54,7 @@ import type { WorkspaceTypeDecl } from "./workspace_type.js";
54
54
  import type { WorkspaceInvariantDecl } from "./framework/workspace_invariant.js";
55
55
  import { domainHash, emitManifestBytes } from "./manifest.js";
56
56
  import { emitUsd } from "./usd.js";
57
+ import { expandCapabilityExports } from "./capability_exports.js";
57
58
 
58
59
  // ─────────────────────────────────────────────────────────────────────────────────
59
60
  // composeDomainModule — the named-keys `module(...)`
@@ -134,6 +135,9 @@ export interface DomainModuleSpec {
134
135
  export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
135
136
  let merged: Mod = {};
136
137
  for (const m of spec.modules) merged = { ...merged, ...m };
138
+ // `capability()` bundles expand into discoverable entries (the package plane's
139
+ // merge point) — the quartet validation below then passes without re-exports.
140
+ merged = expandCapabilityExports(merged);
137
141
 
138
142
  const byId = new Map<string, AggregateHandle>();
139
143
  for (const a of [...aggregatesOf(merged), ...(spec.extraAggregates ?? [])]) byId.set(a.id, a);
@@ -0,0 +1,55 @@
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
+ * capability_exports.ts — the ONE-EXPORT expansion for `capability()` bundles
10
+ * (`architecture/capability_marketplace.md` §4, ruling M2 — "the domain dev
11
+ * declares ONE thing").
12
+ *
13
+ * Every module-exports walk in the house scans TOP-LEVEL exports by shape
14
+ * (aggregates, directives, queries…). A `capability()` declaration is one
15
+ * export holding a task aggregate + the five lifecycle directives as
16
+ * properties — invisible to those walks. This helper, applied at each plane's
17
+ * merge point (the engine lump's `mergeModules`, the package composer's merge,
18
+ * the compile CLI's merge), ADDS the bundle's pieces to the exports bag under
19
+ * synthetic names (`<export>$aggregate`, `<export>$order`, …) so every
20
+ * existing shape-walk discovers them with ZERO new walk semantics. The
21
+ * original bundle export stays (config-named lanes still resolve it); the
22
+ * walks ignore its unmatched shape exactly as before.
23
+ *
24
+ * ZERO imports on purpose: this file is bundled into the sealed engine lump
25
+ * (engine_entry.ts) — it must drag nothing with it.
26
+ */
27
+
28
+ /** Duck-type guard: a `capability()` return (the tag is set by the factory). */
29
+ export function isCapabilityDecl(v: unknown): v is Record<string, unknown> {
30
+ return (
31
+ typeof v === "object" &&
32
+ v !== null &&
33
+ (v as { __isCapabilityDecl?: boolean }).__isCapabilityDecl === true
34
+ );
35
+ }
36
+
37
+ const PIECES = ["aggregate", "order", "complete", "fail", "block", "deadLetter", "tasks"] as const;
38
+
39
+ /**
40
+ * Expand every `capability()` bundle in an exports bag into discoverable
41
+ * top-level entries. Pure; returns the SAME object when nothing expands (the
42
+ * common case stays allocation-free and referentially stable).
43
+ */
44
+ export function expandCapabilityExports<M extends Record<string, unknown>>(mod: M): M {
45
+ let out: Record<string, unknown> | null = null;
46
+ for (const [k, v] of Object.entries(mod)) {
47
+ if (!isCapabilityDecl(v)) continue;
48
+ out ??= { ...mod };
49
+ for (const piece of PIECES) {
50
+ const member = (v as Record<string, unknown>)[piece];
51
+ if (member !== undefined) out[`${k}$${piece}`] = member;
52
+ }
53
+ }
54
+ return (out ?? mod) as M;
55
+ }
@@ -2322,6 +2322,15 @@ export interface DomainModule {
2322
2322
  domain?: string;
2323
2323
  aggregates: AggregateHandle[];
2324
2324
  directives: AnyDirective[];
2325
+ /**
2326
+ * STABLE-ID CONTINUITY (#58 — names are labels, identity is minted). When present
2327
+ * (attached by `nomos-compile` from the PRIOR build's identity manifests), the
2328
+ * canonical manifest's `stableIds` block carries these sids/lineages forward instead
2329
+ * of minting at first appearance — a renamed entity keeps its identity. Keyed by
2330
+ * CURRENT names (the compiler resolves renames BEFORE attaching). Absent ⇒ every
2331
+ * sid mints deterministically at first appearance.
2332
+ */
2333
+ stableIdContinuity?: import("./stable_ids_types.js").StableIds;
2325
2334
  /**
2326
2335
  * The domain's NAMED, INDEXED read declarations (read-side closure step 1).
2327
2336
  * ADDITIVE + OPTIONAL: a domain that declares no query omits this entirely and is
@@ -41,6 +41,7 @@ import type { Directive } from "./directive.js";
41
41
  import type { DomainModule } from "./codegen_dart.js";
42
42
  import type { QueryDecl } from "./query.js";
43
43
  import { finishCount } from "./count.js";
44
+ import { mintedCreateField } from "./workspace_routing.js";
44
45
  import {
45
46
  autoStampFields,
46
47
  tsClientFactoryName,
@@ -211,10 +212,21 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
211
212
  }
212
213
  const synth = synthesizePayload(createDir.payloadSchema as z.ZodTypeAny, `${domain}/${createDir.id}`);
213
214
 
215
+ // THE MINTED-ID RULE (the gate's `check_create_ids`): a `.creates` payload field
216
+ // that IS the target's own id must be KERNEL-MINTED — a hand-written sample id
217
+ // refuses typed at admission (`NotMinted`). The marker-driven trace names the
218
+ // field (never name-guessed); the generated proof MINTS it at runtime
219
+ // (`holon.mint`) instead of synthesizing a sample, so the proof admits
220
+ // everywhere the law does.
221
+ const mintField = mintedCreateField(createDir, agg);
222
+ const baseValue: Record<string, unknown> = { ...synth.value };
223
+ if (mintField !== undefined) delete baseValue[mintField];
224
+
214
225
  // 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.
226
+ // aggregate and every key field has a synthesized payload value to probe with
227
+ // (a query keyed on the MINTED field is skipped — its value exists only at runtime).
216
228
  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),
229
+ (q) => q.returns === agg.id && q.key.length > 0 && q.key.every((k) => !k.includes(".") && baseValue[k] !== undefined),
218
230
  );
219
231
 
220
232
  // The first declared count the create moves: counts the created aggregate,
@@ -226,7 +238,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
226
238
  (c) =>
227
239
  c.of === agg.id &&
228
240
  (c as { where?: unknown }).where == null &&
229
- (c.by == null || synth.value[c.by] !== undefined),
241
+ (c.by == null || baseValue[c.by] !== undefined),
230
242
  );
231
243
 
232
244
  const concurrency = findConcurrencyLeg(mod.directives as Directive<unknown>[], agg);
@@ -239,10 +251,27 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
239
251
  .replace(/^-+|-+$/g, "")
240
252
  .slice(0, 24) || "domain";
241
253
 
242
- const payloadLit = JSON.stringify(synth.value);
243
- const queryParams = query ? JSON.stringify(Object.fromEntries(query.key.map((k) => [k, synth.value[k]]))) : "";
254
+ const payloadLit =
255
+ mintField !== undefined
256
+ ? `{ ...${JSON.stringify(baseValue)}, ${JSON.stringify(mintField)}: __minted }`
257
+ : JSON.stringify(baseValue);
258
+ const queryParamsObj = query ? Object.fromEntries(query.key.map((k) => [k, baseValue[k]])) : undefined;
259
+ const queryParams = queryParamsObj ? JSON.stringify(queryParamsObj) : "";
244
260
  const listAccessor = `list${agg.id}s`;
245
261
 
262
+ // THE MACHINE-READABLE LEGS (parsed by `githolon`'s offline proof runner —
263
+ // cli/src/proof_offline.ts — instead of regex-scraping the generated source).
264
+ const legsMarker = JSON.stringify({
265
+ domain,
266
+ directiveId: createDir.id,
267
+ aggregateId: agg.id,
268
+ payload: baseValue,
269
+ autoStamped: synth.autoStamped,
270
+ ...(mintField !== undefined ? { mintField } : {}),
271
+ ...(query && queryParamsObj ? { query: { id: query.id, params: queryParamsObj } } : {}),
272
+ ...(count ? { count: { id: count.id, group: count.by != null ? String(baseValue[count.by]) : "" } } : {}),
273
+ });
274
+
246
275
  let step = 0;
247
276
  const n = () => ++step;
248
277
  const out: string[] = [];
@@ -256,6 +285,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
256
285
  `//`,
257
286
  `// The flow mirrors the scaffold's test/e2e.mts (docs/ in a scaffolded app explains`,
258
287
  `// every call — start at docs/01-mental-model.md). Regenerated on every compile; do not edit.`,
288
+ `// @nomos-proof-legs ${legsMarker}`,
259
289
  `import { readFileSync } from "node:fs";`,
260
290
  `import { connect } from "@githolon/client";`,
261
291
  `import { ${factory}, ${hashConst} } from "./${opts.packageName}.client.ts";`,
@@ -266,18 +296,25 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
266
296
  `// IDENTITY NOTE: this proof uses the bare x-nomos-principal lane so it is`,
267
297
  `// SELF-CONTAINED (a throwaway workspace, no stored credentials). Real apps use`,
268
298
  `// \`githolon login --agent\` (a VERIFIED identity) + \`githolon ws create/deploy\`.`,
269
- `const fail: (m: string) => never = (m) => { console.error("✗ " + m); process.exit(1); };`,
299
+ `const fail: (m: string) => never = (m) => { throw new Error(m); };`,
270
300
  `const ok = (m: string) => console.log("✓ " + m);`,
271
301
  ``,
272
302
  `const deploy = JSON.parse(readFileSync(new URL(${JSON.stringify(`./${opts.packageName}.deploy.json`)}, import.meta.url), "utf8"));`,
273
303
  ``,
304
+ `// CLEANUP CONTRACT: the throwaway workspace is RETIRED on every exit (green or`,
305
+ `// jammed) — a proof never leaves orphans. PROOF_KEEP=1 (githolon proof --live --keep)`,
306
+ `// keeps it and prints the FULL secret once, with the persist command.`,
307
+ `let SECRET = "";`,
308
+ `let runError: unknown = null;`,
309
+ `try {`,
310
+ ``,
274
311
  `// ${n()}. a throwaway workspace (the ONE-TIME secret comes back on create)`,
275
312
  `let r = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}\`, { method: "POST", headers: { "x-nomos-principal": "githolon-proof" } });`,
276
313
  `let d = await r.json();`,
277
314
  `if (!d.ok) fail(\`create workspace: \${JSON.stringify(d)}\`);`,
278
- `const SECRET: string = d.workspaceSecret;`,
315
+ `SECRET = d.workspaceSecret;`,
279
316
  `if (!SECRET?.startsWith("nws_v1_")) fail(\`no workspaceSecret returned: \${JSON.stringify(d)}\`);`,
280
- `ok(\`workspace \${WS} created secret \${SECRET.slice(0, 10)}…\`);`,
317
+ `ok(\`workspace \${WS} created (one-time secret held for cleanup — PROOF_KEEP=1 keeps the workspace and prints it)\`);`,
281
318
  ``,
282
319
  `// ${n()}. deploy YOUR compiled law (build/${opts.packageName}.deploy.json) WITH the secret`,
283
320
  `r = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/domains\`, { method: "POST", headers: { "content-type": "application/json", authorization: \`Bearer \${SECRET}\` }, body: JSON.stringify(deploy) });`,
@@ -297,8 +334,16 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
297
334
  `const realFetch = globalThis.fetch;`,
298
335
  `globalThis.fetch = (() => { throw new Error("OFFLINE PROOF VIOLATED: local authoring touched the network"); }) as typeof fetch;`,
299
336
  ``,
337
+ ...(mintField !== undefined
338
+ ? [
339
+ `// the target's own id is KERNEL-MINTED (never hand-written — the gate's`,
340
+ `// check_create_ids refuses sample ids typed): the local wasm mints, the id`,
341
+ `// rides the payload, replay reads the capture.`,
342
+ `const __minted = await holon.mint(${JSON.stringify(agg.id)});`,
343
+ ]
344
+ : []),
300
345
  `await app.${createDir.id}(${payloadLit});`,
301
- `ok("dispatch ${domain}/${createDir.id} — offline write under the pulled law (payload synthesized from YOUR schema)");`,
346
+ `ok("dispatch ${domain}/${createDir.id} — offline write under the pulled law (payload synthesized from YOUR schema${mintField !== undefined ? `; \`${mintField}\` kernel-minted` : ``})");`,
302
347
  ``,
303
348
  );
304
349
 
@@ -326,7 +371,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
326
371
 
327
372
  if (count) {
328
373
  const accessor = lcFirst(camel(count.id));
329
- const callArg = count.by != null ? JSON.stringify(String(synth.value[count.by])) : "";
374
+ const callArg = count.by != null ? JSON.stringify(String(baseValue[count.by])) : "";
330
375
  out.push(
331
376
  `// ${n()}. the first declared count — the maintained O(1) tally, locally`,
332
377
  `const localCount = await app.${accessor}(${callArg});`,
@@ -370,7 +415,7 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
370
415
  if (count) {
371
416
  out.push(
372
417
  `// ${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();`,
418
+ `d = await (await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/counts/${count.id}${count.by != null ? `?group=\${encodeURIComponent(${JSON.stringify(String(baseValue[count.by]))})}` : ``}\`)).json();`,
374
419
  `if (!(d.ok && d.count === 1)) fail(\`cloud count ${count.id}: \${JSON.stringify(d)}\`);`,
375
420
  `ok("cloud count ${count.id} = 1 — the O(1) maintained read, at the edge too");`,
376
421
  ``,
@@ -424,6 +469,22 @@ export function generateTsProof(modules: readonly DomainModule[], opts: TsProofO
424
469
  ``,
425
470
  `console.log(\`\\nALL GREEN — generated proof for "${opts.packageName}": deploy → offline write → local reads → admission → cloud reads${concurrency ? ` → AddWins merge` : ``} → convergence (\${WS})\`);`,
426
471
  ``,
472
+ `} catch (e) {`,
473
+ ` runError = e;`,
474
+ ` console.error("✗ " + ((e as Error)?.message ?? e));`,
475
+ `} finally {`,
476
+ ` // THE CLEANUP CONTRACT: never an orphaned cloud workspace. Retired on every`,
477
+ ` // exit path; PROOF_KEEP=1 keeps it and prints the FULL secret exactly once.`,
478
+ ` if (SECRET && process.env.PROOF_KEEP === "1") {`,
479
+ ` console.log(\`workspace \${WS} KEPT — secret \${SECRET}\`);`,
480
+ ` console.log(\` persist it: githolon secret set \${WS} <the secret above> (then: githolon ws retire \${WS} when done)\`);`,
481
+ ` } else if (SECRET) {`,
482
+ ` const rr = await fetch(\`\${CLOUD}/v1/workspaces/\${WS}/retire\`, { method: "POST", headers: { authorization: \`Bearer \${SECRET}\` } }).then((x) => x.json()).catch(() => ({}));`,
483
+ ` console.log(\` retired \${WS}: \${(rr as { ok?: boolean }).ok === true}\`);`,
484
+ ` }`,
485
+ ` process.exit(runError ? 1 : 0);`,
486
+ `}`,
487
+ ``,
427
488
  );
428
489
 
429
490
  return out.join("\n");
@@ -21,10 +21,10 @@
21
21
  * Per domain: `key` is the engine dispatch key AND the identity-manifest name
22
22
  * (`name` overrides the latter); `modules` is the ORDERED module list (later wins on
23
23
  * a name collision — the framework `identity` union pattern). Read-side declarations
24
- * (queries / counts / spatials / deriveds / combineds) are AUTO-DISCOVERED from the
25
- * merged module exports by SHAPE (both individual decls and exported arrays of them,
26
- * deduped); anything undiscoverable can be passed explicitly as EXPORT NAMES, e.g.
27
- * `{ sums: ["GUESTBOOK_SUMS"], extraAggregates: ["SomeFrameworkHandle"] }`.
24
+ * (queries / counts / sums / spatials / deriveds / combineds) are AUTO-DISCOVERED
25
+ * from the merged module exports by SHAPE (both individual decls and exported arrays
26
+ * of them, deduped); anything undiscoverable can be passed explicitly as EXPORT
27
+ * NAMES, e.g. `{ mins: ["LOWEST_BIDS"], extraAggregates: ["SomeFrameworkHandle"] }`.
28
28
  * Reports: `{ reports: { my_report_v1: "./report/my_report.ts#myReportV1" } }`.
29
29
  *
30
30
  * It emits into `outDir` (default `<config dir>/build`):
@@ -49,6 +49,7 @@
49
49
  * `globalThis.plan`.
50
50
  */
51
51
  import { execFileSync } from "node:child_process";
52
+ import { expandCapabilityExports, isCapabilityDecl } from "./capability_exports.js";
52
53
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
53
54
  import { createRequire } from "node:module";
54
55
  import path from "node:path";
@@ -93,6 +94,14 @@ import {
93
94
  layeredRootUsda,
94
95
  type LayeredModuleInput,
95
96
  } from "./usd_layers.js";
97
+ import { domainManifest } from "./manifest.js";
98
+ import type { WireSchema } from "./wire.js";
99
+ import {
100
+ deriveStableIdContinuity,
101
+ stableIdsFromManifestJson,
102
+ type InferredRename,
103
+ type StableIds,
104
+ } from "./stable_ids.js";
96
105
  import type { UsdLayer } from "./usd.js";
97
106
  import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
98
107
  import { generateTsProof } from "./codegen_proof.js";
@@ -106,8 +115,9 @@ interface DomainConfig {
106
115
  // Explicit EXPORT-NAME escape hatches (resolved on the merged module exports):
107
116
  readonly queries?: readonly string[];
108
117
  readonly counts?: readonly string[];
118
+ /** Escape hatch only since #57 — exported SumDecls auto-discover like queries/counts. */
109
119
  readonly sums?: readonly string[];
110
- /** Maintained MIN/MAX reads (#47) — export names, config-named like sums. */
120
+ /** Maintained MIN/MAX reads (#47) — export names (config-named; not yet auto-discovered). */
111
121
  readonly mins?: readonly string[];
112
122
  readonly maxes?: readonly string[];
113
123
  readonly spatials?: readonly string[];
@@ -232,6 +242,13 @@ function isCombinedDecl(v: unknown): v is CombinedDecl {
232
242
  );
233
243
  }
234
244
 
245
+ /** A sum decl/builder — the `{id, of, sumField}` mark (counts/extremums never carry `sumField`). */
246
+ function isSumDecl(v: unknown): v is AnySum {
247
+ if (!v || typeof v !== "object") return false;
248
+ const o = v as Record<string, unknown>;
249
+ return typeof o.id === "string" && typeof o.of === "string" && typeof o.sumField === "string";
250
+ }
251
+
235
252
  /** A count decl/builder — `{id, of}` with NONE of the other decl families' marks. */
236
253
  function isCountDecl(v: unknown): v is AnyCount {
237
254
  if (!v || typeof v !== "object") return false;
@@ -470,6 +487,25 @@ async function main(): Promise<void> {
470
487
  const layered = layeredFlag || cfg.layered === true;
471
488
  const layeredDomains: { key: string; inputs: LayeredModuleInput[] }[] = [];
472
489
  let importSeq = 0;
490
+ /**
491
+ * THE CAPABILITY LOWERING (capability_marketplace.md §4 — the deploy-warning
492
+ * prerequisite + the executor's task→capability→domain map). Rides the
493
+ * UNHASHED deploy sidecar only (a top-level deploy-body key, like
494
+ * `dispositions`) — never the read manifest the wasm parses, never any
495
+ * hash-bearing artifact: declaring a capability moves no identity hash, and
496
+ * capability-free deploy bodies stay byte-identical (omit-when-empty).
497
+ */
498
+ const capabilityLowerings: {
499
+ capability: string;
500
+ domain: string;
501
+ aggregateId: string;
502
+ order: string;
503
+ complete: string;
504
+ fail: string;
505
+ block: string;
506
+ deadLetter: string;
507
+ tasksQueryId: string | null;
508
+ }[] = [];
473
509
 
474
510
  for (const d of cfg.domains) {
475
511
  if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
@@ -492,6 +528,9 @@ async function main(): Promise<void> {
492
528
  }
493
529
  let merged: Mod = {};
494
530
  for (const m of mods) merged = { ...merged, ...m };
531
+ // `capability()` bundles expand into discoverable entries (the CLI plane's
532
+ // merge point) — queries/sums/sid-minting/manifests all see the pieces.
533
+ merged = expandCapabilityExports(merged);
495
534
  let shardingSums: AnySum[] = [];
496
535
  let shardingMins: AnyExtremum[] = [];
497
536
  let shardingMaxes: AnyExtremum[] = [];
@@ -555,9 +594,9 @@ async function main(): Promise<void> {
555
594
  merged = { ...merged, ...shardingMod };
556
595
  shardingModForDomain = shardingMod;
557
596
  // The derived sharding law's MAINTAINED SUMS (`nomosEstateSummary` — the §5.1
558
- // estate total over subtotal rows) must reach the read manifest + identity:
559
- // sums are config-named (never auto-discovered hash stability for existing
560
- // packages), so the appended module's sums thread through explicitly here.
597
+ // estate total over subtotal rows) must reach the read manifest + identity.
598
+ // (Sums auto-discover since #57; this explicit thread predates that and stays
599
+ // as belt-and-braces the reference-dedupe below collapses the overlap.)
561
600
  shardingSums = Object.values(shardingMod).filter(
562
601
  (v): v is AnySum =>
563
602
  !!v && typeof v === "object" &&
@@ -652,14 +691,47 @@ async function main(): Promise<void> {
652
691
  ...discover<WorkspaceInvariantDecl>(merged, isWorkspaceInvariantDecl),
653
692
  ...resolveNamed<WorkspaceInvariantDecl>(merged, d.workspaceInvariants, "workspaceInvariants"),
654
693
  ];
655
- const sums = [...resolveNamed<AnySum>(merged, d.sums, "sums"), ...shardingSums];
694
+ // Sums are AUTO-DISCOVERED like queries/counts (#57 an exported SumDecl used to be
695
+ // SILENTLY DROPPED unless config-named; the config lane stays as the escape hatch).
696
+ // Reference-dedupe: a sum that is BOTH exported and config-named is the same object.
697
+ const sums = [
698
+ ...new Set([
699
+ ...discover<AnySum>(merged, isSumDecl),
700
+ ...resolveNamed<AnySum>(merged, d.sums, "sums"),
701
+ ...shardingSums,
702
+ ]),
703
+ ];
656
704
  const mins = [...resolveNamed<AnyExtremum>(merged, d.mins, "mins"), ...shardingMins];
657
705
  const maxes = [...resolveNamed<AnyExtremum>(merged, d.maxes, "maxes"), ...shardingMaxes];
658
- const impureCapabilities = resolveNamed<ImpureCapabilityDecl>(
706
+ // `capability()` declarations AUTO-DISCOVER (the marketplace lane, M2 —
707
+ // "declare ONE thing"); the config-named lane stays for hand-rolled
708
+ // impureCapability quartets. Deduped by task-aggregate id (a config-named
709
+ // capability() counts once).
710
+ const discoveredCapabilities = Object.values(merged).filter(isCapabilityDecl) as unknown as ImpureCapabilityDecl[];
711
+ const namedCapabilities = resolveNamed<ImpureCapabilityDecl>(
659
712
  merged,
660
713
  d.impureCapabilities,
661
714
  "impureCapabilities",
662
715
  );
716
+ const capByAggregate = new Map<string, ImpureCapabilityDecl>();
717
+ for (const c of [...discoveredCapabilities, ...namedCapabilities]) capByAggregate.set(c.aggregate.id, c);
718
+ const impureCapabilities = [...capByAggregate.values()];
719
+ for (const c of impureCapabilities) {
720
+ const meta = c as unknown as { name?: unknown; capability?: unknown; tasks?: { id?: unknown } };
721
+ const cap = typeof meta.name === "string" ? meta.name : typeof meta.capability === "string" ? meta.capability : null;
722
+ if (cap === null) continue; // a binding-free quartet lowers nothing (omit-when-absent)
723
+ capabilityLowerings.push({
724
+ capability: cap,
725
+ domain: d.key,
726
+ aggregateId: c.aggregate.id,
727
+ order: c.order.id,
728
+ complete: c.complete.id,
729
+ fail: c.fail.id,
730
+ block: c.block.id,
731
+ deadLetter: c.deadLetter.id,
732
+ tasksQueryId: typeof meta.tasks?.id === "string" ? meta.tasks.id : null,
733
+ });
734
+ }
663
735
  const extraAggregates = resolveNamed<AggregateHandle>(merged, d.extraAggregates, "extraAggregates");
664
736
 
665
737
  // Dart-codegen hints (frontend-only — never law; see DomainConfig):
@@ -804,6 +876,96 @@ async function main(): Promise<void> {
804
876
  }
805
877
  }
806
878
 
879
+ // ── STABLE-ID CONTINUITY (#58 — names are labels, identity is minted) ──
880
+ // Derive each composed domain's stable ids from the PRIOR compile: the committed
881
+ // `nomos.stable-ids.json` lockfile beside the config (the durable source — survives
882
+ // a clean clone), else the previous build's identity manifests. Match by name;
883
+ // exactly ONE disappeared + ONE appeared name of the same kind/shape is an inferred
884
+ // RENAME (sid carried, lineage recorded, printed below); anything else mints fresh
885
+ // at first appearance (a leftover disappearance is THE EVOLVE GATE's typed question
886
+ // at deploy — answered in the upgrade intent's disposition). The resolved continuity
887
+ // is attached to the module so the canonical manifest + USD-IR carry it.
888
+ interface StableIdLock {
889
+ v: 1;
890
+ domains: Record<string, { stableIds: StableIds; schemas: Record<string, WireSchema> }>;
891
+ }
892
+ const lockPath = path.join(cfgDir, "nomos.stable-ids.json");
893
+ const priorLock: StableIdLock | undefined = existsSync(lockPath)
894
+ ? (JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLock)
895
+ : undefined;
896
+ const priorBuildManifestsPath = path.join(outDir, "domain_identity_manifests.json");
897
+ const priorBuildManifests: Record<string, string> | undefined = existsSync(priorBuildManifestsPath)
898
+ ? (JSON.parse(readFileSync(priorBuildManifestsPath, "utf8")) as Record<string, string>)
899
+ : undefined;
900
+ const inferredRenames: { domain: string; renames: InferredRename[] }[] = [];
901
+ const nextLock: StableIdLock = { v: 1, domains: {} };
902
+ for (const mod of domainModules) {
903
+ const identityName = mod.name;
904
+ // The CURRENT canonical aggregate schemas (a pre-continuity manifest pass —
905
+ // aggregates/schemas do not depend on continuity).
906
+ let currentAggs: { id: string; schema: WireSchema; kinds?: Record<string, string> }[];
907
+ try {
908
+ const preManifest = domainManifest(mod);
909
+ currentAggs = preManifest.aggregates.map((a) => ({
910
+ id: a.id,
911
+ schema: a.schema,
912
+ // The CURRENT field kinds (off the pre-continuity stableIds emission) — the
913
+ // pair rule's kind axis ("rename + add a field" in one release still infers).
914
+ kinds: Object.fromEntries(
915
+ Object.entries(preManifest.stableIds?.aggregates[a.id]?.fields ?? {}).map(
916
+ ([f, v]) => [f, v.kind ?? ""] as [string, string],
917
+ ),
918
+ ),
919
+ }));
920
+ } catch {
921
+ // A palette-gap domain has no canonical identity (recorded-and-excluded later);
922
+ // it carries no stable ids either.
923
+ continue;
924
+ }
925
+ // Continuity source: the lockfile first (durable, committed), else the prior build.
926
+ let prior: StableIds | undefined;
927
+ let priorSchemas: Record<string, WireSchema> | undefined;
928
+ const locked = priorLock?.domains[identityName];
929
+ if (locked !== undefined) {
930
+ prior = locked.stableIds;
931
+ priorSchemas = locked.schemas;
932
+ } else if (priorBuildManifests?.[identityName] !== undefined) {
933
+ prior = stableIdsFromManifestJson(priorBuildManifests[identityName]!);
934
+ try {
935
+ const parsed = JSON.parse(priorBuildManifests[identityName]!) as {
936
+ aggregates?: { id: string; schema: WireSchema }[];
937
+ };
938
+ priorSchemas = Object.fromEntries((parsed.aggregates ?? []).map((a) => [a.id, a.schema]));
939
+ } catch {
940
+ priorSchemas = undefined;
941
+ }
942
+ }
943
+ const { continuity, inferred } = deriveStableIdContinuity(
944
+ identityName,
945
+ currentAggs,
946
+ prior,
947
+ priorSchemas,
948
+ );
949
+ mod.stableIdContinuity = continuity;
950
+ // The layered emission's per-module layers must carry the SAME sids (the flatten
951
+ // homomorphism is byte-asserted), so the continuity rides every layer input too.
952
+ for (const ld of layeredDomains) {
953
+ if (ld.key === (mod.domain ?? mod.name)) {
954
+ for (const inp of ld.inputs) inp.module.stableIdContinuity = continuity;
955
+ }
956
+ }
957
+ if (inferred.length > 0) inferredRenames.push({ domain: identityName, renames: inferred });
958
+ // The lockfile records the EMITTED surface (sids + lineage + kinds — the next
959
+ // compile's pair rule reads the kinds), not the bare continuity.
960
+ nextLock.domains[identityName] = {
961
+ stableIds: domainManifest(mod).stableIds ?? continuity,
962
+ schemas: Object.fromEntries(currentAggs.map((a) => [a.id, a.schema])),
963
+ };
964
+ }
965
+ // Persist the lockfile (commit it with the law: it is how a rename keeps its
966
+ // identity across clean clones). Deterministic — re-compiles rewrite the same bytes.
967
+ writeFileSync(lockPath, JSON.stringify(nextLock, null, 2) + "\n", "utf8");
968
+
807
969
  // ── reports ──
808
970
  const entryReports: string[] = [];
809
971
  for (const [reportId, ref] of Object.entries(cfg.reports ?? {})) {
@@ -931,6 +1093,54 @@ async function main(): Promise<void> {
931
1093
  fail(`bundled engine entry does not assign globalThis.plan — refusing to package`);
932
1094
  }
933
1095
 
1096
+ // ── 1a. THE CAPTURED-READ GATE (#57, lifted-when-declared since #58) ──
1097
+ // `read()` (the captured-read lane) is served by the sealed engine ONLY for law
1098
+ // that DECLARES it: a directive whose plan reads must declare each query with
1099
+ // `.reads(theQuery)`, which lands the `nomosReadGate` key in the law's USD-IR (the
1100
+ // era key — the gate serves/captures/CAS-verifies reads only under it). A lump that
1101
+ // references read() while NO directive declares a captured-read query would still
1102
+ // halt at first dispatch (the engine provides no nomos.read for it), so THAT
1103
+ // compile refuses fail-closed. The marker is a string that lives ONLY inside
1104
+ // `read()`'s body (dsl/src/read.ts — keep them byte-identical) and is tree-shaken
1105
+ // out of read-free lumps.
1106
+ const CAPTURED_READ_LUMP_MARKER = "nomos.read is not provided by this host";
1107
+ const declaresCapturedReads = domainModules.some((m) =>
1108
+ m.directives.some(
1109
+ (d) => ((d as { declaredQueryReads?: unknown[] }).declaredQueryReads ?? []).length > 0,
1110
+ ),
1111
+ );
1112
+ if (javascript.includes(CAPTURED_READ_LUMP_MARKER) && !declaresCapturedReads) {
1113
+ fail(
1114
+ `this law references read() — the CAPTURED-READ lane — but NO directive declares a ` +
1115
+ `captured-read query, so the sealed engine would provide no nomos.read and every ` +
1116
+ `dispatch of a read()-calling plan would halt at runtime. The compile refuses fail-closed.\n` +
1117
+ ` remedy: declare the read on the directive whose plan calls it — ` +
1118
+ `.reads(theQueryDecl) (the same QueryDecl the read() call passes). The law then ` +
1119
+ `opts into the captured-read gate: reads are served live at author, captured onto ` +
1120
+ `the intent, replayed at verify, and CAS-checked at every gate.\n` +
1121
+ ` alternative: commit the premise in the payload instead — the CALLER reads (a ` +
1122
+ `typed query/count off the holon) and the payload carries the result as a stamped fact.`,
1123
+ );
1124
+ }
1125
+ // The symmetric validation: every DECLARED captured-read query must return a known
1126
+ // aggregate of its domain (fail-closed — a read recipe over an unknown type can
1127
+ // never be derived at the gate).
1128
+ for (const m of domainModules) {
1129
+ const aggIds = new Set(m.aggregates.map((a) => a.id));
1130
+ for (const d of m.directives) {
1131
+ for (const q of (d as { declaredQueryReads?: { id: string; returns: string }[] })
1132
+ .declaredQueryReads ?? []) {
1133
+ if (!aggIds.has(q.returns)) {
1134
+ fail(
1135
+ `domain '${m.domain ?? m.name}': directive '${d.id}' declares captured read ` +
1136
+ `'${q.id}' returning '${q.returns}', which is not an aggregate of this domain — ` +
1137
+ `captured reads are same-workspace, same-domain declared reads.`,
1138
+ );
1139
+ }
1140
+ }
1141
+ }
1142
+ }
1143
+
934
1144
  // ── 1b. THE FROZEN-SANDBOX BOOT PROBE (lazy boot only, fail-closed) ──
935
1145
  // The sealed engine freezes globalThis after the lump's top level; a lazy
936
1146
  // domain's module init runs LATER, inside the dispatch. Any module that writes
@@ -1053,6 +1263,7 @@ async function main(): Promise<void> {
1053
1263
  directives: m.directives.map((d) => d.id),
1054
1264
  queries: (m.queries ?? []).map((q) => q.id),
1055
1265
  counts: (m.counts ?? []).map((c) => c.id),
1266
+ sums: (m.sums ?? []).map((s) => s.id),
1056
1267
  identityHash: identity.hashes[m.domain ?? m.name],
1057
1268
  })),
1058
1269
  };
@@ -1062,6 +1273,10 @@ async function main(): Promise<void> {
1062
1273
  readManifest,
1063
1274
  identityManifests: identity.manifests,
1064
1275
  identityHashes: identity.hashes,
1276
+ // The capability lowering (unhashed sidecar; omit-when-empty — see the
1277
+ // collector's doc above). The worker stages it on the manifest overlay:
1278
+ // the deploy-time binding warning + the executor's scan map read it there.
1279
+ ...(capabilityLowerings.length > 0 ? { capabilities: capabilityLowerings } : {}),
1065
1280
  };
1066
1281
  const deployPath = path.join(outDir, `${cfg.name}.deploy.json`);
1067
1282
  writeFileSync(deployPath, JSON.stringify(deployBody) + "\n", "utf8");
@@ -1087,7 +1302,7 @@ async function main(): Promise<void> {
1087
1302
  `domain '${d.domain}'${d.identityHash ? ` (identity ${d.identityHash.slice(0, 12)}…)` : " (EXCLUDED from identity manifest)"}`,
1088
1303
  ` aggregates: ${d.aggregates.join(", ") || "—"}`,
1089
1304
  ` directives: ${d.directives.join(", ") || "—"}`,
1090
- ` queries: ${d.queries.join(", ") || "—"} counts: ${d.counts.join(", ") || "—"}`,
1305
+ ` queries: ${d.queries.join(", ") || "—"} counts: ${d.counts.join(", ") || "—"} sums: ${d.sums.join(", ") || "—"}`,
1091
1306
  ]),
1092
1307
  `deploy: POST ${cfg.name}.deploy.json to /v1/workspaces/<ws>/domains (or: githolon deploy <ws>)`,
1093
1308
  `typed client: ${cfg.name}.client.ts — ${tsClientFactoryName(domainModules[0]?.domain ?? domainModules[0]?.name ?? cfg.name)}(holon), law hash baked in`,
@@ -1129,10 +1344,21 @@ async function main(): Promise<void> {
1129
1344
  ` layered ${rel(layeredRootPath)} (${layeredFileCount} module layer(s) in build/layers/ — flatten-verified byte-identical to the canonical IR)`,
1130
1345
  );
1131
1346
  }
1132
- console.log(` read ${rel(readPath)} (${Object.keys(readManifest.aggregateFieldKinds).length} aggregate(s), ${readManifest.queries.length} quer(y/ies), ${readManifest.counts.length} count(s))`);
1347
+ console.log(` read ${rel(readPath)} (${Object.keys(readManifest.aggregateFieldKinds).length} aggregate(s), ${readManifest.queries.length} quer(y/ies), ${readManifest.counts.length} count(s), ${readManifest.sums?.length ?? 0} sum(s))`);
1133
1348
  console.log(` identity ${rel(manifestsPath)} (${Object.keys(identity.manifests).length} domain(s)${identity.excluded.length ? `, ${identity.excluded.length} EXCLUDED (palette gap)` : ""})`);
1134
1349
  for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);
1135
1350
  for (const ex of identity.excluded) console.log(` EXCLUDED ${ex.domain}: ${ex.reason}`);
1351
+ // STABLE IDS (#58): the minted/carried identity surface + every INFERRED rename —
1352
+ // the silent lane made visible (if an inference is wrong — you meant remove+add —
1353
+ // split the edit into two compiles, or answer the evolve gate with a disposition).
1354
+ console.log(` stable-ids ${rel(lockPath)} (committed lockfile — identity across renames)`);
1355
+ for (const { domain, renames } of inferredRenames) {
1356
+ for (const r of renames) {
1357
+ console.log(
1358
+ ` INFERRED RENAME [${domain}] ${r.from} -> ${r.to} (stable id ${r.sid} carried; a rename is metadata — same identity, new label)`,
1359
+ );
1360
+ }
1361
+ }
1136
1362
  for (const m of domainModules) {
1137
1363
  if (m.workspaceTypes !== undefined && m.workspaceTypes.length > 0) {
1138
1364
  console.log(
@@ -1156,7 +1382,9 @@ async function main(): Promise<void> {
1156
1382
  console.log(` # raw lane: curl -X POST -H 'content-type: application/json' -H 'Authorization: Bearer <workspaceSecret>' \\`);
1157
1383
  console.log(` # --data-binary @${rel(deployPath)} https://nomos.captainapp.co.uk/v1/workspaces/<ws>/domains`);
1158
1384
  if (proofPath !== undefined) {
1159
- console.log(`prove it: npx githolon proof # GENERATED from your law — offline write -> sync -> admission -> cloud reads, live`);
1385
+ console.log(`prove it OFFLINE first: npx githolon proof # the generated proof's offline legs on a LOCAL holon (no cloud workspace)`);
1386
+ console.log(` inner loop: npx githolon dev # watch -> recompile -> law live locally -> offline proof, on every save`);
1387
+ console.log(` then the cloud loop: npx githolon proof --live # throwaway workspace, retired on exit (--keep keeps it)`);
1160
1388
  } else {
1161
1389
  console.log(`prove it: npm run e2e # offline write -> sync -> admission -> cloud query, live`);
1162
1390
  }