@githolon/dsl 0.54.2 → 0.55.1

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.
@@ -31,7 +31,7 @@ import {
31
31
  planBirthRecordOps,
32
32
  } from "./birth_meter.js";
33
33
  import type { AggregateHandle } from "./aggregate.js";
34
- import type { Field, EvidenceRefValue } from "./fields.js";
34
+ import { admitsNull, type Field, type EvidenceRefValue } from "./fields.js";
35
35
  import type { Directive, ReferentialMarker } from "./directive.js";
36
36
  import type { PlannedOp, FieldOp } from "./ops.js";
37
37
  import { deterministicPorts, type Ports } from "./ctx.js";
@@ -77,6 +77,15 @@ export function encodeKernelSchema(agg: AggregateHandle): WireSchema {
77
77
  return out;
78
78
  }
79
79
 
80
+ /** Recursively true iff `value` is (or contains, at any depth) a json `null` — the no-null check the
81
+ * encode-path null gate runs on a forbid `t.json()` field. Bounded by the value's own structure. */
82
+ function jsonHasNull(value: unknown): boolean {
83
+ if (value === null) return true;
84
+ if (Array.isArray(value)) return value.some(jsonHasNull);
85
+ if (typeof value === "object") return Object.values(value as Record<string, unknown>).some(jsonHasNull);
86
+ return false;
87
+ }
88
+
80
89
  /** A shape description for a value that failed to encode (diagnostics only). */
81
90
  function describeShape(value: unknown): string {
82
91
  if (value === null) return "null";
@@ -93,6 +102,21 @@ function encodeKernelValue(field: Field | undefined, value: unknown, op?: FieldO
93
102
  // string): the authored {hash, mediaType, byteLength, storageClass} maps to the
94
103
  // kernel's snake_case serde shape + the `Git`/`External` unit-variant. Checked BEFORE
95
104
  // the scalar fallthroughs (an evidence value is an object, never a Str/Int).
105
+ // NULL SEMANTICS (`architecture/null_semantics.md`) — the LUMP null gate, top-level null. A null
106
+ // value is lawful ONLY on a field that declared a null-admitting semantic (nullable / explicit-empty
107
+ // / clearable via `clear(...)`); it lowers to `Value::Json(null)` regardless of the field's kind (a
108
+ // cleared string clears to json null). A null on a forbid field is a typed refusal HERE (the encode
109
+ // runs in the sealed engine on every peer) — the runtime twin of the check-time refusal. Era-safe: a
110
+ // pre-null-semantics package froze its old lump (no `nullSemantic`), so this only tightens new law.
111
+ if (value === null && field !== undefined) {
112
+ if (admitsNull(field)) return { Json: null };
113
+ const aggName = op?.aggregateType ?? op?.aggregateId ?? "?";
114
+ throw new Error(
115
+ `${aggName}.${op?.field ?? field.kind}: writes null, but null has no law-level meaning here. ` +
116
+ `Use t.clearable(...) + clear(...) for explicit clearing, or t.nullable(..., { reason }) / ` +
117
+ `t.json({ null: "explicit-empty", reason }) for a real empty value.`,
118
+ );
119
+ }
96
120
  if (field?.kind === "evidence") {
97
121
  const v = value as EvidenceRefValue;
98
122
  return {
@@ -109,7 +133,23 @@ function encodeKernelValue(field: Field | undefined, value: unknown, op?: FieldO
109
133
  // value — object, array, string, number, boolean, null — commits as one consistent value kind
110
134
  // (the kernel canonicalises it at the boundary). This is the new-era encoding; a package compiled
111
135
  // before this change froze the old `{ Str: JSON.stringify(value) }` lump and replays unchanged.
112
- if (field?.kind === "json") return { Json: value };
136
+ if (field?.kind === "json") {
137
+ // NULL SEMANTICS (`architecture/null_semantics.md`) — the LUMP null gate. A `t.json()` field forbids
138
+ // null at every depth unless it declared a null-admitting semantic (nullable / explicit-empty /
139
+ // clearable via `clear(...)`). A plan-injected null on a forbid field is a typed refusal HERE (the
140
+ // encode runs inside the sealed engine on every peer), the runtime twin of the check-time refusal.
141
+ // Era-safe: a pre-null-semantics package froze its old lump and never carries a `nullSemantic`, so
142
+ // this branch only tightens NEWLY compiled law (the field defaults to "forbid" at construction).
143
+ if (!admitsNull(field) && jsonHasNull(value)) {
144
+ const aggName = op?.aggregateType ?? op?.aggregateId ?? "?";
145
+ throw new Error(
146
+ `${aggName}.${op?.field ?? field.kind}: json field carries a null, but null has no law-level ` +
147
+ `meaning here. Use t.clearable(...) + clear(...) for explicit clearing, or ` +
148
+ `t.nullable(..., { reason }) / t.json({ null: "explicit-empty", reason }) for a real empty value.`,
149
+ );
150
+ }
151
+ return { Json: value };
152
+ }
113
153
  // A `t.map(t.json())` / `t.map(VO.field())` ENTRY value is a json leaf too. Its wire DRIVER is
114
154
  // `MapOf(Lww)` (whose leaves are ordinary kernel `Value`s), so a json-valued entry lowers to the SAME
115
155
  // first-class `Value::Json` a top-level json field does — the map entry round-trips as its OBJECT, not a