@githolon/dsl 0.42.0 → 0.44.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@githolon/dsl",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
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/aggregate.ts CHANGED
@@ -313,6 +313,14 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
313
313
  ? { hasInvariant: true as const, invariant: composedInvariant }
314
314
  : {}),
315
315
  ...(fromBirthFields.length > 0 ? { fromBirthFields } : {}),
316
+ // VALUE-FREE INVARIANT (nomosFlowGate rule 4a): when the ONLY invariant is the synthesized
317
+ // fromBirth presence check, its refusal correlates with STRUCTURE the law itself guarantees
318
+ // publicly (a fromBirth field is present on every lawful row) — never with a secret VALUE.
319
+ // The flow gate exempts it from the declassify requirement; ANY user invariant (which may
320
+ // read plaintext values) re-arms the rule.
321
+ ...(fromBirthFields.length > 0 && userInvariant === undefined
322
+ ? { invariantValueFree: true as const }
323
+ : {}),
316
324
  ...(opts?.invariantDeclassify !== undefined ? { invariantDeclassify: opts.invariantDeclassify } : {}),
317
325
  ...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
318
326
  ...(opts?.global === true ? { global: true } : {}),
@@ -1392,6 +1392,10 @@ function voToJsonExpr(f: ValueObjectFieldDescriptor, expr: string, depth = 0): s
1392
1392
  * per file when the file emits ≥1 VO class (`toJson()`-based value equality). */
1393
1393
  function emitVoHelpers(): string {
1394
1394
  return [
1395
+ `/// The copyWith ABSENT-vs-null sentinel: an omitted optional param keeps the current`,
1396
+ `/// value; an explicit \`null\` clears it. (Dart cannot distinguish the two otherwise.)`,
1397
+ `const Object _voUnset = Object();`,
1398
+ ``,
1395
1399
  `/// Deep json equality for generated value-object classes (toJson-based).`,
1396
1400
  `bool _voJsonEq(Object? a, Object? b) {`,
1397
1401
  ` if (a is Map && b is Map) {`,
@@ -1428,6 +1432,36 @@ function emitVoHelpers(): string {
1428
1432
  * `toJson` (the authored payload shape — optionals OMITTED when null, matching the zod
1429
1433
  * `.optional()` contract), and toJson-based value equality. A `geo.*` VO emits the
1430
1434
  * fixed RFC 7946 shape (typed `coordinates`; `type` is the constant geometry kind). */
1435
+ /** The generated `copyWith` for a VO/arm class: non-nullable fields take an optional param
1436
+ * (`?? this.x` — they cannot be cleared; presence is the type's contract); OPTIONAL fields use
1437
+ * the `_voUnset` sentinel so `copyWith()` keeps the value while `copyWith(x: null)` CLEARS it —
1438
+ * the two intents Dart's named params cannot otherwise distinguish. The Dart idiom for the TS
1439
+ * spread (dart_codegen_parity.md P3). */
1440
+ function emitCopyWith(
1441
+ cls: string,
1442
+ fields: readonly { member: string; dartType: string; optional: boolean }[],
1443
+ ): string[] {
1444
+ if (fields.length === 0) return [];
1445
+ const params = fields.map((x) =>
1446
+ x.optional ? ` Object? ${x.member} = _voUnset,` : ` ${x.dartType}? ${x.member},`,
1447
+ );
1448
+ const args = fields.map((x) =>
1449
+ x.optional
1450
+ ? ` ${x.member}: identical(${x.member}, _voUnset) ? this.${x.member} : ${x.member} as ${x.dartType}?,`
1451
+ : ` ${x.member}: ${x.member} ?? this.${x.member},`,
1452
+ );
1453
+ return [
1454
+ ` /// A copy with the given fields replaced. Omitting an optional param keeps its value;`,
1455
+ ` /// passing \`null\` explicitly CLEARS it (the _voUnset sentinel distinguishes the two).`,
1456
+ ` ${cls} copyWith({`,
1457
+ ...params,
1458
+ ` }) =>`,
1459
+ ` ${cls}(`,
1460
+ ...args,
1461
+ ` );`,
1462
+ ];
1463
+ }
1464
+
1431
1465
  /** Emit a `union(...)` as a SEALED Dart hierarchy: the sealed base (decode switch on the
1432
1466
  * blessed `type` discriminator + abstract `toJson`), one final arm class per arm (anonymous
1433
1467
  * arms carry their fields; NAMED-VO arms hold a single `value` of the named class), and the
@@ -1441,6 +1475,8 @@ function emitUnionClasses(d: ValueObjectDescriptor): string {
1441
1475
  `/// Union \`${d.name}\` — a sum type stored as ONE json leaf (wholesale-replace; the`,
1442
1476
  `/// \`type\` discriminator names the arm). Adding an arm is ADDITIVE (older clients decode`,
1443
1477
  `/// it as [${d.name}Unknown]); removing/renaming an arm is a retype (#58 dispositions).`,
1478
+ `/// \`.encrypted()\` on a field of this type seals the WHOLE leaf — discriminator included;`,
1479
+ `/// per-arm sensitivity cannot be split (model differently-classified arms as separate fields).`,
1444
1480
  `sealed class ${d.name} {`,
1445
1481
  ` const ${d.name}();`,
1446
1482
  ` Map<String, Object?> toJson();`,
@@ -1464,6 +1500,9 @@ function emitUnionClasses(d: ValueObjectDescriptor): string {
1464
1500
  ` ${cls}(${arm.ref}.fromProjected((m['value']! as Map).cast<String, Object?>()));`,
1465
1501
  ` @override`,
1466
1502
  ` Map<String, Object?> toJson() => <String, Object?>{'type': '${key}', 'value': value.toJson()};`,
1503
+ ``,
1504
+ ` /// A copy with [value] replaced.`,
1505
+ ` ${cls} copyWith({${arm.ref}? value}) => ${cls}(value ?? this.value);`,
1467
1506
  ` @override`,
1468
1507
  ` bool operator ==(Object other) => other is ${cls} && _voJsonEq(other.toJson(), toJson());`,
1469
1508
  ` @override`,
@@ -1512,6 +1551,8 @@ function emitUnionClasses(d: ValueObjectDescriptor): string {
1512
1551
  ...literalToLines,
1513
1552
  ...toLines,
1514
1553
  ` };`,
1554
+ ``,
1555
+ ...emitCopyWith(cls, fields.map((x) => ({ member: x.member, dartType: dartTypeOfVoField(x.f, cls, x.wireName), optional: x.optional }))),
1515
1556
  ` @override`,
1516
1557
  ` bool operator ==(Object other) => other is ${cls} && _voJsonEq(other.toJson(), toJson());`,
1517
1558
  ` @override`,
@@ -1624,6 +1665,8 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1624
1665
  ` 'coordinates': coordinates,`,
1625
1666
  ` };`,
1626
1667
  ``,
1668
+ ...emitCopyWith(d.name, [{ member: "coordinates", dartType: coordsType, optional: false }]),
1669
+ ``,
1627
1670
  ` @override`,
1628
1671
  ` bool operator ==(Object other) =>`,
1629
1672
  ` other is ${d.name} && _voJsonEq(other.toJson(), toJson());`,
@@ -1686,6 +1729,8 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
1686
1729
  ...toLines,
1687
1730
  ` };`,
1688
1731
  ``,
1732
+ ...emitCopyWith(d.name, fields.map((x) => ({ member: x.member, dartType: dartTypeOfVoField(x.f, d.name, x.wireName), optional: x.optional }))),
1733
+ ``,
1689
1734
  ` @override`,
1690
1735
  ` bool operator ==(Object other) =>`,
1691
1736
  ` other is ${d.name} && _voJsonEq(other.toJson(), toJson());`,
package/src/flow_gate.ts CHANGED
@@ -38,6 +38,10 @@ type FlowModule = {
38
38
  readonly aggregates?: readonly {
39
39
  readonly id: string;
40
40
  readonly hasInvariant?: boolean;
41
+ /** True when the invariant is PROVABLY value-free (only the synthesized fromBirth
42
+ * presence check — no user predicate). Its refusal correlates with law-guaranteed
43
+ * structure, never a secret value, so rule 4a exempts it from declassify. */
44
+ readonly invariantValueFree?: boolean;
41
45
  readonly invariantDeclassify?: string;
42
46
  readonly encryptedFields?: readonly string[];
43
47
  readonly fields?: Record<string, unknown>;
@@ -150,6 +154,9 @@ export function assertFlowGate(m: CanonicalManifest, mod?: FlowModule): void {
150
154
  // 4a — an aggregate `invariant` over a private aggregate with a plaintext field.
151
155
  for (const a of mod.aggregates ?? []) {
152
156
  if (!a.hasInvariant || !privateTypes.has(a.id) || a.invariantDeclassify) continue;
157
+ // A VALUE-FREE invariant (the synthesized fromBirth presence check alone) refuses only on
158
+ // ABSENCE of a field the law guarantees present — structure, not secret. No declassify needed.
159
+ if (a.invariantValueFree === true) continue;
153
160
  const enc = new Set(a.encryptedFields ?? []);
154
161
  const hasPlaintext = Object.keys(a.fields ?? {}).some((f) => !enc.has(f));
155
162
  if (!hasPlaintext) continue; // fully-encrypted: the invariant reads only ciphertext — can't leak this way
@@ -166,8 +173,19 @@ export function assertFlowGate(m: CanonicalManifest, mod?: FlowModule): void {
166
173
  let refs: readonly { readonly aggregate: string }[];
167
174
  try {
168
175
  refs = wi.reads({ intent: new Proxy({}, { get: () => "" }) as Record<string, unknown>, principal: "" }) ?? [];
169
- } catch {
170
- continue; // the read-set can't be resolved on a probe intent — skip (no false refusal; conservative on availability)
176
+ } catch (e) {
177
+ // THE 4b PROBE HOLE, CLOSED (classified_first_class.md §2.1): a read-set that cannot be
178
+ // resolved on the probe used to be SKIPPED — "conservative on availability" was a hole on
179
+ // SAFETY (an invariant could escape rule 4 by accident of a throwing reads()). On a domain
180
+ // that HAS private types, an unresolvable read-set now REFUSES: make reads() total over a
181
+ // probe intent (every field reads as ""), or declare declassify if the read-set is
182
+ // genuinely intent-dependent and touches private state.
183
+ throw new FlowGateError(
184
+ `[nomosFlowGate rule 4b — unresolvable read-set] workspaceInvariant '${wi.id}' on '${wi.on}': its ` +
185
+ `.reads() threw on the compile probe (${String((e as Error)?.message ?? e).slice(0, 120)}) so the gate ` +
186
+ `cannot prove the refusal is secret-free while this domain declares private types. Make .reads() total ` +
187
+ `over a probe intent (every intent field reads as ""), or acknowledge with .declassify("<why>").`,
188
+ );
171
189
  }
172
190
  for (const r of refs) {
173
191
  const short = r.aggregate.includes(".") ? r.aggregate.slice(r.aggregate.lastIndexOf(".") + 1) : r.aggregate;