@githolon/dsl 0.3.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 (34) 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 +262 -17
  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 +114 -2
  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/src/workspace_sharding.ts +123 -18
  21. package/src/workspace_type.ts +7 -5
  22. package/dart/.dart_tool/package_config.json +0 -328
  23. package/dart/.dart_tool/package_graph.json +0 -485
  24. package/dart/.dart_tool/pub/bin/test/test.dart-3.11.5.snapshot +0 -0
  25. package/dart/.dart_tool/test/incremental_kernel.Ly9AZGFydD0zLjU= +0 -0
  26. package/dart/.dart_tool/version +0 -1
  27. package/dart/build/native_assets/macos/native_assets.json +0 -1
  28. package/dart/build/test_cache/build/89a6598c8854ed031dfc25d83c80860e.cache.dill.track.dill +0 -0
  29. package/dart/build/unit_test_assets/AssetManifest.bin +0 -0
  30. package/dart/build/unit_test_assets/FontManifest.json +0 -1
  31. package/dart/build/unit_test_assets/NOTICES.Z +0 -0
  32. package/dart/build/unit_test_assets/NativeAssetsManifest.json +0 -1
  33. package/dart/build/unit_test_assets/shaders/ink_sparkle.frag +0 -0
  34. package/dart/build/unit_test_assets/shaders/stretch_effect.frag +0 -0
@@ -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;
@@ -242,7 +259,8 @@ function isCountDecl(v: unknown): v is AnyCount {
242
259
  typeof o.on !== "string" && // spatial
243
260
  typeof o.fn !== "function" && // derived/combined
244
261
  !Array.isArray(o.key) && // query
245
- !("field" in o) && // extremum
262
+ !("field" in o) && // extremum (wire-descriptor shape)
263
+ !("valueField" in o) && // extremum (the ExtremumDecl/Extremum-builder mark — never a count)
246
264
  !("sumField" in o) && // sum (the SumDecl/Sum-builder mark — never a count)
247
265
  !("orderBy" in o) && // ordered read
248
266
  !("_existsMarker" in o) && // exists
@@ -469,6 +487,25 @@ async function main(): Promise<void> {
469
487
  const layered = layeredFlag || cfg.layered === true;
470
488
  const layeredDomains: { key: string; inputs: LayeredModuleInput[] }[] = [];
471
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
+ }[] = [];
472
509
 
473
510
  for (const d of cfg.domains) {
474
511
  if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
@@ -491,7 +528,12 @@ async function main(): Promise<void> {
491
528
  }
492
529
  let merged: Mod = {};
493
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);
494
534
  let shardingSums: AnySum[] = [];
535
+ let shardingMins: AnyExtremum[] = [];
536
+ let shardingMaxes: AnyExtremum[] = [];
495
537
  let shardingModForDomain: Mod | undefined;
496
538
  let shardingLazyExpr: string | undefined;
497
539
 
@@ -552,9 +594,9 @@ async function main(): Promise<void> {
552
594
  merged = { ...merged, ...shardingMod };
553
595
  shardingModForDomain = shardingMod;
554
596
  // The derived sharding law's MAINTAINED SUMS (`nomosEstateSummary` — the §5.1
555
- // estate total over subtotal rows) must reach the read manifest + identity:
556
- // sums are config-named (never auto-discovered hash stability for existing
557
- // 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.)
558
600
  shardingSums = Object.values(shardingMod).filter(
559
601
  (v): v is AnySum =>
560
602
  !!v && typeof v === "object" &&
@@ -562,6 +604,20 @@ async function main(): Promise<void> {
562
604
  typeof (v as { of?: unknown }).of === "string" &&
563
605
  typeof (v as { sumField?: unknown }).sumField === "string",
564
606
  );
607
+ // SLICE 8: the derived sharding law's MAINTAINED EXTREMUMS — the estate
608
+ // min/max over subtotal rows (`nomosEstateExtremumMin/Max`, extremize over
609
+ // per-shard committed values) — thread through the same explicit lane.
610
+ const shardingExtrema = (kind: "min" | "max"): AnyExtremum[] =>
611
+ Object.values(shardingMod).filter(
612
+ (v): v is AnyExtremum =>
613
+ !!v && typeof v === "object" &&
614
+ typeof (v as { id?: unknown }).id === "string" &&
615
+ typeof (v as { of?: unknown }).of === "string" &&
616
+ typeof (v as { valueField?: unknown }).valueField === "string" &&
617
+ (v as { kind?: unknown }).kind === kind,
618
+ );
619
+ shardingMins = shardingExtrema("min");
620
+ shardingMaxes = shardingExtrema("max");
565
621
  const specJson = JSON.stringify(spec);
566
622
  if (eagerLump) {
567
623
  if (!entryImports.some((l) => l.includes("workspace-sharding"))) {
@@ -635,14 +691,47 @@ async function main(): Promise<void> {
635
691
  ...discover<WorkspaceInvariantDecl>(merged, isWorkspaceInvariantDecl),
636
692
  ...resolveNamed<WorkspaceInvariantDecl>(merged, d.workspaceInvariants, "workspaceInvariants"),
637
693
  ];
638
- const sums = [...resolveNamed<AnySum>(merged, d.sums, "sums"), ...shardingSums];
639
- const mins = resolveNamed<AnyExtremum>(merged, d.mins, "mins");
640
- const maxes = resolveNamed<AnyExtremum>(merged, d.maxes, "maxes");
641
- const impureCapabilities = resolveNamed<ImpureCapabilityDecl>(
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
+ ];
704
+ const mins = [...resolveNamed<AnyExtremum>(merged, d.mins, "mins"), ...shardingMins];
705
+ const maxes = [...resolveNamed<AnyExtremum>(merged, d.maxes, "maxes"), ...shardingMaxes];
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>(
642
712
  merged,
643
713
  d.impureCapabilities,
644
714
  "impureCapabilities",
645
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
+ }
646
735
  const extraAggregates = resolveNamed<AggregateHandle>(merged, d.extraAggregates, "extraAggregates");
647
736
 
648
737
  // Dart-codegen hints (frontend-only — never law; see DomainConfig):
@@ -709,7 +798,7 @@ async function main(): Promise<void> {
709
798
  }
710
799
  } catch { /* already failed above */ }
711
800
  }
712
- for (const read of [...(composed.counts ?? []), ...(composed.sums ?? [])]) {
801
+ for (const read of [...(composed.counts ?? []), ...(composed.sums ?? []), ...(composed.mins ?? []), ...(composed.maxes ?? [])]) {
713
802
  const scope = (read as { scope?: unknown }).scope;
714
803
  if (scope === undefined) continue;
715
804
  const id = (read as { id?: unknown }).id;
@@ -787,6 +876,96 @@ async function main(): Promise<void> {
787
876
  }
788
877
  }
789
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
+
790
969
  // ── reports ──
791
970
  const entryReports: string[] = [];
792
971
  for (const [reportId, ref] of Object.entries(cfg.reports ?? {})) {
@@ -914,6 +1093,54 @@ async function main(): Promise<void> {
914
1093
  fail(`bundled engine entry does not assign globalThis.plan — refusing to package`);
915
1094
  }
916
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
+
917
1144
  // ── 1b. THE FROZEN-SANDBOX BOOT PROBE (lazy boot only, fail-closed) ──
918
1145
  // The sealed engine freezes globalThis after the lump's top level; a lazy
919
1146
  // domain's module init runs LATER, inside the dispatch. Any module that writes
@@ -1036,6 +1263,7 @@ async function main(): Promise<void> {
1036
1263
  directives: m.directives.map((d) => d.id),
1037
1264
  queries: (m.queries ?? []).map((q) => q.id),
1038
1265
  counts: (m.counts ?? []).map((c) => c.id),
1266
+ sums: (m.sums ?? []).map((s) => s.id),
1039
1267
  identityHash: identity.hashes[m.domain ?? m.name],
1040
1268
  })),
1041
1269
  };
@@ -1045,6 +1273,10 @@ async function main(): Promise<void> {
1045
1273
  readManifest,
1046
1274
  identityManifests: identity.manifests,
1047
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 } : {}),
1048
1280
  };
1049
1281
  const deployPath = path.join(outDir, `${cfg.name}.deploy.json`);
1050
1282
  writeFileSync(deployPath, JSON.stringify(deployBody) + "\n", "utf8");
@@ -1070,7 +1302,7 @@ async function main(): Promise<void> {
1070
1302
  `domain '${d.domain}'${d.identityHash ? ` (identity ${d.identityHash.slice(0, 12)}…)` : " (EXCLUDED from identity manifest)"}`,
1071
1303
  ` aggregates: ${d.aggregates.join(", ") || "—"}`,
1072
1304
  ` directives: ${d.directives.join(", ") || "—"}`,
1073
- ` queries: ${d.queries.join(", ") || "—"} counts: ${d.counts.join(", ") || "—"}`,
1305
+ ` queries: ${d.queries.join(", ") || "—"} counts: ${d.counts.join(", ") || "—"} sums: ${d.sums.join(", ") || "—"}`,
1074
1306
  ]),
1075
1307
  `deploy: POST ${cfg.name}.deploy.json to /v1/workspaces/<ws>/domains (or: githolon deploy <ws>)`,
1076
1308
  `typed client: ${cfg.name}.client.ts — ${tsClientFactoryName(domainModules[0]?.domain ?? domainModules[0]?.name ?? cfg.name)}(holon), law hash baked in`,
@@ -1112,10 +1344,21 @@ async function main(): Promise<void> {
1112
1344
  ` layered ${rel(layeredRootPath)} (${layeredFileCount} module layer(s) in build/layers/ — flatten-verified byte-identical to the canonical IR)`,
1113
1345
  );
1114
1346
  }
1115
- 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))`);
1116
1348
  console.log(` identity ${rel(manifestsPath)} (${Object.keys(identity.manifests).length} domain(s)${identity.excluded.length ? `, ${identity.excluded.length} EXCLUDED (palette gap)` : ""})`);
1117
1349
  for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);
1118
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
+ }
1119
1362
  for (const m of domainModules) {
1120
1363
  if (m.workspaceTypes !== undefined && m.workspaceTypes.length > 0) {
1121
1364
  console.log(
@@ -1139,7 +1382,9 @@ async function main(): Promise<void> {
1139
1382
  console.log(` # raw lane: curl -X POST -H 'content-type: application/json' -H 'Authorization: Bearer <workspaceSecret>' \\`);
1140
1383
  console.log(` # --data-binary @${rel(deployPath)} https://nomos.captainapp.co.uk/v1/workspaces/<ws>/domains`);
1141
1384
  if (proofPath !== undefined) {
1142
- 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)`);
1143
1388
  } else {
1144
1389
  console.log(`prove it: npm run e2e # offline write -> sync -> admission -> cloud query, live`);
1145
1390
  }
package/src/directive.ts CHANGED
@@ -21,6 +21,7 @@ import type { PlannedOp } from "./ops.js";
21
21
  import type { Ports } from "./ctx.js";
22
22
  import type { CertifiedReadDecl } from "./certified_read.js";
23
23
  import type { RelationDecl } from "./relation.js";
24
+ import type { QueryDecl } from "./query.js";
24
25
 
25
26
  export type ReferentialMarker = "creates" | "mutates" | "ensures" | "archives";
26
27
 
@@ -70,6 +71,18 @@ export interface Directive<P = unknown> {
70
71
  * (read ⊆ declared) is a SEPARATE later step; here it only DECLARES.
71
72
  */
72
73
  readonly declaredReads: string[];
74
+ /**
75
+ * The directive's DECLARED CAPTURED-READ queries (#58 — the captured-read lane):
76
+ * the O(1) DSL-defined queries its `plan` may `read()` on the write path. Each is a
77
+ * declared, indexed {@link QueryDecl}; the gate serves `nomos.read` LIVE from the
78
+ * pre-apply committed state at author, replays the captured result at verify, and
79
+ * RE-DERIVES it at the pre-apply position for the read-conflict (CAS-as-law) check.
80
+ * Same-workspace only, size-bounded. Defaults to `[]` (no captured reads — the
81
+ * engine then provides no `nomos.read`, exactly the pre-#58 posture). OPTIONAL +
82
+ * ADDITIVE (a hand-built directive object without it is a read-free directive);
83
+ * OMITTED from the canonical manifest when empty (hash-stable for read-free directives).
84
+ */
85
+ readonly declaredQueryReads?: QueryDecl[];
73
86
  /**
74
87
  * The directive's DECLARED emit boundary: the event types its `plan` may EMIT,
75
88
  * each with an optional `max` bound. Part of the domain identity. Defaults to `{}`
@@ -130,13 +143,18 @@ export interface RequirableDirective<P> extends Directive<P> {
130
143
  */
131
144
  requires(capability?: string, scopeFrom?: ScopeFrom<P>): RequirableDirective<P>;
132
145
  /**
133
- * Declare the directive's READ boundary: the ref types its `plan` may read. Callable
134
- * MULTIPLE times to accumulate; results are deduped + sorted on the directive's
135
- * `declaredReads`. Purely additive — a directive that never calls this declares no
136
- * reads (`[]`) and is byte-identical in the canonical manifest to before this existed.
137
- * Returns a NEW requirable directive (non-destructive).
146
+ * Declare the directive's READ boundary. TWO argument kinds, one declaration site:
147
+ * * a STRING declares a ref TYPE its `plan` may read (the original boundary —
148
+ * deduped + sorted on `declaredReads`);
149
+ * * a {@link QueryDecl} declares a CAPTURED-READ query (#58): an O(1) DSL query
150
+ * its `plan` may `read()` on the write path — served live at author from the
151
+ * pre-apply state, captured onto the intent, replayed + CAS-re-derived at every
152
+ * later gate (deduped by query id on `declaredQueryReads`).
153
+ * Callable MULTIPLE times to accumulate. Purely additive — a directive that never
154
+ * calls this declares no reads and is byte-identical in the canonical manifest to
155
+ * before this existed. Returns a NEW requirable directive (non-destructive).
138
156
  */
139
- reads(...refTypes: string[]): RequirableDirective<P>;
157
+ reads(...reads: (string | QueryDecl)[]): RequirableDirective<P>;
140
158
  /**
141
159
  * Declare ONE entry of the directive's EMIT boundary: an `eventType` its `plan` may
142
160
  * emit, with an optional `max` count bound. Callable MULTIPLE times to accumulate
@@ -239,6 +257,7 @@ class DirectivePlanStep<Id extends string, P> {
239
257
  payloadSchema: this.payloadSchema,
240
258
  plan: fn,
241
259
  declaredReads: [],
260
+ declaredQueryReads: [],
242
261
  declaredEmits: {},
243
262
  declaredCertifiedReads: {},
244
263
  declaredRelations: [],
@@ -263,11 +282,17 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
263
282
  ...(scopeFrom !== undefined ? { scopeFrom } : {}),
264
283
  });
265
284
  },
266
- reads(...refTypes: string[]): RequirableDirective<P> {
267
- // Accumulate onto any prior reads, dedup, sort order is incidental to the
268
- // declared boundary; only the SET of read ref types is the contract.
285
+ reads(...reads: (string | QueryDecl)[]): RequirableDirective<P> {
286
+ // Split by argument kind: strings are the ref-type boundary; QueryDecls are the
287
+ // captured-read lane (#58). Both accumulate, dedup (ref types by value, queries
288
+ // by id — later decl wins), sort — only the SET is the contract.
289
+ const refTypes = reads.filter((r): r is string => typeof r === "string");
290
+ const queryDecls = reads.filter((r): r is QueryDecl => typeof r !== "string");
269
291
  const merged = [...new Set([...base.declaredReads, ...refTypes])].sort();
270
- return makeRequirable({ ...base, declaredReads: merged });
292
+ const byId = new Map<string, QueryDecl>((base.declaredQueryReads ?? []).map((q) => [q.id, q]));
293
+ for (const q of queryDecls) byId.set(q.id, q);
294
+ const mergedQueries = [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
295
+ return makeRequirable({ ...base, declaredReads: merged, declaredQueryReads: mergedQueries });
271
296
  },
272
297
  emits(eventType: string, opts?: { max?: number }): RequirableDirective<P> {
273
298
  // Accumulate one event type onto any prior emits; a `max` bound is recorded only
@@ -52,6 +52,7 @@
52
52
  * tenant package for the long-form rationale of each branch.
53
53
  */
54
54
  import { executeDirectiveToIntent } from "./wire_encode.js";
55
+ import { expandCapabilityExports } from "./capability_exports.js";
55
56
  import type { Directive } from "./directive.js";
56
57
  import type { AggregateHandle, AggregateInvariantFn, AggregateInvariantVerdict } from "./aggregate.js";
57
58
  import type { Ports } from "./ctx.js";
@@ -200,7 +201,10 @@ function combinedsOf(mod: DomainModuleExports): CombinedDecl[] {
200
201
  function mergeModules(mods: readonly DomainModuleExports[]): DomainModuleExports {
201
202
  let merged: DomainModuleExports = {};
202
203
  for (const m of mods) merged = { ...merged, ...m };
203
- return merged;
204
+ // `capability()` bundles expand into discoverable entries here — the engine
205
+ // plane's ONE merge point, so the registry sees the task aggregate + quartet
206
+ // without per-piece re-exports (capability_exports.ts).
207
+ return expandCapabilityExports(merged);
204
208
  }
205
209
 
206
210
  /**