@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.
package/dart/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## 0.44.0
2
+
3
+ - Activity-feed runtime reads on `NomosBridge`/`NomosSession`: `activityLog({base, limit})` — the committed
4
+ intent log as raw rows `{intentId, domain, directiveId, actorSubject, occurredAt, payload}` (decoded
5
+ payload; the verified author; the physical HLC) — and `watchActivity({base})`, a stream re-deriving the
6
+ feed on every sync tick. Generic (raw Maps); the generated `renderActivity` (from directives' declared
7
+ `.activity()` metadata) types them into `ActivityEvent`s. Requires `nomos_types >= 0.1.2`.
8
+
1
9
  ## 0.43.0
2
10
 
3
11
  - `NomosBridge.foreignReader({cloud})` — a `NomosForeignReader` backed by the device's multi-workspace bridge
@@ -1168,6 +1168,26 @@ class NomosBridge {
1168
1168
  .toList();
1169
1169
  }
1170
1170
 
1171
+ /// RUNTIME activity feed — per-committed-intent rows (newest-first) from the local ledger, each carrying
1172
+ /// `{intentId, domain, directiveId, actorSubject, occurredAt, payload}` (payload = the DECODED directive
1173
+ /// payload). `base` = a commit-oid cursor (only intents newer than it); omitted = from the tip bounded by
1174
+ /// `limit` (default 200). Generic raw rows — the typed shaping is the codegen `renderActivity`.
1175
+ Future<List<Map<String, dynamic>>> activityLog(
1176
+ {String? base, int? limit}) async {
1177
+ final r = _asMap(await _call('activityLog', {
1178
+ if (base != null) 'base': base,
1179
+ if (limit != null) 'limit': limit,
1180
+ }));
1181
+ return ((r['rows'] as List?) ?? const [])
1182
+ .map((e) => (e as Map).cast<String, dynamic>())
1183
+ .toList();
1184
+ }
1185
+
1186
+ /// Watch the RUNTIME activity feed: a stream that re-emits the newest-first activity rows on every local
1187
+ /// change (doorbell/sync tick). `base` = a commit-oid cursor. Cancelling drops the watch on the runner.
1188
+ Stream<List<dynamic>> watchActivity({String? base, String? session}) =>
1189
+ _watchStream(kind: 'activity', base: base, session: session);
1190
+
1171
1191
  /// Mint an ed25519 DEVICE KEY in-kernel → `{secret, public}` (base64). The SECRET stays on this device
1172
1192
  /// (persist it in secure storage); pass it to [connect] as `authorSecret` (or per-offer `signWith`) so the
1173
1193
  /// holon SIGNS each intent. The PUBLIC half is what gets enrolled so the gate re-verifies the signature on
@@ -1594,6 +1614,7 @@ class NomosBridge {
1594
1614
  String? id,
1595
1615
  String? spatialId,
1596
1616
  NomosBbox? bbox,
1617
+ String? base,
1597
1618
  String? session}) {
1598
1619
  final subId = 'w${++_seq}';
1599
1620
  late final StreamController<List<dynamic>> ctrl;
@@ -1606,6 +1627,7 @@ class NomosBridge {
1606
1627
  if (id != null) 'id': id,
1607
1628
  if (spatialId != null) 'spatialId': spatialId,
1608
1629
  if (bbox != null) 'bbox': bbox.toJson(),
1630
+ if (base != null) 'base': base,
1609
1631
  if (session != null) 'session': session,
1610
1632
  }),
1611
1633
  onCancel: () async {
@@ -1709,6 +1731,9 @@ class NomosSession extends NomosBridge {
1709
1731
  [String? session]) =>
1710
1732
  parent.watchSpatial(spatialId, bbox, session ?? workspace);
1711
1733
  @override
1734
+ Stream<List<dynamic>> watchActivity({String? base, String? session}) =>
1735
+ parent.watchActivity(base: base, session: session ?? workspace);
1736
+ @override
1712
1737
  Stream<Map<String, Object?>> watchCompatibility(
1713
1738
  Map<String, Object?> requirement,
1714
1739
  [String? session]) =>
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.43.0
14
+ version: 0.44.0
15
15
 
16
16
  environment:
17
17
  sdk: ^3.5.0
@@ -20,7 +20,7 @@ dependencies:
20
20
  # The pure-data core (wire types, offer codec, NomosReads, schema validation, error
21
21
  # info). nomos_client depends on and re-exports it, so existing imports are unchanged.
22
22
  # For local monorepo dev, pubspec_overrides.yaml points this at ../dart_types.
23
- nomos_types: ^0.1.1
23
+ nomos_types: ^0.1.2
24
24
  json_schema: ^5.2.2
25
25
  crypto: ^3.0.0
26
26
 
@@ -1,3 +1,10 @@
1
+ ## 0.1.2
2
+
3
+ - Activity-feed runtime types: `ActivityEvent` (the shared activity row — kind/actor/occurredAt, rendered
4
+ title+message, stable `titleKey`/`messageKey` + params for localization, typed `refs`, category/icon/
5
+ severity, domain/directiveId provenance), `ActivityRef`, `ActivitySeverity`, `renderActivityTemplate`.
6
+ Consumed by the generated `renderActivity`/`describe`/`describeHistory`. Purely additive.
7
+
1
8
  ## 0.1.1
2
9
 
3
10
  - Add `t.foreignRef` runtime types: `ForeignRef<T>` (a typed cross-workspace locator), `NomosForeignReader` /
@@ -31,3 +31,4 @@ export 'src/errors.dart';
31
31
  // `NomosForeignReader`/`NomosForeignWorkspaceReader` read surfaces + the named resolve outcomes. Pure data +
32
32
  // abstract interfaces (no bridge) — so both the layered TYPES package and nomos_client resolve them.
33
33
  export 'src/foreign_ref.dart';
34
+ export 'src/activity.dart';
@@ -0,0 +1,160 @@
1
+ // NOMOS — the ACTIVITY-FEED runtime types (directive `.activity(...)` descriptors). The shared, tenant-
2
+ // agnostic shape a client renders from the intent log: a directive DECLARES its activity metadata (kind,
3
+ // message template, refs, params) and the generated `renderActivity` turns each committed intent into an
4
+ // [ActivityEvent] — no string-enricher guessing meaning beside the truth. The kernel/ledger stays the audit
5
+ // truth; this is a pure client-side human projection.
6
+ //
7
+ // These live in the pure `nomos_types` core (no bridge/lifecycle) so BOTH the layered TYPES package (where
8
+ // the generated pure `renderActivity` lives) AND the runtime `nomos_client` package resolve them.
9
+ library;
10
+
11
+ /// A declared navigation target on an activity row: the referenced aggregate/contract TYPE + the resolved id
12
+ /// (read from a payload field the directive named). Replaces the old enricher's blind id-probing — the app
13
+ /// knows exactly what `id` points at and can deep-link/resolve it.
14
+ class ActivityRef {
15
+ final String type;
16
+ final String id;
17
+ const ActivityRef({required this.type, required this.id});
18
+
19
+ factory ActivityRef.fromJson(Map<String, dynamic> j) =>
20
+ ActivityRef(type: (j['type'] ?? '') as String, id: (j['id'] ?? '') as String);
21
+ Map<String, dynamic> toJson() => {'type': type, 'id': id};
22
+
23
+ @override
24
+ bool operator ==(Object other) => other is ActivityRef && other.type == type && other.id == id;
25
+ @override
26
+ int get hashCode => Object.hash(type, id);
27
+ }
28
+
29
+ /// Row prominence — a declared default the app may style or threshold on.
30
+ enum ActivitySeverity {
31
+ info,
32
+ success,
33
+ warning,
34
+ critical;
35
+
36
+ static ActivitySeverity fromWire(String? s) {
37
+ switch (s) {
38
+ case 'success':
39
+ return ActivitySeverity.success;
40
+ case 'warning':
41
+ return ActivitySeverity.warning;
42
+ case 'critical':
43
+ return ActivitySeverity.critical;
44
+ default:
45
+ return ActivitySeverity.info;
46
+ }
47
+ }
48
+
49
+ String get wire => name;
50
+ }
51
+
52
+ /// A rendered activity row — the tenant-agnostic "tiny shared activity contract". Produced by the generated
53
+ /// `renderActivity(intentMeta, payload)` from a directive's declared `.activity(...)` descriptor. The UI
54
+ /// consumes this directly (title/message already interpolated; `refs` for navigation; `titleKey`/`messageKey`
55
+ /// + `params` for later localization).
56
+ class ActivityEvent {
57
+ /// The intent id — a stable per-event identity (dedupe/order key).
58
+ final String eventId;
59
+
60
+ /// The declared STABLE event kind (e.g. `"site.created"`).
61
+ final String kind;
62
+
63
+ /// The intent's author (principal) — the "who".
64
+ final String actorSubject;
65
+
66
+ /// When it happened (the intent's physical HLC timestamp, ISO-8601 when available).
67
+ final String occurredAt;
68
+
69
+ /// The rendered title (template interpolated); [titleKey] + [params] localize it later.
70
+ final String title;
71
+
72
+ /// The rendered message (template interpolated); [messageKey] + [params] localize it later.
73
+ final String message;
74
+
75
+ /// Stable i18n key for the title (`activity.<kind>.title`).
76
+ final String titleKey;
77
+
78
+ /// Stable i18n key for the message (`activity.<kind>.message`).
79
+ final String messageKey;
80
+
81
+ /// The interpolation params (name → display value, read from the payload) — for localized re-rendering.
82
+ final Map<String, String> params;
83
+
84
+ /// Declared navigation targets (name → typed ref).
85
+ final Map<String, ActivityRef> refs;
86
+
87
+ /// Optional grouping category (e.g. `"estate"`).
88
+ final String? category;
89
+
90
+ /// Optional default icon token (app-interpreted).
91
+ final String? icon;
92
+
93
+ /// Row prominence (default [ActivitySeverity.info]).
94
+ final ActivitySeverity severity;
95
+
96
+ /// Provenance — the domain + directive that produced this event.
97
+ final String domain;
98
+ final String directiveId;
99
+
100
+ const ActivityEvent({
101
+ required this.eventId,
102
+ required this.kind,
103
+ required this.actorSubject,
104
+ required this.occurredAt,
105
+ required this.title,
106
+ required this.message,
107
+ required this.titleKey,
108
+ required this.messageKey,
109
+ required this.params,
110
+ required this.refs,
111
+ required this.severity,
112
+ required this.domain,
113
+ required this.directiveId,
114
+ this.category,
115
+ this.icon,
116
+ });
117
+
118
+ factory ActivityEvent.fromJson(Map<String, dynamic> j) => ActivityEvent(
119
+ eventId: (j['eventId'] ?? '') as String,
120
+ kind: (j['kind'] ?? '') as String,
121
+ actorSubject: (j['actorSubject'] ?? '') as String,
122
+ occurredAt: (j['occurredAt'] ?? '') as String,
123
+ title: (j['title'] ?? '') as String,
124
+ message: (j['message'] ?? '') as String,
125
+ titleKey: (j['titleKey'] ?? '') as String,
126
+ messageKey: (j['messageKey'] ?? '') as String,
127
+ params: ((j['params'] as Map?)?.cast<String, dynamic>() ?? const {})
128
+ .map((k, v) => MapEntry(k, '$v')),
129
+ refs: ((j['refs'] as Map?)?.cast<String, dynamic>() ?? const {})
130
+ .map((k, v) => MapEntry(k, ActivityRef.fromJson((v as Map).cast<String, dynamic>()))),
131
+ category: j['category'] as String?,
132
+ icon: j['icon'] as String?,
133
+ severity: ActivitySeverity.fromWire(j['severity'] as String?),
134
+ domain: (j['domain'] ?? '') as String,
135
+ directiveId: (j['directiveId'] ?? '') as String,
136
+ );
137
+
138
+ Map<String, dynamic> toJson() => {
139
+ 'eventId': eventId,
140
+ 'kind': kind,
141
+ 'actorSubject': actorSubject,
142
+ 'occurredAt': occurredAt,
143
+ 'title': title,
144
+ 'message': message,
145
+ 'titleKey': titleKey,
146
+ 'messageKey': messageKey,
147
+ 'params': params,
148
+ 'refs': refs.map((k, v) => MapEntry(k, v.toJson())),
149
+ if (category != null) 'category': category,
150
+ if (icon != null) 'icon': icon,
151
+ 'severity': severity.wire,
152
+ 'domain': domain,
153
+ 'directiveId': directiveId,
154
+ };
155
+ }
156
+
157
+ /// Interpolate `{name}` placeholders in [template] from [vars] — the render helper the generated
158
+ /// `renderActivity` uses. A missing var renders empty (the compiler already validated every placeholder).
159
+ String renderActivityTemplate(String template, Map<String, String> vars) =>
160
+ template.replaceAllMapped(RegExp(r'\{([a-zA-Z0-9_]+)\}'), (m) => vars[m.group(1)] ?? '');
@@ -11,7 +11,7 @@ description: >-
11
11
  Pure kernel wire/data types for Nomos Dart frontends — no transport, no bridge,
12
12
  no runtime. The hand-written serde-JSON-compatible types plus the offer codec,
13
13
  reactive read engine, schema validation, and data-only error/trace types.
14
- version: 0.1.1
14
+ version: 0.1.2
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.65.1",
3
+ "version": "0.67.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",
@@ -0,0 +1,179 @@
1
+ // NOMOS — DIRECTIVE ACTIVITY DESCRIPTORS (architecture/activity_descriptors.md). A directive OPT-IN declares
2
+ // its own user-facing activity metadata, so a client can fold typed activity rows straight from the intent
3
+ // log — no string-enricher guessing meaning beside the truth. The kernel/ledger stays the audit truth; this
4
+ // is a pure CLIENT-SIDE human projection, declared where the event semantics live.
5
+ //
6
+ // directive("createSite").creates(EstateSite).payload(SitePayload)
7
+ // .activity({
8
+ // kind: "site.created", // STABLE event id (seeds the i18n keys)
9
+ // title: "Created site",
10
+ // message: "{actor} created {siteName}", // {actor} + declared params
11
+ // category: "estate", icon: "add_location", severity: "info",
12
+ // refs: { site: { type: EstateSite, from: "siteId" } }, // navigation target (type + payload path)
13
+ // params: { siteName: "name" }, // display value ← payload field path
14
+ // })
15
+ //
16
+ // NON-LAW, additive metadata (same class as `.doc()`): a directive that never calls `.activity()` is
17
+ // byte-identical in the canonical manifest.
18
+ import { z } from "zod";
19
+ import type { AggregateHandle } from "./aggregate.js";
20
+ import { isContractHandle, type ContractHandle } from "./contract.js";
21
+
22
+ /** How prominent an activity row is — a declared default the app may style/threshold on. */
23
+ export type ActivitySeverity = "info" | "success" | "warning" | "critical";
24
+
25
+ /** A declared navigation target: the referenced aggregate/contract TYPE + the payload field path holding its
26
+ * id. Replaces the enricher's blind id-probing — the reference is declared, not guessed. */
27
+ export interface ActivityRefInput {
28
+ /** The target type — an aggregate handle or a `contract(...)` (reuse contracts for the nav target). */
29
+ readonly type: AggregateHandle | ContractHandle;
30
+ /** The payload field path holding the target's id (dotted for nested, e.g. `"location.siteId"`). */
31
+ readonly from: string;
32
+ }
33
+
34
+ /** The `.activity({...})` input (declarative — field PATHS, never functions, so it generates to Dart too). */
35
+ export interface ActivityInput {
36
+ /** STABLE event id (e.g. `"site.created"`) — the wire kind + the seed for the i18n keys. */
37
+ readonly kind: string;
38
+ /** Default (English) title; falls back to `kind` if omitted. */
39
+ readonly title?: string;
40
+ /** Message template — `{actor}` + any declared `params` name in braces (e.g. `"{actor} created {siteName}"`). */
41
+ readonly message: string;
42
+ /** Optional grouping category (e.g. `"estate"`). */
43
+ readonly category?: string;
44
+ /** Optional default icon token (app-interpreted). */
45
+ readonly icon?: string;
46
+ /** Row prominence (default `"info"`). */
47
+ readonly severity?: ActivitySeverity;
48
+ /** Navigation targets: name → { target type, payload path to its id }. */
49
+ readonly refs?: Record<string, ActivityRefInput>;
50
+ /** Display values: template-param name → payload field path (dotted ok). */
51
+ readonly params?: Record<string, string>;
52
+ }
53
+
54
+ /** The NORMALIZED, serializable descriptor stored on the directive + emitted to codegen/IR. */
55
+ export interface ActivityDescriptor {
56
+ readonly kind: string;
57
+ readonly title: string;
58
+ readonly message: string;
59
+ readonly category?: string;
60
+ readonly icon?: string;
61
+ readonly severity: ActivitySeverity;
62
+ /** Refs sorted by name; `type` is the target's wire id (a string). */
63
+ readonly refs: readonly { readonly name: string; readonly type: string; readonly from: string }[];
64
+ /** Params sorted by name. */
65
+ readonly params: readonly { readonly name: string; readonly from: string }[];
66
+ /** Stable i18n keys derived from `kind` (`activity.<kind>.title` / `.message`). */
67
+ readonly titleKey: string;
68
+ readonly messageKey: string;
69
+ }
70
+
71
+ const SEVERITIES: ReadonlySet<string> = new Set(["info", "success", "warning", "critical"]);
72
+
73
+ /** The i18n key for a `kind` + slot — stable, derived (never hand-authored). */
74
+ export function activityKey(kind: string, slot: "title" | "message"): string {
75
+ return `activity.${kind}.${slot}`;
76
+ }
77
+
78
+ /** Interpolate `{name}` placeholders in a template from `vars` (a missing var renders as the empty string —
79
+ * the compile-time validator already guarantees every placeholder is declared, so this is defensive only).
80
+ * The ONE render helper shared by the TS + Dart renderers (each reimplements the same trivial substitution). */
81
+ export function renderActivityTemplate(template: string, vars: Record<string, string>): string {
82
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_m, name: string) => vars[name] ?? "");
83
+ }
84
+
85
+ /** The `{placeholder}` names referenced by a template. */
86
+ function templateVars(template: string): string[] {
87
+ const out: string[] = [];
88
+ for (const m of template.matchAll(/\{([a-zA-Z0-9_]+)\}/g)) out.push(m[1]!);
89
+ return out;
90
+ }
91
+
92
+ /** Top-level payload field names (best-effort — a plain `z.object`); `undefined` when the schema isn't a
93
+ * destructurable object (validation of `from` roots is then skipped, staying permissive). */
94
+ function payloadFieldNames(schema: unknown): Set<string> | undefined {
95
+ const def = (schema as { _def?: { shape?: unknown }; def?: { shape?: unknown } })?._def
96
+ ?? (schema as { def?: { shape?: unknown } })?.def;
97
+ const shape = (def as { shape?: unknown })?.shape;
98
+ const resolved = typeof shape === "function" ? (shape as () => Record<string, unknown>)() : shape;
99
+ if (resolved === undefined || resolved === null || typeof resolved !== "object") return undefined;
100
+ return new Set(Object.keys(resolved as Record<string, unknown>));
101
+ }
102
+
103
+ /**
104
+ * Validate + normalize an `.activity({...})` input at author time (throws a typed error naming the problem).
105
+ * Checks: `kind`/`message` non-empty; `severity` valid; every message/title `{placeholder}` is `actor` or a
106
+ * declared param; every ref/param `from` ROOT names a real payload field (when the payload shape is
107
+ * introspectable). Returns the serializable {@link ActivityDescriptor}. Pure.
108
+ */
109
+ export function normalizeActivity(directiveId: string, input: ActivityInput, payloadSchema?: unknown): ActivityDescriptor {
110
+ const where = `directive '${directiveId}' .activity(...)`;
111
+ if (typeof input.kind !== "string" || input.kind.trim().length === 0) {
112
+ throw new Error(`${where}: 'kind' must be a non-empty stable string (e.g. "site.created").`);
113
+ }
114
+ if (typeof input.message !== "string" || input.message.trim().length === 0) {
115
+ throw new Error(`${where}: 'message' must be a non-empty template string.`);
116
+ }
117
+ const severity = input.severity ?? "info";
118
+ if (!SEVERITIES.has(severity)) {
119
+ throw new Error(`${where}: 'severity' must be one of info|success|warning|critical (got '${severity}').`);
120
+ }
121
+ const params = Object.entries(input.params ?? {}).map(([name, from]) => {
122
+ if (typeof from !== "string" || from.length === 0) {
123
+ throw new Error(`${where}: param '${name}' must map to a payload field path string.`);
124
+ }
125
+ return { name, from };
126
+ });
127
+ const paramNames = new Set(params.map((p) => p.name));
128
+ // Every {placeholder} must be `actor` or a declared param — an undeclared var is a typo, refuse it.
129
+ for (const tmpl of [input.title ?? "", input.message]) {
130
+ for (const v of templateVars(tmpl)) {
131
+ if (v !== "actor" && !paramNames.has(v)) {
132
+ throw new Error(
133
+ `${where}: template references '{${v}}' but no such param is declared (known: actor${
134
+ paramNames.size > 0 ? ", " + [...paramNames].join(", ") : ""
135
+ }). Declare it in params or fix the template.`,
136
+ );
137
+ }
138
+ }
139
+ }
140
+ const refs = Object.entries(input.refs ?? {}).map(([name, r]) => {
141
+ const targetId = (r?.type as { id?: unknown })?.id;
142
+ const isTarget = typeof targetId === "string" && ((r.type as { __isAggregateHandle?: unknown }).__isAggregateHandle === true || isContractHandle(r.type));
143
+ if (!isTarget) {
144
+ throw new Error(`${where}: ref '${name}'.type must be an aggregate handle or a contract(...) (got ${typeof r?.type}).`);
145
+ }
146
+ if (typeof r.from !== "string" || r.from.length === 0) {
147
+ throw new Error(`${where}: ref '${name}'.from must be a payload field path string.`);
148
+ }
149
+ return { name, type: targetId as string, from: r.from };
150
+ });
151
+ // Best-effort payload-field existence check (root of a dotted path).
152
+ const fields = payloadFieldNames(payloadSchema);
153
+ if (fields !== undefined) {
154
+ for (const { name, from } of [...params, ...refs.map((r) => ({ name: `ref ${r.name}`, from: r.from }))]) {
155
+ const root = from.split(".")[0]!;
156
+ if (!fields.has(root)) {
157
+ throw new Error(
158
+ `${where}: ${name} reads payload field '${root}' which the payload does not declare ` +
159
+ `(known: ${[...fields].sort().join(", ")}). Fix the path or add the field.`,
160
+ );
161
+ }
162
+ }
163
+ }
164
+ return {
165
+ kind: input.kind,
166
+ title: input.title ?? input.kind,
167
+ message: input.message,
168
+ ...(input.category !== undefined ? { category: input.category } : {}),
169
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
170
+ severity,
171
+ refs: refs.sort((a, b) => a.name.localeCompare(b.name)),
172
+ params: params.sort((a, b) => a.name.localeCompare(b.name)),
173
+ titleKey: activityKey(input.kind, "title"),
174
+ messageKey: activityKey(input.kind, "message"),
175
+ };
176
+ }
177
+
178
+ /** Re-exported so directive.ts can accept the zod payload type in the `.activity` overload without a cycle. */
179
+ export type { z };
package/src/aggregate.ts CHANGED
@@ -14,6 +14,7 @@
14
14
  * string: a typo'd handle is a compile error.
15
15
  */
16
16
  import type { Field } from "./fields.js";
17
+ import { satisfiesContract, type ContractHandle } from "./contract.js";
17
18
 
18
19
  /**
19
20
  * A pure, replay-stable predicate over one aggregate's PROJECTED snapshot —
@@ -155,6 +156,10 @@ export interface AggregateHandle<
155
156
  readonly hasMany: HasManyFields<F>;
156
157
  /** Brand so a plain string is never accepted where a handle is required. */
157
158
  readonly __isAggregateHandle: true;
159
+ /** DOMAIN CONTRACTS this aggregate `.implements(...)` — the stable public shapes it satisfies (compile-
160
+ * checked). Codegen marks the aggregate as implementing them so cross-domain refs can target the CONTRACT
161
+ * instead of this concrete type. Additive metadata (not hashed law). OMITTED when it implements none. */
162
+ readonly implementsContracts?: readonly string[];
158
163
  /**
159
164
  * FRAMEWORK-SHARED brand (Dart-codegen hygiene). Set (by `framework/framework_aggregates.ts`)
160
165
  * ONLY on the framework-owned aggregate singletons declared in `dsl/src/framework/` — most
@@ -235,6 +240,9 @@ export interface AudienceMethods<Id extends string, F extends Record<string, Fie
235
240
  /** The owner (+ per-record grantees) read; owner = the verified author, stamped into `ownerField`
236
241
  * (default `__owner`); operator-blind. NOTE: full owned read-scoping + E2E land in the next slice. */
237
242
  owned(ownerField?: string): AggregateHandle<Id, F>;
243
+ /** DOMAIN CONTRACTS: assert this aggregate satisfies `contract`'s public shape (compile-checked — a
244
+ * mismatch throws naming the field). Chainable; records the contract so cross-domain refs can target it. */
245
+ implements(contract: ContractHandle): AggregateHandle<Id, F> & AudienceMethods<Id, F>;
238
246
  }
239
247
 
240
248
  export function aggregate<const Id extends string, F extends Record<string, Field>>(
@@ -354,16 +362,26 @@ export function aggregate<const Id extends string, F extends Record<string, Fiel
354
362
  const build = (
355
363
  audience: Audience | undefined,
356
364
  visibility: ReadVisibility | undefined,
365
+ contracts: readonly string[] = [],
357
366
  ): AggregateHandle<Id, F> & AudienceMethods<Id, F> => ({
358
367
  ...base,
359
368
  ...(audience !== undefined ? { audience } : {}),
360
369
  ...(visibility !== undefined ? { visibility } : {}),
361
- public: () => build({ kind: "public" }, undefined),
370
+ ...(contracts.length > 0 ? { implementsContracts: contracts } : {}),
371
+ public: () => build({ kind: "public" }, undefined, contracts),
362
372
  readableBy: (cap: string) => {
363
373
  const v = requires(cap); // validates the capability name
364
- return build({ kind: "readableBy", cap: v.requires }, v);
374
+ return build({ kind: "readableBy", cap: v.requires }, v, contracts);
375
+ },
376
+ owned: (ownerField: string = OWNER_FIELD) => build({ kind: "owned", ownerField }, undefined, contracts),
377
+ // DOMAIN CONTRACTS: assert this aggregate satisfies `contractHandle`'s public shape — compile-checked
378
+ // NOW (a mismatch throws a typed error naming the field). Records the contract so cross-domain refs may
379
+ // target it. Chainable (`.implements(A).implements(B)`); the aggregate keeps its concrete fields.
380
+ implements: (contractHandle: ContractHandle) => {
381
+ const verdict = satisfiesContract(id, stored as Record<string, Field>, contractHandle);
382
+ if (!verdict.ok) throw new Error(verdict.reason);
383
+ return build(audience, visibility, contracts.includes(contractHandle.id) ? contracts : [...contracts, contractHandle.id]);
365
384
  },
366
- owned: (ownerField: string = OWNER_FIELD) => build({ kind: "owned", ownerField }, undefined),
367
385
  });
368
386
 
369
387
  // Legacy `{ visibility: requires(cap) }` IS `readableBy(cap)` — carry both so existing law is unchanged.
@@ -99,6 +99,17 @@ export function aggregatesOf(mod: Mod): AggregateHandle[] {
99
99
  return out;
100
100
  }
101
101
 
102
+ /** Collect every exported `contract(...)` handle (`__isContract` duck-type). */
103
+ export function contractsOf(mod: Mod): import("./contract.js").ContractHandle[] {
104
+ const out: import("./contract.js").ContractHandle[] = [];
105
+ for (const v of Object.values(mod)) {
106
+ if (v && typeof v === "object" && (v as { __isContract?: boolean }).__isContract === true) {
107
+ out.push(v as import("./contract.js").ContractHandle);
108
+ }
109
+ }
110
+ return out;
111
+ }
112
+
102
113
  /** Collect every exported `Directive` ({id, plan, aggregateId} duck-type). */
103
114
  export function directivesOf(mod: Mod): Directive<any>[] {
104
115
  const out: Directive<any>[] = [];
@@ -262,6 +273,12 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
262
273
  // the additive `nomosValueObjects` USD key. Omit-when-empty (era rule): a VO-free domain's
263
274
  // module — and thus its USD/package bytes — is byte-identical to before VOs existed.
264
275
  const valueObjects = collectValueObjectDescriptors(aggregates);
276
+ // DOMAIN CONTRACTS (domain_contracts.md): the public DTO shapes this domain declares, discovered by
277
+ // shape across the merged module list. Deduped by id (later-wins), sorted for byte-determinism. Omit-
278
+ // when-empty (era rule): a contract-free domain's module/USD/package bytes are byte-identical.
279
+ const contractById = new Map<string, import("./contract.js").ContractHandle>();
280
+ for (const c of contractsOf(merged)) contractById.set(c.id, c);
281
+ const contracts = [...contractById.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
265
282
  // SPATIAL × VO PAIRING (fail at build, not at fold): a spatial index over a VO-typed field
266
283
  // must index a GEO value object (an RFC 7946 geometry — the R*Tree's bbox source). A
267
284
  // structural VO (e.g. a PostalAddress) has no geometry to index. Non-VO fields (a
@@ -394,6 +411,9 @@ export function composeDomainModule(spec: DomainModuleSpec): DomainModule {
394
411
  // Omit-when-empty — the `nomosValueObjects` era rule: a VO-free domain's USD/package
395
412
  // bytes are byte-identical to before value objects existed.
396
413
  ...(valueObjects.length > 0 ? { valueObjects } : {}),
414
+ // Omit-when-empty — the `nomosContracts` era rule: a contract-free domain's USD/package bytes
415
+ // are byte-identical to before domain contracts existed (pure additive typing metadata).
416
+ ...(contracts.length > 0 ? { contracts } : {}),
397
417
  // THE PEER-SOVEREIGN ROOT-OF-TRUST: omit-when-false — a law that doesn't require parent-attested
398
418
  // birth is byte-identical in the canonical manifest/USD (the nomosParentAttestedBirth era rule).
399
419
  ...(parentAttestedBirth ? { parentAttestedBirth } : {}),