@githolon/dsl 0.66.0 → 0.68.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.
@@ -0,0 +1,197 @@
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"`), OR the
31
+ * sentinel `"@created"` — the id the LAW MINTED for this intent's created aggregate of `type` (read from
32
+ * the sealed Create events at render time; the fix for "a .creates payload carries no id"). */
33
+ readonly from: string;
34
+ }
35
+
36
+ /** The `.activity({...})` input (declarative — field PATHS, never functions, so it generates to Dart too). */
37
+ export interface ActivityInput {
38
+ /** STABLE event id (e.g. `"site.created"`) — the wire kind + the seed for the i18n keys. */
39
+ readonly kind: string;
40
+ /** Default (English) title; falls back to `kind` if omitted. */
41
+ readonly title?: string;
42
+ /** Message template — `{actor}` + any declared `params` name in braces (e.g. `"{actor} created {siteName}"`). */
43
+ readonly message: string;
44
+ /** Optional grouping category (e.g. `"estate"`). */
45
+ readonly category?: string;
46
+ /** Optional default icon token (app-interpreted). */
47
+ readonly icon?: string;
48
+ /** Row prominence (default `"info"`). */
49
+ readonly severity?: ActivitySeverity;
50
+ /** Navigation targets: name → { target type, payload path to its id }. */
51
+ readonly refs?: Record<string, ActivityRefInput>;
52
+ /** Display values: template-param name → payload field path (dotted ok). */
53
+ readonly params?: Record<string, string>;
54
+ }
55
+
56
+ /** The NORMALIZED, serializable descriptor stored on the directive + emitted to codegen/IR. */
57
+ export interface ActivityDescriptor {
58
+ readonly kind: string;
59
+ readonly title: string;
60
+ readonly message: string;
61
+ readonly category?: string;
62
+ readonly icon?: string;
63
+ readonly severity: ActivitySeverity;
64
+ /** Refs sorted by name; `type` is the target's wire id (a string). */
65
+ readonly refs: readonly { readonly name: string; readonly type: string; readonly from: string }[];
66
+ /** Params sorted by name. */
67
+ readonly params: readonly { readonly name: string; readonly from: string }[];
68
+ /** Stable i18n keys derived from `kind` (`activity.<kind>.title` / `.message`). */
69
+ readonly titleKey: string;
70
+ readonly messageKey: string;
71
+ }
72
+
73
+ const SEVERITIES: ReadonlySet<string> = new Set(["info", "success", "warning", "critical"]);
74
+
75
+ /** The i18n key for a `kind` + slot — stable, derived (never hand-authored). */
76
+ export function activityKey(kind: string, slot: "title" | "message"): string {
77
+ return `activity.${kind}.${slot}`;
78
+ }
79
+
80
+ /** Interpolate `{name}` placeholders in a template from `vars` (a missing var renders as the empty string —
81
+ * the compile-time validator already guarantees every placeholder is declared, so this is defensive only).
82
+ * The ONE render helper shared by the TS + Dart renderers (each reimplements the same trivial substitution). */
83
+ export function renderActivityTemplate(template: string, vars: Record<string, string>): string {
84
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_m, name: string) => vars[name] ?? "");
85
+ }
86
+
87
+ /** The `{placeholder}` names referenced by a template. */
88
+ function templateVars(template: string): string[] {
89
+ const out: string[] = [];
90
+ for (const m of template.matchAll(/\{([a-zA-Z0-9_]+)\}/g)) out.push(m[1]!);
91
+ return out;
92
+ }
93
+
94
+ /** Top-level payload field names (best-effort — a plain `z.object`); `undefined` when the schema isn't a
95
+ * destructurable object (validation of `from` roots is then skipped, staying permissive). */
96
+ function payloadFieldNames(schema: unknown): Set<string> | undefined {
97
+ const def = (schema as { _def?: { shape?: unknown }; def?: { shape?: unknown } })?._def
98
+ ?? (schema as { def?: { shape?: unknown } })?.def;
99
+ const shape = (def as { shape?: unknown })?.shape;
100
+ const resolved = typeof shape === "function" ? (shape as () => Record<string, unknown>)() : shape;
101
+ if (resolved === undefined || resolved === null || typeof resolved !== "object") return undefined;
102
+ return new Set(Object.keys(resolved as Record<string, unknown>));
103
+ }
104
+
105
+ /**
106
+ * Validate + normalize an `.activity({...})` input at author time (throws a typed error naming the problem).
107
+ * Checks: `kind`/`message` non-empty; `severity` valid; every message/title `{placeholder}` is `actor` or a
108
+ * declared param; every ref/param `from` ROOT names a real payload field (when the payload shape is
109
+ * introspectable). Returns the serializable {@link ActivityDescriptor}. Pure.
110
+ */
111
+ export function normalizeActivity(
112
+ directiveId: string,
113
+ input: ActivityInput,
114
+ payloadSchema?: unknown,
115
+ createdAggregateIds?: readonly string[],
116
+ ): ActivityDescriptor {
117
+ const where = `directive '${directiveId}' .activity(...)`;
118
+ if (typeof input.kind !== "string" || input.kind.trim().length === 0) {
119
+ throw new Error(`${where}: 'kind' must be a non-empty stable string (e.g. "site.created").`);
120
+ }
121
+ if (typeof input.message !== "string" || input.message.trim().length === 0) {
122
+ throw new Error(`${where}: 'message' must be a non-empty template string.`);
123
+ }
124
+ const severity = input.severity ?? "info";
125
+ if (!SEVERITIES.has(severity)) {
126
+ throw new Error(`${where}: 'severity' must be one of info|success|warning|critical (got '${severity}').`);
127
+ }
128
+ const params = Object.entries(input.params ?? {}).map(([name, from]) => {
129
+ if (typeof from !== "string" || from.length === 0) {
130
+ throw new Error(`${where}: param '${name}' must map to a payload field path string.`);
131
+ }
132
+ return { name, from };
133
+ });
134
+ const paramNames = new Set(params.map((p) => p.name));
135
+ // Every {placeholder} must be `actor` or a declared param — an undeclared var is a typo, refuse it.
136
+ for (const tmpl of [input.title ?? "", input.message]) {
137
+ for (const v of templateVars(tmpl)) {
138
+ if (v !== "actor" && !paramNames.has(v)) {
139
+ throw new Error(
140
+ `${where}: template references '{${v}}' but no such param is declared (known: actor${
141
+ paramNames.size > 0 ? ", " + [...paramNames].join(", ") : ""
142
+ }). Declare it in params or fix the template.`,
143
+ );
144
+ }
145
+ }
146
+ }
147
+ const refs = Object.entries(input.refs ?? {}).map(([name, r]) => {
148
+ const targetId = (r?.type as { id?: unknown })?.id;
149
+ const isTarget = typeof targetId === "string" && ((r.type as { __isAggregateHandle?: unknown }).__isAggregateHandle === true || isContractHandle(r.type));
150
+ if (!isTarget) {
151
+ throw new Error(`${where}: ref '${name}'.type must be an aggregate handle or a contract(...) (got ${typeof r?.type}).`);
152
+ }
153
+ if (typeof r.from !== "string" || r.from.length === 0) {
154
+ throw new Error(`${where}: ref '${name}'.from must be a payload field path string.`);
155
+ }
156
+ return { name, type: targetId as string, from: r.from };
157
+ });
158
+ // "@created" refs: the id comes from the intent's sealed Create events, not the payload — so the
159
+ // directive must actually CREATE that aggregate type (when the creates-set is known, enforce it).
160
+ for (const r of refs) {
161
+ if (r.from === "@created" && createdAggregateIds !== undefined && !createdAggregateIds.includes(r.type)) {
162
+ throw new Error(
163
+ `${where}: ref '${r.name}' uses "@created" but this directive does not .creates ${r.type} ` +
164
+ `(creates: ${createdAggregateIds.length > 0 ? createdAggregateIds.join(", ") : "nothing"}). ` +
165
+ `"@created" binds to the law-minted id of a created aggregate.`,
166
+ );
167
+ }
168
+ }
169
+ // Best-effort payload-field existence check (root of a dotted path). "@created" is not a payload path.
170
+ const fields = payloadFieldNames(payloadSchema);
171
+ if (fields !== undefined) {
172
+ for (const { name, from } of [...params, ...refs.filter((r) => r.from !== "@created").map((r) => ({ name: `ref ${r.name}`, from: r.from }))]) {
173
+ const root = from.split(".")[0]!;
174
+ if (!fields.has(root)) {
175
+ throw new Error(
176
+ `${where}: ${name} reads payload field '${root}' which the payload does not declare ` +
177
+ `(known: ${[...fields].sort().join(", ")}). Fix the path or add the field.`,
178
+ );
179
+ }
180
+ }
181
+ }
182
+ return {
183
+ kind: input.kind,
184
+ title: input.title ?? input.kind,
185
+ message: input.message,
186
+ ...(input.category !== undefined ? { category: input.category } : {}),
187
+ ...(input.icon !== undefined ? { icon: input.icon } : {}),
188
+ severity,
189
+ refs: refs.sort((a, b) => a.name.localeCompare(b.name)),
190
+ params: params.sort((a, b) => a.name.localeCompare(b.name)),
191
+ titleKey: activityKey(input.kind, "title"),
192
+ messageKey: activityKey(input.kind, "message"),
193
+ };
194
+ }
195
+
196
+ /** Re-exported so directive.ts can accept the zod payload type in the `.activity` overload without a cycle. */
197
+ export type { z };
@@ -1197,6 +1197,10 @@ function emitAggregate(
1197
1197
  ` /// indexed/by-id accessor matched on (e.g. the siteId). Empty string only when`,
1198
1198
  ` /// the aggregate is built from a bare \`data\` map with no row id (e.g. a unit test).`,
1199
1199
  ` final ${idType} aggregateId;`,
1200
+ ` /// THE ONE REFERENCE (architecture/nomos_ref.md): this instance's canonical, workspace-qualified`,
1201
+ ` /// [NomosRef] (\`nomos://<workspace>/<type>/<id>\`) — composed at decode from the session workspace +`,
1202
+ ` /// the row's type/id. Workspace is '' when decoded off the bare bridge (the client accessors fill it).`,
1203
+ ` final NomosRef ref;`,
1200
1204
  ...specs.flatMap((s) => {
1201
1205
  const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
1202
1206
  // NULL SEMANTICS: a null-admitting field is the sealed tri-state slot (Omitted / ExplicitNull /
@@ -1208,6 +1212,7 @@ function emitAggregate(
1208
1212
  ].join("\n");
1209
1213
  const ctorParams = [
1210
1214
  ` this.aggregateId = const ${idType}.fromRow(''),`,
1215
+ ` this.ref = const NomosRef(workspace: '', type: '${agg.id}', id: ''),`,
1211
1216
  ...specs.map(
1212
1217
  // A required scalar stays `required`; an optional scalar OR a collection is not
1213
1218
  // `required` (an absent optional scalar is null; an absent collection is empty).
@@ -1229,6 +1234,7 @@ function emitAggregate(
1229
1234
  // threaded from the row id (GAP 2) — defaulting to '' for a bare-map call.
1230
1235
  const parseLines = [
1231
1236
  ` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
1237
+ ` ref: NomosRef(workspace: workspace, type: type.isEmpty ? '${agg.id}' : type, id: id),`,
1232
1238
  ...specs.map((s) =>
1233
1239
  s.nullAdmits === true
1234
1240
  ? ` ${s.name}: NomosSlot.decode<${stripNullable(s.dartType)}>(data, '${s.wireName}', ${slotDecodeCallback(s, "aggregate")}),`
@@ -1281,7 +1287,10 @@ function emitAggregate(
1281
1287
  ` /// decoded to bare JSON), carrying the kernel aggregate [id] from the row \`id\``,
1282
1288
  ` /// (defaults to '' for a bare-map call). PARTIAL folds: every scalar decodes`,
1283
1289
  ` /// \`null\` when absent (never throws); absent collections decode empty.`,
1284
- ` factory ${className}.fromJson(Map<String, dynamic> data, [String id = '']) {`,
1290
+ ` ///`,
1291
+ ` /// [workspace] + [type] compose the canonical [ref] (both optional — the client accessors thread the`,
1292
+ ` /// session workspace + the row type; a bare decode leaves workspace '' and falls back to the type '${agg.id}').`,
1293
+ ` factory ${className}.fromJson(Map<String, dynamic> data, [String id = '', String workspace = '', String type = '']) {`,
1285
1294
  ` return ${className}(`,
1286
1295
  parseLines,
1287
1296
  ` );`,
@@ -2002,6 +2011,154 @@ export function generateDartContracts(contracts: readonly import("./contract.js"
2002
2011
  ].join("\n");
2003
2012
  }
2004
2013
 
2014
+ // ── ACTIVITY DESCRIPTORS (architecture/activity_descriptors.md) — shared registry codegen ──
2015
+
2016
+ /** One directive's declared activity, tagged with its provenance (domain + directive id). */
2017
+ export interface PackageActivityEntry {
2018
+ readonly domain: string;
2019
+ readonly directiveId: string;
2020
+ readonly desc: import("./activity.js").ActivityDescriptor;
2021
+ }
2022
+
2023
+ /** Collect every directive's `.activity(...)` descriptor across the package's modules, tagged with its
2024
+ * domain + directive id, sorted by directive id (byte-deterministic switch/registry order). Empty when
2025
+ * no directive opts in — an activity-free package then emits NO registry (byte-identical). Shared by the
2026
+ * flat + layered Dart emit AND the TS client (one source of truth). */
2027
+ export function collectActivityDescriptors(modules: readonly DomainModule[]): PackageActivityEntry[] {
2028
+ const out: PackageActivityEntry[] = [];
2029
+ for (const mod of modules) {
2030
+ const domain = mod.domain ?? mod.name;
2031
+ for (const d of mod.directives as AnyDirective[]) {
2032
+ const desc = (d as { activityDescriptor?: import("./activity.js").ActivityDescriptor }).activityDescriptor;
2033
+ if (desc !== undefined) out.push({ domain, directiveId: d.id, desc });
2034
+ }
2035
+ }
2036
+ return out.sort((a, b) => (a.directiveId < b.directiveId ? -1 : a.directiveId > b.directiveId ? 1 : 0));
2037
+ }
2038
+
2039
+ /** Dart string literal (single-quoted, backslash/quote/`$` escaped). */
2040
+ const dartLit = (s: string): string => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\$/g, "\\$")}'`;
2041
+
2042
+ /** Emit ONE `case '<directiveId>':` arm folding a raw row into a typed [ActivityEvent]. */
2043
+ function emitDartActivityArm(e: PackageActivityEntry): string[] {
2044
+ const { directiveId, desc } = e;
2045
+ const paramEntries = desc.params.map((p) => ` ${dartLit(p.name)}: _activityPayloadPath(payload, ${dartLit(p.from)}),`);
2046
+ const refEntries = desc.refs.map((r) => {
2047
+ // A `from: "@created"` ref reads the id the LAW MINTED (the row's `created` list, first type match); any
2048
+ // other `from` is a payload path. Every ref carries the session workspace (the row's `workspace`).
2049
+ const id = r.from === "@created"
2050
+ ? `_activityCreatedId(created, ${dartLit(r.type)})`
2051
+ : `_activityPayloadPath(payload, ${dartLit(r.from)})`;
2052
+ return ` ${dartLit(r.name)}: ActivityRef(workspace: workspace, type: ${dartLit(r.type)}, id: ${id}),`;
2053
+ });
2054
+ return [
2055
+ ` case ${dartLit(directiveId)}:`,
2056
+ ` {`,
2057
+ ` final params = <String, String>{`,
2058
+ ...paramEntries,
2059
+ ` };`,
2060
+ ` final vars = <String, String>{'actor': actorSubject, ...params};`,
2061
+ ` return ActivityEvent(`,
2062
+ ` eventId: (rawRow['intentId'] ?? '') as String,`,
2063
+ ` kind: ${dartLit(desc.kind)},`,
2064
+ ` actorSubject: actorSubject,`,
2065
+ ` occurredAt: (rawRow['occurredAt'] ?? '') as String,`,
2066
+ ` title: renderActivityTemplate(${dartLit(desc.title)}, vars),`,
2067
+ ` message: renderActivityTemplate(${dartLit(desc.message)}, vars),`,
2068
+ ` titleKey: ${dartLit(desc.titleKey)},`,
2069
+ ` messageKey: ${dartLit(desc.messageKey)},`,
2070
+ ` params: params,`,
2071
+ ` refs: <String, ActivityRef>{`,
2072
+ ...refEntries,
2073
+ ` },`,
2074
+ ...(desc.category !== undefined ? [` category: ${dartLit(desc.category)},`] : []),
2075
+ ...(desc.icon !== undefined ? [` icon: ${dartLit(desc.icon)},`] : []),
2076
+ ` severity: ActivitySeverity.${desc.severity},`,
2077
+ ` domain: (rawRow['domain'] ?? '') as String,`,
2078
+ ` directiveId: ${dartLit(directiveId)},`,
2079
+ ` );`,
2080
+ ` }`,
2081
+ ];
2082
+ }
2083
+
2084
+ /**
2085
+ * The full source of the shared `activity_registry.dart` file — the PURE per-directive activity registry
2086
+ * + `renderActivity(rawRow) => ActivityEvent?`, emitted ONCE per package into the TYPES package (both the
2087
+ * flat + layered emit). References only nomos_types (`ActivityEvent`/`ActivityRef`/`ActivitySeverity`/
2088
+ * `renderActivityTemplate`), so the file is PURE (frontend-safe, no bridge). A raw row is
2089
+ * `{ intentId, domain, directiveId, actorSubject, occurredAt, payload }` (the runtime's per-committed-intent
2090
+ * shape); `renderActivity` returns null for a directive with no declared `.activity(...)`.
2091
+ */
2092
+ export function generateDartActivityRegistry(entries: readonly PackageActivityEntry[]): string {
2093
+ const sorted = [...entries].sort((a, b) => (a.directiveId < b.directiveId ? -1 : a.directiveId > b.directiveId ? 1 : 0));
2094
+ return [
2095
+ `// GENERATED by nomos-compile — the package's shared ACTIVITY REGISTRY (architecture/activity_descriptors.md). DO NOT EDIT.`,
2096
+ `// A directive OPT-IN declares its user-facing activity metadata; \`renderActivity\` folds each committed-intent`,
2097
+ `// raw row into a typed ActivityEvent. Pure (nomos_types only) — the kernel/ledger stays the audit truth.`,
2098
+ `//`,
2099
+ `// ignore_for_file: unused_import`,
2100
+ `library;`,
2101
+ ``,
2102
+ `import 'package:nomos_client/nomos_client.dart';`,
2103
+ ``,
2104
+ `/// A NARRATIVE over the activity log (for an in-app LLM): the filtered [events] + a plain-English`,
2105
+ `/// [transcript] (ONE LINE PER EVENT, oldest-first, \`"<occurredAt> — <title>: <message>"\`) grounded in`,
2106
+ `/// the VERIFIED log, never raw payloads. Produced by the client's \`describe\` / \`describeHistory\`.`,
2107
+ `class ActivityNarrative {`,
2108
+ ` final List<ActivityEvent> events;`,
2109
+ ` final String transcript;`,
2110
+ ` const ActivityNarrative({required this.events, required this.transcript});`,
2111
+ `}`,
2112
+ ``,
2113
+ `/// Wrap events into an [ActivityNarrative], building the LLM-ready transcript: one line per event,`,
2114
+ `/// OLDEST-FIRST (the log yields newest-first).`,
2115
+ `ActivityNarrative activityNarrative(List<ActivityEvent> events) => ActivityNarrative(`,
2116
+ ` events: events,`,
2117
+ ` transcript: events.reversed.map((e) => '\${e.occurredAt} — \${e.title}: \${e.message}').join('\\n'),`,
2118
+ ` );`,
2119
+ ``,
2120
+ `/// Read a dotted path off a decoded intent payload, coerced to String ('' when absent/null).`,
2121
+ `String _activityPayloadPath(Map<String, dynamic> payload, String path) {`,
2122
+ ` dynamic cur = payload;`,
2123
+ ` for (final seg in path.split('.')) {`,
2124
+ ` if (cur is! Map) return '';`,
2125
+ ` cur = cur[seg];`,
2126
+ ` }`,
2127
+ ` return cur == null ? '' : '\$cur';`,
2128
+ `}`,
2129
+ ``,
2130
+ `/// The id the LAW minted for a \`from: "@created"\` ref — the first entry of the row's \`created\` list`,
2131
+ `/// whose type matches the declared aggregate (the runtime type is uuid-stripped + may be namespaced, so`,
2132
+ `/// match on equality OR \`<domain>.<Type>\` suffix). '' when the holon returned no created list (era-safe).`,
2133
+ `String _activityCreatedId(List<Map<String, dynamic>> created, String declared) {`,
2134
+ ` for (final c in created) {`,
2135
+ ` final t = (c['type'] ?? '') as String;`,
2136
+ ` if (t == declared || t.endsWith('.\$declared')) return (c['id'] ?? '') as String;`,
2137
+ ` }`,
2138
+ ` return '';`,
2139
+ `}`,
2140
+ ``,
2141
+ `/// Fold ONE committed-intent raw row into a typed [ActivityEvent], or null when the directive declares`,
2142
+ `/// no \`.activity(...)\`. rawRow = { intentId, domain, directiveId, actorSubject, occurredAt, payload,`,
2143
+ `/// created, workspace } — \`created\` (\`[{type,id}]\`) backs \`from: "@created"\` refs; \`workspace\` stamps them.`,
2144
+ `ActivityEvent? renderActivity(Map<String, dynamic> rawRow) {`,
2145
+ ` final directiveId = (rawRow['directiveId'] ?? '') as String;`,
2146
+ ` final actorSubject = (rawRow['actorSubject'] ?? '') as String;`,
2147
+ ` final workspace = (rawRow['workspace'] ?? '') as String;`,
2148
+ ` final payload = ((rawRow['payload'] as Map?)?.cast<String, dynamic>()) ?? const <String, dynamic>{};`,
2149
+ ` final created = ((rawRow['created'] as List?) ?? const <dynamic>[])`,
2150
+ ` .map((e) => (e as Map).cast<String, dynamic>())`,
2151
+ ` .toList();`,
2152
+ ` switch (directiveId) {`,
2153
+ ...sorted.flatMap(emitDartActivityArm),
2154
+ ` default:`,
2155
+ ` return null;`,
2156
+ ` }`,
2157
+ `}`,
2158
+ ``,
2159
+ ].join("\n");
2160
+ }
2161
+
2005
2162
  /** True when ANY aggregate field in the module is `t.evidence()` — gates emitting the
2006
2163
  * shared [emitEvidenceRefClass] once. */
2007
2164
  function moduleUsesEvidence(aggregates: readonly AggregateHandle[]): boolean {
@@ -2397,6 +2554,10 @@ function emitReadModel(
2397
2554
  ` /// The kernel aggregate id (the \`{type,id,data}\` row's \`id\`, e.g. the siteId).`,
2398
2555
  ` /// Empty string only when built from a bare \`data\` map with no row id (a unit test).`,
2399
2556
  ` final ${idType} aggregateId;`,
2557
+ ` /// THE ONE REFERENCE (architecture/nomos_ref.md): this row's canonical, workspace-qualified [NomosRef]`,
2558
+ ` /// (\`nomos://<workspace>/<type>/<id>\`) — composed at decode from the session workspace + the row's`,
2559
+ ` /// type/id. The generated client accessors thread the session workspace; a bare decode leaves it ''.`,
2560
+ ` final NomosRef ref;`,
2400
2561
  ...specs.flatMap((s) => {
2401
2562
  const doc = s.nullReason !== undefined ? [` /// null semantic: ${s.nullReason}`] : [];
2402
2563
  return s.nullAdmits === true
@@ -2414,6 +2575,7 @@ function emitReadModel(
2414
2575
  ].join("\n");
2415
2576
  const ctorParams = [
2416
2577
  ` this.aggregateId = const ${idType}.fromRow(''),`,
2578
+ ` this.ref = const NomosRef(workspace: '', type: '${agg.id}', id: ''),`,
2417
2579
  ...specs.map((s) =>
2418
2580
  // A required field is `required`; an optional scalar, a collection, OR a
2419
2581
  // permissive json leaf (already nullable `Object?`) takes its default/null.
@@ -2431,6 +2593,7 @@ function emitReadModel(
2431
2593
 
2432
2594
  const parseLines = [
2433
2595
  ` aggregateId: ${aggregateIdFromRowExpr(agg.id, "id")},`,
2596
+ ` ref: NomosRef(workspace: workspace, type: type.isEmpty ? '${agg.id}' : type, id: id),`,
2434
2597
  ...specs.map((s) =>
2435
2598
  s.nullAdmits === true
2436
2599
  ? ` ${s.name}: NomosSlot.decode<${stripNullable(readModelDartType(s))}>(data, '${s.wireName}', ${slotDecodeCallback(s, "readModel")}),`
@@ -2464,7 +2627,10 @@ function emitReadModel(
2464
2627
  ` /// already decoded to bare/structured JSON), carrying the kernel aggregate [id] from`,
2465
2628
  ` /// the row \`id\` (defaults to '' for a bare-map call). PARTIAL folds: every scalar`,
2466
2629
  ` /// decodes \`null\` when absent (never throws); absent collections decode empty.`,
2467
- ` factory ${className}.fromProjected(Map<String, dynamic> data, [String id = '']) {`,
2630
+ ` ///`,
2631
+ ` /// [workspace] + [type] compose the canonical [ref] (both optional — the client accessors thread the`,
2632
+ ` /// session workspace + the row type; a bare decode leaves workspace '' and falls back to the type '${agg.id}').`,
2633
+ ` factory ${className}.fromProjected(Map<String, dynamic> data, [String id = '', String workspace = '', String type = '']) {`,
2468
2634
  ` return ${className}(`,
2469
2635
  parseLines,
2470
2636
  ` );`,