@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 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.30.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.41.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
- ...(opts?.invariant !== undefined
288
- ? { hasInvariant: true as const, invariant: opts.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 } : {}),
@@ -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
- : { ...base, optional: true };
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; every field is partial-fold optional) ──`, ``);
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
- out.push(` ${name}?: ${tsTypeOfField(field as Field)};`);
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] = {
@@ -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). */