@githolon/dsl 0.74.0 → 0.75.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.
@@ -142,6 +142,17 @@ export interface DartBridgeClientOptions {
142
142
  * omitted. Only meaningful when {@link emitActivity} is set.
143
143
  */
144
144
  readonly activityImportUri?: string;
145
+ /**
146
+ * CROSS-DOMAIN REF TRAVERSAL (dart_codegen_parity.md §2a follow-on): every aggregate id in the
147
+ * PACKAGE → the domain module that owns it (`domain` for the client class name, `name` for the
148
+ * relative `<name>.client.dart` import). A same-workspace `t.ref` whose target lives in ANOTHER
149
+ * domain of this package emits a resolver taking THAT domain's generated client (the shared barrel
150
+ * makes the class + read model reachable; the client file imports the sibling client with `show`).
151
+ * Same-domain refs (target in THIS module) resolve via this client and never consult the map.
152
+ * Omit (or supply only self-owned aggregates) ⇒ NO cross-domain accessors are emitted — a package
153
+ * whose refs are all same-domain / foreign is byte-identical to before this existed.
154
+ */
155
+ readonly packageAggregateDomains?: Readonly<Record<string, { domain: string; name: string }>>;
145
156
  }
146
157
 
147
158
  /** Generate the `<name>.client.dart` async bridge client for one domain module. */
@@ -489,32 +500,54 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
489
500
  );
490
501
  }
491
502
 
492
- // ── REF-TRAVERSAL ACCESSORS (dart_codegen_parity.md P2 — ref ergonomics) ──
493
- // For every SAME-WORKSPACE `t.ref(Target)` field whose target aggregate is ALSO in THIS domain module
494
- // (so its by-id accessor is right here on this SAME client class), emit a companion extension method
495
- // that resolves the stored ref id to the actual target row — reusing the EXISTING `<target>ById`
496
- // by-id accessor above (no second read path, no new bridge op). SCOPE (deliberately narrow):
497
- // - `t.foreignRef` fields are NOT covered — a foreign ref crosses workspaces (its own locator +
498
- // attested-read trust plumbing, a different lane entirely; `ForeignRef<T>` stays as-is).
499
- // - a same-workspace ref field whose target aggregate is a CROSS-DOMAIN reference (not present in
500
- // `mod.aggregates`) is skipped too — its by-id accessor lives on a DIFFERENT domain's generated
501
- // client, unreachable from an extension scoped to this file.
502
- // A domain with no resolvable ref field emits none of this (byte-identical to before this existed).
503
+ // ── REF-TRAVERSAL ACCESSORS (dart_codegen_parity.md P2 + §2a follow-on — ref ergonomics) ──
504
+ // For every reference field on a read model, emit a companion extension method that resolves the
505
+ // stored locator to the actual target row, composed ONLY from surface that already exists (no new
506
+ // read path, no new bridge op). THREE kinds, one grammar (`<Model>Refs on <Model>`):
507
+ // 1. SAME-DOMAIN `t.ref(Target)` (target in THIS module) — resolver takes THIS client, dispatches
508
+ // to its own `<target>ById` accessor. (The #72 original.)
509
+ // 2. CROSS-DOMAIN same-workspace `t.ref(Target)` (target in ANOTHER domain of the package) — the
510
+ // target's by-id accessor lives on the OTHER domain's client, so the resolver takes THAT client
511
+ // (`asset.listing(CatalogueClient c)`); the shared package barrel makes the class + read model
512
+ // reachable and the client file imports the sibling client with `show` (§2a follow-on).
513
+ // 3. CROSS-WORKSPACE `t.foreignRef` — the read model already surfaces the field as a typed
514
+ // `ForeignRef<Target>`; the resolver is thin sugar over the ESTABLISHED foreign-read semantic
515
+ // (`ref.resolve(engine.foreignReader())` — follow+read on the session-bound multi-workspace
516
+ // plane, no clone), NOT the law-grade attested-read lane (that is `ref.attest`, opt-in via
517
+ // `trustSource` + `.readsFrom`). Returns the typed concrete target row or null.
518
+ // A read model with no reference field emits nothing; a package with only same-domain / foreign refs
519
+ // (no cross-domain resolvable target) is byte-identical to before the §2a follow-on existed.
520
+ const pkgDomains = opts.packageAggregateDomains ?? {};
521
+ const thisDomainName = mod.name;
503
522
  const refExtensions: string[] = [];
523
+ // importUri → the set of sibling names the ref resolvers reference (imported `show`-limited): a
524
+ // cross-domain `t.ref` needs the sibling CLIENT class + its read model (from `<name>.client.dart`); a
525
+ // cross-domain `t.foreignRef` needs the target's concrete class (from the sibling TYPES `<name>.dart`).
526
+ const crossDomainImports = new Map<string, Set<string>>();
527
+ const addImport = (uri: string, name: string): void => {
528
+ if (!crossDomainImports.has(uri)) crossDomainImports.set(uri, new Set());
529
+ crossDomainImports.get(uri)!.add(name);
530
+ };
504
531
  for (const agg of mod.aggregates) {
505
532
  const model = readModelClassName(agg.id);
506
- const refFields = Object.entries(agg.fields).filter(([, f]) => {
507
- const field = f as Field;
508
- return field.kind === "ref" && field.foreignRef === undefined && field.refAggregateId !== undefined;
509
- }) as [string, Field][];
510
- const targetableFields = refFields.filter(([, f]) => aggById.has(f.refAggregateId!));
511
- if (targetableFields.length === 0) continue;
533
+ const entries = Object.entries(agg.fields) as [string, Field][];
534
+ const sameDomainRefs = entries.filter(
535
+ ([, f]) => f.kind === "ref" && f.foreignRef === undefined && f.refAggregateId !== undefined && aggById.has(f.refAggregateId!),
536
+ );
537
+ const crossDomainRefs = entries.filter(([, f]) => {
538
+ if (!(f.kind === "ref" && f.foreignRef === undefined && f.refAggregateId !== undefined)) return false;
539
+ if (aggById.has(f.refAggregateId!)) return false; // same-domain — handled above
540
+ const owner = pkgDomains[f.refAggregateId!];
541
+ return owner !== undefined && owner.domain !== domain; // resolvable on a SIBLING domain's client
542
+ });
543
+ const foreignRefs = entries.filter(([, f]) => f.kind === "ref" && f.foreignRef !== undefined);
544
+ if (sameDomainRefs.length === 0 && crossDomainRefs.length === 0 && foreignRefs.length === 0) continue;
512
545
  const lines: string[] = [
513
546
  `/// Ref-traversal accessors for [${model}] (dart_codegen_parity.md P2) — resolve a stored ref id`,
514
547
  `/// to the actual target row via the SAME by-id read lane already exposed above.`,
515
548
  `extension ${model}Refs on ${model} {`,
516
549
  ];
517
- for (const [fieldName, f] of targetableFields) {
550
+ for (const [fieldName, f] of sameDomainRefs) {
518
551
  const targetType = f.refAggregateId!;
519
552
  const targetModel = readModelClassName(targetType);
520
553
  const targetConcrete = concreteClassName(targetType);
@@ -546,6 +579,76 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
546
579
  ``,
547
580
  );
548
581
  }
582
+ for (const [fieldName, f] of crossDomainRefs) {
583
+ const targetType = f.refAggregateId!;
584
+ const owner = pkgDomains[targetType]!;
585
+ const targetModel = readModelClassName(targetType);
586
+ const targetConcrete = concreteClassName(targetType);
587
+ const targetLc = lcFirst(camel(targetConcrete));
588
+ const byIdMethod = safeMethod(`${targetLc}ById`);
589
+ const targetClient = dartClientClassName(owner.domain);
590
+ // The sibling CLIENT file exposes both the client class AND (via its `export '<name>.dart'`) the
591
+ // read model — pull BOTH `show`-limited so this file references neither an unimported type.
592
+ const importUri = `${owner.name}.client.dart`;
593
+ addImport(importUri, targetClient);
594
+ addImport(importUri, targetModel);
595
+ const member = dartMemberName(fieldName);
596
+ const methodStem = member.length > 2 && member.endsWith("Id") ? member.slice(0, -2) : `${member}Ref`;
597
+ const method = safeMethod(methodStem);
598
+ const memberNullable = f.isFromBirth !== true;
599
+ lines.push(
600
+ ` /// Resolve \`${fieldName}\` (${agg.id}.${fieldName} -> ${targetType}, a CROSS-DOMAIN ref) by`,
601
+ ` /// dispatching to [${targetClient}.${byIdMethod}] on the target domain's client — same by-id`,
602
+ ` /// read lane, never a second one. The shared package barrel makes [${targetClient}] +`,
603
+ ` /// [${targetModel}] reachable; pass that domain's bound client.`,
604
+ ` Future<${targetModel}?> ${method}(${targetClient} client) async {`,
605
+ ...(memberNullable
606
+ ? [
607
+ ` final id = ${member};`,
608
+ ` if (id == null) return null;`,
609
+ ` final rows = await client.${byIdMethod}(id.value);`,
610
+ ]
611
+ : [` final rows = await client.${byIdMethod}(${member}.value);`]),
612
+ ` return rows.isEmpty ? null : rows.first;`,
613
+ ` }`,
614
+ ``,
615
+ );
616
+ }
617
+ for (const [fieldName, f] of foreignRefs) {
618
+ const fr = f.foreignRef!;
619
+ const targetType = fr.targetAggregateId;
620
+ // The resolver returns the target's CONCRETE class (what `ForeignRef<T>.resolve` decodes to). We can
621
+ // only reference it when it is reachable from this file: SAME-domain (already in the imported types),
622
+ // or a CROSS-DOMAIN concrete target in the package (import its sibling `<name>.dart` `show`-limited).
623
+ // A contract target (its DTO lives in the shared contracts file) or an unmapped target is left as the
624
+ // raw `ForeignRef<T>` field — no resolver, no dangling type reference.
625
+ if (fr.targetIsContract === true) continue;
626
+ const owner = pkgDomains[targetType];
627
+ const targetInThisDomain = aggById.has(targetType) || owner?.name === thisDomainName;
628
+ if (!targetInThisDomain) {
629
+ if (owner === undefined) continue; // target not in this package — unreachable, skip the resolver
630
+ addImport(`${owner.name}.dart`, concreteClassName(targetType));
631
+ }
632
+ const targetConcrete = concreteClassName(targetType);
633
+ const member = dartMemberName(fieldName);
634
+ // The stored locator member is a typed `ForeignRef<Target>?` getter, so the resolver takes a
635
+ // `remote…` name to avoid colliding with it (and to read as "the far side of the reference").
636
+ const method = safeMethod(`remote${cap(camel(member))}`);
637
+ lines.push(
638
+ ` /// Resolve \`${fieldName}\` (${agg.id}.${fieldName} -> ${targetType} @ another workspace, a`,
639
+ ` /// \`t.foreignRef\` locator) over the session-bound multi-workspace plane —`,
640
+ ` /// \`${member}.resolve(engine.foreignReader())\`, which opens the foreign workspace ON DEMAND`,
641
+ ` /// (no clone). This is the informational follow+read lane, NOT the law-grade attested-read one`,
642
+ ` /// (use \`${member}!.attest(engine.foreignReader())\` when your law depends on the foreign fact).`,
643
+ ` /// Null locator (absent / empty id or workspace) ⇒ null.`,
644
+ ` Future<${targetConcrete}?> ${method}(NomosEngine engine, {String? cloud}) {`,
645
+ ` final ref = ${member};`,
646
+ ` if (ref == null) return Future<${targetConcrete}?>.value(null);`,
647
+ ` return ref.resolve(engine.foreignReader(cloud: cloud));`,
648
+ ` }`,
649
+ ``,
650
+ );
651
+ }
549
652
  lines.push(`}`);
550
653
  refExtensions.push(lines.join("\n"));
551
654
  }
@@ -631,6 +734,14 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
631
734
  // Framework-shared aggregate types (RelationTupleReadModel, …) live in the ONE shared file — import
632
735
  // them (the by-id accessors reference them) and re-export them (once per package, Dart de-dups).
633
736
  ...(opts.typesImportUri === undefined && opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
737
+ // CROSS-DOMAIN REF TRAVERSAL (§2a follow-on): a cross-domain resolver references a SIBLING domain's
738
+ // generated client class + read model (from `<name>.client.dart`) or a foreignRef target's concrete
739
+ // class (from the sibling TYPES `<name>.dart`) — all co-located in the same generated dir (flat OR
740
+ // layered), so a same-dir relative import `show`-limited to exactly the referenced names. Empty for a
741
+ // domain with no cross-domain ref (byte-identical to before this existed).
742
+ ...[...crossDomainImports.entries()]
743
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
744
+ .map(([uri, names]) => `import '${uri}' show ${[...names].sort().join(", ")};`),
634
745
  `// Re-export the sibling types (read models + payloads + the bridge runtime) so ONE import of this`,
635
746
  `// client file gives an app the whole typed surface.`,
636
747
  ...(opts.typesImportUri !== undefined ? [`export '${opts.typesImportUri}';`] : [`export '${mod.name}.dart';`]),
package/src/codegen_ts.ts CHANGED
@@ -1499,7 +1499,24 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1499
1499
  `_stampRefsP(holon.workspace, ${foreignRefFields.length > 0 ? `_hydrateP(${expr})` : expr})`;
1500
1500
  const wrapRows = (expr: string): string =>
1501
1501
  `_stampRefs(holon.workspace, ${foreignRefFields.length > 0 ? `_hydrateRows(${expr})` : expr})`;
1502
+ // ATTRIBUTED READS (architecture/field_provenance.md): the shared provenance shape +
1503
+ // per-aggregate accessors — emitted ONLY when the package declares ≥1 `.attributed()`
1504
+ // field, so a provenance-free package's client is byte-identical (the era rule).
1505
+ const anyAttributed = [...aggByType.values()].some(
1506
+ (a) => ((a as { attributedFields?: readonly string[] }).attributedFields ?? []).length > 0,
1507
+ );
1508
+ if (anyAttributed) {
1509
+ out.push(
1510
+ `/** Per-field AUTHOR PROVENANCE (attributed reads — field_provenance.md): the verified`,
1511
+ ` * author of the field's CURRENT value (null when unknown — an anonymous or`,
1512
+ ` * pre-attribution-era write) + whether an auto-merge discarded a concurrent competing`,
1513
+ ` * write since it landed (escalate the field to Surfaced(...) law to retain the values). */`,
1514
+ `export interface FieldProvenance { author: string | null; wasContested: boolean }`,
1515
+ ``,
1516
+ );
1517
+ }
1502
1518
  for (const [typeId, agg] of [...aggByType].sort(([a], [b]) => a.localeCompare(b))) {
1519
+ const attributed = (agg as { attributedFields?: readonly string[] }).attributedFields ?? [];
1503
1520
  out.push(`export interface ${typeId}Data {`);
1504
1521
  for (const [name, field] of Object.entries(agg.fields)) {
1505
1522
  const required = (field as Field).isFromBirth === true;
@@ -1509,8 +1526,35 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1509
1526
  const fr = (field as { foreignRef?: { workspaceField: string; targetAggregateId: string } }).foreignRef;
1510
1527
  if (fr !== undefined) foreignRefFields.push({ agg: typeId, field: name, workspaceField: fr.workspaceField, target: fr.targetAggregateId });
1511
1528
  }
1529
+ if (attributed.length > 0) {
1530
+ out.push(
1531
+ ` /** attributed-read provenance (the reserved per-row map; prefer the generated accessors) */`,
1532
+ ` "__prov"?: { ${attributed.map((f) => `${f}?: FieldProvenance`).join("; ")} };`,
1533
+ );
1534
+ }
1512
1535
  for (const extra of extrasByType.get(typeId) ?? []) out.push(extra);
1513
1536
  out.push(`}`, ``);
1537
+ if (attributed.length > 0) {
1538
+ const ns = camel(typeId);
1539
+ out.push(
1540
+ `/** Attributed-read accessors for ${typeId} (\`.attributed()\` fields only): per-field`,
1541
+ ` * provenance + the \`lastAuthors\` convenience (verified author per attributed field,`,
1542
+ ` * unknown-author fields omitted). */`,
1543
+ `export const ${ns}Provenance = {`,
1544
+ ...attributed.map(
1545
+ (f) => ` ${f}: (data: ${typeId}Data): FieldProvenance | undefined => data.__prov?.${f},`,
1546
+ ),
1547
+ ` lastAuthors: (data: ${typeId}Data): Record<string, string> => {`,
1548
+ ` const out: Record<string, string> = {};`,
1549
+ ` for (const [field, p] of Object.entries(data.__prov ?? {})) {`,
1550
+ ` if (p !== undefined && p.author !== null) out[field] = p.author;`,
1551
+ ` }`,
1552
+ ` return out;`,
1553
+ ` },`,
1554
+ `} as const;`,
1555
+ ``,
1556
+ );
1557
+ }
1514
1558
  }
1515
1559
  // MULTI-WORKSPACE LOCATOR FACTORY (t.foreignRef): per aggregate, a typed builder that turns a projection
1516
1560
  // row into a `ForeignRef<TargetData>` — reading the foreign id (the field) + the foreign workspace id (the
@@ -500,6 +500,24 @@ function collectPackageContracts(modules: readonly DomainModule[]): ContractHand
500
500
  return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
501
501
  }
502
502
 
503
+ /**
504
+ * CROSS-DOMAIN REF TRAVERSAL map (dart_codegen_parity.md §2a follow-on): every aggregate id in the package
505
+ * → its owning domain (`domain` for the generated client class name; `name` for the sibling
506
+ * `<name>.client.dart` import). Handed to `generateDartBridgeClient` so a same-workspace `t.ref` whose
507
+ * target lives in ANOTHER domain of the package emits a resolver taking that domain's client. A same-domain
508
+ * ref never consults this (its target is in the local module); a foreign ref uses its own locator.
509
+ */
510
+ function buildPackageAggregateDomains(
511
+ modules: readonly DomainModule[],
512
+ ): Record<string, { domain: string; name: string }> {
513
+ const map: Record<string, { domain: string; name: string }> = {};
514
+ for (const m of modules) {
515
+ const owner = { domain: m.domain ?? m.name, name: m.name };
516
+ for (const agg of (m.aggregates as { id: string }[])) map[agg.id] = owner;
517
+ }
518
+ return map;
519
+ }
520
+
503
521
  /**
504
522
  * CIRCULAR-DEPENDENCY GUARD (domain_contracts.md). A `t.foreignRef` targeting a CONCRETE aggregate makes
505
523
  * the referencing domain's generated file import the target domain's file. Two domains that concretely
@@ -789,6 +807,11 @@ function emitDart(
789
807
  for (const m of modules) {
790
808
  for (const agg of (m.aggregates as { id: string }[])) aggIdToDartFile.set(agg.id, m.name);
791
809
  }
810
+ // CROSS-DOMAIN `t.ref` traversal (dart_codegen_parity.md §2a follow-on): the generated ref-traversal
811
+ // accessor for a same-workspace ref whose target lives in ANOTHER domain of THIS package takes that
812
+ // domain's client. Map every aggregate id → its owning domain (for the client class name) + file base
813
+ // (for the sibling `<name>.client.dart` import). Shared across both Dart-emit lanes.
814
+ const packageAggregateDomains = buildPackageAggregateDomains(modules);
792
815
  // CIRCULAR-DEP GUARD (domain_contracts.md): concrete-domain → concrete-domain import edges (from
793
816
  // concrete-aggregate-target foreignRefs). A 2-cycle A→B & B→A is the anti-pattern contracts exist to
794
817
  // break — fail with a clear diagnostic. Contract-mediated refs go through contracts.dart (no concrete edge).
@@ -845,6 +868,7 @@ function emitDart(
845
868
  supportImport,
846
869
  ...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
847
870
  ...(packageActivity.length > 0 ? { emitActivity: true, ...(activityImportUri !== undefined ? { activityImportUri } : {}) } : {}),
871
+ packageAggregateDomains,
848
872
  });
849
873
  const cp = path.join(genDir, `${mod.name}.client.dart`);
850
874
  writeFileSync(cp, clientSrc, "utf8");
@@ -904,7 +928,7 @@ const NOMOS_TYPES_DEP = "^0.3.0";
904
928
  // 0.48.x: session-bound clients (XClient.bind/NomosClientInterface/DomainClientSessionMismatch/
905
929
  // DomainNotInstalledForSession, engine.follow, pendingConflicts lanes) — the generated client
906
930
  // REQUIRES this surface. 0.48.1 fixes NomosSession.watchPendingConflicts delegation.
907
- const NOMOS_CLIENT_DEP = "^0.50.0";
931
+ const NOMOS_CLIENT_DEP = "^0.51.0";
908
932
  // DRIFT TRIPWIRE: pubspec_pins.test.ts asserts these equal the CURRENT versions in dsl/dart/pubspec.yaml
909
933
  // and dsl/dart_types/pubspec.yaml — a Dart release without this bump fails the suite, so tenants never
910
934
  // land back in dependency_overrides (the exact co2 report this closes, third occurrence).
@@ -1181,6 +1205,7 @@ function emitDartPackages(
1181
1205
  supportImport: "package",
1182
1206
  typesImportUri,
1183
1207
  ...(packageActivity.length > 0 ? { emitActivity: true } : {}),
1208
+ packageAggregateDomains: buildPackageAggregateDomains(modules),
1184
1209
  });
1185
1210
  const cp = path.join(clientGen, `${mod.name}.client.dart`);
1186
1211
  writeFileSync(cp, clientSrc, "utf8");
package/src/drivers.ts CHANGED
@@ -37,7 +37,8 @@ export type KernelDriverKind =
37
37
  | "RemoveWins"
38
38
  | "Counter"
39
39
  | "LastPosition"
40
- | "Surfaced";
40
+ | "Surfaced"
41
+ | "Attributed";
41
42
  /**
42
43
  * Drivers in the design palette the kernel does not yet merge. Currently EMPTY —
43
44
  * every palette driver encodes. Kept as a (never-)union so a future palette
@@ -183,6 +184,13 @@ export function encodeDriver(d: Driver): WireDriver {
183
184
  case "MapOf":
184
185
  if (!d.inner) throw new Error("MapOf driver is missing its inner driver");
185
186
  return { MapOf: encodeDriver(d.inner) };
187
+ case "Attributed":
188
+ // serde newtype variant `Driver::Attributed(Box<Driver>)` → `{ "Attributed": <inner> }`
189
+ // (architecture/field_provenance.md — the wrapper aggregate() applies for
190
+ // `.attributed()` fields; a pre-provenance kernel's strict manifest serde refuses
191
+ // it, the clean era refusal).
192
+ if (!d.inner) throw new Error("Attributed driver is missing its inner driver");
193
+ return { Attributed: encodeDriver(d.inner) };
186
194
  case "Surfaced":
187
195
  // serde newtype variant `Driver::Surfaced(Box<Driver>)` → `{ "Surfaced": <inner> }`
188
196
  // (externally tagged, mirroring MapOf exactly).
package/src/fields.ts CHANGED
@@ -361,6 +361,18 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
361
361
  * the `encrypted()` METHOD sets it, mirroring `optional()`/`isOptional`.)
362
362
  */
363
363
  readonly isEncrypted?: true;
364
+ /**
365
+ * ATTRIBUTED READS (architecture/field_provenance.md): keep per-field AUTHOR PROVENANCE
366
+ * beside this field's folded value — `author` (the verified principal of the current
367
+ * value) + `wasContested` (did an auto-merge discard a concurrent competing write?).
368
+ * O(1) per field forever (one author + one boolean — the ledger stays the history;
369
+ * escalate to `.merge(Surfaced(...))` if you need the discarded VALUES). PRESENT only
370
+ * when declared; the wire driver becomes `{"Attributed": <inner>}` (a LAW change —
371
+ * old kernels refuse it cleanly, unattributed fields are byte-identical). VALID ONLY
372
+ * on Lww-class scalar fields; `aggregate()` enforces it. (Flag named `isAttributed` —
373
+ * the `attributed()` METHOD sets it, mirroring `encrypted()`/`isEncrypted`.)
374
+ */
375
+ readonly isAttributed?: true;
364
376
  /**
365
377
  * METADATA (secure-by-default rework — `secure_by_default_dsl.md`): on an `.owned()` aggregate the
366
378
  * default is that content fields are E2E (operator-blind); `.meta()` opts THIS field OUT to plaintext,
@@ -385,6 +397,9 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
385
397
  withFacet<Fa extends FieldFacet>(facet: Fa): Field<T, K> & { readonly facet: Fa };
386
398
  /** Mark this field's value ENCRYPTED (sets {@link Field.isEncrypted}). Chainable + non-destructive. */
387
399
  encrypted(): Field<T, K>;
400
+ /** Keep AUTHOR PROVENANCE beside this field's folded value (sets {@link Field.isAttributed}) —
401
+ * `<field>Provenance` accessors are generated on the read models. Lww-class scalars only. */
402
+ attributed(): Field<T, K>;
388
403
  /** Mark this field plaintext METADATA (sets {@link Field.isMeta}) — the indexable opt-out on an owned aggregate. */
389
404
  meta(): Field<T, K>;
390
405
  /**
@@ -432,6 +447,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
432
447
  valueObject?: ValueObjectFieldMeta;
433
448
  mapValueVo?: ValueObjectFieldMeta;
434
449
  encrypted?: true;
450
+ attributed?: true;
435
451
  meta?: true;
436
452
  facet?: FieldFacet;
437
453
  nullSemantic?: NullSemantic;
@@ -456,6 +472,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
456
472
  ...(extra.valueObject !== undefined ? { valueObject: extra.valueObject } : {}),
457
473
  ...(extra.mapValueVo !== undefined ? { mapValueVo: extra.mapValueVo } : {}),
458
474
  ...(extra.encrypted ? { isEncrypted: true as const } : {}),
475
+ ...(extra.attributed ? { isAttributed: true as const } : {}),
459
476
  ...(extra.meta ? { isMeta: true as const } : {}),
460
477
  ...(extra.fromBirth ? { isFromBirth: true as const } : {}),
461
478
  ...(extra.nullSemantic !== undefined ? { nullSemantic: extra.nullSemantic } : {}),
@@ -513,6 +530,12 @@ function makeField<T, K extends FieldKind = FieldKind>(
513
530
  // `nomosEncryption` era key, built by aggregate()/manifest, is what carries the fact.
514
531
  return makeField<T, K>(kind, zod, driver, { ...extra, encrypted: true });
515
532
  },
533
+ attributed(): Field<T, K> {
534
+ // Carry the flag through merge/optional (extra is threaded), so `.merge(Lww).attributed()`
535
+ // and `.attributed().merge(Lww)` agree. aggregate() validates Lww-class eligibility and
536
+ // wraps the wire driver in `{"Attributed": <inner>}` (the law-era mechanism).
537
+ return makeField<T, K>(kind, zod, driver, { ...extra, attributed: true });
538
+ },
516
539
  meta(): Field<T, K> {
517
540
  // Plaintext-metadata opt-out (owned aggregates) — threaded through merge/optional like `encrypted`.
518
541
  // Driver/schema unchanged; the fact rides the aggregate's audience surface, not the wire kind.
package/src/manifest.ts CHANGED
@@ -107,6 +107,14 @@ export interface CanonicalAggregate {
107
107
  * so adding/changing it MOVES the hash. The scope is the aggregate's `requiresRead`.
108
108
  */
109
109
  readonly encryptedFields?: string[];
110
+ /**
111
+ * ATTRIBUTED READS (architecture/field_provenance.md) — the STORED field names whose
112
+ * folded values keep author provenance (`.attributed()`), SORTED. PRESENT only when
113
+ * ≥1 field is attributed; OMITTED otherwise (an unattributed aggregate is
114
+ * byte-identical). The hash-bearing LAW is the wrapped `{"Attributed": <inner>}`
115
+ * driver in `schema`; this key is the self-describing mirror codegen/openapi read.
116
+ */
117
+ readonly attributedFields?: string[];
110
118
  }
111
119
 
112
120
  /** One captured directive contract — NO `plan`/`scopeFrom` behaviour. */
@@ -1057,6 +1065,9 @@ export function domainManifest(mod: DomainModule): CanonicalManifest {
1057
1065
  // E2E ENCRYPTION (slice 2) — the encrypted field names. Hash-bearing law;
1058
1066
  // omit-when-none so plaintext aggregates are byte-stable.
1059
1067
  ...(agg.encryptedFields !== undefined ? { encryptedFields: [...agg.encryptedFields] } : {}),
1068
+ // ATTRIBUTED READS — the attributed field names (the driver wrap in `schema` is
1069
+ // the hash-bearing law; this mirror is what codegen reads). Omit-when-none.
1070
+ ...(agg.attributedFields !== undefined ? { attributedFields: [...agg.attributedFields] } : {}),
1060
1071
  };
1061
1072
  })
1062
1073
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
package/src/usd.ts CHANGED
@@ -238,6 +238,21 @@ export interface UsdLayer {
238
238
  * as `queries`/`references`. Hand-adding it to a foreign bundle only opts IN to
239
239
  * stricter checking; removing it makes new law, for that law's own chains (status quo).
240
240
  */
241
+ /**
242
+ * THE ATTRIBUTED-READS DECLARATION (architecture/field_provenance.md — "the law is the
243
+ * era"). Present IFF the layer declares ≥1 `.attributed()` field: the law's EXPLICIT
244
+ * opt-in to per-field author-provenance capture at the one gate/fold. The hash-bearing
245
+ * enforcement rides the aggregate schema's `{"Attributed": <inner>}` wire driver (a
246
+ * pre-provenance kernel's strict manifest serde REFUSES it — the clean era refusal);
247
+ * this key is the SELF-DESCRIBING surface (which aggregates/fields are attributed)
248
+ * hosts and tooling read without re-deriving it from drivers. OMITTED for
249
+ * unattributed law — byte-identical hash, the `nomosInvariantGate` discipline.
250
+ */
251
+ readonly nomosAttributedGate?: {
252
+ readonly v: 1;
253
+ /** aggregate TYPE → its attributed STORED field names, keys + fields sorted. */
254
+ readonly aggregates: Record<string, string[]>;
255
+ };
241
256
  readonly nomosInvariantGate?: {
242
257
  readonly v: 1;
243
258
  /** Aggregate types whose declared `invariant` bodies the gate executes, sorted. */
@@ -1332,8 +1347,14 @@ export function emitUsd(
1332
1347
  // (byte-determinism); omit-when-empty so an all-public layer is byte-identical.
1333
1348
  const visAggregates: Record<string, { requires: string }> = {};
1334
1349
  const encAggregates: Record<string, { scope: string; fields: string[]; delegatable: false }> = {};
1350
+ // ATTRIBUTED READS (field_provenance.md): aggregate TYPE → its attributed field
1351
+ // names — the self-describing nomosAttributedGate era key. Omit-when-empty.
1352
+ const attrAggregates: Record<string, string[]> = {};
1335
1353
  for (const a of [...manifest.aggregates].sort((x, y) => (x.id < y.id ? -1 : x.id > y.id ? 1 : 0))) {
1336
1354
  if (a.requiresRead !== undefined) visAggregates[a.id] = { requires: a.requiresRead };
1355
+ if (a.attributedFields !== undefined && a.attributedFields.length > 0) {
1356
+ attrAggregates[a.id] = [...a.attributedFields];
1357
+ }
1337
1358
  // THE ENCRYPTION GATE (slice 2): an aggregate with encrypted field(s) — its scope
1338
1359
  // is its read cap (aggregate()/the gate guarantee they coincide). delegatable:false.
1339
1360
  if (a.encryptedFields !== undefined && a.encryptedFields.length > 0 && a.requiresRead !== undefined) {
@@ -1486,6 +1507,9 @@ export function emitUsd(
1486
1507
  ...(Object.keys(encAggregates).length > 0
1487
1508
  ? { nomosEncryption: { v: 1 as const, aggregates: encAggregates } }
1488
1509
  : {}),
1510
+ ...(Object.keys(attrAggregates).length > 0
1511
+ ? { nomosAttributedGate: { v: 1 as const, aggregates: attrAggregates } }
1512
+ : {}),
1489
1513
  ...(nomosCapabilities.length > 0
1490
1514
  ? { nomosCapabilities: { v: 1 as const, capabilities: nomosCapabilities } }
1491
1515
  : {}),
package/src/usd_layers.ts CHANGED
@@ -111,6 +111,8 @@ export function driverToken(driver: WireDriver): string {
111
111
  if ("LwwBy" in driver) return `LwwBy(${driver.LwwBy})`;
112
112
  // `Surfaced(<inner>)` — the user-resolvable posture stays legible in the tree too.
113
113
  if ("Surfaced" in driver) return `Surfaced(${driverToken(driver.Surfaced)})`;
114
+ // `Attributed(<inner>)` — attributed reads (field_provenance.md), legible in the tree.
115
+ if ("Attributed" in driver) return `Attributed(${driverToken(driver.Attributed)})`;
114
116
  return `MapOf(${driverToken(driver.MapOf)})`;
115
117
  }
116
118
 
@@ -122,6 +124,9 @@ export function parseDriverToken(token: string): WireDriver {
122
124
  if (token.startsWith("Surfaced(") && token.endsWith(")")) {
123
125
  return { Surfaced: parseDriverToken(token.slice("Surfaced(".length, -1)) };
124
126
  }
127
+ if (token.startsWith("Attributed(") && token.endsWith(")")) {
128
+ return { Attributed: parseDriverToken(token.slice("Attributed(".length, -1)) };
129
+ }
125
130
  // `LwwBy(<principal>)` — the principal is an opaque non-empty string (uids carry
126
131
  // hyphens, so it is NOT the bare-identifier shape the unit-driver branch validates).
127
132
  if (token.startsWith("LwwBy(") && token.endsWith(")")) {
@@ -175,6 +180,9 @@ function domainLayerAttrLines(domain: string, layer: UsdLayer, indent: string):
175
180
  ...(layer.nomosEncryption !== undefined
176
181
  ? [`${indent}custom string nomos:encryptionHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosEncryption)))}`]
177
182
  : []),
183
+ ...(layer.nomosAttributedGate !== undefined
184
+ ? [`${indent}custom string nomos:attributedGateHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosAttributedGate)))}`]
185
+ : []),
178
186
  ...(layer.nomosRelations !== undefined
179
187
  ? [`${indent}custom string nomos:relationsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosRelations)))}`]
180
188
  : []),
@@ -767,6 +775,21 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
767
775
  encryption = { v: 1, aggregates: aggregatesRec };
768
776
  }
769
777
  }
778
+ // THE ATTRIBUTED-READS GATE (field_provenance.md): per-aggregate attributed-field
779
+ // sets merged later-wins, SORTED keys (matching the composed emission). Omit-when-empty.
780
+ let attributedGate: UsdLayer["nomosAttributedGate"];
781
+ {
782
+ const a = new Map<string, string[]>();
783
+ for (const l of stack) {
784
+ if (l.nomosAttributedGate === undefined) continue;
785
+ for (const [type, fields] of Object.entries(l.nomosAttributedGate.aggregates)) a.set(type, fields);
786
+ }
787
+ if (a.size > 0) {
788
+ const aggregatesRec: Record<string, string[]> = {};
789
+ for (const k of [...a.keys()].sort()) aggregatesRec[k] = a.get(k)!;
790
+ attributedGate = { v: 1, aggregates: aggregatesRec };
791
+ }
792
+ }
770
793
  // THE RELATION SCHEMA (P1): per-type relation defs merged later-wins across the stack,
771
794
  // SORTED keys (matching the composed emission). Omit-when-empty (the nomosRelations era rule).
772
795
  let relations: UsdLayer["nomosRelations"];
@@ -1002,6 +1025,7 @@ return {
1002
1025
  ...(readGate !== undefined ? { nomosReadGate: readGate } : {}),
1003
1026
  ...(readVisibility !== undefined ? { nomosReadVisibility: readVisibility } : {}),
1004
1027
  ...(encryption !== undefined ? { nomosEncryption: encryption } : {}),
1028
+ ...(attributedGate !== undefined ? { nomosAttributedGate: attributedGate } : {}),
1005
1029
  ...(relations !== undefined ? { nomosRelations: relations } : {}),
1006
1030
  ...(relationNamespace !== undefined ? { nomosRelationNamespace: relationNamespace } : {}),
1007
1031
  ...(readProjectionNamespace !== undefined ? { nomosReadProjectionNamespace: readProjectionNamespace } : {}),
@@ -1143,6 +1167,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1143
1167
  const readGateByDomain = new Map<string, UsdLayer["nomosReadGate"]>();
1144
1168
  const readVisibilityByDomain = new Map<string, UsdLayer["nomosReadVisibility"]>();
1145
1169
  const encryptionByDomain = new Map<string, UsdLayer["nomosEncryption"]>();
1170
+ const attributedGateByDomain = new Map<string, UsdLayer["nomosAttributedGate"]>();
1146
1171
  const relationsByDomain = new Map<string, UsdLayer["nomosRelations"]>();
1147
1172
  const relationGateByDomain = new Map<string, UsdLayer["nomosRelationGate"]>();
1148
1173
  const relationNamespaceByDomain = new Map<string, UsdLayer["nomosRelationNamespace"]>();
@@ -1193,6 +1218,11 @@ export function parseUsdaDocument(text: string): UsdDocument {
1193
1218
  if (typeof encHex === "string") {
1194
1219
  encryptionByDomain.set(domain, JSON.parse(unhexUtf8(encHex)) as UsdLayer["nomosEncryption"]);
1195
1220
  }
1221
+ // THE ATTRIBUTED-READS GATE (field_provenance.md): the hex-carried per-aggregate map.
1222
+ const attrHex = p.attrs.get("nomos:attributedGateHex");
1223
+ if (typeof attrHex === "string") {
1224
+ attributedGateByDomain.set(domain, JSON.parse(unhexUtf8(attrHex)) as UsdLayer["nomosAttributedGate"]);
1225
+ }
1196
1226
  // THE RELATION SCHEMA (P1): the hex-carried per-type relation rewrite map.
1197
1227
  const relHex = p.attrs.get("nomos:relationsHex");
1198
1228
  if (typeof relHex === "string") {
@@ -1359,6 +1389,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1359
1389
  const rg = readGateByDomain.get(domain);
1360
1390
  const rv = readVisibilityByDomain.get(domain);
1361
1391
  const enc = encryptionByDomain.get(domain);
1392
+ const attrGate = attributedGateByDomain.get(domain);
1362
1393
  const rel = relationsByDomain.get(domain);
1363
1394
  const relGate = relationGateByDomain.get(domain);
1364
1395
  const relNs = relationNamespaceByDomain.get(domain);
@@ -1386,6 +1417,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1386
1417
  ...(rg !== undefined ? { nomosReadGate: rg } : {}),
1387
1418
  ...(rv !== undefined ? { nomosReadVisibility: rv } : {}),
1388
1419
  ...(enc !== undefined ? { nomosEncryption: enc } : {}),
1420
+ ...(attrGate !== undefined ? { nomosAttributedGate: attrGate } : {}),
1389
1421
  ...(rel !== undefined ? { nomosRelations: rel } : {}),
1390
1422
  ...(relNs !== undefined ? { nomosRelationNamespace: relNs } : {}),
1391
1423
  ...(rpNs !== undefined ? { nomosReadProjectionNamespace: rpNs } : {}),
package/src/wire.ts CHANGED
@@ -222,7 +222,11 @@ export type WireDriver =
222
222
  | { MapOf: WireDriver }
223
223
  // `Surfaced(Box<Driver>)` — the user-resolvable third merge posture
224
224
  // (architecture/surfaced_merge_conflict.md): `{ "Surfaced": <inner> }`.
225
- | { Surfaced: WireDriver };
225
+ | { Surfaced: WireDriver }
226
+ // `Attributed(Box<Driver>)` — attributed reads (architecture/field_provenance.md):
227
+ // `{ "Attributed": <inner> }`. Value semantics = the inner driver's; the wrapper makes
228
+ // the fold capture per-field author provenance.
229
+ | { Attributed: WireDriver };
226
230
 
227
231
  /** `Schema = BTreeMap<FieldId, Driver>` — a plain object, field -> Driver. */
228
232
  export type WireSchema = Record<string, WireDriver>;