@githolon/dsl 0.41.0 → 0.43.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/dart/CHANGELOG.md +4 -0
- package/dart/lib/src/aggregate_meta.dart +5 -0
- package/dart/pubspec.yaml +1 -1
- package/package.json +1 -1
- package/src/aggregate.ts +36 -2
- package/src/codegen_dart.ts +50 -1
- package/src/codegen_ts.ts +3 -2
- package/src/compile_package_main.ts +29 -0
- package/src/fields.ts +28 -0
- package/src/flow_gate.ts +7 -0
- package/src/manifest.ts +4 -1
- package/src/stable_ids_types.ts +4 -0
package/dart/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 0.31.0
|
|
2
|
+
|
|
3
|
+
- `NomosFieldMeta.fromBirth` — the registry surfaces BIRTH-PRESENCE LAW (`.fromBirth()` fields, tooling ≥0.42): a gate invariant guarantees the field present on every row, and the generated read models type it non-nullable.
|
|
4
|
+
|
|
1
5
|
## 0.30.0
|
|
2
6
|
|
|
3
7
|
- RUNTIME AGGREGATE METADATA: `NomosAggregateMeta` / `NomosFieldMeta` / `mergeAggregateMeta` (`src/aggregate_meta.dart`) — the support types for the generated per-domain `const Map<String, NomosAggregateMeta> <domain>AggregateMeta` registry (`nomos-compile` ≥0.41 tooling). Generic surfaces (history/audit UIs, op renderers, in-app agents) resolve `NomosAggregateMeta.typeOfId(op.aggregate)` → field kinds, enum wire values, VO/variant names, and the typed `fromJson` decoder — the compiler's knowledge emitted as data, derived from the law's own declarations.
|
|
@@ -31,12 +31,17 @@ class NomosFieldMeta {
|
|
|
31
31
|
/// For `ref` kinds: the referenced aggregate's wire type, when the law declares it.
|
|
32
32
|
final String? refType;
|
|
33
33
|
|
|
34
|
+
/// True when the field carries BIRTH-PRESENCE LAW (`.fromBirth()`): a gate invariant
|
|
35
|
+
/// guarantees it present on every row, and the generated read model types it non-nullable.
|
|
36
|
+
final bool fromBirth;
|
|
37
|
+
|
|
34
38
|
const NomosFieldMeta({
|
|
35
39
|
required this.kind,
|
|
36
40
|
this.enumValues,
|
|
37
41
|
this.vo,
|
|
38
42
|
this.isVariant = false,
|
|
39
43
|
this.refType,
|
|
44
|
+
this.fromBirth = false,
|
|
40
45
|
});
|
|
41
46
|
}
|
|
42
47
|
|
package/dart/pubspec.yaml
CHANGED
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
Nomos 2 Dart frontend types — the hand-written kernel wire types plus the
|
|
12
12
|
code-generated per-domain aggregate + directive-payload types. (De)serializes
|
|
13
13
|
the EXACT serde JSON the Rust kernel + projection produce/consume.
|
|
14
|
-
version: 0.
|
|
14
|
+
version: 0.31.0
|
|
15
15
|
|
|
16
16
|
environment:
|
|
17
17
|
sdk: ^3.5.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.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
|
@@ -277,6 +277,31 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
277
277
|
.map(([name]) => name)
|
|
278
278
|
.sort();
|
|
279
279
|
|
|
280
|
+
// BIRTH-PRESENCE LAW (`.fromBirth()`, dart_codegen_parity.md): the declared fields become an
|
|
281
|
+
// AUTO-GENERATED aggregate invariant — the SAME #41 gate machinery a hand-written invariant
|
|
282
|
+
// uses, executing on EVERY event touching the type (author, edge admission, verify_chain), so
|
|
283
|
+
// a plan that leaves one absent is a TYPED refusal and the generated read models may type
|
|
284
|
+
// these fields NON-NULLABLE (absence is unrepresentable BY LAW, not by convention). Presence
|
|
285
|
+
// is monotone for the allowed kinds (no op unsets a value leaf), which keeps the claim sound
|
|
286
|
+
// on every later event. Composed BEFORE any user invariant (presence first, then domain law).
|
|
287
|
+
const fromBirthFields = Object.entries(stored)
|
|
288
|
+
.filter(([, f]) => (f as Field).isFromBirth === true)
|
|
289
|
+
.map(([name]) => name)
|
|
290
|
+
.sort();
|
|
291
|
+
const userInvariant = opts?.invariant;
|
|
292
|
+
const composedInvariant: AggregateInvariantFn | undefined =
|
|
293
|
+
fromBirthFields.length > 0
|
|
294
|
+
? (snapshot) => {
|
|
295
|
+
for (const name of fromBirthFields) {
|
|
296
|
+
const v = snapshot[name];
|
|
297
|
+
if (v === undefined || v === null) {
|
|
298
|
+
return { reject: `${id}.${name} is fromBirth() — every ${id} must carry it from creation (the plan left it unset)` };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return userInvariant !== undefined ? userInvariant(snapshot) : { accept: true };
|
|
302
|
+
}
|
|
303
|
+
: userInvariant;
|
|
304
|
+
|
|
280
305
|
// The audience-free DATA shape; the audience is attached below (from the legacy `{visibility}` opt or a
|
|
281
306
|
// fluent `.public()`/`.readableBy()`/`.owned()` call).
|
|
282
307
|
const base = {
|
|
@@ -284,8 +309,17 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
|
|
|
284
309
|
fields: stored as StoredFields<F>,
|
|
285
310
|
hasMany: hasMany as HasManyFields<F>,
|
|
286
311
|
__isAggregateHandle: true as const,
|
|
287
|
-
...(
|
|
288
|
-
? { hasInvariant: true as const, invariant:
|
|
312
|
+
...(composedInvariant !== undefined
|
|
313
|
+
? { hasInvariant: true as const, invariant: composedInvariant }
|
|
314
|
+
: {}),
|
|
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 }
|
|
289
323
|
: {}),
|
|
290
324
|
...(opts?.invariantDeclassify !== undefined ? { invariantDeclassify: opts.invariantDeclassify } : {}),
|
|
291
325
|
...(opts?.cap !== undefined ? { cap: opts.cap } : {}),
|
package/src/codegen_dart.ts
CHANGED
|
@@ -743,9 +743,14 @@ function aggregateFieldSpecs(
|
|
|
743
743
|
// decode would crash mid-fold). `.optional()` on the DSL field no longer
|
|
744
744
|
// changes the read surface; it was nullable in practice — this makes the type
|
|
745
745
|
// honest. Collections are unchanged (they already empty-default when absent).
|
|
746
|
+
// BIRTH-PRESENCE LAW (`.fromBirth()`): the gate refuses any event that leaves the field
|
|
747
|
+
// absent, so the read model may type it NON-NULLABLE — the one lawful exception to the
|
|
748
|
+
// partial-fold nullability doctrine (absence is unrepresentable by law, not convention).
|
|
746
749
|
const spec: AggFieldSpec = isCollectionKind(base.kind)
|
|
747
750
|
? base
|
|
748
|
-
:
|
|
751
|
+
: f.isFromBirth === true
|
|
752
|
+
? { ...base, optional: false }
|
|
753
|
+
: { ...base, optional: true };
|
|
749
754
|
if (used.has(spec.name)) {
|
|
750
755
|
throw new Error(
|
|
751
756
|
`aggregate '${agg.id}' field '${spec.wireName}' maps to duplicate Dart field '${spec.name}'`,
|
|
@@ -1387,6 +1392,10 @@ function voToJsonExpr(f: ValueObjectFieldDescriptor, expr: string, depth = 0): s
|
|
|
1387
1392
|
* per file when the file emits ≥1 VO class (`toJson()`-based value equality). */
|
|
1388
1393
|
function emitVoHelpers(): string {
|
|
1389
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
|
+
``,
|
|
1390
1399
|
`/// Deep json equality for generated value-object classes (toJson-based).`,
|
|
1391
1400
|
`bool _voJsonEq(Object? a, Object? b) {`,
|
|
1392
1401
|
` if (a is Map && b is Map) {`,
|
|
@@ -1423,6 +1432,36 @@ function emitVoHelpers(): string {
|
|
|
1423
1432
|
* `toJson` (the authored payload shape — optionals OMITTED when null, matching the zod
|
|
1424
1433
|
* `.optional()` contract), and toJson-based value equality. A `geo.*` VO emits the
|
|
1425
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
|
+
|
|
1426
1465
|
/** Emit a `union(...)` as a SEALED Dart hierarchy: the sealed base (decode switch on the
|
|
1427
1466
|
* blessed `type` discriminator + abstract `toJson`), one final arm class per arm (anonymous
|
|
1428
1467
|
* arms carry their fields; NAMED-VO arms hold a single `value` of the named class), and the
|
|
@@ -1459,6 +1498,9 @@ function emitUnionClasses(d: ValueObjectDescriptor): string {
|
|
|
1459
1498
|
` ${cls}(${arm.ref}.fromProjected((m['value']! as Map).cast<String, Object?>()));`,
|
|
1460
1499
|
` @override`,
|
|
1461
1500
|
` Map<String, Object?> toJson() => <String, Object?>{'type': '${key}', 'value': value.toJson()};`,
|
|
1501
|
+
``,
|
|
1502
|
+
` /// A copy with [value] replaced.`,
|
|
1503
|
+
` ${cls} copyWith({${arm.ref}? value}) => ${cls}(value ?? this.value);`,
|
|
1462
1504
|
` @override`,
|
|
1463
1505
|
` bool operator ==(Object other) => other is ${cls} && _voJsonEq(other.toJson(), toJson());`,
|
|
1464
1506
|
` @override`,
|
|
@@ -1507,6 +1549,8 @@ function emitUnionClasses(d: ValueObjectDescriptor): string {
|
|
|
1507
1549
|
...literalToLines,
|
|
1508
1550
|
...toLines,
|
|
1509
1551
|
` };`,
|
|
1552
|
+
``,
|
|
1553
|
+
...emitCopyWith(cls, fields.map((x) => ({ member: x.member, dartType: dartTypeOfVoField(x.f, cls, x.wireName), optional: x.optional }))),
|
|
1510
1554
|
` @override`,
|
|
1511
1555
|
` bool operator ==(Object other) => other is ${cls} && _voJsonEq(other.toJson(), toJson());`,
|
|
1512
1556
|
` @override`,
|
|
@@ -1566,6 +1610,7 @@ function emitAggregateMetaRegistry(
|
|
|
1566
1610
|
if (f.kind === "ref" && refTarget !== undefined) {
|
|
1567
1611
|
parts.push(`refType: '${domainKey}.${refTarget}'`);
|
|
1568
1612
|
}
|
|
1613
|
+
if ((f as { isFromBirth?: true }).isFromBirth === true) parts.push(`fromBirth: true`);
|
|
1569
1614
|
return ` '${name}': NomosFieldMeta(${parts.join(", ")}),`;
|
|
1570
1615
|
});
|
|
1571
1616
|
return [
|
|
@@ -1618,6 +1663,8 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
|
|
|
1618
1663
|
` 'coordinates': coordinates,`,
|
|
1619
1664
|
` };`,
|
|
1620
1665
|
``,
|
|
1666
|
+
...emitCopyWith(d.name, [{ member: "coordinates", dartType: coordsType, optional: false }]),
|
|
1667
|
+
``,
|
|
1621
1668
|
` @override`,
|
|
1622
1669
|
` bool operator ==(Object other) =>`,
|
|
1623
1670
|
` other is ${d.name} && _voJsonEq(other.toJson(), toJson());`,
|
|
@@ -1680,6 +1727,8 @@ function emitValueObjectClass(d: ValueObjectDescriptor): string {
|
|
|
1680
1727
|
...toLines,
|
|
1681
1728
|
` };`,
|
|
1682
1729
|
``,
|
|
1730
|
+
...emitCopyWith(d.name, fields.map((x) => ({ member: x.member, dartType: dartTypeOfVoField(x.f, d.name, x.wireName), optional: x.optional }))),
|
|
1731
|
+
``,
|
|
1683
1732
|
` @override`,
|
|
1684
1733
|
` bool operator ==(Object other) =>`,
|
|
1685
1734
|
` other is ${d.name} && _voJsonEq(other.toJson(), toJson());`,
|
package/src/codegen_ts.ts
CHANGED
|
@@ -527,11 +527,12 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
|
|
|
527
527
|
}
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
out.push(`// ── read models (projection \`data\` shapes;
|
|
530
|
+
out.push(`// ── read models (projection \`data\` shapes; partial-fold optional, EXCEPT .fromBirth()`, `// fields — their presence is gate law, so the type is non-optional) ──`, ``);
|
|
531
531
|
for (const [typeId, agg] of [...aggByType].sort(([a], [b]) => a.localeCompare(b))) {
|
|
532
532
|
out.push(`export interface ${typeId}Data {`);
|
|
533
533
|
for (const [name, field] of Object.entries(agg.fields)) {
|
|
534
|
-
|
|
534
|
+
const required = (field as Field).isFromBirth === true;
|
|
535
|
+
out.push(` ${name}${required ? "" : "?"}: ${tsTypeOfField(field as Field)};`);
|
|
535
536
|
}
|
|
536
537
|
for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
|
|
537
538
|
out.push(`}`, ``);
|
|
@@ -1094,6 +1094,35 @@ async function main(): Promise<void> {
|
|
|
1094
1094
|
}
|
|
1095
1095
|
}
|
|
1096
1096
|
if (inferred.length > 0) inferredRenames.push({ domain: identityName, renames: inferred });
|
|
1097
|
+
// ── THE fromBirth EVOLVE GUARD (dart_codegen_parity.md): birth-presence may be declared at a
|
|
1098
|
+
// field's FIRST appearance (fresh sid — every row of the type is born under the invariant) or
|
|
1099
|
+
// carried forward, but NEVER newly added to a sid that existed without it: rows born under the
|
|
1100
|
+
// earlier law may lack the field, and the non-nullable read model would LIE about them. The
|
|
1101
|
+
// lawful adoption path for an existing field is a data migration (backfill every row, then
|
|
1102
|
+
// re-birth/dispositions) — named here so the refusal teaches it. ──
|
|
1103
|
+
if (prior !== undefined) {
|
|
1104
|
+
const priorFromBirthBySid = new Map<string, boolean>();
|
|
1105
|
+
for (const a of Object.values(prior.aggregates)) {
|
|
1106
|
+
for (const f of Object.values(a.fields)) priorFromBirthBySid.set(f.sid, f.fromBirth === true);
|
|
1107
|
+
}
|
|
1108
|
+
for (const agg of mod.aggregates) {
|
|
1109
|
+
const fields = (agg.fields ?? {}) as Record<string, { isFromBirth?: true }>;
|
|
1110
|
+
for (const [fieldName, f] of Object.entries(fields)) {
|
|
1111
|
+
if (f?.isFromBirth !== true) continue;
|
|
1112
|
+
const sid = continuity.aggregates[agg.id]?.fields[fieldName]?.sid;
|
|
1113
|
+
if (sid === undefined) continue; // first appearance — lawful
|
|
1114
|
+
const priorHad = priorFromBirthBySid.get(sid);
|
|
1115
|
+
if (priorHad === false) {
|
|
1116
|
+
throw new Error(
|
|
1117
|
+
`${identityName}.${agg.id}.${fieldName}: .fromBirth() cannot be NEWLY declared on an existing field — ` +
|
|
1118
|
+
`rows born under the earlier law may lack it, and the non-nullable read model would lie about them. ` +
|
|
1119
|
+
`Either declare fromBirth at the field's first appearance, or migrate: backfill every ${agg.id} row, ` +
|
|
1120
|
+
`then adopt via a re-birth (the #58 dispositions lane).`,
|
|
1121
|
+
);
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1097
1126
|
// The lockfile records the EMITTED surface (sids + lineage + kinds — the next
|
|
1098
1127
|
// compile's pair rule reads the kinds), not the bare continuity.
|
|
1099
1128
|
nextLock.domains[identityName] = {
|
package/src/fields.ts
CHANGED
|
@@ -154,6 +154,12 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
154
154
|
readonly kind: K;
|
|
155
155
|
readonly zod: z.ZodType<T>;
|
|
156
156
|
readonly driver: Driver;
|
|
157
|
+
/** BIRTH-PRESENCE LAW (`.fromBirth()`): the aggregate() declaration SYNTHESIZES a real
|
|
158
|
+
* #41 gate invariant requiring this field present on EVERY event touching the type — so
|
|
159
|
+
* the generated read models type it NON-NULLABLE (absence is law-refused, not convention).
|
|
160
|
+
* Scalar Lww-class kinds only; mutually exclusive with `.optional()`. Flag named
|
|
161
|
+
* `isFromBirth` — the `fromBirth()` METHOD sets it (the isOptional/isEncrypted convention). */
|
|
162
|
+
readonly isFromBirth?: true;
|
|
157
163
|
/** For `t.ref` cross-workspace edges: the foreign workspace id, if any. */
|
|
158
164
|
readonly refWorkspace?: string;
|
|
159
165
|
/** For `t.ref` (the target) AND `t.hasMany` (the child type): the related aggregate's wire id. */
|
|
@@ -253,6 +259,9 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
|
|
|
253
259
|
* schema IS the single source of truth codegen derives required-vs-optional from.
|
|
254
260
|
*/
|
|
255
261
|
optional(): Field<T | undefined, K>;
|
|
262
|
+
/** Declare BIRTH-PRESENCE LAW: aggregate() synthesizes a gate invariant requiring this field
|
|
263
|
+
* present on every event touching the type; read models type it NON-NULLABLE. */
|
|
264
|
+
fromBirth(): Field<T, K>;
|
|
256
265
|
/** Phantom to keep T load-bearing for inference. */
|
|
257
266
|
readonly _t?: T;
|
|
258
267
|
}
|
|
@@ -266,6 +275,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
266
275
|
refAggregateId?: string;
|
|
267
276
|
viaField?: string;
|
|
268
277
|
isOptional?: boolean;
|
|
278
|
+
fromBirth?: true;
|
|
269
279
|
mapValueKind?: FieldKind;
|
|
270
280
|
jsonShape?: "object";
|
|
271
281
|
valueObject?: ValueObjectFieldMeta;
|
|
@@ -289,10 +299,14 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
289
299
|
...(extra.valueObject !== undefined ? { valueObject: extra.valueObject } : {}),
|
|
290
300
|
...(extra.encrypted ? { isEncrypted: true as const } : {}),
|
|
291
301
|
...(extra.meta ? { isMeta: true as const } : {}),
|
|
302
|
+
...(extra.fromBirth ? { isFromBirth: true as const } : {}),
|
|
292
303
|
merge(d: Driver) {
|
|
293
304
|
return makeField<T, K>(kind, zod, d, extra);
|
|
294
305
|
},
|
|
295
306
|
optional(): Field<T | undefined, K> {
|
|
307
|
+
if (extra.fromBirth) {
|
|
308
|
+
throw new Error("a .fromBirth() field cannot be .optional() — birth-presence and optionality contradict.");
|
|
309
|
+
}
|
|
296
310
|
// Wrap the Zod once (`.optional()` is idempotent enough — re-wrapping an
|
|
297
311
|
// already-optional schema stays optional). Carry the flag so codegen reads it
|
|
298
312
|
// directly without re-introspecting Zod.
|
|
@@ -301,6 +315,20 @@ function makeField<T, K extends FieldKind = FieldKind>(
|
|
|
301
315
|
isOptional: true,
|
|
302
316
|
});
|
|
303
317
|
},
|
|
318
|
+
fromBirth(): Field<T, K> {
|
|
319
|
+
// BIRTH-PRESENCE LAW: only scalar Lww-class kinds — a set/map/counter/position reads as
|
|
320
|
+
// an empty/zero default (presence is meaningless), a hasMany is virtual. Presence is
|
|
321
|
+
// MONOTONE for the allowed kinds (no op unsets a value leaf), which is exactly what makes
|
|
322
|
+
// the non-nullable read type sound.
|
|
323
|
+
const allowed: readonly FieldKind[] = ["string", "int", "bool", "enum", "json", "ref", "evidence"];
|
|
324
|
+
if (!allowed.includes(kind)) {
|
|
325
|
+
throw new Error(`.fromBirth() is not available on a '${kind}' field — collections/CRDT scalars read as defaults; presence law applies to value leaves (${allowed.join("/")}).`);
|
|
326
|
+
}
|
|
327
|
+
if (isOptional) {
|
|
328
|
+
throw new Error("a .optional() field cannot be .fromBirth() — birth-presence and optionality contradict.");
|
|
329
|
+
}
|
|
330
|
+
return makeField<T, K>(kind, zod, driver, { ...extra, fromBirth: true });
|
|
331
|
+
},
|
|
304
332
|
encrypted(): Field<T, K> {
|
|
305
333
|
// Carry the flag through merge/optional (extra is threaded), so `.merge(Lww).encrypted()`
|
|
306
334
|
// and `.encrypted().merge(Lww)` agree. The wire schema is UNCHANGED (driver-only) — the
|
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
|
package/src/manifest.ts
CHANGED
|
@@ -978,7 +978,7 @@ function stableIdsForModule(
|
|
|
978
978
|
const aggSid = prior?.sid ?? mintAggregateSid(domainSid, agg.id);
|
|
979
979
|
const handleFields = (handlesById.get(agg.id)?.fields ?? {}) as Record<
|
|
980
980
|
string,
|
|
981
|
-
{ kind?: string } | undefined
|
|
981
|
+
{ kind?: string; isFromBirth?: true } | undefined
|
|
982
982
|
>;
|
|
983
983
|
const fields: Record<string, StableFieldId> = {};
|
|
984
984
|
for (const fieldName of Object.keys(agg.schema).sort()) {
|
|
@@ -989,6 +989,9 @@ function stableIdsForModule(
|
|
|
989
989
|
...(pf?.was !== undefined && pf.was.length > 0 ? { was: pf.was } : {}),
|
|
990
990
|
// The CURRENT kind, always from the live module (continuity never supplies it).
|
|
991
991
|
...(kind !== undefined ? { kind } : {}),
|
|
992
|
+
// Birth-presence law rides identity: the evolve guard reads it (fromBirth may not be
|
|
993
|
+
// NEWLY declared on a sid that existed without it).
|
|
994
|
+
...(handleFields[fieldName]?.isFromBirth === true ? { fromBirth: true as const } : {}),
|
|
992
995
|
};
|
|
993
996
|
}
|
|
994
997
|
out[agg.id] = {
|
package/src/stable_ids_types.ts
CHANGED
|
@@ -21,6 +21,10 @@ export interface StableFieldId {
|
|
|
21
21
|
* Always set at emission; continuity input may omit it (the current module is the
|
|
22
22
|
* source of the CURRENT kind). */
|
|
23
23
|
readonly kind?: string;
|
|
24
|
+
/** BIRTH-PRESENCE LAW (`.fromBirth()`): recorded at the field's first fromBirth emission.
|
|
25
|
+
* The compile guard refuses NEWLY declaring fromBirth on a sid that existed without it
|
|
26
|
+
* (old rows may lack the field — the non-nullable read type would lie). */
|
|
27
|
+
readonly fromBirth?: true;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
/** One aggregate's stable identity: sid + label lineage + per-field sids (keyed by CURRENT field name). */
|