@githolon/dsl 0.54.2 → 0.55.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/fields.ts CHANGED
@@ -35,6 +35,66 @@ export type JsonValue =
35
35
  | JsonValue[]
36
36
  | { readonly [k: string]: JsonValue };
37
37
 
38
+ /**
39
+ * NULL SEMANTICS (`architecture/null_semantics.md`). JSON's one `null` collapses three distinct
40
+ * law meanings; a field NAMES which it admits before the law lets a null through:
41
+ * - `"forbid"` — null is refused anywhere (the `t.json()` default; the explicit spelling
42
+ * is `t.json({ null: "forbid" })`).
43
+ * - `"explicit-empty"` — nulls are admitted as a documented domain value (`t.json({ null:
44
+ * "explicit-empty", reason })`); `nullReason` records why.
45
+ * - `"nullable"` — null IS a real domain value on this field (`t.nullable(X, { reason })`);
46
+ * `nullReason` records why (rides docs/openapi).
47
+ * The EXPLICIT-CLEAR semantic is ORTHOGONAL, carried by the separate `isClearable` flag (`t.clearable(X)`
48
+ * + `.clear(field)` / the generated `clearX(...)` lane, lowered to a Set of json `null`) — a field can be
49
+ * BOTH clearable and nullable. FRONTEND/IR-ONLY metadata: neither ever enters the kernel wire `Schema`
50
+ * (driver-only — see `encodeKernelSchema`), so a field's domainHash is UNCHANGED. Codegen reads
51
+ * `nullSemantic`/`nullReason`/`isClearable` to shape the tri-state client surface; the runtime enforcement
52
+ * is the payload zod (no-null refine) + the encode-path null guard (`wire_encode.ts`).
53
+ */
54
+ export type NullSemantic = "forbid" | "explicit-empty" | "nullable";
55
+
56
+ /** The null semantics that ADMIT a null value on a field (everything else refuses null). NOTE: a
57
+ * `isClearable` field ALSO admits null (a cleared field reads back as json null) — use {@link admitsNull}
58
+ * to combine both axes. */
59
+ export const NULL_ADMITTING: ReadonlySet<NullSemantic> = new Set<NullSemantic>(["explicit-empty", "nullable"]);
60
+
61
+ /** True iff a field admits a null VALUE — either a null-admitting `nullSemantic` OR `isClearable`
62
+ * (a cleared field reads back as json null → ExplicitNull, so the read type must be tri-state). This
63
+ * is the ONE predicate the encode guard, the check twins, and codegen's tri-state trigger share. */
64
+ export function admitsNull(field: { readonly nullSemantic?: NullSemantic; readonly isClearable?: true }): boolean {
65
+ return field.isClearable === true || (field.nullSemantic !== undefined && NULL_ADMITTING.has(field.nullSemantic));
66
+ }
67
+
68
+ /**
69
+ * A recursive JSON schema that REFUSES `null` at every depth (`architecture/null_semantics.md`).
70
+ * The PAYLOAD helper for a `t.json()`-shaped field: declare a directive payload field with it and a
71
+ * null anywhere refuses at `.parse()` (the lump gate). Deterministic (a fixed `z.lazy` union — no
72
+ * clock/rng), so it validates byte-identically on every peer. `null` is simply not a member of any
73
+ * arm. (The aggregate FIELD zod stays `z.string()` — type-level, hash-stable; the no-null LAW on the
74
+ * write path is the encode-path guard in `wire_encode.ts`.) */
75
+ export const jsonNoNullSchema: z.ZodType<JsonValue> = z.lazy(() =>
76
+ z.union([
77
+ z.string(),
78
+ z.number(),
79
+ z.boolean(),
80
+ z.array(jsonNoNullSchema),
81
+ z.record(z.string(), jsonNoNullSchema),
82
+ ]),
83
+ ) as unknown as z.ZodType<JsonValue>;
84
+
85
+ /** A recursive JSON schema that ADMITS `null` at every depth — the `t.json({ null: "explicit-empty" })`
86
+ * field zod (nulls are a declared domain value). Same deterministic `z.lazy` union, plus a `z.null()` arm. */
87
+ export const jsonWithNullSchema: z.ZodType<JsonValue> = z.lazy(() =>
88
+ z.union([
89
+ z.null(),
90
+ z.string(),
91
+ z.number(),
92
+ z.boolean(),
93
+ z.array(jsonWithNullSchema),
94
+ z.record(z.string(), jsonWithNullSchema),
95
+ ]),
96
+ ) as unknown as z.ZodType<JsonValue>;
97
+
38
98
  export type FieldKind =
39
99
  | "string"
40
100
  | "int"
@@ -184,6 +244,28 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
184
244
  * Scalar Lww-class kinds only; mutually exclusive with `.optional()`. Flag named
185
245
  * `isFromBirth` — the `fromBirth()` METHOD sets it (the isOptional/isEncrypted convention). */
186
246
  readonly isFromBirth?: true;
247
+ /**
248
+ * NULL SEMANTICS (`architecture/null_semantics.md`): what `null` MEANS on this field. Set by the
249
+ * `t.json(...)` null option and the `t.optional`/`t.clearable`/`t.nullable` wrappers. `undefined`
250
+ * ⇒ the field carries no null declaration (a scalar/collection whose null-ness never arises); a
251
+ * `json` field with no wrapper defaults to `"forbid"` at construction. FRONTEND/IR-ONLY — never in
252
+ * the kernel wire `Schema` (driver-only), so it changes NO hash. See {@link NullSemantic}.
253
+ */
254
+ readonly nullSemantic?: NullSemantic;
255
+ /** The REQUIRED reason string for `nullable`/`explicit-empty` (law documentation — rides docs/openapi).
256
+ * Present iff `nullSemantic` is `"nullable"` or `"explicit-empty"`. */
257
+ readonly nullReason?: string;
258
+ /** EXPLICIT-CLEAR (`architecture/null_semantics.md`): set by `t.clearable(...)`. The field admits an
259
+ * explicit-clear write (`clear(handle, "field")` / `.clear("field")` / the generated `clearX(...)`
260
+ * lane), lowered to a Set of json `null`. ORTHOGONAL to `nullSemantic` (a field may be both). A
261
+ * cleared field reads back as json null, so codegen surfaces it tri-state (see {@link admitsNull}).
262
+ * FRONTEND/IR-ONLY — no hash change. */
263
+ readonly isClearable?: true;
264
+ /** The AUTHZ relation the GENERATED clear directive requires (`t.clearable(field, { requires })`). When
265
+ * set, the compose-seam-injected `clear<Agg><Field>` directive carries `.requires(relation)`; when
266
+ * absent the clear lane is ungated (and `law_check` WARNs if sibling writes ARE gated). Compose/codegen
267
+ * metadata — the REAL law is the generated directive's `requiresRelationGate` (hashed there, not here). */
268
+ readonly clearRequires?: string;
187
269
  /** For `t.ref` cross-workspace edges: the foreign workspace id, if any. */
188
270
  readonly refWorkspace?: string;
189
271
  /** For `t.ref` (the target) AND `t.hasMany` (the child type): the related aggregate's wire id. */
@@ -295,6 +377,15 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
295
377
  /** Declare BIRTH-PRESENCE LAW: aggregate() synthesizes a gate invariant requiring this field
296
378
  * present on every event touching the type; read models type it NON-NULLABLE. */
297
379
  fromBirth(): Field<T, K>;
380
+ /**
381
+ * INTERNAL (the `t.optional`/`t.clearable`/`t.nullable` wrappers call this): tag this field's null
382
+ * semantic (+ optional reason). Threaded through merge/optional like every other flag. Not for
383
+ * direct authoring — reach it via the wrappers, which validate driver/kind eligibility.
384
+ */
385
+ withNull(semantic: NullSemantic, reason?: string): Field<T, K>;
386
+ /** INTERNAL (`t.clearable` calls this): mark this field EXPLICIT-CLEARABLE (sets {@link Field.isClearable}
387
+ * + optional {@link Field.clearRequires}). Threaded through merge/optional like every other flag. */
388
+ clearable(requires?: string): Field<T, K> & { readonly isClearable: true };
298
389
  /** Phantom to keep T load-bearing for inference. */
299
390
  readonly _t?: T;
300
391
  }
@@ -316,6 +407,10 @@ function makeField<T, K extends FieldKind = FieldKind>(
316
407
  encrypted?: true;
317
408
  meta?: true;
318
409
  facet?: FieldFacet;
410
+ nullSemantic?: NullSemantic;
411
+ nullReason?: string;
412
+ isClearable?: true;
413
+ clearRequires?: string;
319
414
  } = {},
320
415
  ): Field<T, K> {
321
416
  const isOptional = extra.isOptional ?? false;
@@ -335,6 +430,10 @@ function makeField<T, K extends FieldKind = FieldKind>(
335
430
  ...(extra.encrypted ? { isEncrypted: true as const } : {}),
336
431
  ...(extra.meta ? { isMeta: true as const } : {}),
337
432
  ...(extra.fromBirth ? { isFromBirth: true as const } : {}),
433
+ ...(extra.nullSemantic !== undefined ? { nullSemantic: extra.nullSemantic } : {}),
434
+ ...(extra.nullReason !== undefined ? { nullReason: extra.nullReason } : {}),
435
+ ...(extra.isClearable ? { isClearable: true as const } : {}),
436
+ ...(extra.clearRequires !== undefined ? { clearRequires: extra.clearRequires } : {}),
338
437
  merge(d: Driver) {
339
438
  return makeField<T, K>(kind, zod, d, extra);
340
439
  },
@@ -364,6 +463,22 @@ function makeField<T, K extends FieldKind = FieldKind>(
364
463
  }
365
464
  return makeField<T, K>(kind, zod, driver, { ...extra, fromBirth: true });
366
465
  },
466
+ withNull(semantic: NullSemantic, reason?: string): Field<T, K> {
467
+ // Threaded through merge/optional like every other flag (it rides `extra`). Reason is carried
468
+ // only for the semantics that document one; the wrappers validate eligibility before calling.
469
+ return makeField<T, K>(kind, zod, driver, {
470
+ ...extra,
471
+ nullSemantic: semantic,
472
+ ...(reason !== undefined ? { nullReason: reason } : {}),
473
+ });
474
+ },
475
+ clearable(requires?: string): Field<T, K> & { readonly isClearable: true } {
476
+ return makeField<T, K>(kind, zod, driver, {
477
+ ...extra,
478
+ isClearable: true,
479
+ ...(requires !== undefined ? { clearRequires: requires } : {}),
480
+ }) as Field<T, K> & { readonly isClearable: true };
481
+ },
367
482
  encrypted(): Field<T, K> {
368
483
  // Carry the flag through merge/optional (extra is threaded), so `.merge(Lww).encrypted()`
369
484
  // and `.encrypted().merge(Lww)` agree. The wire schema is UNCHANGED (driver-only) — the
@@ -411,6 +526,42 @@ export const publicField = <T, K extends FieldKind>(field: Field<T, K>): Field<T
411
526
  /** Infer the authored value type of a Field. */
412
527
  export type FieldValue<F> = F extends Field<infer T> ? T : never;
413
528
 
529
+ /** The kinds a `clearable`/`nullable` null semantic may ride: VALUE LEAVES only. A collection/CRDT
530
+ * (`set`/`map`/`counter`/`position`/`hasMany`) clears/empties through its own additive op, never a null. */
531
+ const NULL_ELIGIBLE_KINDS: ReadonlySet<FieldKind> = new Set<FieldKind>([
532
+ "string",
533
+ "int",
534
+ "bool",
535
+ "enum",
536
+ "json",
537
+ "ref",
538
+ "evidence",
539
+ ]);
540
+
541
+ /** Guard the `t.clearable`/`t.nullable` wrappers: the field must be a value leaf with an Lww-class
542
+ * driver (the value is replaced/cleared WHOLESALE). Throws with the fix inside the message. */
543
+ function assertNullEligible(field: Field<unknown, FieldKind>, wrapper: "clearable" | "nullable"): void {
544
+ if (!NULL_ELIGIBLE_KINDS.has(field.kind)) {
545
+ throw new Error(
546
+ `t.${wrapper}(...) is not available on a '${field.kind}' field — a collection/CRDT clears through ` +
547
+ `its own additive op (removeFromSet / decrement), never a null. Value leaves only ` +
548
+ `(${[...NULL_ELIGIBLE_KINDS].join("/")}).`,
549
+ );
550
+ }
551
+ const dk = (field.driver as { kind?: string }).kind;
552
+ if (dk !== "Lww" && dk !== "LwwCausal" && dk !== "LwwBy") {
553
+ throw new Error(
554
+ `t.${wrapper}(...) needs an Lww-class field (Lww / LwwCausal / LwwBy) — a null/clear replaces the ` +
555
+ `value WHOLESALE, which a '${dk}' merge cannot express. Declare the field Lww-class first.`,
556
+ );
557
+ }
558
+ if (field.isFromBirth === true) {
559
+ throw new Error(
560
+ `t.${wrapper}(...) contradicts .fromBirth() — a birth-present field is NON-NULLABLE by law; drop one.`,
561
+ );
562
+ }
563
+ }
564
+
414
565
  export const t = {
415
566
  string(): Field<string, "string"> {
416
567
  return makeField("string", z.string(), Lww);
@@ -491,11 +642,76 @@ export const t = {
491
642
  * JSON leaf — the kernel `Value` is Str|Int only), so the v1-free projection surfaces
492
643
  * it as a permissive `Object?`. Use `t.jsonObject()` when the leaf is ALWAYS an OBJECT
493
644
  * value-object (e.g. a `location`) to get a strict `Map<String,Object?>` read type. */
494
- json(): Field<JsonValue, "json"> {
495
- // AUTHORING type is the structured JsonValue (the kernel stores Value::Json), so a plan may
496
- // set()/setEntry() a real object/array/scalar. The runtime wire + zod are UNCHANGED (a `Str`
497
- // leaf at op time — no hash change); only the accepted TS value widens (a `string` stays valid).
498
- return makeField<string, "json">("json", z.string(), Lww) as unknown as Field<JsonValue, "json">;
645
+ json(opts?: { null?: "forbid" | "explicit-empty"; reason?: string }): Field<JsonValue, "json"> {
646
+ // NULL SEMANTICS (`architecture/null_semantics.md`): `t.json()` EXCLUDES null at every depth by
647
+ // default. The FIELD zod stays `z.string()` (TYPE-LEVEL only — the wire is a `Value::Json` leaf,
648
+ // identical bytes/hash to before), and the no-null LAW is enforced two ways: the encode-path null
649
+ // guard (`wire_encode.ts`) refuses a plan-injected null in the lump on every peer, and a payload
650
+ // declared with the exported {@link jsonNoNullSchema} refuses a null at `.parse()`. `{ null:
651
+ // "explicit-empty", reason }` admits nulls as a documented domain value (reason REQUIRED — a
652
+ // meaning-free null is the whole footgun); the `nullSemantic` metadata is FRONTEND/IR-ONLY (codegen
653
+ // shapes the tri-state client surface from it) and never enters the hashed IR.
654
+ const mode = opts?.null ?? "forbid";
655
+ if (mode === "explicit-empty") {
656
+ if (typeof opts?.reason !== "string" || opts.reason.trim() === "") {
657
+ throw new Error(
658
+ `t.json({ null: "explicit-empty" }) requires a non-empty \`reason\` — the reason is law ` +
659
+ `documentation (it rides docs/openapi so every consumer knows what a null MEANS here). ` +
660
+ `Give a reason, or use the default t.json() which forbids null.`,
661
+ );
662
+ }
663
+ return makeField<string, "json">("json", z.string(), Lww, {
664
+ nullSemantic: "explicit-empty",
665
+ nullReason: opts.reason,
666
+ }) as unknown as Field<JsonValue, "json">;
667
+ }
668
+ return makeField<string, "json">("json", z.string(), Lww, {
669
+ nullSemantic: "forbid",
670
+ }) as unknown as Field<JsonValue, "json">;
671
+ },
672
+ /**
673
+ * `t.optional(field)` — the OMISSION semantic: a folded aggregate may LACK this field (absent =
674
+ * not supplied / inherit). The wrapper spelling of `field.optional()` (`architecture/null_semantics.md`);
675
+ * null NEVER arrives for an optional field — the law forbids it — so the generated client stays an
676
+ * idiomatic `T?` (absence only). Chainable-equivalent to `.optional()`.
677
+ */
678
+ optional<T, K extends FieldKind>(field: Field<T, K>): Field<T | undefined, K> {
679
+ return field.optional();
680
+ },
681
+ /**
682
+ * `t.clearable(field)` — the EXPLICIT-CLEAR semantic (`architecture/null_semantics.md`): the field
683
+ * admits an explicit-clear write (`.clear("field")` / the generated `clearX(...)` lane), lowered to a
684
+ * Set of json `null`. Value leaves only (Lww-class scalar / json / value-object — a collection/CRDT
685
+ * clears by its own additive op, never by null), else a compile-time throw. Codegen emits the clear lane.
686
+ */
687
+ clearable<T, K extends FieldKind>(
688
+ field: Field<T, K>,
689
+ opts?: { requires?: string },
690
+ ): Field<T, K> & { readonly isClearable: true } {
691
+ assertNullEligible(field, "clearable");
692
+ return field.clearable(opts?.requires);
693
+ },
694
+ /**
695
+ * `t.nullable(field, { reason })` — null IS a real domain value on this field (`architecture/
696
+ * null_semantics.md`): e.g. external-import fidelity. `reason` is REQUIRED (a meaning-free null is the
697
+ * footgun this feature removes) and rides docs/openapi. Value leaves only (Lww-class), else a throw.
698
+ * The field's zod is widened to admit null; the generated client gets the tri-state `| null` arm.
699
+ */
700
+ nullable<T, K extends FieldKind>(
701
+ field: Field<T, K>,
702
+ opts: { reason: string },
703
+ ): Field<T | null, K> & { readonly nullSemantic: "nullable" } {
704
+ if (typeof opts?.reason !== "string" || opts.reason.trim() === "") {
705
+ throw new Error(
706
+ `t.nullable(field, { reason }) requires a non-empty \`reason\` — null is a real value here, and ` +
707
+ `the reason is law documentation (it rides docs/openapi). Give a reason, or use t.optional(...) ` +
708
+ `for omission / t.clearable(...) for explicit clearing.`,
709
+ );
710
+ }
711
+ assertNullEligible(field, "nullable");
712
+ return field.withNull("nullable", opts.reason) as unknown as Field<T | null, K> & {
713
+ readonly nullSemantic: "nullable";
714
+ };
499
715
  },
500
716
  /** A JSON-OBJECT value-object leaf: an arbitrary JSON-string leaf (identical wire +
501
717
  * driver to `t.json()` — a `Str` at op time, so NO hash change) whose decoded value is
package/src/index.ts CHANGED
@@ -31,6 +31,11 @@ export {
31
31
  type FieldValue,
32
32
  type FieldKind,
33
33
  type JsonValue,
34
+ type NullSemantic,
35
+ NULL_ADMITTING,
36
+ admitsNull,
37
+ jsonNoNullSchema,
38
+ jsonWithNullSchema,
34
39
  type EvidenceRefValue,
35
40
  evidenceRefSchema,
36
41
  // FIELD FACETS (the endorse model — security_model.md): authority/standing/attribution gate
@@ -94,6 +99,7 @@ export {
94
99
  } from "./drivers.js";
95
100
  export {
96
101
  set,
102
+ clear,
97
103
  addToSet,
98
104
  setEntry,
99
105
  increment,
@@ -106,6 +112,10 @@ export {
106
112
  type PlannedEventMarker,
107
113
  type StrikeOp,
108
114
  } from "./ops.js";
115
+ // GENERATED CLEAR LANE (null_semantics.md): `clearDirectiveId(aggId, field)` is the id convention
116
+ // (`clear<Agg><Field>`) the codegen agent's `clearX()` client methods bind to; the directives
117
+ // themselves are compose-injected (`withClearDirectives`) so they ride every plane's module surface.
118
+ export { clearDirectiveId, makeClearDirective, withClearDirectives } from "./clear_directive.js";
109
119
  export {
110
120
  directive,
111
121
  recordObject,
package/src/law_check.ts CHANGED
@@ -30,7 +30,7 @@ import { z } from "zod";
30
30
  import type { AggregateHandle } from "./aggregate.js";
31
31
  import type { Directive } from "./directive.js";
32
32
  import type { DomainModule } from "./codegen_dart.js";
33
- import type { Field, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
33
+ import { admitsNull, type Field, type ValueObjectDescriptor, type ValueObjectFieldDescriptor } from "./fields.js";
34
34
  import type { FieldOp, PlannedOp } from "./ops.js";
35
35
  import { deterministicPorts } from "./ctx.js";
36
36
  import { synthesizePayload, withBirthProbeGlobals, type SynthesizedPayload } from "./codegen_proof.js";
@@ -43,6 +43,7 @@ import { finishCount } from "./count.js";
43
43
  import { finishSum } from "./sum.js";
44
44
  import { finishExtremum } from "./extremum.js";
45
45
  import { finishExists } from "./exists.js";
46
+ import { clearDirectiveId } from "./clear_directive.js";
46
47
 
47
48
  /** One author-time finding — the gate refusal pre-fired at compile, or a courtesy note. */
48
49
  export interface Finding {
@@ -656,6 +657,183 @@ function dottedQueryKeyFindings(mod: DomainModule, aggById: ReadonlyMap<string,
656
657
  return out;
657
658
  }
658
659
 
660
+ // ── TWIN N (null_semantics.md) — the NULL-SEMANTICS twins ──────────────────────────
661
+ // JSON's one `null` collapses three law meanings, so the DSL makes the author NAME which a field
662
+ // admits before a null is lawful (t.optional / t.clearable / t.nullable / t.json({null})). These
663
+ // twins pre-fire the three refusals the gate would raise: (a) a payload field that ADMITS null with
664
+ // no null-admitting receiver; (b) a null-admitting field used where a null is structurally illegal
665
+ // (authority gate, identity ref, spatial index, read key); (c) a plan op writing literal null into a
666
+ // field that never declared null a meaning. All PAYLOAD-INDEPENDENT (pure declarations) ⇒ REFUSE.
667
+
668
+ /** The verbatim null-semantics guidance (`null_semantics.md`) — the `fix` on twin N-a/N-c. */
669
+ const NULL_GUIDANCE =
670
+ 'Use optional for omission, clearable(...) for explicit clearing, or nullable(..., reason: "...") for an explicit empty/import value.';
671
+
672
+ /** Minimal zod `_def`/`def` accessor (the `_def ?? def` convention, node-builtin-free). */
673
+ function zDef(zt: z.ZodTypeAny): { type?: string; innerType?: z.ZodTypeAny; options?: readonly z.ZodTypeAny[]; value?: unknown } {
674
+ const raw = zt as unknown as { _def?: Record<string, unknown>; def?: Record<string, unknown> };
675
+ return (raw._def ?? raw.def ?? {}) as { type?: string; innerType?: z.ZodTypeAny; options?: readonly z.ZodTypeAny[]; value?: unknown };
676
+ }
677
+
678
+ /** True iff a payload schema ADMITS a `null` value (a `.nullable()` wrap, `z.null()`/`z.literal(null)`,
679
+ * or a union with a null arm) — the static signal twin N-a keys on. Bounded recursion. */
680
+ function zodAdmitsNull(schema: z.ZodTypeAny | undefined, depth = 0): boolean {
681
+ if (schema === undefined || depth > 16) return false;
682
+ const k = zodKind(schema);
683
+ if (k === "null" || k === "nullable") return true;
684
+ if (k === "literal") return zDef(schema).value === null;
685
+ if (k === "optional" || k === "default") return zodAdmitsNull(zDef(schema).innerType, depth + 1);
686
+ if (k === "union") return (zDef(schema).options ?? []).some((o) => zodAdmitsNull(o, depth + 1));
687
+ return false;
688
+ }
689
+
690
+ /** The set of a domain's aggregate fields (by name) whose null semantic ADMITS a null — the receivers
691
+ * that legitimately take a null-bearing payload field. Name-keyed (the `.set("x", p.x)` convention). */
692
+ function nullAdmittingFieldNames(aggById: ReadonlyMap<string, AggregateHandle>): ReadonlySet<string> {
693
+ const out = new Set<string>();
694
+ for (const agg of aggById.values()) {
695
+ for (const [name, f] of Object.entries(agg.fields)) {
696
+ if (admitsNull(f as Field)) out.add(name);
697
+ }
698
+ }
699
+ return out;
700
+ }
701
+
702
+ // TWIN N-a — a directive PAYLOAD field admits null with no null-admitting receiver.
703
+ function nullPayloadFindings(
704
+ domain: string,
705
+ dir: Directive<unknown>,
706
+ aggById: ReadonlyMap<string, AggregateHandle>,
707
+ ): Finding[] {
708
+ const receivers = nullAdmittingFieldNames(aggById);
709
+ const out: Finding[] = [];
710
+ for (const [name, f] of Object.entries(safeShape(dir.payloadSchema as z.ZodTypeAny))) {
711
+ if (!zodAdmitsNull(f)) continue;
712
+ if (receivers.has(name)) continue; // a field of the same name declared nullable/clearable — lawful
713
+ out.push({
714
+ domain,
715
+ directiveId: dir.id,
716
+ level: "refuse",
717
+ what: `payload field '${name}' admits null, but null has no law-level meaning`,
718
+ fix: NULL_GUIDANCE,
719
+ });
720
+ }
721
+ return out;
722
+ }
723
+
724
+ // TWIN N-c — a plan op writes literal null into a field that never declared null a meaning.
725
+ function nullWriteFindings(
726
+ domain: string,
727
+ dir: Directive<unknown>,
728
+ probe: ProbeResult,
729
+ aggById: ReadonlyMap<string, AggregateHandle>,
730
+ ): Finding[] {
731
+ if (probe.threw) return [];
732
+ const out: Finding[] = [];
733
+ const seen = new Set<string>();
734
+ for (const op of probe.ops) {
735
+ if (op.kind !== "set" || op.value !== null) continue;
736
+ const field =
737
+ op.targetField ??
738
+ (typeof op.aggregateType === "string"
739
+ ? (aggById.get(op.aggregateType)?.fields[op.field] as Field | undefined)
740
+ : (aggById.get(op.aggregateId)?.fields[op.field] as Field | undefined));
741
+ if (field === undefined) continue; // foreign/framework target — runtime gate arbitrates
742
+ if (admitsNull(field)) continue; // clearable/nullable — lawful
743
+ const key = `${op.aggregateType ?? op.aggregateId}.${op.field}`;
744
+ if (seen.has(key)) continue;
745
+ seen.add(key);
746
+ out.push({
747
+ domain,
748
+ directiveId: dir.id,
749
+ level: "refuse",
750
+ what: `the plan writes null into '${op.field}' on '${op.aggregateType ?? op.aggregateId}', which never declared null a meaning`,
751
+ fix: `declare the field t.clearable(...) and use clear(handle, "${op.field}"), or t.nullable(..., { reason }). ${NULL_GUIDANCE}`,
752
+ });
753
+ }
754
+ return out;
755
+ }
756
+
757
+ // TWIN N-b — a null-admitting field used where a null is structurally ILLEGAL.
758
+ function nullBanFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
759
+ const domain = mod.domain ?? mod.name;
760
+ const out: Finding[] = [];
761
+ const seen = new Set<string>();
762
+ const admits = (aggId: string, fieldName: string): Field | undefined => {
763
+ const f = aggById.get(aggId)?.fields[fieldName] as Field | undefined;
764
+ return f !== undefined && admitsNull(f) ? f : undefined;
765
+ };
766
+ const ban = (aggId: string, fieldName: string, rule: string, fix: string): void => {
767
+ if (admits(aggId, fieldName) === undefined) return;
768
+ const key = `${rule}:${aggId}.${fieldName}`;
769
+ if (seen.has(key)) return;
770
+ seen.add(key);
771
+ out.push({
772
+ domain,
773
+ level: "refuse",
774
+ what: `'${aggId}.${fieldName}' is a null-admitting field (nullable/clearable/explicit-empty) used as ${rule} — a null there is structurally illegal`,
775
+ fix,
776
+ });
777
+ };
778
+
779
+ // Facet gates (authority/standing) + identity refs — a null in these breaks the very thing they decide.
780
+ for (const agg of mod.aggregates) {
781
+ for (const [name, f0] of Object.entries(agg.fields)) {
782
+ const f = f0 as Field;
783
+ if (admits(agg.id, name) === undefined) continue;
784
+ if (f.facet === "authority" || f.facet === "standing") {
785
+ ban(agg.id, name, "an authority/standing gate", "drop the null semantic — an authority sink must always resolve to a subject.");
786
+ }
787
+ if (f.kind === "ref") {
788
+ ban(agg.id, name, "an identity reference", "an identity ref must always point somewhere — model absence with a separate presence flag, not a null ref.");
789
+ }
790
+ }
791
+ }
792
+ // Read keys / group-bys / spatial indexes — a null key row is a silent dead index.
793
+ for (const q of mod.queries ?? []) for (const k of q.key) if (!k.includes(".")) ban(q.returns, k, `the query '${q.id}' key`, "key on a non-null field — a null key never indexes.");
794
+ const banBy = (of: string, by: string | undefined, id: string, noun: string): void => {
795
+ if (by !== undefined && !by.includes(".")) ban(of, by, `the ${noun} '${id}' group-by`, "group by a non-null field — a null group is a silent dead bucket.");
796
+ };
797
+ for (const c of mod.counts ?? []) { const d = finishCount(c); banBy(d.of, d.by, d.id, "count"); }
798
+ for (const s of mod.sums ?? []) { const d = finishSum(s); banBy(d.of, d.by, d.id, "sum"); }
799
+ for (const m of mod.mins ?? []) { const d = finishExtremum(m); banBy(d.of, d.by, d.id, "min"); }
800
+ for (const m of mod.maxes ?? []) { const d = finishExtremum(m); banBy(d.of, d.by, d.id, "max"); }
801
+ for (const e of mod.exists_ ?? []) { const d = finishExists(e); banBy(d.of, d.by, d.id, "exists"); }
802
+ for (const sp of mod.spatials ?? []) if (!sp.on.includes(".")) ban(sp.of, sp.on, `the spatial index '${sp.id}'`, "a spatial field must always carry a geometry — a null point is not indexable.");
803
+ return out;
804
+ }
805
+
806
+ // TWIN N-d (null_semantics.md) — an UNGATED generated clear lane beside GATED sibling writes.
807
+ // A `t.clearable(field)` mints a `clear<Agg><Field>` WRITE directive. If it is ungated (no
808
+ // `requires:`) while OTHER writes on the same aggregate ARE `.requires`-gated, the clear lane is an
809
+ // accidental authz hole (you can null a field you can't otherwise write). WARN (not refuse — an
810
+ // ungated clear may be intentional; name the fix). The generated directive carries the real gate;
811
+ // this twin reads the field's `clearRequires` metadata to see whether the author declared one.
812
+ function nullClearAuthorityFindings(mod: DomainModule, aggById: ReadonlyMap<string, AggregateHandle>): Finding[] {
813
+ const domain = mod.domain ?? mod.name;
814
+ // Aggregate ids that carry at least one `.requires`-gated directive (the authz axis).
815
+ const gatedAggs = new Set<string>();
816
+ for (const d of mod.directives as Directive<unknown>[]) {
817
+ if ((d as { requiresRelationGate?: unknown }).requiresRelationGate !== undefined) gatedAggs.add(d.aggregateId);
818
+ }
819
+ const out: Finding[] = [];
820
+ for (const agg of mod.aggregates) {
821
+ if (!gatedAggs.has(agg.id)) continue; // no gated sibling → an ungated clear is not a relative hole
822
+ for (const [name, f0] of Object.entries(agg.fields)) {
823
+ const f = f0 as Field;
824
+ if (f.isClearable !== true || f.clearRequires !== undefined) continue; // gated clear → fine
825
+ out.push({
826
+ domain,
827
+ directiveId: clearDirectiveId(agg.id, name),
828
+ level: "warn",
829
+ what: `the generated clear lane for '${agg.id}.${name}' is UNGATED, but sibling writes on '${agg.id}' are .requires-gated — a clear is a write, so this can null a field you otherwise can't write`,
830
+ fix: `pass an authority to t.clearable("${name}", { requires: "<relation>" }), or gate it deliberately if an open clear is intended`,
831
+ });
832
+ }
833
+ }
834
+ return out;
835
+ }
836
+
659
837
  // ── TWIN 3 (slice 2) — INVARIANT SAMPLE-PROBE (aggregate + workspace invariants) ────
660
838
  // An aggregate invariant (`aggregate(id, fields, { invariant })`) runs on every touching event; a
661
839
  // workspaceInvariant(id).on(dir).reads(...).assert(...) runs at the gate. We probe each like a plan:
@@ -856,12 +1034,16 @@ export function checkDomainModules(
856
1034
  findings.push(...unreadPayloadFindings(domain, dir, probe));
857
1035
  findings.push(...opVsSchemaFindings(domain, dir, probe, aggById));
858
1036
  findings.push(...deletedAmbientFindings(domain, dir, probe));
1037
+ findings.push(...nullPayloadFindings(domain, dir, aggById));
1038
+ findings.push(...nullWriteFindings(domain, dir, probe, aggById));
859
1039
  }
860
1040
 
861
1041
  // Slice-2/slice-3 twins over the whole module: invariant sample-probes + encryption grouping.
862
1042
  findings.push(...invariantFindings(mod, aggById));
863
1043
  findings.push(...encryptionGroupingFindings(mod, aggById));
864
1044
  findings.push(...dottedQueryKeyFindings(mod, aggById));
1045
+ findings.push(...nullBanFindings(mod, aggById));
1046
+ findings.push(...nullClearAuthorityFindings(mod, aggById));
865
1047
  }
866
1048
 
867
1049
  // The BIRTH-RECIPE twin (unify): fold the scattered `.births()` recipe diagnostics into the
@@ -0,0 +1,65 @@
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 QuickJS engine}.
4
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
5
+
6
+ /**
7
+ * NULL-SEMANTICS METADATA CONSUMPTION (architecture/null_semantics.md — the codegen half).
8
+ *
9
+ * The DSL half (`fields.ts`/`ops.ts`/`law_check.ts`) TAGS a field with its null semantic:
10
+ * `nullSemantic` (`"forbid"` default · `"explicit-empty"` · `"nullable"`), the required
11
+ * `nullReason` documentation for the null-admitting semantics, and `isClearable` for the
12
+ * explicit-clear lane. This module is the ONE reader of those props for the 5 codegen surfaces
13
+ * (TS/Dart clients, the Dart bridge client, proof synthesis, openapi) — so a shape change lands
14
+ * in one place, and the read is a structural cast (the props are additive/optional on `Field`,
15
+ * so a field carrying none is byte-identical to a pre-feature field).
16
+ */
17
+
18
+ import { admitsNull } from "./fields.js";
19
+
20
+ // THE null-admission predicate is the DSL half's canonical `admitsNull` (fields.ts) — it treats a
21
+ // `nullSemantic` of `explicit-empty`/`nullable` AND `isClearable` alike (a cleared field reads back as
22
+ // json null → ExplicitNull), so the encode guard, the check twins, and codegen's tri-state trigger all
23
+ // share ONE definition. Re-exported so the 5 codegen surfaces import it from here with the doc helpers.
24
+ export { admitsNull };
25
+
26
+ /** The three named null semantics (`forbid` is the default when the field carries none). */
27
+ export type NullSemantic = "forbid" | "explicit-empty" | "nullable";
28
+
29
+ /** The null-semantics view of a field — a structural read of the DSL-tagged props. */
30
+ export interface NullMeta {
31
+ readonly nullSemantic: NullSemantic;
32
+ /** The law-documentation reason (required by the DSL for the null-admitting semantics). */
33
+ readonly nullReason?: string;
34
+ /** The field declares an explicit-CLEAR lane (`t.clearable(...)`). */
35
+ readonly clearable: boolean;
36
+ }
37
+
38
+ /** The structural shape the DSL half writes onto a null-aware field (all optional/additive). */
39
+ interface NullTaggedField {
40
+ readonly nullSemantic?: NullSemantic;
41
+ readonly nullReason?: string;
42
+ readonly isClearable?: true;
43
+ }
44
+
45
+ /** Read a field's (or any tagged value object descriptor's) null-semantics view. A field carrying
46
+ * none reads as the `forbid` default with no reason and non-clearable — the era-safe zero. */
47
+ export function nullMetaOf(field: unknown): NullMeta {
48
+ const f = (field ?? {}) as NullTaggedField;
49
+ const nullSemantic: NullSemantic = f.nullSemantic ?? "forbid";
50
+ return {
51
+ nullSemantic,
52
+ ...(f.nullReason !== undefined ? { nullReason: f.nullReason } : {}),
53
+ clearable: f.isClearable === true,
54
+ };
55
+ }
56
+
57
+ /** The human doc line for a null-admitting field, e.g. `null semantic: external import fidelity`.
58
+ * Surfaces in generated openapi field descriptions + TS/Dart doc comments. `undefined` when the
59
+ * field forbids null (nothing to say) or carries no reason. */
60
+ export function nullDocLine(field: unknown): string | undefined {
61
+ const m = nullMetaOf(field);
62
+ if (m.nullSemantic === "forbid") return undefined;
63
+ const which = m.nullSemantic === "nullable" ? "nullable" : "explicit-empty";
64
+ return m.nullReason !== undefined ? `null semantic (${which}): ${m.nullReason}` : `null semantic: ${which}`;
65
+ }
package/src/ops.ts CHANGED
@@ -182,6 +182,38 @@ export function set<
182
182
  return { ...addressOf(agg), ...targetFieldOf(agg, field), field, kind: "set", value };
183
183
  }
184
184
 
185
+ /**
186
+ * `clear(handle, "field")` — the EXPLICIT-CLEAR write (`architecture/null_semantics.md`) on a
187
+ * `t.clearable(...)` (or `t.nullable(...)`) field: it writes json `null`, lowering to a plain `Set`
188
+ * of `Value::Json(null)` (no new wire op — the kernel already stores a json null fine; the DSL null
189
+ * semantic is what makes it LAWFUL). NEVER hand-write `.set("field", null)` for this — `clear` names
190
+ * the intent and the encode-path guard admits its null because the target field declared it clearable.
191
+ * REFUSES at author time (and the type level collapses the field name) if the target field is NOT
192
+ * null-admitting.
193
+ */
194
+ export function clear<
195
+ A extends Target,
196
+ K extends {
197
+ [P in keyof Fields<A>]: Fields<A>[P] extends { readonly isClearable: true }
198
+ ? P
199
+ : Fields<A>[P] extends { readonly nullSemantic: "nullable" }
200
+ ? P
201
+ : never;
202
+ }[keyof Fields<A>] &
203
+ string,
204
+ >(agg: A, field: K): FieldOp {
205
+ const f = (agg as { fields?: Record<string, Field> }).fields?.[field] as
206
+ | { isClearable?: true; nullSemantic?: string }
207
+ | undefined;
208
+ if (f !== undefined && f.isClearable !== true && f.nullSemantic !== "nullable") {
209
+ throw new Error(
210
+ `clear('${field}'): '${field}' is not a clearable/nullable field — declare it t.clearable(...) ` +
211
+ `(explicit clear) or t.nullable(..., { reason }) (null is a real value) before clearing it.`,
212
+ );
213
+ }
214
+ return { ...addressOf(agg), ...targetFieldOf(agg, field), field, kind: "set", value: null };
215
+ }
216
+
185
217
  /** Return the same field op with an explicit per-aggregate event marker. */
186
218
  export function withMarker(op: FieldOp, marker: PlannedEventMarker): FieldOp {
187
219
  return { ...op, marker };