@githolon/dsl 0.48.1 → 0.50.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.
@@ -121,6 +121,7 @@ import { generateTsClient, tsClientFactoryName } from "./codegen_ts.js";
121
121
  import { generateOpenApiFragment, mergeOpenApiFragments } from "./codegen_openapi.js";
122
122
  import { domainInterface, type DomainInterface } from "./shape_compat.js";
123
123
  import { generateTsProof, proofLegsIndex } from "./codegen_proof.js";
124
+ import { checkDomainModules, formatFindings, hasRefusal } from "./law_check.js";
124
125
  import type { NomosCompat } from "./compat_manifest.js";
125
126
 
126
127
  const DSL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -212,6 +213,18 @@ interface PackageConfig {
212
213
  * `docs/usd_layered_composition.md`).
213
214
  */
214
215
  readonly layered?: boolean;
216
+ /**
217
+ * THE COMPACT PACKAGE ERA (opt-in; also `--compact` / NOMOS_COMPACT=1). ONE batched,
218
+ * NAMED domainHash move that shrinks the package ~4x (guestbook 1.48MB → ~0.35MB):
219
+ * • minified engine lump (esbuild minify — string literals, and so refusal codes, unchanged);
220
+ * • zod locales stripped to `en` only (the locale barrel is 35% of every lump and is
221
+ * namespace-re-exported so tree-shaking can't drop it; `en` keeps error messages
222
+ * byte-identical to today's default);
223
+ * • base64 blob fields (`*B64`, 1.33x text) instead of hex (2x).
224
+ * REQUIRES the whole consuming stack (cloud kernel + clients) at ≥ the release that shipped
225
+ * the decode arms. Default stays off until a future major; hex packages parse forever.
226
+ */
227
+ readonly compact?: boolean;
215
228
  }
216
229
 
217
230
  function fail(msg: string): never {
@@ -608,6 +621,7 @@ function emitDart(
608
621
  async function main(): Promise<void> {
609
622
  const argv = process.argv.slice(2);
610
623
  const layeredFlag = argv.includes("--layered");
624
+ const compactFlag = argv.includes("--compact") || process.env["NOMOS_COMPACT"] === "1";
611
625
  // §"Shipped (1–4)" follow-up: `githolon compile --analyze` (or NOMOS_ANALYZE=1) runs
612
626
  // `dart analyze` on the generated Dart package after emission and fails the compile on red.
613
627
  const analyzeFlag = argv.includes("--analyze") || process.env.NOMOS_ANALYZE === "1";
@@ -1346,12 +1360,44 @@ async function main(): Promise<void> {
1346
1360
  // JS shim with the platform-native binary, which only an exec can run.
1347
1361
  const esbuildBin = path.join(path.dirname(esbuildPkgJson), esbuildBinRel ?? "bin/esbuild");
1348
1362
 
1349
- const javascript =
1350
- execFileSync(
1363
+ const compact = compactFlag || cfg.compact === true;
1364
+ let javascript: string;
1365
+ if (compact) {
1366
+ // COMPACT LUMP (see PackageConfig.compact): the programmatic esbuild API (same pinned
1367
+ // package as the binary) so a resolve-plugin can stub zod's locale barrel to en-only —
1368
+ // the CLI binary has no plugin surface. Same bundle options + minify; deterministic
1369
+ // (pinned esbuild version, deterministic minifier).
1370
+ const esbuildMod = (await import(pathToFileURL(path.join(path.dirname(esbuildPkgJson), "lib", "main.js")).href)) as typeof import("esbuild");
1371
+ const localeStripToEn = {
1372
+ name: "nomos-zod-locales-en-only",
1373
+ setup(build: import("esbuild").PluginBuild) {
1374
+ build.onResolve({ filter: /locales\/index\.js$/ }, (args) => {
1375
+ const resolved = path.resolve(args.resolveDir, args.path);
1376
+ if (!/[\/\\]zod[\/\\]/.test(resolved)) return null; // only zod's own barrel
1377
+ return { path: resolved, namespace: "nomos-zod-locales-en" };
1378
+ });
1379
+ build.onLoad({ filter: /.*/, namespace: "nomos-zod-locales-en" }, (args) => ({
1380
+ // en ONLY: the default error map — messages stay byte-identical to today's default,
1381
+ // so typed refusal payloads (which carry codes + en text) replay unchanged.
1382
+ contents: 'export { default as en } from "./en.js";',
1383
+ resolveDir: path.dirname(args.path),
1384
+ loader: "js" as const,
1385
+ }));
1386
+ },
1387
+ };
1388
+ const built = await esbuildMod.build({
1389
+ entryPoints: [entryPath], bundle: true, format: "iife", target: "es2020",
1390
+ platform: "neutral", charset: "utf8", minify: true, write: false,
1391
+ absWorkingDir: cfgDir, plugins: [localeStripToEn], logLevel: "silent",
1392
+ });
1393
+ javascript = built.outputFiles![0]!.text.trimEnd() + "\n";
1394
+ } else {
1395
+ javascript = execFileSync(
1351
1396
  esbuildBin,
1352
1397
  [entryPath, "--bundle", "--format=iife", "--target=es2020", "--platform=neutral", "--charset=utf8"],
1353
1398
  { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], cwd: cfgDir, maxBuffer: 256 * 1024 * 1024 },
1354
1399
  ).trimEnd() + "\n";
1400
+ }
1355
1401
  if (!/globalThis\.plan\s*=/.test(javascript) && !/globalThis\["plan"\]\s*=/.test(javascript)) {
1356
1402
  fail(`bundled engine entry does not assign globalThis.plan — refusing to package`);
1357
1403
  }
@@ -1462,7 +1508,7 @@ async function main(): Promise<void> {
1462
1508
 
1463
1509
  // ── 2. USD IR + the package envelope ──
1464
1510
  const usdJson = emitUsdJsonForModules(domainModules);
1465
- const text = packageUsda(usdJson, javascript);
1511
+ const text = packageUsda(usdJson, javascript, { compact });
1466
1512
  const pkgPath = path.join(outDir, `${cfg.name}.package.usda`);
1467
1513
  writeFileSync(pkgPath, text, "utf8");
1468
1514
  const domainHash = sha256HexUtf8(text);
@@ -1732,6 +1778,14 @@ async function main(): Promise<void> {
1732
1778
  domainModules.reduce((n, m) => n + (pick(m)?.length ?? 0), 0);
1733
1779
  console.log(` reads ${sum((m) => m.queries)} quer(y/ies), ${sum((m) => m.counts)} count(s), ${sum((m) => m.sums)} sum(s) — declared (routed via the openapi)`);
1734
1780
  }
1781
+ // SECONDARY-CREATE AUTO-DECLARE (secondary_ensure.ts): surface every type the compiler sanctioned
1782
+ // from a plan's deterministic secondary `ensure(...)` — the id-mint-gate DX made visible (the author
1783
+ // wrote `ensure(B, …)`; the compiler declared `Ensure → B` so the deterministic id admits).
1784
+ for (const m of domainModules) {
1785
+ for (const ty of m.autoDeclaredEnsures ?? []) {
1786
+ console.log(` ensure auto-declared Ensure→${ty} in '${m.domain ?? m.name}' from a plan's secondary ensure(...) (id-mint gate opened)`);
1787
+ }
1788
+ }
1735
1789
  console.log(` identity ${Object.keys(identity.manifests).length} domain(s) (carried in the package IR + nomos.stable-ids.json; no sidecar files)${identity.excluded.length ? ` — ${identity.excluded.length} EXCLUDED (palette gap)` : ""}`);
1736
1790
  for (const [dom, h] of Object.entries(identity.hashes)) console.log(` ${dom.padEnd(20)} ${h}`);
1737
1791
  for (const ex of identity.excluded) console.log(` EXCLUDED ${ex.domain}: ${ex.reason}`);
@@ -1763,6 +1817,24 @@ async function main(): Promise<void> {
1763
1817
  for (const w of birthWarnings) {
1764
1818
  console.log(` ⚠ births ${w}`);
1765
1819
  }
1820
+ // ── THE AUTHOR-TIME GATE (architecture/author_time_gate.md) — the static twins of the kernel
1821
+ // gate's refusal vocabulary, unified into ONE report. `githolon check` runs the same pass;
1822
+ // compile runs it implicitly and REFUSES on any refuse-level finding (a statically-provable
1823
+ // runtime refusal shipped as law). Warns/notes are printed but never block the compile.
1824
+ const checkFindings = checkDomainModules(domainModules);
1825
+ if (checkFindings.length > 0) {
1826
+ console.log(` check ${checkFindings.length} finding(s) (githolon check):`);
1827
+ for (const line of formatFindings(checkFindings)) {
1828
+ const mark = line.includes("REFUSE") ? "✗" : line.includes("WARN") ? "⚠" : "•";
1829
+ console.log(` ${mark} ${line}`);
1830
+ }
1831
+ if (hasRefusal(checkFindings)) {
1832
+ // Emit the artifacts (already written) but REFUSE the compile: a refuse-level finding is a
1833
+ // statically-provable runtime gate refusal — shipping it as law would fail at first dispatch.
1834
+ console.error(` ✗ check REFUSED — fix the refuse-level finding(s) above (or run \`githolon check\`)`);
1835
+ process.exitCode = 1;
1836
+ }
1837
+ }
1766
1838
  if (dartOut !== undefined) {
1767
1839
  const supportText =
1768
1840
  dartSupportImport === "vendored" ? "vendored nomos_client support" : "package nomos_client imports";
package/src/fields.ts CHANGED
@@ -404,6 +404,18 @@ export const t = {
404
404
  position(): Field<string, "position"> {
405
405
  return makeField<string, "position">("position", z.string(), LastPosition);
406
406
  },
407
+ /**
408
+ * An ARRAY of a value object (or any zod) as a VALUE-OBJECT-SHAPE / directive-PAYLOAD
409
+ * element — the ergonomic sugar for `z.array(SomeVo.zod)`. Accepts a VO handle
410
+ * (`t.array(DrawingGeoControlPoint)`), a Field, or a raw zod, and returns the branded
411
+ * `z.array(...)` so the nested VO's descriptor tree threads (vo_first_class.md slice 1).
412
+ * NOTE: this returns a ZOD SCHEMA — a shape/payload element — NOT a `t.*` aggregate field
413
+ * (an array of VOs is stored as one wholesale-replace json leaf; place it via a value object).
414
+ */
415
+ array(inner: { readonly zod: z.ZodTypeAny } | z.ZodTypeAny): z.ZodArray<z.ZodTypeAny> {
416
+ const el = (inner as { zod?: z.ZodTypeAny }).zod ?? (inner as z.ZodTypeAny);
417
+ return z.array(el);
418
+ },
407
419
  /** Enum via `as const` literal union — value typos squeal at compile time. */
408
420
  enum<const E extends readonly [string, ...string[]]>(values: E): Field<E[number], "enum"> {
409
421
  return makeField<E[number], "enum">(
@@ -0,0 +1,375 @@
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}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * THE AUTHOR-TIME GATE — `githolon check` (architecture/author_time_gate.md, slice 1).
8
+ *
9
+ * Every DX bug the tenant teams reported was the same disease: the kernel gate refused
10
+ * something at RUNTIME that was STATICALLY VISIBLE at compile. The cure is to pre-fire
11
+ * the refusal at author time, with the FIX inside the message. This module is the
12
+ * unified surface: it runs the STATIC TWINS of the gate's refusal vocabulary over the
13
+ * composed domain modules and returns typed {@link Finding}s.
14
+ *
15
+ * Three verdict levels:
16
+ * - **refuse** — the fault is PAYLOAD-INDEPENDENT (return shape, schema/driver mismatch),
17
+ * so the runtime gate WILL refuse it. `githolon check` exits non-zero; `compile` refuses.
18
+ * - **warn** — the probe SAW something suspicious but can't PROVE it (a plan that threw on
19
+ * the one synthesized sample — a conditional/payload-dependent path). Names the payload.
20
+ * - **note** — an auto-repair was applied (e.g. the auto-declared secondary ensure).
21
+ *
22
+ * FALSE-POSITIVE DISCIPLINE (the hard rule, from the design doc): the probe runs ONE
23
+ * synthesized payload — a conditional branch it doesn't take proves nothing. A twin may
24
+ * REFUSE only when the fault is payload-independent; everything else is a WARN naming the
25
+ * payload that exhibited it. The runtime gate remains the arbiter; `check` never claims
26
+ * completeness, only that what it flags is real.
27
+ */
28
+ import type { z } from "zod";
29
+
30
+ import type { AggregateHandle } from "./aggregate.js";
31
+ import type { Directive } from "./directive.js";
32
+ import type { DomainModule } from "./codegen_dart.js";
33
+ import type { Field } from "./fields.js";
34
+ import type { FieldOp, PlannedOp } from "./ops.js";
35
+ import { deterministicPorts } from "./ctx.js";
36
+ import { synthesizePayload, withBirthProbeGlobals, type SynthesizedPayload } from "./codegen_proof.js";
37
+ import { __drainAuthoring, __resetAuthoring } from "./authoring.js";
38
+ import { __drainReads, __resetReads } from "./read.js";
39
+ import { __drainBirths, __resetBirths } from "./births.js";
40
+ import { proofLegsIndex } from "./codegen_proof.js";
41
+ import { autoStampFields, zodKind, zodObjectShape } from "./codegen_ts.js";
42
+
43
+ /** One author-time finding — the gate refusal pre-fired at compile, or a courtesy note. */
44
+ export interface Finding {
45
+ /** The domain key the directive lives in. */
46
+ readonly domain: string;
47
+ /** The directive the finding is about (absent for a domain-wide note). */
48
+ readonly directiveId?: string;
49
+ /** `refuse` — provably refuses at the gate · `warn` — suspicious, can't prove · `note` — auto-repair. */
50
+ readonly level: "refuse" | "warn" | "note";
51
+ /** What is wrong (or, for a note, what was auto-repaired). */
52
+ readonly what: string;
53
+ /** How to fix it (or, for a note, how the repair keeps the law valid). */
54
+ readonly fix: string;
55
+ }
56
+
57
+ /** Payload field names the FRAMEWORK consumes off the intent (never the plan) — excluded from the
58
+ * unread-payload-field warn so a birth/timestamp convention field is never falsely flagged as dead.
59
+ * Sources: `autoStampFields` (the `…At` clock convention) + `birthMeterSubject`/`birthMeterBornAt`. */
60
+ const FRAMEWORK_CONSUMED_PAYLOAD_FIELDS: ReadonlySet<string> = new Set([
61
+ "principal",
62
+ "owner",
63
+ "ownerSubject",
64
+ "bornAt",
65
+ "requestedAt",
66
+ ]);
67
+
68
+ /** The outcome of running one directive's plan on its synthesized sample under the probe globals. */
69
+ interface ProbeResult {
70
+ readonly synth: SynthesizedPayload;
71
+ /** The stamped-and-filled sample the plan was run with (what a WARN names). */
72
+ readonly probePayload: Record<string, unknown>;
73
+ /** The plan's RETURN value (whatever it was — checked for array-ness by the return twin). */
74
+ readonly returned: unknown;
75
+ /** True when the plan THREW on the sample (payload-dependent — everything degrades to a WARN). */
76
+ readonly threw: boolean;
77
+ readonly error?: unknown;
78
+ /** The FIELD ops the plan emitted (authoring sink + returned ops), when it didn't throw. */
79
+ readonly ops: FieldOp[];
80
+ /** The top-level payload keys the plan actually READ (via the tracking proxy). */
81
+ readonly readFields: ReadonlySet<string>;
82
+ }
83
+
84
+ /** Run a directive's plan ONCE on a tracking-proxy sample under the birth-probe globals, capturing the
85
+ * return value, whether it threw, the emitted field ops, and which payload fields it read. `undefined`
86
+ * ⇒ the payload is unsynthesizable (an exotic zod shape) — the directive can't be probed at all. */
87
+ function probeDirective(dir: Directive<unknown>, where: string): ProbeResult | undefined {
88
+ let synth: SynthesizedPayload;
89
+ try {
90
+ synth = synthesizePayload(dir.payloadSchema as z.ZodTypeAny, where);
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ // Build the sample: required non-stamped fields from the synth, plus the auto-stamped `…At` fields
95
+ // filled with a fixed ISO stamp (the client stamps them at dispatch; the plan may read them here).
96
+ const probePayload: Record<string, unknown> = { ...synth.value };
97
+ for (const f of synth.autoStamped) probePayload[f] = "2026-01-01T00:00:00Z";
98
+
99
+ const shapeKeys = new Set(Object.keys(safeShape(dir.payloadSchema as z.ZodTypeAny)));
100
+ const readFields = new Set<string>();
101
+ const tracked = new Proxy(probePayload, {
102
+ get(target, prop, recv) {
103
+ if (typeof prop === "string" && shapeKeys.has(prop)) readFields.add(prop);
104
+ return Reflect.get(target, prop, recv);
105
+ },
106
+ });
107
+
108
+ let returned: unknown;
109
+ let threw = false;
110
+ let error: unknown;
111
+ let ops: FieldOp[] = [];
112
+ withBirthProbeGlobals(() => {
113
+ __resetAuthoring();
114
+ __resetReads();
115
+ __resetBirths();
116
+ try {
117
+ returned = dir.plan(tracked as never, deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }));
118
+ const drained: PlannedOp[] = [...__drainAuthoring(), ...(Array.isArray(returned) ? returned : [])];
119
+ ops = drained.filter((o): o is FieldOp => (o as { kind?: string }).kind !== "strike");
120
+ } catch (e) {
121
+ threw = true;
122
+ error = e;
123
+ } finally {
124
+ // Drain the sinks on every path so a throwing probe never leaks state into the next.
125
+ try { __drainAuthoring(); } catch { /* drained */ }
126
+ try { __drainReads(); } catch { /* drained */ }
127
+ try { __drainBirths(); } catch { /* drained */ }
128
+ }
129
+ });
130
+ return { synth, probePayload, returned, threw, error, ops, readFields };
131
+ }
132
+
133
+ /** `zodObjectShape` with a swallow — a non-object payload yields `{}` (the return twin handles it). */
134
+ function safeShape(schema: z.ZodTypeAny): Record<string, z.ZodTypeAny> {
135
+ try {
136
+ if (zodKind(schema) !== "object") return {};
137
+ return zodObjectShape(schema);
138
+ } catch {
139
+ return {};
140
+ }
141
+ }
142
+
143
+ /** The REQUIRED top-level payload fields (optional/defaulted omitted — an unread optional proves nothing). */
144
+ function requiredPayloadFields(schema: z.ZodTypeAny): string[] {
145
+ const out: string[] = [];
146
+ for (const [name, f] of Object.entries(safeShape(schema))) {
147
+ const k = zodKind(f);
148
+ if (k === "optional" || k === "default") continue;
149
+ out.push(name);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /** A short, safe render of the sample payload a WARN names (bounded so a huge payload doesn't flood). */
155
+ function samplePayloadText(payload: Record<string, unknown>): string {
156
+ let s: string;
157
+ try {
158
+ s = JSON.stringify(payload);
159
+ } catch {
160
+ s = "<unserializable>";
161
+ }
162
+ return s.length > 200 ? `${s.slice(0, 197)}…` : s;
163
+ }
164
+
165
+ // ── TWIN A — the UNIVERSAL plan-return probe ──────────────────────────────────────
166
+ // The engine assembles a plan's output with `[...returned]` (wire_encode.ts). A plan whose
167
+ // `plan` returns a NON-ITERABLE (undefined — a forgotten `return`; a builder — a forgotten
168
+ // `return []` after `create(...).set(...)`; a plain object) throws "not iterable" at the FIRST
169
+ // dispatch on every peer. This is payload-INDEPENDENT (the return TYPE is fixed) ⇒ REFUSE.
170
+ // A plan that THREW on the sample is payload-DEPENDENT (can't prove) ⇒ WARN naming the sample.
171
+ function planReturnFindings(domain: string, dir: Directive<unknown>, probe: ProbeResult): Finding[] {
172
+ if (probe.threw) {
173
+ return [
174
+ {
175
+ domain,
176
+ directiveId: dir.id,
177
+ level: "warn",
178
+ what: `the plan THREW on the synthesized sample (${(probe.error as Error)?.message ?? String(probe.error)}) — can't prove the return shape`,
179
+ fix: `run it on the sample ${samplePayloadText(probe.probePayload)} — if the throw is real, the runtime gate refuses; if it's a payload-dependent branch, ignore`,
180
+ },
181
+ ];
182
+ }
183
+ if (!Array.isArray(probe.returned)) {
184
+ const got =
185
+ probe.returned === undefined
186
+ ? "undefined (a missing `return`)"
187
+ : typeof probe.returned === "object" && probe.returned !== null
188
+ ? "an object/builder (a forgotten `return []`)"
189
+ : `a ${typeof probe.returned}`;
190
+ return [
191
+ {
192
+ domain,
193
+ directiveId: dir.id,
194
+ level: "refuse",
195
+ what: `the plan returns ${got}, not an array of ops — the engine assembles plan output with [...returned] and would throw "not iterable" at the first dispatch`,
196
+ fix: `return an array of ops — the fluent surface (create/set/add) records ops as a side-effect, so end the plan with \`return [];\` (or return the typed ops directly)`,
197
+ },
198
+ ];
199
+ }
200
+ return [];
201
+ }
202
+
203
+ // ── TWIN B — the UNREAD-PAYLOAD-FIELD warn ────────────────────────────────────────
204
+ // A REQUIRED payload field the plan never reads on the sample path is usually a bug (dead field
205
+ // or missing logic). WARN only (a conditional read the sample didn't reach proves nothing), and
206
+ // only for a plan that ran cleanly (a throwing plan read nothing — twin A already flagged it).
207
+ // Framework-consumed convention fields (auto-stamped `…At`, birth subject/time) are excluded.
208
+ function unreadPayloadFindings(domain: string, dir: Directive<unknown>, probe: ProbeResult): Finding[] {
209
+ if (probe.threw) return [];
210
+ const stamped = new Set(autoStampFields(dir.payloadSchema as z.ZodTypeAny));
211
+ // The GATE reads some payload fields the PLAN never does: `.requires(rel, { objectFrom })` derives
212
+ // the gate object from a named payload id field (per-record authz). Excluding it keeps a real
213
+ // gate-consumed field from being flagged dead (the todo-pro `shareTodo.listId` case).
214
+ const gateConsumed = new Set<string>();
215
+ const objectFromField = (dir as { requiresRelationGate?: { objectFrom?: { idField?: string } } })
216
+ .requiresRelationGate?.objectFrom?.idField;
217
+ if (typeof objectFromField === "string") gateConsumed.add(objectFromField);
218
+ const out: Finding[] = [];
219
+ for (const f of requiredPayloadFields(dir.payloadSchema as z.ZodTypeAny)) {
220
+ if (probe.readFields.has(f)) continue;
221
+ if (stamped.has(f)) continue; // the client stamps it; the plan needn't read it
222
+ if (FRAMEWORK_CONSUMED_PAYLOAD_FIELDS.has(f)) continue; // the meter reads it off the intent
223
+ if (gateConsumed.has(f)) continue; // the authz gate derives its object from it
224
+ out.push({
225
+ domain,
226
+ directiveId: dir.id,
227
+ level: "warn",
228
+ what: `payload field '${f}' is never read by the plan (on the sampled path) — dead field or missing logic?`,
229
+ fix: `read it in the plan (e.g. \`.set("${f}", p.${f})\`), make it \`.optional()\` if it is genuinely optional, or drop it from the payload`,
230
+ });
231
+ }
232
+ return out;
233
+ }
234
+
235
+ // ── TWIN C — the OP-VS-SCHEMA / driver twin ───────────────────────────────────────
236
+ // Walk the probed ops against the target aggregate's schema. An op on a field the aggregate
237
+ // does NOT declare is a hard mismatch (REFUSE, naming the real fields). A `set` on an additive
238
+ // set (AddWins/RemoveWins) OVERWRITES the CRDT instead of merging — REFUSE naming addToSet/
239
+ // removeFromSet. A `setEntry` on a non-map field is likewise a driver mismatch. Conservative:
240
+ // only ops targeting an aggregate THIS domain declares are judged (a foreign/framework tuple is
241
+ // left to the runtime gate); every refusal here is a combination the engine provably rejects.
242
+ function opVsSchemaFindings(
243
+ domain: string,
244
+ dir: Directive<unknown>,
245
+ probe: ProbeResult,
246
+ aggById: ReadonlyMap<string, AggregateHandle>,
247
+ ): Finding[] {
248
+ if (probe.threw) return [];
249
+ const out: Finding[] = [];
250
+ const seen = new Set<string>(); // dedupe identical (agg, field, kind) findings
251
+ for (const op of probe.ops) {
252
+ const aggId = op.aggregateType;
253
+ if (typeof aggId !== "string") continue;
254
+ const agg = aggById.get(aggId);
255
+ if (agg === undefined) continue; // foreign / framework aggregate — the runtime gate arbitrates
256
+ const key = `${aggId}.${op.field}.${op.kind}`;
257
+ if (seen.has(key)) continue;
258
+ seen.add(key);
259
+ const field = agg.fields[op.field] as Field | undefined;
260
+ if (field === undefined) {
261
+ const real = Object.keys(agg.fields).sort();
262
+ out.push({
263
+ domain,
264
+ directiveId: dir.id,
265
+ level: "refuse",
266
+ what: `the plan writes '${op.field}' on '${aggId}', which is not a declared field of that aggregate`,
267
+ fix: `write one of ${aggId}'s fields — ${real.length ? real.join(", ") : "(none)"} — or add '${op.field}' to the aggregate`,
268
+ });
269
+ continue;
270
+ }
271
+ const driverKind = (field.driver as { kind?: string }).kind;
272
+ if (op.kind === "set" && driverKind === "AddWins") {
273
+ out.push({
274
+ domain,
275
+ directiveId: dir.id,
276
+ level: "refuse",
277
+ what: `the plan \`set\`s '${op.field}' on '${aggId}', an AddWins set — set() OVERWRITES the merge (concurrent adds would be lost)`,
278
+ fix: `use \`addToSet(instance(${aggId}, id), "${op.field}", values)\` — the additive write that commutes`,
279
+ });
280
+ } else if (op.kind === "set" && driverKind === "RemoveWins") {
281
+ out.push({
282
+ domain,
283
+ directiveId: dir.id,
284
+ level: "refuse",
285
+ what: `the plan \`set\`s '${op.field}' on '${aggId}', a RemoveWins set — set() OVERWRITES the 2P-set CRDT`,
286
+ fix: `use \`addToSet(...)\` / \`removeFromSet(instance(${aggId}, id), "${op.field}", values)\` — the additive/tombstone writes`,
287
+ });
288
+ } else if (op.kind === "setEntry" && field.kind !== "map") {
289
+ out.push({
290
+ domain,
291
+ directiveId: dir.id,
292
+ level: "refuse",
293
+ what: `the plan \`setEntry\`s '${op.field}' on '${aggId}', a '${field.kind}' field — setEntry targets a map (MapOf) field only`,
294
+ fix: `write a t.map(...) field with setEntry, or use \`set(...)\` for the '${field.kind}' field '${op.field}'`,
295
+ });
296
+ }
297
+ }
298
+ return out;
299
+ }
300
+
301
+ /**
302
+ * Run every author-time twin over the composed domain modules and return the findings, ordered
303
+ * refuse → warn → note (stable within a level, by domain/directive appearance). This is the ONE
304
+ * entry `githolon check` and `compile`'s summary both call.
305
+ */
306
+ export function checkDomainModules(modules: readonly DomainModule[]): Finding[] {
307
+ const findings: Finding[] = [];
308
+
309
+ for (const mod of modules) {
310
+ const domain = mod.domain ?? mod.name;
311
+ const aggById = new Map<string, AggregateHandle>();
312
+ for (const a of mod.aggregates) aggById.set(a.id, a);
313
+
314
+ // The NOTE twin (unify): the auto-declared secondary ensures composeDomainModule injected.
315
+ for (const ty of mod.autoDeclaredEnsures ?? []) {
316
+ findings.push({
317
+ domain,
318
+ level: "note",
319
+ what: `auto-declared Ensure→${ty} from a plan's secondary ensure(...) — the id-mint gate was opened for it`,
320
+ fix: `no action needed; to make it explicit, export a directive that \`.ensures(${ty})\``,
321
+ });
322
+ }
323
+
324
+ for (const dir of mod.directives as Directive<unknown>[]) {
325
+ const where = `${domain}/${dir.id}`;
326
+ const probe = probeDirective(dir, where);
327
+ if (probe === undefined) continue; // unsynthesizable payload — proven by hand, not by check
328
+ findings.push(...planReturnFindings(domain, dir, probe));
329
+ findings.push(...unreadPayloadFindings(domain, dir, probe));
330
+ findings.push(...opVsSchemaFindings(domain, dir, probe, aggById));
331
+ }
332
+ }
333
+
334
+ // The BIRTH-RECIPE twin (unify): fold the scattered `.births()` recipe diagnostics into the
335
+ // one report as WARNs (a discoverable-but-suspect recipe — missing seed, uncomposed child domain).
336
+ try {
337
+ const idx = proofLegsIndex(modules as DomainModule[]);
338
+ if (idx !== null) {
339
+ for (const [domain, legs] of Object.entries(idx.domains)) {
340
+ for (const diag of legs.birthDiagnostics ?? []) {
341
+ findings.push({
342
+ domain,
343
+ level: "warn",
344
+ what: `birth recipe: ${diag}`,
345
+ fix: `see architecture/g3_kernel_fulfils_birth.md / the births DX — the recipe must install both framework + child law and seed genesis`,
346
+ });
347
+ }
348
+ }
349
+ }
350
+ } catch {
351
+ /* birth discovery is best-effort — its absence never blocks the check */
352
+ }
353
+
354
+ const rank = { refuse: 0, warn: 1, note: 2 } as const;
355
+ return findings
356
+ .map((f, i) => ({ f, i }))
357
+ .sort((a, b) => rank[a.f.level] - rank[b.f.level] || a.i - b.i)
358
+ .map(({ f }) => f);
359
+ }
360
+
361
+ /** Does the finding set carry a hard refusal (⇒ `githolon check` exits non-zero, `compile` refuses)? */
362
+ export function hasRefusal(findings: readonly Finding[]): boolean {
363
+ return findings.some((f) => f.level === "refuse");
364
+ }
365
+
366
+ /** Format one finding as the unified report line: `<domain>/<directive>: <LEVEL> — <what> — fix: <fix>`. */
367
+ export function formatFinding(f: Finding): string {
368
+ const subject = f.directiveId !== undefined ? `${f.domain}/${f.directiveId}` : f.domain;
369
+ return `${subject}: ${f.level.toUpperCase()} — ${f.what} — fix: ${f.fix}`;
370
+ }
371
+
372
+ /** The full `check` report body (one line per finding), ordered refuse → warn → note. */
373
+ export function formatFindings(findings: readonly Finding[]): string[] {
374
+ return findings.map(formatFinding);
375
+ }
@@ -0,0 +1,86 @@
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}. If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * SECONDARY-CREATE AUTO-DECLARE (the co2-dev id-mint-gate DX).
8
+ *
9
+ * A directive carries ONE referential marker for its PRIMARY aggregate (`.creates(A)` /
10
+ * `.ensures(A)` / …). But a plan may ALSO introduce a SECOND aggregate at a caller-chosen
11
+ * DETERMINISTIC id — `ensure(B, "b:singleton")` — of a type the directive does NOT declare
12
+ * `.ensures()` for. The kernel's id-mint gate admits an Ensure-introduced typed id ONLY through
13
+ * the sanctioned declared-ensures lane: the CERTIFIED manifest must carry a directive whose marker
14
+ * is `Ensure` targeting that type (exactly how `birth_meter_ensure.ts` sanctions `NomosBirthRecord`).
15
+ * With no such declaration the deterministic id is REFUSED ("[id-mint gate]") — correct BY DESIGN
16
+ * (an unsanctioned dev-chosen id can't be forged) but an opaque footgun for the author.
17
+ *
18
+ * This module closes the footgun WITHOUT weakening the gate: `composeDomainModule` PLAN-PROBES each
19
+ * directive (the same probe machinery `codegen_proof.ts` uses), discovers the secondary types its
20
+ * plan ensures, and — for any type NO directive in the domain already declares `.ensures()` for —
21
+ * injects a NO-OP sanctioning `.ensures(Type)` directive (a {@link sanctioningEnsureDirective},
22
+ * modelled on `nomosBirthRecordEnsure`). The manifest/USD then carries the `Ensure → Type`
23
+ * declaration the id-mint gate reads, so the deterministic id is admitted. The synthesized directive
24
+ * is DELIBERATELY USELESS TO DISPATCH (its plan writes nothing); its only load-bearing property is
25
+ * its manifest presence.
26
+ *
27
+ * ERA-SAFE: a domain whose plans do NO secondary ensures is returned untouched (byte-identical). A
28
+ * domain that DOES gains the sanction — and such a domain could not have deployed before this without
29
+ * the author hand-writing the declaration, so no already-shippable manifest hash silently drifts.
30
+ *
31
+ * BEST-EFFORT (graceful degradation): the probe runs each plan ONCE under a synthesized payload. A
32
+ * plan that ensures CONDITIONALLY on a payload shape the synthesis didn't hit (or that can't be
33
+ * probed) simply gets no auto-sanction — and the runtime id-mint gate refuses the ensure exactly as
34
+ * before (no regression, no forged-id hole). Auto-declare only ever ADDS a sanction the author's own
35
+ * plan already proves it intends; it never blocks a valid deploy.
36
+ */
37
+ import { z } from "zod";
38
+ import { directive } from "./directive.js";
39
+ import type { Directive } from "./directive.js";
40
+ import type { AggregateHandle } from "./aggregate.js";
41
+ import { probeEnsuredTypes } from "./codegen_proof.js";
42
+
43
+ /** The synthesized directive id sanctioning `Ensure → <aggId>`. Derived purely from the aggregate id
44
+ * so recompiles are stable; non-id chars are flattened so the id stays route-safe. */
45
+ export function sanctioningEnsureDirectiveId(aggId: string): string {
46
+ return `nomosEnsure_${aggId.replace(/[^A-Za-z0-9_]/g, "_")}`;
47
+ }
48
+
49
+ /**
50
+ * A no-op sanctioning `.ensures(agg)` directive — the twin of `nomosBirthRecordEnsure`, parameterized
51
+ * by an arbitrary aggregate. Its plan emits ZERO ops (it cannot forge a row); its ONLY purpose is the
52
+ * `Ensure → agg` marker the certified manifest carries so the id-mint gate opens the declared-ensures
53
+ * lane for a plan's secondary deterministic `ensure(agg, …)`.
54
+ */
55
+ export function sanctioningEnsureDirective(agg: AggregateHandle): Directive<unknown> {
56
+ return directive(sanctioningEnsureDirectiveId(agg.id))
57
+ .ensures(agg)
58
+ .payload(z.object({}).strict())
59
+ .plan(() => []) as unknown as Directive<unknown>;
60
+ }
61
+
62
+ /**
63
+ * Discover the secondary-ensured types a domain's directives introduce that NO directive already
64
+ * declares `.ensures()` for — the SORTED gap set the compose seam must sanction. Pure discovery:
65
+ * probes every directive, unions the ensured types, subtracts the domain-wide DECLARED-ensures set.
66
+ */
67
+ export function discoverUnsanctionedEnsures(
68
+ directives: readonly Directive<unknown>[],
69
+ domain: string,
70
+ ): string[] {
71
+ // Types some directive already DECLARES as its primary `.ensures(...)` target — already sanctioned.
72
+ const declared = new Set<string>();
73
+ for (const d of directives) {
74
+ if ((d as { marker?: string }).marker === "ensures") declared.add(d.aggregateId);
75
+ }
76
+ const gap = new Set<string>();
77
+ for (const d of directives) {
78
+ // A `.births()` directive's genesis-recipe plan runs under a different probe surface; its meter
79
+ // record is sanctioned by `birth_meter_ensure.ts`, so leave it to that lane (avoid double probing).
80
+ if ((d as { birthsChild?: boolean }).birthsChild === true) continue;
81
+ for (const ty of probeEnsuredTypes(d, `${domain}/${d.id}`)) {
82
+ if (!declared.has(ty)) gap.add(ty);
83
+ }
84
+ }
85
+ return [...gap].sort();
86
+ }