@githolon/dsl 0.50.0 → 0.51.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.
package/src/law_check.ts CHANGED
@@ -25,7 +25,7 @@
25
25
  * payload that exhibited it. The runtime gate remains the arbiter; `check` never claims
26
26
  * completeness, only that what it flags is real.
27
27
  */
28
- import type { z } from "zod";
28
+ import { z } from "zod";
29
29
 
30
30
  import type { AggregateHandle } from "./aggregate.js";
31
31
  import type { Directive } from "./directive.js";
@@ -39,6 +39,10 @@ import { __drainReads, __resetReads } from "./read.js";
39
39
  import { __drainBirths, __resetBirths } from "./births.js";
40
40
  import { proofLegsIndex } from "./codegen_proof.js";
41
41
  import { autoStampFields, zodKind, zodObjectShape } from "./codegen_ts.js";
42
+ import { finishCount } from "./count.js";
43
+ import { finishSum } from "./sum.js";
44
+ import { finishExtremum } from "./extremum.js";
45
+ import { finishExists } from "./exists.js";
42
46
 
43
47
  /** One author-time finding — the gate refusal pre-fired at compile, or a courtesy note. */
44
48
  export interface Finding {
@@ -162,6 +166,164 @@ function samplePayloadText(payload: Record<string, unknown>): string {
162
166
  return s.length > 200 ? `${s.slice(0, 197)}…` : s;
163
167
  }
164
168
 
169
+ // ── SLICE 2 — the DELETED-AMBIENT twin (crypto/fetch/setTimeout/… → refuse) ────────
170
+ // The sealed sandbox HARDENs `globalThis` by ALLOWLIST-DELETE (deterministic-rquickjs/src/lib.rs
171
+ // `HARDEN`): it keeps ONLY the pure ECMAScript surface + `__ports`, and DELETES everything else.
172
+ // A plan that references one of those deleted ambients runs fine in NODE (where the probe lives —
173
+ // `crypto`/`fetch`/`setTimeout`/… all exist), so the ordinary probe never sees it, but throws
174
+ // "'<X>' is not defined" at the FIRST dispatch on every peer. So we run the plan a SECOND time with
175
+ // those globals shadowed by THROWING stubs and catch the marker — a reference UNCONDITIONAL on the
176
+ // sample path is payload-independent ⇒ REFUSE (a branch the sample doesn't take never fires the
177
+ // stub ⇒ no finding, the hard rule). Date/Math stay REAL: `Date.now()`/`new Date()`/`Math.random()`
178
+ // are LEGAL (shimmed to the captured ports — deterministic by design), NOT deleted.
179
+ //
180
+ // The list is DERIVED from HARDEN's excluded-by-omission set (the `HARDEN` doc-comment enumerates
181
+ // it), restricted to the names that (a) EXIST in Node — so the ordinary probe can't catch them —
182
+ // and (b) a plan could plausibly reach for. `console` is deliberately EXCLUDED: internal DSL/probe
183
+ // code may log, which would be a false REFUSE, and the doc's list doesn't name it. So are the names
184
+ // Node/undici touch INTERNALLY (`queueMicrotask`/`setImmediate`/`Intl`/`structuredClone`/`Text*`) —
185
+ // stubbing them risks perturbing Node's own machinery. QuickJS-only globals (`os`/`std`/`print`/
186
+ // `scriptArgs`) are absent in Node too, so a plan using them already throws in the ordinary probe
187
+ // (twin A WARNs it) — no stub needed.
188
+ const DELETED_AMBIENT_ALTERNATIVE: Readonly<Record<string, string>> = {
189
+ crypto:
190
+ "randomness rides the captured ports — `Math.random()` is shimmed to the deterministic captured draws; a random id comes from Nomos minting it (`create(Agg)` mints)",
191
+ performance:
192
+ "time rides the captured ports — `Date.now()` / `new Date()` are shimmed to the deterministic captured clock; use those, or carry an ISO timestamp IN the payload",
193
+ fetch:
194
+ "network never happens in a plan (it must be a pure function of (payload, ports)) — carry the data IN the payload, or read it via a declared read() (`.reads(theQuery)`)",
195
+ XMLHttpRequest:
196
+ "network never happens in a plan — carry the data IN the payload, or read it via a declared read() (`.reads(theQuery)`)",
197
+ WebSocket:
198
+ "network never happens in a plan — carry the data IN the payload, or read it via a declared read() (`.reads(theQuery)`)",
199
+ setTimeout: "a plan is synchronous + deterministic — there are no timers; compute the value inline",
200
+ clearTimeout: "a plan is synchronous + deterministic — there are no timers; compute the value inline",
201
+ setInterval: "a plan is synchronous + deterministic — there are no timers; compute the value inline",
202
+ clearInterval: "a plan is synchronous + deterministic — there are no timers; compute the value inline",
203
+ process: "there is no host/OS ambient in the sandbox — carry any config IN the payload",
204
+ require: "there are no modules in the sandbox — the plan is a self-contained pure function",
205
+ };
206
+ const DELETED_AMBIENTS: readonly string[] = Object.keys(DELETED_AMBIENT_ALTERNATIVE);
207
+
208
+ /** The marker a shadowed ambient throws when the plan USES it — carries the name so the twin can name
209
+ * it (and its captured alternative). */
210
+ function deletedAmbientMarker(name: string): Error {
211
+ return Object.assign(new Error(`the sealed sandbox removes '${name}'`), { nomosDeletedAmbient: name });
212
+ }
213
+
214
+ /** A stub VALUE for a deleted ambient: a Proxy that is INERT when merely held or read as a binding
215
+ * (so Node/DSL internals that pass the reference around stay functional) but throws its marker the
216
+ * instant a PLAN actually USES it — a property access (`crypto.randomUUID`, `performance.now`,
217
+ * `process.env`), a call (`fetch(...)`, `setTimeout(...)`, `require(...)`), or a construct
218
+ * (`new WebSocket(...)`). This is the whole realistic surface by which a plan reaches an ambient;
219
+ * a bare unused `const c = crypto` is under-detected on purpose (the runtime gate stays the arbiter),
220
+ * the safe trade for never poisoning Node internals. Symbol reads return `undefined` so util.inspect /
221
+ * thenable probing can't trip it. */
222
+ function deletedAmbientStub(name: string): unknown {
223
+ const target = function () {} as unknown as object;
224
+ return new Proxy(target, {
225
+ get(_t, prop) {
226
+ if (typeof prop === "symbol") return undefined;
227
+ throw deletedAmbientMarker(name);
228
+ },
229
+ apply() {
230
+ throw deletedAmbientMarker(name);
231
+ },
232
+ construct() {
233
+ throw deletedAmbientMarker(name);
234
+ },
235
+ });
236
+ }
237
+
238
+ /** Run `run()` (the SYNCHRONOUS plan call only) with every {@link DELETED_AMBIENTS} name shadowed by a
239
+ * {@link deletedAmbientStub} data-property on `globalThis`, restored in a `finally` on the SAME tick —
240
+ * never across an await/module-load boundary (the teammate's bug: a stub visible during Node's lazy
241
+ * undici load). Because the stub is inert unless USED, and this window is one synchronous plan call,
242
+ * Node internals never trip it. Restores the exact prior descriptors on every path. */
243
+ function withDeletedAmbientStubs<T>(run: () => T): T {
244
+ const g = globalThis as unknown as Record<string, unknown>;
245
+ // PRE-MATERIALIZE (with NO stub installed yet — so nothing here can throw a marker). Node defines
246
+ // `fetch`/`performance`/`crypto` as LAZY globals that materialize — and pull in undici, which reads
247
+ // `performance` — on first access, AND (Node 25) even on a `getOwnPropertyDescriptor` query. Forcing
248
+ // each to a concrete value NOW, before any shadow, means the descriptor-capture + the sync plan run
249
+ // below never trigger a lazy load: the stub window stays synchronous and load-free (the teammate's
250
+ // bug was a stub visible during that undici load — this keeps the load strictly before the window).
251
+ for (const name of DELETED_AMBIENTS) {
252
+ try {
253
+ void g[name];
254
+ } catch {
255
+ /* a global that throws on plain read — nothing to pre-materialize */
256
+ }
257
+ }
258
+ const prior = new Map<string, PropertyDescriptor | undefined>();
259
+ for (const name of DELETED_AMBIENTS) {
260
+ prior.set(name, Object.getOwnPropertyDescriptor(g, name));
261
+ try {
262
+ Object.defineProperty(g, name, { configurable: true, writable: true, value: deletedAmbientStub(name) });
263
+ } catch {
264
+ // A non-configurable global can't be shadowed — leave it (the ordinary probe still runs).
265
+ prior.delete(name);
266
+ }
267
+ }
268
+ try {
269
+ return run();
270
+ } finally {
271
+ for (const [name, desc] of prior) {
272
+ try {
273
+ if (desc === undefined) delete g[name];
274
+ else Object.defineProperty(g, name, desc);
275
+ } catch {
276
+ /* best-effort restore */
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ /** Re-run the directive's plan on its synthesized sample with the deleted ambients shadowed; return the
283
+ * ambient name the plan READ on the sample path, or `undefined`. Inconclusive when the ordinary probe
284
+ * already threw (a broken sample path proves nothing about the ambient reference). */
285
+ function probeDeletedAmbient(dir: Directive<unknown>, probe: ProbeResult): string | undefined {
286
+ if (probe.threw) return undefined;
287
+ let hit: string | undefined;
288
+ withBirthProbeGlobals(() => {
289
+ withDeletedAmbientStubs(() => {
290
+ __resetAuthoring();
291
+ __resetReads();
292
+ __resetBirths();
293
+ try {
294
+ dir.plan(
295
+ probe.probePayload as never,
296
+ deterministicPorts({ physical: 1, replica: 1, principal: "principal:probe" }),
297
+ );
298
+ } catch (e) {
299
+ const name = (e as { nomosDeletedAmbient?: unknown } | null)?.nomosDeletedAmbient;
300
+ if (typeof name === "string") hit = name;
301
+ // A non-marker throw is a payload-dependent branch (twin A already WARNs it) — ignore.
302
+ } finally {
303
+ try { __drainAuthoring(); } catch { /* drained */ }
304
+ try { __drainReads(); } catch { /* drained */ }
305
+ try { __drainBirths(); } catch { /* drained */ }
306
+ }
307
+ });
308
+ });
309
+ return hit;
310
+ }
311
+
312
+ // ── TWIN 1 (slice 2) — the DELETED-AMBIENT refusal ────────────────────────────────
313
+ function deletedAmbientFindings(domain: string, dir: Directive<unknown>, probe: ProbeResult): Finding[] {
314
+ const name = probeDeletedAmbient(dir, probe);
315
+ if (name === undefined) return [];
316
+ return [
317
+ {
318
+ domain,
319
+ directiveId: dir.id,
320
+ level: "refuse",
321
+ what: `the plan references '${name}', which the sealed sandbox REMOVES (HARDEN allowlist-delete) — it would throw "'${name}' is not defined" at the first dispatch on every peer`,
322
+ fix: DELETED_AMBIENT_ALTERNATIVE[name] ?? "the sandbox keeps only the pure ECMAScript surface + the captured ports — carry data IN the payload or via declared reads",
323
+ },
324
+ ];
325
+ }
326
+
165
327
  // ── TWIN A — the UNIVERSAL plan-return probe ──────────────────────────────────────
166
328
  // The engine assembles a plan's output with `[...returned]` (wire_encode.ts). A plan whose
167
329
  // `plan` returns a NON-ITERABLE (undefined — a forgotten `return`; a builder — a forgotten
@@ -298,19 +460,254 @@ function opVsSchemaFindings(
298
460
  return out;
299
461
  }
300
462
 
463
+ // ── TWIN 4 (sealing slice 3) — GROUP/KEY BY an .encrypted() field REFUSES ──────────
464
+ // An `.encrypted()` field is a ciphertext envelope on the wire; grouping or keying a read BY it
465
+ // makes the PLAINTEXT public (the group key / index value leaks the very thing you sealed —
466
+ // e2e_encryption.md's "METADATA IS PUBLIC" footgun). Every read decl that GROUPS or KEYS by a
467
+ // field is checked against the target aggregate's `encryptedFields`: a hit is payload-independent
468
+ // (a static declaration) ⇒ REFUSE. (Twin 4b — an `.encrypted()` field on a non-`requires()`
469
+ // aggregate — is REFUSED earlier by `aggregate()` itself, before compose; a defensive re-check is
470
+ // folded in below so the unified report names it too if the state is ever reached.)
471
+ function encryptionGroupingFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
472
+ const domain = mod.domain ?? mod.name;
473
+ const out: Finding[] = [];
474
+ const seen = new Set<string>();
475
+ const encryptedOf = (aggId: string): ReadonlySet<string> =>
476
+ new Set(aggById.get(aggId)?.encryptedFields ?? []);
477
+ const flag = (readKind: string, readId: string, aggId: string, field: string, verb: string): void => {
478
+ if (!encryptedOf(aggId).has(field)) return;
479
+ const key = `${readKind}:${readId}:${aggId}.${field}`;
480
+ if (seen.has(key)) return;
481
+ seen.add(key);
482
+ out.push({
483
+ domain,
484
+ directiveId: readId,
485
+ level: "refuse",
486
+ what: `the ${readKind} '${readId}' ${verb} by '${field}' on '${aggId}', an .encrypted() field — ${verb === "keys" ? "keying" : "grouping"} by a sealed field makes its plaintext PUBLIC (the ${verb === "keys" ? "index key" : "group key"} leaks what you encrypted)`,
487
+ fix: `${verb} by a PLAINTEXT field instead, or drop .encrypted() from '${field}' — you can't both seal a value and expose it as a ${verb === "keys" ? "read key" : "group"}`,
488
+ });
489
+ };
490
+
491
+ for (const q of mod.queries ?? []) for (const k of q.key) flag("query", q.id, q.returns, k, "keys");
492
+ for (const c of mod.counts ?? []) {
493
+ const d = finishCount(c);
494
+ if (d.by !== undefined) flag("count", d.id, d.of, d.by, "groups");
495
+ }
496
+ for (const s of mod.sums ?? []) {
497
+ const d = finishSum(s);
498
+ if (d.by !== undefined) flag("sum", d.id, d.of, d.by, "groups");
499
+ }
500
+ for (const m of mod.mins ?? []) {
501
+ const d = finishExtremum(m);
502
+ if (d.by !== undefined) flag("min", d.id, d.of, d.by, "groups");
503
+ }
504
+ for (const m of mod.maxes ?? []) {
505
+ const d = finishExtremum(m);
506
+ if (d.by !== undefined) flag("max", d.id, d.of, d.by, "groups");
507
+ }
508
+ for (const e of mod.exists_ ?? []) {
509
+ const d = finishExists(e);
510
+ if (d.by !== undefined) flag("exists", d.id, d.of, d.by, "groups");
511
+ }
512
+ for (const sp of mod.spatials ?? []) flag("spatial", sp.id, sp.of, sp.on, "keys");
513
+
514
+ // Twin 4b (defensive unify — normally pre-empted by aggregate()'s throw): an encrypted field on a
515
+ // non-private aggregate. Unreachable in practice (the aggregate never composes), kept for completeness.
516
+ for (const agg of mod.aggregates) {
517
+ if ((agg.encryptedFields?.length ?? 0) > 0 && agg.visibility === undefined) {
518
+ out.push({
519
+ domain,
520
+ level: "refuse",
521
+ what: `aggregate '${agg.id}' has .encrypted() field(s) ${(agg.encryptedFields ?? []).join(", ")} but is not private — the encryption scope IS the read scope`,
522
+ fix: `add { visibility: requires("<cap>") } to '${agg.id}' (or drop .encrypted())`,
523
+ });
524
+ }
525
+ }
526
+ return out;
527
+ }
528
+
529
+ // ── TWIN 3 (slice 2) — INVARIANT SAMPLE-PROBE (aggregate + workspace invariants) ────
530
+ // An aggregate invariant (`aggregate(id, fields, { invariant })`) runs on every touching event; a
531
+ // workspaceInvariant(id).on(dir).reads(...).assert(...) runs at the gate. We probe each like a plan:
532
+ // synthesize a sample (the aggregate's fields for an aggregate invariant; the `on`-directive's payload
533
+ // + a synthesized snapshot per declared ref for a workspace invariant) and run the predicate. A THROW
534
+ // (not a `reject` verdict — a reject is legitimate law) is a predicate BUG ⇒ WARN naming the exact
535
+ // sample serialized. NEVER a REFUSE (sample-dependent — a different input may not throw, the hard rule).
536
+
537
+ /** Synthesize a plausible folded-snapshot for an aggregate from its stored fields' zod schemas.
538
+ * `undefined` when the shape is unsynthesizable (an exotic field) — the predicate can't be probed. */
539
+ function synthesizeSnapshot(agg: AggregateHandle, where: string): Record<string, unknown> | undefined {
540
+ try {
541
+ const shape: Record<string, z.ZodTypeAny> = {};
542
+ for (const [name, f] of Object.entries(agg.fields)) shape[name] = (f as Field).zod as z.ZodTypeAny;
543
+ return synthesizePayload(z.object(shape), where).value as Record<string, unknown>;
544
+ } catch {
545
+ return undefined;
546
+ }
547
+ }
548
+
549
+ function invariantFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
550
+ const domain = mod.domain ?? mod.name;
551
+ const out: Finding[] = [];
552
+
553
+ // Aggregate invariants — probe the composed `invariant` fn on a synthesized snapshot.
554
+ for (const agg of mod.aggregates) {
555
+ if (typeof agg.invariant !== "function") continue;
556
+ const snap = synthesizeSnapshot(agg, `${domain}/${agg.id}#invariant`);
557
+ if (snap === undefined) continue; // unsynthesizable — proven by hand, not by check
558
+ try {
559
+ withBirthProbeGlobals(() => agg.invariant!(snap));
560
+ } catch (e) {
561
+ out.push({
562
+ domain,
563
+ level: "warn",
564
+ what: `aggregate '${agg.id}' invariant THREW on the synthesized snapshot (${(e as Error)?.message ?? String(e)}) — a predicate bug, or a payload-dependent path the sample happened to hit`,
565
+ fix: `run the invariant on the snapshot ${samplePayloadText(snap)} — if the throw is real, every touching event refuses; if it's a shape the real data never has, guard the predicate`,
566
+ });
567
+ }
568
+ }
569
+
570
+ // Workspace invariants — resolve the ref-set from a synthesized intent, synth a snapshot per ref,
571
+ // then run the predicate. A throw at either step (reads() or assert()) → WARN naming the input.
572
+ const dirById = new Map<string, Directive<unknown>>();
573
+ for (const d of mod.directives as Directive<unknown>[]) dirById.set(d.id, d);
574
+ for (const wi of mod.workspaceInvariants ?? []) {
575
+ // The `on` may be `<domain>.<Directive>` or a bare id — match the suffix.
576
+ const onId = wi.on.includes(".") ? wi.on.slice(wi.on.lastIndexOf(".") + 1) : wi.on;
577
+ const dir = dirById.get(wi.on) ?? dirById.get(onId);
578
+ let intent: Record<string, unknown> = {};
579
+ if (dir !== undefined) {
580
+ const probe = probeDirective(dir, `${domain}/${wi.id}#intent`);
581
+ if (probe !== undefined) intent = probe.probePayload;
582
+ }
583
+ const ctx = { intent, principal: "principal:probe", principalVerified: true } as const;
584
+ let threwAt: "reads" | "assert" | undefined;
585
+ let err: unknown;
586
+ let snapshots: Record<string, Record<string, unknown>> = {};
587
+ withBirthProbeGlobals(() => {
588
+ try {
589
+ const refs = wi.reads(ctx);
590
+ for (const r of refs) {
591
+ const targetAgg = aggById.get(r.aggregate);
592
+ const snap = targetAgg !== undefined ? synthesizeSnapshot(targetAgg, `${domain}/${wi.id}#${r.name}`) : undefined;
593
+ snapshots[r.name] = snap ?? {};
594
+ }
595
+ } catch (e) {
596
+ threwAt = "reads";
597
+ err = e;
598
+ }
599
+ if (threwAt === undefined) {
600
+ try {
601
+ wi.assert(snapshots, { ...ctx, reads: [] });
602
+ } catch (e) {
603
+ threwAt = "assert";
604
+ err = e;
605
+ }
606
+ }
607
+ });
608
+ if (threwAt !== undefined) {
609
+ out.push({
610
+ domain,
611
+ directiveId: wi.on,
612
+ level: "warn",
613
+ what: `workspace invariant '${wi.id}' ${threwAt === "reads" ? "reads()" : "assert()"} THREW on the synthesized sample (${(err as Error)?.message ?? String(err)}) — a predicate bug, or a payload-dependent path the sample hit`,
614
+ fix: `run it on intent=${samplePayloadText(intent)} snapshots=${samplePayloadText(snapshots)} — if the throw is real, the gate refuses on '${wi.on}'; if the sample's shape never occurs, guard the predicate`,
615
+ });
616
+ }
617
+ }
618
+ return out;
619
+ }
620
+
621
+ // ── TWIN 2 (slice 2) — TOP-LEVEL PORT-READ pool-eligibility WARN ───────────────────
622
+ // A tenant MODULE whose TOP LEVEL reads the captured ports (`Date.now()`/`new Date()`/`Math.random()`
623
+ // at module scope, not inside a plan) makes the built engine lump PRISTINE-POOL-INELIGIBLE
624
+ // (deterministic-rquickjs's "boot-reads hole"): the resident-sandbox pool can't reuse it, so EVERY
625
+ // dispatch pays a full boot. The tenant modules are imported at compose time — their top level runs
626
+ // in Node BEFORE any probe — so this is caught by INSTRUMENTING the import: {@link recordTopLevelPortReads}
627
+ // shims those three ambients to set a flag during the import and reports which module top levels read
628
+ // them (the honest, reliable mechanism — a cache-busted re-eval or a built-lump scan would be heavier
629
+ // and less faithful). `check_package_main` / `compile` wrap each tenant `import()` in it and pass the
630
+ // offending DOMAIN keys here; the DomainModule itself carries no source, so absent that wiring (unit
631
+ // tests) the twin is simply silent. WARN only — a boot cost, never a correctness fault.
632
+
633
+ /** True iff `run()` (a tenant-module import) READS the captured ports at top level. Shims `Date.now`/
634
+ * the no-arg `Date` ctor / `Math.random` to set a flag, then restores them. Call it AROUND each tenant
635
+ * `await import(...)` in the check/compile lane; map a true result to the domain key and feed it to
636
+ * {@link checkDomainModules}'s `portReadingDomains`. (Exported so the module-load lane can wrap it with
637
+ * ONE additive call — no restructuring of the loader.) */
638
+ export async function recordTopLevelPortReads<T>(run: () => Promise<T>): Promise<{ value: T; read: boolean }> {
639
+ let read = false;
640
+ const realNow = Date.now;
641
+ const realRandom = Math.random;
642
+ const RealDate = Date;
643
+ const ShimDate = function (this: unknown, ...args: unknown[]): unknown {
644
+ if (args.length === 0) read = true; // no-arg `new Date()` reads the wall clock == a port read
645
+ // Delegate to the real Date so the module still evaluates correctly.
646
+ return new (RealDate as unknown as new (...a: unknown[]) => object)(...args);
647
+ } as unknown as DateConstructor;
648
+ ShimDate.now = () => {
649
+ read = true;
650
+ return realNow.call(Date);
651
+ };
652
+ ShimDate.parse = RealDate.parse;
653
+ ShimDate.UTC = RealDate.UTC;
654
+ // `new ShimDate(...)` delegates to RealDate, so instances already carry RealDate.prototype; no
655
+ // need to reassign the (read-only) `prototype` slot on the function object.
656
+ try {
657
+ // eslint-disable-next-line no-global-assign
658
+ (globalThis as { Date: DateConstructor }).Date = ShimDate;
659
+ Date.now = () => {
660
+ read = true;
661
+ return realNow.call(RealDate);
662
+ };
663
+ Math.random = () => {
664
+ read = true;
665
+ return realRandom.call(Math);
666
+ };
667
+ const value = await run();
668
+ return { value, read };
669
+ } finally {
670
+ (globalThis as { Date: DateConstructor }).Date = RealDate;
671
+ Date.now = realNow;
672
+ Math.random = realRandom;
673
+ }
674
+ }
675
+
676
+ function topLevelPortReadFindings(mod: DomainModule, portReadingDomains: ReadonlySet<string>): Finding[] {
677
+ const domain = mod.domain ?? mod.name;
678
+ if (!portReadingDomains.has(domain) && !portReadingDomains.has(mod.name)) return [];
679
+ return [
680
+ {
681
+ domain,
682
+ level: "warn",
683
+ what: `this module's TOP LEVEL reads the captured ports (Date.now()/new Date()/Math.random() at module scope) — the built law loses sandbox POOLING (the pristine-pool "boot-reads hole"), so every write pays a full boot`,
684
+ fix: `move the read INSIDE the plan (a plan is where the captured ports belong); a module-level constant must not read the clock/rng`,
685
+ },
686
+ ];
687
+ }
688
+
301
689
  /**
302
690
  * Run every author-time twin over the composed domain modules and return the findings, ordered
303
691
  * 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.
692
+ * entry `githolon check` and `compile`'s summary both call. `opts.portReadingDomains` (optional) is
693
+ * the set of domain keys whose module top level read the captured ports at import — collected by the
694
+ * module-load lane via {@link recordTopLevelPortReads}; absent ⇒ the top-level-port-read twin is silent.
305
695
  */
306
- export function checkDomainModules(modules: readonly DomainModule[]): Finding[] {
696
+ export function checkDomainModules(
697
+ modules: readonly DomainModule[],
698
+ opts?: { portReadingDomains?: ReadonlySet<string> },
699
+ ): Finding[] {
307
700
  const findings: Finding[] = [];
701
+ const portReadingDomains = opts?.portReadingDomains ?? new Set<string>();
308
702
 
309
703
  for (const mod of modules) {
310
704
  const domain = mod.domain ?? mod.name;
311
705
  const aggById = new Map<string, AggregateHandle>();
312
706
  for (const a of mod.aggregates) aggById.set(a.id, a);
313
707
 
708
+ // Slice-2 twin: the module top level read the captured ports (pristine-pool ineligibility).
709
+ findings.push(...topLevelPortReadFindings(mod, portReadingDomains));
710
+
314
711
  // The NOTE twin (unify): the auto-declared secondary ensures composeDomainModule injected.
315
712
  for (const ty of mod.autoDeclaredEnsures ?? []) {
316
713
  findings.push({
@@ -328,7 +725,12 @@ export function checkDomainModules(modules: readonly DomainModule[]): Finding[]
328
725
  findings.push(...planReturnFindings(domain, dir, probe));
329
726
  findings.push(...unreadPayloadFindings(domain, dir, probe));
330
727
  findings.push(...opVsSchemaFindings(domain, dir, probe, aggById));
728
+ findings.push(...deletedAmbientFindings(domain, dir, probe));
331
729
  }
730
+
731
+ // Slice-2/slice-3 twins over the whole module: invariant sample-probes + encryption grouping.
732
+ findings.push(...invariantFindings(mod, aggById));
733
+ findings.push(...encryptionGroupingFindings(mod, aggById));
332
734
  }
333
735
 
334
736
  // The BIRTH-RECIPE twin (unify): fold the scattered `.births()` recipe diagnostics into the