@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.
@@ -26,6 +26,9 @@ library;
26
26
  export 'src/wire.dart';
27
27
  export 'src/driver.dart';
28
28
  export 'src/offer.dart';
29
+ // THE TRI-STATE SLOT — NomosSlot<T> (Omitted / ExplicitNull / Present) for `nullable`/`clearable`
30
+ // declared fields (architecture/null_semantics.md); generated code references it, never redefines it.
31
+ export 'src/null_slot.dart';
29
32
  // homeNameOf(subject) — the canonical home-workspace name (parity with @githolon/client + the law side).
30
33
  export 'src/home.dart';
31
34
  export 'src/subscriptions.dart';
@@ -0,0 +1,128 @@
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} on V8 + WASI-shim, byte-identical everywhere. V8 = portability; the one
5
+ // wasm = determinism. No native, no wasmtime, no 2nd artifact, no domain-JS on bare V8.
6
+ // If a file isn't this / hosting this / authoring for this / proving this — it's gone.
7
+
8
+ /// THE TRI-STATE SLOT for a `nullable`-declared field (architecture/null_semantics.md).
9
+ ///
10
+ /// JSON's one `null` collapses three DIFFERENT domain meanings the DSL names apart —
11
+ /// OMISSION (not supplied / inherit), EXPLICIT CLEARING/EMPTY (a chosen `null`), and a
12
+ /// PRESENT value. A plain `T?` cannot hold all three (its `null` is ambiguous). A field the
13
+ /// domain declared `t.nullable(...)` / `t.clearable(...)` generates as a `NomosSlot<T>` so the
14
+ /// call site MUST handle each case — the app-edge footgun (collapsing to `Object?`/`any`) is
15
+ /// unrepresentable.
16
+ ///
17
+ /// - [NomosSlot.omitted] — the field never folded / was not supplied. On a payload it is
18
+ /// OMITTED from the wire JSON entirely (the plan never sees it).
19
+ /// - [NomosSlot.explicitNull] — an explicit domain `null` (the `explicit-empty` semantic, or a
20
+ /// `clearable` clear). On a payload it writes a real JSON `null`.
21
+ /// - [NomosSlot.of] — a present [value]. On a payload it writes the value.
22
+ ///
23
+ /// This is the DECODE contract on read models too: an ABSENT key decodes to [omitted], an
24
+ /// explicit JSON `null` to [explicitNull], a value to `NomosSlot.of(value)` — the three are
25
+ /// NEVER collapsed.
26
+ sealed class NomosSlot<T> {
27
+ const NomosSlot();
28
+
29
+ /// The field was not supplied / has not folded — omitted from the wire entirely.
30
+ const factory NomosSlot.omitted() = _Omitted<T>;
31
+
32
+ /// An explicit domain `null` (the `explicit-empty` value, or a `clearable` clear) — writes a
33
+ /// real JSON `null` on the wire.
34
+ const factory NomosSlot.explicitNull() = _ExplicitNull<T>;
35
+
36
+ /// A present [value].
37
+ const factory NomosSlot.of(T value) = _Present<T>;
38
+
39
+ /// DECODE a projection/read-model json map entry into a slot: the key ABSENT ⇒ [omitted], an
40
+ /// explicit `null` ⇒ [explicitNull], any other value ⇒ `NomosSlot.of(decode(raw))`. The
41
+ /// [decode] callback turns the raw json leaf into the typed [T] (identity for a scalar; a
42
+ /// `VoClass.fromProjected` for a value object).
43
+ static NomosSlot<T> decode<T>(
44
+ Map<String, Object?> data,
45
+ String key,
46
+ T Function(Object value) decode,
47
+ ) {
48
+ if (!data.containsKey(key)) return NomosSlot<T>.omitted();
49
+ final raw = data[key];
50
+ if (raw == null) return NomosSlot<T>.explicitNull();
51
+ return NomosSlot<T>.of(decode(raw));
52
+ }
53
+
54
+ /// True when the field carries a present value.
55
+ bool get isPresent => this is _Present<T>;
56
+
57
+ /// True for the explicit-`null` case (a chosen clear/empty — distinct from omission).
58
+ bool get isExplicitNull => this is _ExplicitNull<T>;
59
+
60
+ /// True when the field was not supplied / has not folded.
61
+ bool get isOmitted => this is _Omitted<T>;
62
+
63
+ /// The present value, or `null` for both omission AND explicit-null. LOSSY on purpose — use
64
+ /// [when] to keep the three cases distinct; reach for this only when omitted≡null is fine.
65
+ T? get valueOrNull => switch (this) {
66
+ _Present<T>(:final value) => value,
67
+ _ => null,
68
+ };
69
+
70
+ /// Exhaustive handling of the three cases — the point of the slot (no case can be forgotten).
71
+ R when<R>({
72
+ required R Function() omitted,
73
+ required R Function() explicitNull,
74
+ required R Function(T value) present,
75
+ }) =>
76
+ switch (this) {
77
+ _Omitted<T>() => omitted(),
78
+ _ExplicitNull<T>() => explicitNull(),
79
+ _Present<T>(:final value) => present(value),
80
+ };
81
+
82
+ /// Serialise INTO a payload json map at [key]: [omitted] writes NOTHING (the key is absent),
83
+ /// [explicitNull] writes a real `null`, [NomosSlot.of] writes `encode(value)`. The [encode]
84
+ /// callback turns the typed value into its wire json leaf (identity for a scalar; `.toJson()`
85
+ /// for a value object).
86
+ void writeTo(
87
+ Map<String, Object?> data,
88
+ String key,
89
+ Object? Function(T value) encode,
90
+ ) {
91
+ switch (this) {
92
+ case _Omitted<T>():
93
+ break; // omitted ⇒ absent from the wire (the plan never sees the field)
94
+ case _ExplicitNull<T>():
95
+ data[key] = null;
96
+ case _Present<T>(:final value):
97
+ data[key] = encode(value);
98
+ }
99
+ }
100
+
101
+ @override
102
+ bool operator ==(Object other) =>
103
+ other is NomosSlot<T> &&
104
+ other.runtimeType == runtimeType &&
105
+ other.valueOrNull == valueOrNull;
106
+
107
+ @override
108
+ int get hashCode => Object.hash(runtimeType, valueOrNull);
109
+ }
110
+
111
+ final class _Omitted<T> extends NomosSlot<T> {
112
+ const _Omitted();
113
+ @override
114
+ String toString() => 'NomosSlot.omitted()';
115
+ }
116
+
117
+ final class _ExplicitNull<T> extends NomosSlot<T> {
118
+ const _ExplicitNull();
119
+ @override
120
+ String toString() => 'NomosSlot.explicitNull()';
121
+ }
122
+
123
+ final class _Present<T> extends NomosSlot<T> {
124
+ const _Present(this.value);
125
+ final T value;
126
+ @override
127
+ String toString() => 'NomosSlot.of($value)';
128
+ }
@@ -0,0 +1,51 @@
1
+ import 'package:nomos_client/nomos_client.dart';
2
+ import 'package:test/test.dart';
3
+
4
+ void main() {
5
+ group('NomosSlot tri-state', () {
6
+ test('decode distinguishes omitted / explicit-null / present', () {
7
+ final present = NomosSlot.decode<String>({'x': 'hi'}, 'x', (v) => v as String);
8
+ final explicit = NomosSlot.decode<String>({'x': null}, 'x', (v) => v as String);
9
+ final omitted = NomosSlot.decode<String>(<String, Object?>{}, 'x', (v) => v as String);
10
+
11
+ expect(present.isPresent, isTrue);
12
+ expect(present.valueOrNull, 'hi');
13
+ expect(explicit.isExplicitNull, isTrue);
14
+ expect(explicit.valueOrNull, isNull);
15
+ expect(omitted.isOmitted, isTrue);
16
+ // The three NEVER collapse: explicit-null and omitted are distinct despite both valueOrNull==null.
17
+ expect(explicit == omitted, isFalse);
18
+ });
19
+
20
+ test('writeTo omits, writes real null, or writes value', () {
21
+ final data = <String, Object?>{};
22
+ const NomosSlot<String>.omitted().writeTo(data, 'a', (v) => v);
23
+ expect(data.containsKey('a'), isFalse); // omitted ⇒ absent
24
+
25
+ const NomosSlot<String>.explicitNull().writeTo(data, 'b', (v) => v);
26
+ expect(data.containsKey('b'), isTrue);
27
+ expect(data['b'], isNull); // explicit ⇒ real json null
28
+
29
+ const NomosSlot.of('v').writeTo(data, 'c', (v) => v);
30
+ expect(data['c'], 'v');
31
+ });
32
+
33
+ test('when() forces exhaustive handling', () {
34
+ String tag(NomosSlot<int> s) => s.when(
35
+ omitted: () => 'omitted',
36
+ explicitNull: () => 'null',
37
+ present: (v) => 'present:$v',
38
+ );
39
+ expect(tag(const NomosSlot.omitted()), 'omitted');
40
+ expect(tag(const NomosSlot.explicitNull()), 'null');
41
+ expect(tag(const NomosSlot.of(7)), 'present:7');
42
+ });
43
+
44
+ test('encode callback lowers a value object on writeTo', () {
45
+ final data = <String, Object?>{};
46
+ NomosSlot.of(const {'lat': 1, 'lng': 2})
47
+ .writeTo(data, 'loc', (v) => v);
48
+ expect(data['loc'], {'lat': 1, 'lng': 2});
49
+ });
50
+ });
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.54.2",
3
+ "version": "0.55.1",
4
4
  "type": "module",
5
5
  "description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
6
6
  "license": "SEE LICENSE IN LICENSE.md",
package/src/authoring.ts CHANGED
@@ -99,6 +99,10 @@ export interface AggregateRef<F extends Record<string, Field> = Record<string, F
99
99
  key: string,
100
100
  value: UntypedFields<F> extends true ? unknown : MapValueOf<F[K]>,
101
101
  ): this;
102
+ /** EXPLICIT-CLEAR a `t.clearable(...)`/`t.nullable(...)` field (`architecture/null_semantics.md`) —
103
+ * writes json `null` (lowered to a plain `Set`). The fluent sibling of the `clear(handle, "field")`
104
+ * op; never hand-write `.set("field", null)`. */
105
+ clear(field: string): this;
102
106
  /** Add to a collection: a `t.hasMany` relation → writes the child's back-ref; a `t.set` → AddToSet on self. */
103
107
  add(field: string, value: unknown): this;
104
108
  /** Relate to another aggregate by reference (writes the ref field on THIS aggregate). */
@@ -152,6 +156,21 @@ class Ref implements AggregateRef {
152
156
  this.emit(rel, "set", { value: target.__id });
153
157
  return this;
154
158
  }
159
+ clear(field: string): this {
160
+ // EXPLICIT-CLEAR: write json null on a clearable/nullable field. The field must declare a
161
+ // null-admitting semantic (the encode-path guard refuses a null on a forbid field regardless).
162
+ const f = (this.handle.fields as Record<string, Field>)[field] as
163
+ | { isClearable?: true; nullSemantic?: string }
164
+ | undefined;
165
+ if (f !== undefined && f.isClearable !== true && f.nullSemantic !== "nullable") {
166
+ throw new Error(
167
+ `.clear('${field}'): '${field}' is not a clearable/nullable field — declare it t.clearable(...) ` +
168
+ `or t.nullable(..., { reason }) before clearing it.`,
169
+ );
170
+ }
171
+ this.emit(field, "set", { value: null });
172
+ return this;
173
+ }
155
174
  add(field: string, value: unknown): this {
156
175
  // `.add` resolves the named member across the aggregate's two member kinds: a virtual
157
176
  // `t.hasMany` relation (write the child's back-ref) vs a stored `t.set` field (AddToSet).
@@ -60,6 +60,7 @@ import {
60
60
  synthesizeBirthQuotaInvariants,
61
61
  } from "./birth_meter.js";
62
62
  import { withBirthMeterExports } from "./birth_meter_ensure.js";
63
+ import { withClearDirectives } from "./clear_directive.js";
63
64
  import { withAttestedReadExports, isTrustSourceDecl, type TrustSourceDecl } from "./attested.js";
64
65
  import type { RelationTypeDecl } from "./relation_schema.js";
65
66
  import { domainHash, emitManifestBytes } from "./manifest.js";
@@ -162,6 +163,10 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
162
163
  // ATTESTED READS (slice B): a foreign-read-declaring domain gains the SourceFrontier
163
164
  // aggregate + its sanctioning ensure at the same seam (era-safe: others byte-identical).
164
165
  merged = withAttestedReadExports(merged);
166
+ // EXPLICIT-CLEAR LANE (null_semantics.md): every `t.clearable(field)` gains a generated
167
+ // `clear<Agg><Field>` directive (mutates + one clear op; `.requires` when declared) at the same
168
+ // seam — manifest-visible, kernel-gated, proof-covered. Clearable-free domains are byte-identical.
169
+ merged = withClearDirectives(merged);
165
170
 
166
171
  const byId = new Map<string, AggregateHandle>();
167
172
  for (const a of [...aggregatesOf(merged), ...(spec.extraAggregates ?? [])]) byId.set(a.id, a);
@@ -0,0 +1,103 @@
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 GENERATED CLEAR DIRECTIVE (architecture/null_semantics.md — the explicit-clear lane).
8
+ *
9
+ * A `t.clearable(field)` field admits an explicit-clear write (a Set of json `null`). So the framework
10
+ * MINTS the write lane for it: a real directive `clear<Agg><Field>` per clearable field, injected at
11
+ * the ONE module-merge seam every plane shares (compose / engine slice / self-describe / compile
12
+ * openapi) — the SAME precedent as `withBirthMeterExports` (the birth-record sanctioning ensure) and
13
+ * the secondary-ensure auto-declaration. Because it is a real directive, it is manifest-visible,
14
+ * kernel-gated, and proof gets the clear leg for free; the generated CLIENT can bind a `clearX()`
15
+ * method to its id.
16
+ *
17
+ * AUTHORITY IS EXPLICIT (the hard requirement): a clear is a WRITE, so a blanket ungated clear on a
18
+ * field whose sibling writes are `.requires`-gated would be an authz hole. `t.clearable(field, {
19
+ * requires })` carries the relation onto the generated directive's `.requires(relation)`; absent it,
20
+ * the lane is ungated BUT `law_check` WARNs when the aggregate has any `.requires`-gated directive
21
+ * touching it (see `law_check.ts` nullClearAuthorityFindings).
22
+ *
23
+ * ERA-SAFE: a domain with NO clearable field gets NO injection (byte-identical). A domain that USES
24
+ * clearable moves its hash — but it is NEW law by definition (the generated directive's
25
+ * `requiresRelationGate`/marker is real, certified law). Existing keys are never overwritten.
26
+ */
27
+ import { z } from "zod";
28
+ import { directive, type Directive, type RequirableDirective } from "./directive.js";
29
+ import { instance, type AggregateHandle } from "./aggregate.js";
30
+ import { clear } from "./ops.js";
31
+ import type { Field } from "./fields.js";
32
+
33
+ /** Capitalize the first character (the clear-directive id joins `clear` + AggId + Field). */
34
+ function cap(s: string): string {
35
+ return s.length === 0 ? s : s[0]!.toUpperCase() + s.slice(1);
36
+ }
37
+
38
+ /**
39
+ * THE ID CONVENTION (tell the codegen agent — `clearX()` client methods bind to this): a clear
40
+ * directive is `clear${aggregateId}${Capitalized(field)}`. Aggregate ids are PascalCase, so `Screen`
41
+ * + `icon` → `clearScreenIcon`.
42
+ */
43
+ export function clearDirectiveId(aggregateId: string, field: string): string {
44
+ return `clear${aggregateId}${cap(field)}`;
45
+ }
46
+
47
+ /**
48
+ * Build the generated clear directive for one `(aggregate, clearable field)`: marker `mutates(Agg)`,
49
+ * payload `{ id, clearedAt }` (the timestamp doctrine — `clearedAt` is auto-stamped, so the plan
50
+ * needn't read it), plan = the one `clear` op. `.requires(relation)` iff the field declared
51
+ * `clearRequires`.
52
+ */
53
+ export function makeClearDirective(agg: AggregateHandle, field: string, requires?: string): Directive<unknown> {
54
+ const built = directive(clearDirectiveId(agg.id, field))
55
+ .mutates(agg)
56
+ .payload(z.object({ id: z.string().min(1), clearedAt: z.string() }))
57
+ .plan((p: { id: string; clearedAt: string }) => [clear(instance(agg, p.id), field as never)])
58
+ .doc(
59
+ `Explicitly clears the '${field}' field of a ${agg.id} (writes an explicit null — the declared clearable lane).`,
60
+ ) as unknown as RequirableDirective<unknown>;
61
+ return requires !== undefined ? built.requires(requires) : built;
62
+ }
63
+
64
+ /** The clearable `(field, requires?)` entries of one aggregate handle. */
65
+ function clearableFieldsOf(agg: AggregateHandle): { field: string; requires?: string }[] {
66
+ const out: { field: string; requires?: string }[] = [];
67
+ for (const [name, f0] of Object.entries(agg.fields as Record<string, Field>)) {
68
+ if (f0.isClearable === true) out.push({ field: name, ...(f0.clearRequires !== undefined ? { requires: f0.clearRequires } : {}) });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Inject a generated {@link makeClearDirective} into a merged module-exports bag for EVERY clearable
75
+ * field on any aggregate in the bag. A clearable-free bag is returned UNTOUCHED (the era rule). An
76
+ * existing directive with the same id (a tenant hand-authored clear) is left in place — never
77
+ * overwritten (the `withBirthMeterExports` courtesy).
78
+ */
79
+ export function withClearDirectives<M extends Record<string, unknown>>(mod: M): M {
80
+ const isAgg = (v: unknown): v is AggregateHandle =>
81
+ v !== null && typeof v === "object" && (v as { __isAggregateHandle?: boolean }).__isAggregateHandle === true;
82
+ const aggs = Object.values(mod).filter(isAgg);
83
+ if (aggs.length === 0) return mod;
84
+
85
+ // The directive ids already present (tenant-authored) — never re-mint over one.
86
+ const existingIds = new Set<string>();
87
+ for (const v of Object.values(mod)) {
88
+ if (v !== null && typeof v === "object" && typeof (v as Directive<unknown>).plan === "function" && typeof (v as Directive<unknown>).id === "string") {
89
+ existingIds.add((v as Directive<unknown>).id);
90
+ }
91
+ }
92
+
93
+ const additions: Record<string, unknown> = {};
94
+ for (const agg of aggs) {
95
+ for (const { field, requires } of clearableFieldsOf(agg)) {
96
+ const id = clearDirectiveId(agg.id, field);
97
+ if (existingIds.has(id) || id in mod) continue;
98
+ additions[id] = makeClearDirective(agg, field, requires);
99
+ existingIds.add(id);
100
+ }
101
+ }
102
+ return Object.keys(additions).length === 0 ? mod : ({ ...mod, ...additions } as M);
103
+ }