@githolon/dsl 0.65.1 → 0.67.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.
@@ -55,11 +55,15 @@ import { fileURLToPath, pathToFileURL } from "node:url";
55
55
  import type { AggregateHandle } from "./aggregate.js";
56
56
  import {
57
57
  generateDartDomain,
58
+ generateDartContracts,
59
+ generateDartActivityRegistry,
60
+ collectActivityDescriptors,
58
61
  type DartImport,
59
62
  type DartRefImport,
60
63
  type DomainModule,
61
64
  type PermissionVocabulary,
62
65
  } from "./codegen_dart.js";
66
+ import type { ContractHandle } from "./contract.js";
63
67
  import { generateDartBridgeClient } from "./codegen_dart_client.js";
64
68
  import {
65
69
  isFrameworkAggregate,
@@ -426,6 +430,7 @@ const DART_SUPPORT_CORE_SRC = [
426
430
  // The foreign-ref locator runtime (ForeignRef<T>/NomosForeignReader/…, t.foreignRef) — a generated read
427
431
  // model with a `t.foreignRef` field references `ForeignRef<Target>` through the barrel, so it must vendor.
428
432
  "foreign_ref.dart",
433
+ "activity.dart",
429
434
  ] as const;
430
435
  // The RUNTIME support files, vendored from `nomos_client` (dsl/dart/lib/src): the async WebView
431
436
  // bridge the generated `<name>.client.dart` drives, and the provider surface. Their
@@ -445,6 +450,59 @@ const DART_GENERATED_SUBDIR = path.join("src", "generated");
445
450
  /** The shared basename of the framework-dedup file emitted once per package. */
446
451
  const FRAMEWORK_FILE = "nomos_framework";
447
452
 
453
+ /** The shared basename of the domain-contracts DTO file emitted once per package (domain_contracts.md). */
454
+ const CONTRACTS_FILE = "contracts";
455
+
456
+ /** The shared basename of the ACTIVITY REGISTRY file emitted once per package (activity_descriptors.md).
457
+ * Absent when no directive declares `.activity(...)` — an activity-free package emits no `activity_registry.dart`. */
458
+ const ACTIVITY_FILE = "activity_registry";
459
+
460
+ /** Collect every domain contract across the package's modules, deduped by id (later-wins). Sorted by id.
461
+ * Empty when no domain declares a contract (contract-free packages emit no `contracts.dart`). */
462
+ function collectPackageContracts(modules: readonly DomainModule[]): ContractHandle[] {
463
+ const byId = new Map<string, ContractHandle>();
464
+ for (const m of modules) for (const c of (m.contracts ?? [])) byId.set(c.id, c);
465
+ return [...byId.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
466
+ }
467
+
468
+ /**
469
+ * CIRCULAR-DEPENDENCY GUARD (domain_contracts.md). A `t.foreignRef` targeting a CONCRETE aggregate makes
470
+ * the referencing domain's generated file import the target domain's file. Two domains that concretely
471
+ * reference each other (A→B and B→A) form an import cycle — the exact anti-pattern domain contracts exist
472
+ * to break. Build the concrete domain→domain edge set and FAIL on any 2-cycle, pointing at contracts as the
473
+ * fix. Contract-mediated refs go through `contracts.dart`, so they create NO concrete edge and never trip this.
474
+ */
475
+ function guardNoConcreteImportCycle(
476
+ modules: readonly DomainModule[],
477
+ aggIdToDartFile: ReadonlyMap<string, string>,
478
+ ): void {
479
+ const edges = new Map<string, Set<string>>(); // fromDomainFile → { toDomainFile }
480
+ for (const mod of modules) {
481
+ for (const agg of mod.aggregates) {
482
+ for (const f of Object.values(agg.fields as Record<string, { foreignRef?: { targetAggregateId: string; targetIsContract?: boolean } }>)) {
483
+ const fr = f.foreignRef;
484
+ if (fr === undefined || fr.targetIsContract === true) continue; // contract edges route via contracts.dart
485
+ const to = aggIdToDartFile.get(fr.targetAggregateId);
486
+ if (to === undefined || to === mod.name) continue;
487
+ (edges.get(mod.name) ?? edges.set(mod.name, new Set()).get(mod.name)!).add(to);
488
+ }
489
+ }
490
+ }
491
+ for (const [from, tos] of edges) {
492
+ for (const to of tos) {
493
+ if (edges.get(to)?.has(from) && from < to) {
494
+ fail(
495
+ `domain contracts: concrete cross-domain import cycle between '${from}' and '${to}' — their ` +
496
+ `generated Dart files would import each other (a t.foreignRef in each targets a concrete aggregate ` +
497
+ `owned by the other). Break it with a domain CONTRACT: declare a contract(...) for the shared shape, ` +
498
+ `.implements(...) it on the owning aggregate, and point the foreignRef at the CONTRACT (it resolves ` +
499
+ `through the shared contracts.dart, keeping the domain graph acyclic).`,
500
+ );
501
+ }
502
+ }
503
+ }
504
+ }
505
+
448
506
  /** FRAMEWORK-SHARED symbol dedup, shared by the flat emit and the layered-packages emit (Dart-codegen
449
507
  * hygiene): framework-owned symbols fold into MANY domains, so their generated Dart would be DEFINED
450
508
  * once per domain — a package barrel re-exporting ≥2 domains then COLLIDES. THREE kinds are deduped,
@@ -673,7 +731,21 @@ function emitDart(
673
731
  if (shared.frameworkMod !== undefined) {
674
732
  writeGenerated(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
675
733
  }
676
-
734
+ // 2a-bis. the shared DOMAIN CONTRACTS file (`contracts.dart`) — every public DTO class, ONCE. Pure
735
+ // (nomos_types + VOs). A foreignRef targeting a contract imports THIS, never the concrete domain.
736
+ const packageContracts = collectPackageContracts(modules);
737
+ const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
738
+ if (packageContracts.length > 0) {
739
+ writeGenerated(CONTRACTS_FILE, generateDartContracts(packageContracts));
740
+ }
741
+ // 2a-ter. the shared ACTIVITY REGISTRY (`activity_registry.dart`) — the pure `renderActivity`, ONCE. Emitted
742
+ // iff a directive opts in via `.activity(...)` (activity-free packages stay byte-identical). Pure
743
+ // (nomos_types), so the client folds typed activity rows from the intent log with no runtime import.
744
+ const packageActivity = collectActivityDescriptors(modules);
745
+ const activityImportUri = packageActivity.length > 0 ? `${ACTIVITY_FILE}.dart` : undefined;
746
+ if (packageActivity.length > 0) {
747
+ writeGenerated(ACTIVITY_FILE, generateDartActivityRegistry(packageActivity));
748
+ }
677
749
  // CROSS-DOMAIN `t.foreignRef` (auto import). A foreignRef field typed `ForeignRef<CatalogueListing>` +
678
750
  // `CatalogueListing.fromJson` needs the TARGET aggregate's Dart class — which lives in the target DOMAIN's
679
751
  // generated file. Within ONE package the compiler knows which domain owns each aggregate, so we auto-emit
@@ -682,6 +754,10 @@ function emitDart(
682
754
  for (const m of modules) {
683
755
  for (const agg of (m.aggregates as { id: string }[])) aggIdToDartFile.set(agg.id, m.name);
684
756
  }
757
+ // CIRCULAR-DEP GUARD (domain_contracts.md): concrete-domain → concrete-domain import edges (from
758
+ // concrete-aggregate-target foreignRefs). A 2-cycle A→B & B→A is the anti-pattern contracts exist to
759
+ // break — fail with a clear diagnostic. Contract-mediated refs go through contracts.dart (no concrete edge).
760
+ guardNoConcreteImportCycle(modules, aggIdToDartFile);
685
761
 
686
762
  // 2b. one generated file per composed domain — the TYPES (`<name>.dart`) + the async bridge
687
763
  // CLIENT (`<name>.client.dart`, the Flutter sibling of `<name>.client.ts`).
@@ -698,11 +774,18 @@ function emitDart(
698
774
  // A target that is framework-shared resolves via the frameworkImportUri (not in the tenant map), so it is
699
775
  // skipped here. Merged with any manual `dartImports`; deduped by uri.
700
776
  const foreignRefImportUris = new Set<string>();
701
- for (const agg of (modForDart.aggregates as { fields: Record<string, { foreignRef?: { targetAggregateId: string } }> }[])) {
777
+ for (const agg of (modForDart.aggregates as { fields: Record<string, { foreignRef?: { targetAggregateId: string; targetIsContract?: boolean } }> }[])) {
702
778
  for (const f of Object.values(agg.fields)) {
703
- const tid = f.foreignRef?.targetAggregateId;
704
- if (tid === undefined) continue;
705
- const targetFile = aggIdToDartFile.get(tid);
779
+ const fr = f.foreignRef;
780
+ if (fr === undefined) continue;
781
+ // A CONTRACT target resolves through the shared `contracts.dart` (the acyclic edge), NEVER the
782
+ // concrete target domain — so estate never imports catalogue. (The contract id may EQUAL an
783
+ // aggregate id, so this branch MUST precede the concrete-file lookup.)
784
+ if (fr.targetIsContract === true) {
785
+ if (contractsImportUri !== undefined) foreignRefImportUris.add(contractsImportUri);
786
+ continue;
787
+ }
788
+ const targetFile = aggIdToDartFile.get(fr.targetAggregateId);
706
789
  if (targetFile !== undefined && targetFile !== modForDart.name) foreignRefImportUris.add(`${targetFile}.dart`);
707
790
  }
708
791
  }
@@ -726,6 +809,7 @@ function emitDart(
726
809
  ...(compat !== undefined ? { compat } : {}),
727
810
  supportImport,
728
811
  ...(domainFrameworkImport !== undefined ? { frameworkImportUri: domainFrameworkImport } : {}),
812
+ ...(packageActivity.length > 0 ? { emitActivity: true, ...(activityImportUri !== undefined ? { activityImportUri } : {}) } : {}),
729
813
  });
730
814
  const cp = path.join(genDir, `${mod.name}.client.dart`);
731
815
  writeFileSync(cp, clientSrc, "utf8");
@@ -751,6 +835,8 @@ function emitDart(
751
835
  ? `export '${genRel}/nomos_client.dart';`
752
836
  : `export 'package:nomos_client/nomos_client.dart';`,
753
837
  ...(hasFrameworkSymbols ? [`export '${genRel}/${FRAMEWORK_FILE}.dart';`] : []),
838
+ ...(contractsImportUri !== undefined ? [`export '${genRel}/${CONTRACTS_FILE}.dart';`] : []),
839
+ ...(activityImportUri !== undefined ? [`export '${genRel}/${ACTIVITY_FILE}.dart';`] : []),
754
840
  ...modules.flatMap((m) => [
755
841
  `export '${genRel}/${m.name}.dart';`,
756
842
  `export '${genRel}/${m.name}.client.dart';`,
@@ -775,8 +861,10 @@ function emitDart(
775
861
  /** The pure-core support import generated TYPES files carry in layered-packages mode. */
776
862
  const DART_TYPES_IMPORT = `import 'package:nomos_types/nomos_types.dart';`;
777
863
  /** Version constraints the scaffolded pubspecs pin — move together with the Dart packages' releases. */
778
- const NOMOS_TYPES_DEP = "^0.1.0";
779
- const NOMOS_CLIENT_DEP = "^0.38.0";
864
+ // FLOOR = the release carrying every symbol generated code may reference (0.1.2: ActivityEvent).
865
+ const NOMOS_TYPES_DEP = "^0.1.2";
866
+ // FLOOR = the release carrying the bridge methods generated clients call (0.44.0: activityLog/watchActivity).
867
+ const NOMOS_CLIENT_DEP = "^0.44.0";
780
868
 
781
869
  const isPubPackageName = (s: string): boolean => /^[a-z][a-z0-9_]*$/.test(s);
782
870
 
@@ -891,7 +979,7 @@ function emitDartPackages(
891
979
  // field as the target aggregate's class, which lives in the target domain's file). A sibling types file is
892
980
  // still PURE (no runtime/bridge surface — the purity gate exists to keep nomos_client out, not to isolate
893
981
  // domain files from each other; Dart allows type-import cycles for mutual refs).
894
- const siblingTypeFiles = new Set<string>([...modules.map((m) => `${m.name}.dart`), `${FRAMEWORK_FILE}.dart`]);
982
+ const siblingTypeFiles = new Set<string>([...modules.map((m) => `${m.name}.dart`), `${FRAMEWORK_FILE}.dart`, `${CONTRACTS_FILE}.dart`, `${ACTIVITY_FILE}.dart`]);
895
983
  const writeTypes = (fileBase: string, src: string): void => {
896
984
  if (!src.includes(DART_PACKAGE_IMPORT)) {
897
985
  fail(`generated Dart for '${fileBase}' does not carry the expected support import — codegen_dart header drifted`);
@@ -920,17 +1008,35 @@ function emitDartPackages(
920
1008
  };
921
1009
 
922
1010
  if (shared.frameworkMod !== undefined) writeTypes(FRAMEWORK_FILE, generateDartDomain(shared.frameworkMod));
1011
+ // DOMAIN CONTRACTS (layered): the shared `contracts.dart` DTO file in the TYPES package (pure —
1012
+ // nomos_types + VOs). A contract-target foreignRef imports THIS sibling, never the concrete domain.
1013
+ const packageContracts = collectPackageContracts(modules);
1014
+ const contractsImportUri = packageContracts.length > 0 ? `${CONTRACTS_FILE}.dart` : undefined;
1015
+ if (packageContracts.length > 0) writeTypes(CONTRACTS_FILE, generateDartContracts(packageContracts));
1016
+ // ACTIVITY REGISTRY (layered): the pure `renderActivity` in the TYPES package (nomos_types only). The
1017
+ // client resolves it through the types-package barrel (which re-exports every types file). Opt-in only.
1018
+ const packageActivity = collectActivityDescriptors(modules);
1019
+ if (packageActivity.length > 0) writeTypes(ACTIVITY_FILE, generateDartActivityRegistry(packageActivity));
923
1020
  // CROSS-DOMAIN foreignRef (layered): map aggregate id → its domain's type file, so a foreignRef field's
924
1021
  // target class resolves via a sibling import (the purity gate now permits sibling type files).
925
1022
  const aggIdToTypeFile = new Map<string, string>();
926
1023
  for (const m of modules) for (const agg of (m.aggregates as { id: string }[])) aggIdToTypeFile.set(agg.id, m.name);
1024
+ // CIRCULAR-DEP GUARD (domain_contracts.md) — the layered path emits per-domain type files too, so the
1025
+ // same concrete-import-cycle guard applies (contract-mediated refs route via contracts.dart, no cycle).
1026
+ guardNoConcreteImportCycle(modules, aggIdToTypeFile);
927
1027
  const perDomain = modules.map((mod) => ({ mod, dd: domainDedupOpts(mod, shared) }));
928
1028
  for (const { mod, dd } of perDomain) {
929
1029
  const frImports = new Set<string>();
930
- for (const agg of (dd.modForDart.aggregates as { fields: Record<string, { foreignRef?: { targetAggregateId: string } }> }[])) {
1030
+ for (const agg of (dd.modForDart.aggregates as { fields: Record<string, { foreignRef?: { targetAggregateId: string; targetIsContract?: boolean } }> }[])) {
931
1031
  for (const f of Object.values(agg.fields)) {
932
- const tid = f.foreignRef?.targetAggregateId;
933
- const tf = tid !== undefined ? aggIdToTypeFile.get(tid) : undefined;
1032
+ const fr = f.foreignRef;
1033
+ if (fr === undefined) continue;
1034
+ // A contract target resolves via contracts.dart (the acyclic edge), not the concrete domain file.
1035
+ if (fr.targetIsContract === true) {
1036
+ if (contractsImportUri !== undefined) frImports.add(contractsImportUri);
1037
+ continue;
1038
+ }
1039
+ const tf = aggIdToTypeFile.get(fr.targetAggregateId);
934
1040
  if (tf !== undefined && tf !== dd.modForDart.name) frImports.add(`${tf}.dart`);
935
1041
  }
936
1042
  }
@@ -1002,6 +1108,7 @@ function emitDartPackages(
1002
1108
  ...(compat !== undefined ? { compat } : {}),
1003
1109
  supportImport: "package",
1004
1110
  typesImportUri,
1111
+ ...(packageActivity.length > 0 ? { emitActivity: true } : {}),
1005
1112
  });
1006
1113
  const cp = path.join(clientGen, `${mod.name}.client.dart`);
1007
1114
  writeFileSync(cp, clientSrc, "utf8");
@@ -0,0 +1,110 @@
1
+ // NOMOS — DOMAIN CONTRACTS (architecture/domain_contracts.md). A CONTRACT is a named, stable PUBLIC SHAPE a
2
+ // domain exports so OTHER domains reference it WITHOUT importing the concrete aggregate implementation — the
3
+ // Nomos2 equivalent of Nomos1 "contracts". It preserves a layered, acyclic domain graph: everything depends
4
+ // on the shared contracts layer, never on each other's private generated types.
5
+ //
6
+ // A contract is PURE TYPING/CODEGEN metadata — NOT hashed law. The kernel folds aggregates, not contracts; an
7
+ // aggregate's domainHash moves only when its OWN fields change. Contracts ride the IR as ADDITIVE metadata
8
+ // (like value objects — era-safe, omit-when-empty) and are stable-id tracked for evolution.
9
+ //
10
+ // const CatalogueListingContract = contract("CatalogueListing", { listingId: t.string(), name: t.string() });
11
+ // const CatalogueListing = aggregate("CatalogueListing", { listingId: t.string().merge(Lww), … })
12
+ // .implements(CatalogueListingContract); // ← compile-checked: the aggregate SATISFIES the shape
13
+ // const EstateAsset = aggregate("EstateAsset", {
14
+ // listing: t.foreignRef({ workspace: "catalogueWorkspaceName", target: CatalogueListingContract, domain: "catalogue" }),
15
+ // }); // ← references the CONTRACT, not the concrete aggregate
16
+ import type { Field } from "./fields.js";
17
+
18
+ /** A contract's declared field — the SAME `t.*` field builders aggregates use (the merge DRIVER is ignored;
19
+ * a contract is a shape, not stored state). So a contract field can be a scalar, an enum, a value object, or
20
+ * a ref, exactly like an aggregate field. */
21
+ export type ContractField = Field;
22
+
23
+ /** A named public shape a domain exports (and other domains reference). Structurally comparable to an
24
+ * aggregate for the `.implements` satisfaction check. */
25
+ export interface ContractHandle<
26
+ Id extends string = string,
27
+ F extends Record<string, ContractField> = Record<string, ContractField>,
28
+ > {
29
+ /** The wire id — the literal you passed in (the STABLE public name). */
30
+ readonly id: Id;
31
+ /** The contract's PUBLIC fields (name → typed shape). */
32
+ readonly fields: F;
33
+ /** Brand so a plain string / an aggregate is never accepted where a contract is required. */
34
+ readonly __isContract: true;
35
+ }
36
+
37
+ /** True iff `x` is a contract handle (not an aggregate handle or a string). */
38
+ export function isContractHandle(x: unknown): x is ContractHandle {
39
+ return typeof x === "object" && x !== null && (x as { __isContract?: unknown }).__isContract === true;
40
+ }
41
+
42
+ /**
43
+ * Declare a domain CONTRACT — a named public shape. Reuse the `t.*` field builders for the fields (the merge
44
+ * driver is irrelevant — a contract is a shape). Export it; a concrete aggregate `.implements(...)` it, and
45
+ * other domains reference it via `t.ref` / `t.foreignRef`. Purely additive: a package with no contract emits
46
+ * byte-identically to before.
47
+ */
48
+ export function contract<const Id extends string, const F extends Record<string, ContractField>>(
49
+ id: Id,
50
+ fields: F,
51
+ ): ContractHandle<Id, F> {
52
+ if (typeof id !== "string" || id.length === 0) {
53
+ throw new Error("contract(id, fields): the id must be a non-empty string (the stable public name).");
54
+ }
55
+ if (typeof fields !== "object" || fields === null || Object.keys(fields).length === 0) {
56
+ throw new Error(`contract('${id}'): declare at least one field (a contract is a public shape).`);
57
+ }
58
+ return { id, fields, __isContract: true };
59
+ }
60
+
61
+ /** The result of checking whether an aggregate satisfies a contract's shape. */
62
+ export type ContractSatisfaction = { ok: true } | { ok: false; reason: string };
63
+
64
+ /**
65
+ * Structural satisfaction check (`.implements`): the aggregate must carry EVERY contract field, each with a
66
+ * compatible KIND (and, for refs/VOs, a compatible target/VO name). EXTRA aggregate fields are fine — that is
67
+ * the whole point (private implementation stays behind the boundary). Pure — no IO. The compiler calls this
68
+ * at `aggregate(...).implements(contract)` and throws a typed error naming the field on a mismatch.
69
+ */
70
+ export function satisfiesContract(
71
+ aggregateId: string,
72
+ aggregateFields: Record<string, Field>,
73
+ contractHandle: ContractHandle,
74
+ ): ContractSatisfaction {
75
+ for (const [name, cField] of Object.entries(contractHandle.fields)) {
76
+ const aField = aggregateFields[name];
77
+ if (aField === undefined) {
78
+ return {
79
+ ok: false,
80
+ reason:
81
+ `aggregate '${aggregateId}' does not satisfy contract '${contractHandle.id}': missing field '${name}' ` +
82
+ `(the contract requires a '${cField.kind}' field named '${name}'). Add it, or drop it from the contract.`,
83
+ };
84
+ }
85
+ // KIND compatibility. A contract's `ref`/`foreignRef` matches the aggregate's `ref`/`foreignRef` (both
86
+ // store an id string); the counter/position kinds read-collapse to int/string, so a contract may declare
87
+ // the collapsed kind. Everything else must match exactly.
88
+ const norm = (k: string): string => (k === "counter" ? "int" : k === "position" ? "string" : k);
89
+ if (norm(aField.kind) !== norm(cField.kind)) {
90
+ return {
91
+ ok: false,
92
+ reason:
93
+ `aggregate '${aggregateId}' does not satisfy contract '${contractHandle.id}': field '${name}' is ` +
94
+ `'${aField.kind}' but the contract requires '${cField.kind}'. Align the field type.`,
95
+ };
96
+ }
97
+ // For a value-object field, the VO NAME must match (same public structured shape).
98
+ const aVo = aField.valueObject?.name;
99
+ const cVo = cField.valueObject?.name;
100
+ if (cVo !== undefined && aVo !== cVo) {
101
+ return {
102
+ ok: false,
103
+ reason:
104
+ `aggregate '${aggregateId}' does not satisfy contract '${contractHandle.id}': field '${name}' uses ` +
105
+ `value object '${aVo ?? "(none)"}' but the contract requires '${cVo}'.`,
106
+ };
107
+ }
108
+ }
109
+ return { ok: true };
110
+ }
package/src/directive.ts CHANGED
@@ -23,6 +23,7 @@ import type { Ports } from "./ctx.js";
23
23
  import type { CertifiedReadDecl } from "./certified_read.js";
24
24
  import type { RelationDecl } from "./relation.js";
25
25
  import type { QueryDecl } from "./query.js";
26
+ import { normalizeActivity, type ActivityInput, type ActivityDescriptor } from "./activity.js";
26
27
  import { BIRTH_METER_QUERIES } from "./birth_meter.js";
27
28
  import {
28
29
  isFromPayloadRef,
@@ -115,6 +116,15 @@ export interface Directive<P = unknown> {
115
116
  * byte-identical in the canonical manifest to before `description` existed).
116
117
  */
117
118
  readonly description?: string;
119
+ /**
120
+ * OPT-IN user-facing ACTIVITY metadata (`architecture/activity_descriptors.md`) — the kind/title/message
121
+ * template, refs, params, category/icon/severity a client uses to render a typed activity row from this
122
+ * directive's intents (no string-enricher). Set by `.activity({...})`; NON-LAW, additive (same class as
123
+ * `description` — a directive that never declares activity is byte-identical). Normalized + validated at
124
+ * author time. `undefined` ⇒ this directive contributes no activity row. (The setter is `.activity({...})`;
125
+ * this stored field is `activityDescriptor` to avoid a method/field name clash on `RequirableDirective`.)
126
+ */
127
+ readonly activityDescriptor?: ActivityDescriptor;
118
128
  /**
119
129
  * NATIVE ZANZIBAR write-authz (P4): the RELATION the captured author must HOLD to author this
120
130
  * directive, and the OBJECT it is checked on. The ONE gate runs `engine.check(schema, object,
@@ -414,6 +424,14 @@ export interface RequirableDirective<P> extends Directive<P> {
414
424
  * calls this declares no prose (codegen falls back to a default derived from the id).
415
425
  */
416
426
  doc(description: string): RequirableDirective<P>;
427
+ /**
428
+ * Declare OPT-IN user-facing ACTIVITY metadata (`architecture/activity_descriptors.md`) — a stable `kind`,
429
+ * a `{actor}`+params message template, navigation `refs`, display `params` (payload field paths), and
430
+ * category/icon/severity. Codegen emits a typed activity descriptor + `renderActivity` so a client folds
431
+ * typed activity rows from the intent log (no string-enricher). Validated at author time (unknown template
432
+ * var / missing payload field refuse). Non-law, additive; a directive that never calls it is byte-identical.
433
+ */
434
+ activity(input: ActivityInput): RequirableDirective<P>;
417
435
  /**
418
436
  * Declare the directive's CertifiedReads (survival Constraint 5; `certified_read.md`
419
437
  * §"Tenant authoring") — the profiled, named write-path reads its `decide()` consults, the
@@ -810,6 +828,11 @@ function makeRequirable<P>(base: Directive<P>): RequirableDirective<P> {
810
828
  doc(description: string): RequirableDirective<P> {
811
829
  return makeRequirable({ ...base, description });
812
830
  },
831
+ activity(input: ActivityInput): RequirableDirective<P> {
832
+ // Normalize + validate against this directive's payload (unknown template var / missing payload field
833
+ // refuse here). Non-law, additive — stored like `description`.
834
+ return makeRequirable({ ...base, activityDescriptor: normalizeActivity(base.id, input, base.payloadSchema) });
835
+ },
813
836
  readsCertified(reads: Record<string, CertifiedReadDecl>): RequirableDirective<P> {
814
837
  // Accumulate onto any prior declared CertifiedReads (a repeated local name's later decl
815
838
  // wins) — additive, non-destructive. The local binding NAME (the key) is what `decide`
package/src/fields.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import { z } from "zod";
18
18
  import { Conflict, Counter, LastPosition, Lww, type Driver } from "./drivers.js";
19
19
  import type { AggregateHandle } from "./aggregate.js";
20
+ import type { ContractHandle } from "./contract.js";
20
21
  import type { FieldFacet } from "./principal_evidence.js";
21
22
 
22
23
  /**
@@ -282,6 +283,10 @@ export interface Field<T = unknown, K extends FieldKind = FieldKind> {
282
283
  readonly workspaceField: string;
283
284
  readonly targetAggregateId: string;
284
285
  readonly targetDomain?: string;
286
+ /** TRUE iff the target is a domain CONTRACT (a public shape), not a concrete aggregate — codegen then
287
+ * references the contract type (in the shared contracts layer) so the referring domain does not import
288
+ * the target's concrete implementation. */
289
+ readonly targetIsContract?: boolean;
285
290
  };
286
291
  /** For `t.ref` (the target) AND `t.hasMany` (the child type): the related aggregate's wire id. */
287
292
  readonly refAggregateId?: string;
@@ -411,7 +416,7 @@ function makeField<T, K extends FieldKind = FieldKind>(
411
416
  driver: Driver,
412
417
  extra: {
413
418
  refWorkspace?: string;
414
- foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string };
419
+ foreignRef?: { workspaceField: string; targetAggregateId: string; targetDomain?: string; targetIsContract?: boolean };
415
420
  refAggregateId?: string;
416
421
  /** LAZY target resolution — `t.ref(() => Agg)` / `t.hasMany(() => Agg)`. Breaks a BIDIRECTIONAL
417
422
  * declaration cycle (a Parent's `hasMany(Child)` ⇄ a Child's `ref(Parent)`): the thunk is stored and
@@ -782,15 +787,17 @@ export const t = {
782
787
  */
783
788
  foreignRef(opts: {
784
789
  workspace: string;
785
- target: AggregateHandle<string, Record<string, Field>>;
790
+ target: AggregateHandle<string, Record<string, Field>> | ContractHandle,
786
791
  domain?: string;
787
792
  }): Field<string, "ref"> {
793
+ const isContract = (opts.target as { __isContract?: unknown }).__isContract === true;
788
794
  return makeField<string, "ref">("ref", z.string(), Conflict, {
789
795
  refAggregateId: opts.target.id,
790
796
  foreignRef: {
791
797
  workspaceField: opts.workspace,
792
798
  targetAggregateId: opts.target.id,
793
799
  ...(opts.domain !== undefined ? { targetDomain: opts.domain } : {}),
800
+ ...(isContract ? { targetIsContract: true } : {}),
794
801
  },
795
802
  });
796
803
  },
package/src/index.ts CHANGED
@@ -135,6 +135,8 @@ export {
135
135
  // — naming no id. A directive's `.plan` calls these and returns `[]` (the recorded graph is the write
136
136
  // surface). The dev never sees a "prim" — that is the GitHolon's internal OpenUSD IR.
137
137
  export { create, existing, ensure, type AggregateRef } from "./authoring.js";
138
+ export { contract, isContractHandle, satisfiesContract, type ContractHandle, type ContractField, type ContractSatisfaction } from "./contract.js";
139
+ export { normalizeActivity, renderActivityTemplate, activityKey, type ActivityInput, type ActivityDescriptor, type ActivityRefInput, type ActivitySeverity } from "./activity.js";
138
140
  // THE CAPTURED READ — a plan reads O(1) DSL queries via `read(query, args)`; the result is committed as
139
141
  // the intent's read footprint (deterministic replay + stale-premise detection). The dev calls typed DSL
140
142
  // queries; the library routes them here. The host capability is `nomos.read` (twin of `nomos.rng`).
package/src/usd.ts CHANGED
@@ -615,6 +615,64 @@ export interface UsdLayer {
615
615
  */
616
616
  readonly usages?: readonly import("./value_object.js").ValueObjectUsage[];
617
617
  };
618
+ /**
619
+ * DOMAIN CONTRACTS (`architecture/domain_contracts.md`) — the public DTO shapes the domain declares.
620
+ * ADDITIVE + hash-inert (like `nomosValueObjects`): omit-when-empty, so a contract-free domain is
621
+ * byte-identical. Each contract carries its public field shapes (name/kind, VO name, ref target) and
622
+ * the concrete aggregate ids that `.implements(...)` it. Drives the shared `contracts.dart` + TS interfaces.
623
+ */
624
+ readonly nomosContracts?: {
625
+ readonly v: 1;
626
+ readonly contracts: readonly {
627
+ readonly id: string;
628
+ readonly fields: readonly { readonly name: string; readonly kind: string; readonly vo?: string; readonly refTarget?: string }[];
629
+ readonly implementedBy: readonly string[];
630
+ }[];
631
+ };
632
+ }
633
+
634
+ /** The `nomosContracts` descriptor type — a domain's public DTO shapes (era-safe, hash-inert). */
635
+ export type NomosContracts = NonNullable<UsdLayer["nomosContracts"]>;
636
+
637
+ /**
638
+ * Build the additive `nomosContracts` descriptor for a module: each declared contract's public field
639
+ * shapes + the concrete aggregate ids that `.implements(...)` it. Returns undefined when the module
640
+ * declares no contract (omit-when-empty — a contract-free domain is byte-identical). SHARED by the
641
+ * whole-doc emit (`usd.ts`) and the layered emit (`usd_layers.ts`) so both produce identical bytes.
642
+ */
643
+ export function buildNomosContracts(module: {
644
+ readonly aggregates: readonly { id: string; implementsContracts?: readonly string[] }[];
645
+ readonly contracts?: readonly import("./contract.js").ContractHandle[];
646
+ }): NomosContracts | undefined {
647
+ const declared = module.contracts;
648
+ if (declared === undefined || declared.length === 0) return undefined;
649
+ // implementedBy: aggregate ids per contract id (from `.implements(...)`), sorted for determinism.
650
+ const implByContract = new Map<string, string[]>();
651
+ for (const agg of module.aggregates) {
652
+ for (const cid of agg.implementsContracts ?? []) {
653
+ const l = implByContract.get(cid) ?? [];
654
+ l.push(agg.id);
655
+ implByContract.set(cid, l);
656
+ }
657
+ }
658
+ const contracts = [...declared]
659
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
660
+ .map((c) => ({
661
+ id: c.id,
662
+ fields: Object.entries(c.fields).map(([name, f]) => {
663
+ const field = f as import("./fields.js").Field;
664
+ const vo = field.valueObject?.name;
665
+ const refTarget = field.foreignRef?.targetAggregateId ?? field.refAggregateId;
666
+ return {
667
+ name,
668
+ kind: field.kind,
669
+ ...(vo !== undefined ? { vo } : {}),
670
+ ...(refTarget !== undefined ? { refTarget } : {}),
671
+ };
672
+ }),
673
+ implementedBy: (implByContract.get(c.id) ?? []).slice().sort(),
674
+ }));
675
+ return { v: 1 as const, contracts };
618
676
  }
619
677
 
620
678
  /** The reserved variant name declaring a set's DEFAULT (USD has a default variant). */
@@ -1366,6 +1424,8 @@ export function emitUsd(
1366
1424
  ...(voUsages.length > 0 ? { usages: voUsages } : {}),
1367
1425
  }
1368
1426
  : undefined;
1427
+ // DOMAIN CONTRACTS: the additive `nomosContracts` descriptor (hash-inert, omit-when-empty).
1428
+ const nomosContracts = buildNomosContracts(l.module);
1369
1429
  const flowEndorse = [...l.module.directives]
1370
1430
  .flatMap((d) => harvestPrincipalFlows(d as never).map((f) => ({ directiveId: d.id, sink: f.sink, facet: f.facet, reason: f.reason })))
1371
1431
  .sort((a, b) =>
@@ -1419,6 +1479,7 @@ export function emitUsd(
1419
1479
  ...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
1420
1480
  ...(nomosForeignRefs !== undefined ? { nomosForeignRefs } : {}),
1421
1481
  ...(nomosValueObjects !== undefined ? { nomosValueObjects } : {}),
1482
+ ...(nomosContracts !== undefined ? { nomosContracts } : {}),
1422
1483
  ...(Object.keys(visAggregates).length > 0
1423
1484
  ? { nomosReadVisibility: { v: 1 as const, aggregates: visAggregates } }
1424
1485
  : {}),
package/src/usd_layers.ts CHANGED
@@ -206,6 +206,9 @@ function domainLayerAttrLines(domain: string, layer: UsdLayer, indent: string):
206
206
  ...(layer.nomosValueObjects !== undefined
207
207
  ? [`${indent}custom string nomos:valueObjectsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosValueObjects)))}`]
208
208
  : []),
209
+ ...(layer.nomosContracts !== undefined
210
+ ? [`${indent}custom string nomos:contractsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosContracts)))}`]
211
+ : []),
209
212
  ...(layer.nomosForeignRefs !== undefined
210
213
  ? [`${indent}custom string nomos:foreignRefsHex = ${usdaString(hexUtf8(JSON.stringify(layer.nomosForeignRefs)))}`]
211
214
  : []),
@@ -953,6 +956,34 @@ export function flattenLayeredUsd(layers: readonly UsdLayer[]): UsdDocument {
953
956
  }
954
957
  let foreignRefs: UsdLayer["nomosForeignRefs"];
955
958
  for (const l of stack) { if (l.nomosForeignRefs !== undefined) foreignRefs = l.nomosForeignRefs; }
959
+ // DOMAIN CONTRACTS: union the per-layer contract descriptors by id (later-wins on the shape),
960
+ // sorted by id — byte-identical to the composed emit (which sorts contracts by id). implementedBy
961
+ // is unioned across layers so a contract declared in one module and implemented in a same-layer
962
+ // aggregate stays intact. Omit-when-empty.
963
+ let contracts: UsdLayer["nomosContracts"];
964
+ {
965
+ type C = NonNullable<UsdLayer["nomosContracts"]>["contracts"][number];
966
+ const byId = new Map<string, C>();
967
+ const implById = new Map<string, Set<string>>();
968
+ for (const l of stack) {
969
+ if (l.nomosContracts === undefined) continue;
970
+ for (const c of l.nomosContracts.contracts) {
971
+ byId.set(c.id, c);
972
+ const s = implById.get(c.id) ?? new Set<string>();
973
+ for (const a of c.implementedBy) s.add(a);
974
+ implById.set(c.id, s);
975
+ }
976
+ }
977
+ if (byId.size > 0) {
978
+ contracts = {
979
+ v: 1,
980
+ contracts: [...byId.keys()].sort().map((id) => ({
981
+ ...byId.get(id)!,
982
+ implementedBy: [...(implById.get(id) ?? [])].sort(),
983
+ })),
984
+ };
985
+ }
986
+ }
956
987
  return {
957
988
  path,
958
989
  prims: [...prims.keys()].sort().map((p) => prims.get(p)!),
@@ -979,6 +1010,7 @@ return {
979
1010
  ...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
980
1011
  ...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
981
1012
  ...(foreignRefs !== undefined ? { nomosForeignRefs: foreignRefs } : {}),
1013
+ ...(contracts !== undefined ? { nomosContracts: contracts } : {}),
982
1014
  };
983
1015
  });
984
1016
 
@@ -1119,6 +1151,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1119
1151
  const eraCertGateByDomain = new Map<string, UsdLayer["nomosEraCertGate"]>();
1120
1152
  const valueObjectsByDomain = new Map<string, UsdLayer["nomosValueObjects"]>();
1121
1153
  const foreignRefsByDomain = new Map<string, UsdLayer["nomosForeignRefs"]>();
1154
+ const contractsByDomain = new Map<string, UsdLayer["nomosContracts"]>();
1122
1155
  for (const p of prims) {
1123
1156
  if (p.attrs.get("nomos:kind") === "domain") {
1124
1157
  const domain = attrString(p, "nomos:domain");
@@ -1218,6 +1251,11 @@ export function parseUsdaDocument(text: string): UsdDocument {
1218
1251
  if (typeof frHex === "string") {
1219
1252
  foreignRefsByDomain.set(domain, JSON.parse(unhexUtf8(frHex)) as UsdLayer["nomosForeignRefs"]);
1220
1253
  }
1254
+ // DOMAIN CONTRACTS: the hex-carried public DTO descriptor set (descriptive, not a gate).
1255
+ const ctHex = p.attrs.get("nomos:contractsHex");
1256
+ if (typeof ctHex === "string") {
1257
+ contractsByDomain.set(domain, JSON.parse(unhexUtf8(ctHex)) as UsdLayer["nomosContracts"]);
1258
+ }
1221
1259
  }
1222
1260
  }
1223
1261
 
@@ -1329,6 +1367,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1329
1367
  const eraCertGate = eraCertGateByDomain.get(domain);
1330
1368
  const valueObjects = valueObjectsByDomain.get(domain);
1331
1369
  const foreignRefs2 = foreignRefsByDomain.get(domain);
1370
+ const contracts2 = contractsByDomain.get(domain);
1332
1371
  return {
1333
1372
  path: `/Nomos/${domain}`,
1334
1373
  prims: [...b.prims].sort(byPath),
@@ -1355,6 +1394,7 @@ export function parseUsdaDocument(text: string): UsdDocument {
1355
1394
  ...(eraCertGate !== undefined ? { nomosEraCertGate: eraCertGate } : {}),
1356
1395
  ...(valueObjects !== undefined ? { nomosValueObjects: valueObjects } : {}),
1357
1396
  ...(foreignRefs2 !== undefined ? { nomosForeignRefs: foreignRefs2 } : {}),
1397
+ ...(contracts2 !== undefined ? { nomosContracts: contracts2 } : {}),
1358
1398
  };
1359
1399
  })
1360
1400
  .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));