@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.
@@ -87,10 +87,11 @@ function dartKeyParam(key: string): string {
87
87
  return safeMethod(safe);
88
88
  }
89
89
 
90
- /** Parse ONE bridge row (`{type,id,data}`) into a typed read model. Works for a `Map` (one-shot
90
+ /** Parse ONE bridge row (`{type,id,data}`) into a typed read model, composing the canonical `.ref` from the
91
+ * session workspace (`_ws`, a getter on the client) + the row's type/id. Works for a `Map` (one-shot
91
92
  * `query`/`queryById` rows) or a `dynamic` (`watch` stream rows) — indexing resolves on both. */
92
93
  function rowToModel(model: string, rowVar: string): string {
93
- return `${model}.fromProjected((${rowVar}['data'] as Map).cast<String, dynamic>(), (${rowVar}['id'] ?? '').toString())`;
94
+ return `${model}.fromProjected((${rowVar}['data'] as Map).cast<String, dynamic>(), (${rowVar}['id'] ?? '').toString(), _ws, (${rowVar}['type'] ?? '').toString())`;
94
95
  }
95
96
 
96
97
  export interface DartBridgeClientOptions {
@@ -121,6 +122,19 @@ export interface DartBridgeClientOptions {
121
122
  * client re-exports that barrel so one import of the client still gives the whole typed surface.
122
123
  */
123
124
  readonly typesImportUri?: string;
125
+ /**
126
+ * ACTIVITY FEED (architecture/activity_descriptors.md): true iff the PACKAGE declares ≥1 directive
127
+ * `.activity(...)`. When set the client emits `watchActivity()` / `activityLog()` (folding the bridge's
128
+ * raw committed-intent rows through the shared pure `renderActivity`). Off ⇒ byte-identical to before.
129
+ */
130
+ readonly emitActivity?: boolean;
131
+ /**
132
+ * FLAT (vendored) mode only: the relative import of the shared `activity_registry.dart` (where the pure
133
+ * `renderActivity` lives), needed because a flat client imports only its sibling `<name>.dart`. In layered
134
+ * mode the registry resolves through {@link typesImportUri} (the types barrel re-exports it), so this is
135
+ * omitted. Only meaningful when {@link emitActivity} is set.
136
+ */
137
+ readonly activityImportUri?: string;
124
138
  }
125
139
 
126
140
  /** Generate the `<name>.client.dart` async bridge client for one domain module. */
@@ -186,16 +200,19 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
186
200
  // E2E SEALING (the sealing client): a directive with `.encrypted()` payload fields SEALS them via the
187
201
  // bound [keyStore] before dispatch (a missing key throws [EncryptionKeyUnavailable] — never a plaintext
188
202
  // write). One async block covers creates + mutate + gated + stamps; `seal: false` is the escape hatch.
203
+ // THE ONE WRITE OUTCOME (architecture/nomos_ref.md): EVERY directive method returns Future<NomosOfferOutcome>
204
+ // via the ONE lane `bridge.offerOutcome` — `outcome.created` carries every minted aggregate (each with its
205
+ // typed `.ref`), `outcome.head` the new local head. The breaking cut: creates AND non-creates alike.
206
+ const createsDoc = (d as { marker?: string }).marker === "creates"
207
+ ? ` The LAW mints the ${(d as { aggregateId?: string }).aggregateId ?? "aggregate"} id inside the sealed intent — it rides back on \`outcome.created\` (no id in the payload, no query-after).`
208
+ : "";
209
+ const stampsDoc = stamps.length > 0 ? `\n /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.` : "";
189
210
  if (encMap?.directives?.[d.id] !== undefined) {
190
- const isCreates = (d as { marker?: string }).marker === "creates";
191
- const retType = isCreates ? "NomosOfferOutcome" : "String";
192
- const offerFn = isCreates ? "offerCreates" : "offerDirective";
193
211
  methods.push(
194
- ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
195
- ` /// Encrypted field(s) are SEALED via the bound [keyStore] before dispatch (pass \`seal: false\` to skip).`,
196
- ...(stamps.length > 0 ? [` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`] : []),
212
+ ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the typed [NomosOfferOutcome].${createsDoc}`,
213
+ ` /// Encrypted field(s) are SEALED via the bound [keyStore] before dispatch (pass \`seal: false\` to skip).${stampsDoc}`,
197
214
  ...(gateDoc ? [gateDoc] : []),
198
- ` Future<${retType}> ${method}(${payloadClass} payload, {bool seal = true}) async {`,
215
+ ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload, {bool seal = true}) async {`,
199
216
  ...(gateGuard ? [gateGuard] : []),
200
217
  ` final data = payload.toPayloadJson();`,
201
218
  ...(stamps.length > 0
@@ -205,64 +222,42 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
205
222
  ]
206
223
  : []),
207
224
  ` await _sealPayload(data, ${dartStr(d.id)}, seal);`,
208
- ` return bridge.${offerFn}(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
225
+ ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
209
226
  ` }`,
210
227
  ``,
211
228
  );
212
229
  continue;
213
230
  }
214
- if ((d as { marker?: string }).marker === "creates") {
215
- // LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
216
- // carries none — callers never supply ids). The bridge's offerCreates hands the minted ids back with the
217
- // head, so the create method returns the typed id with NO query-after round-trip.
231
+ if (stamps.length > 0) {
218
232
  methods.push(
219
- ` /// Author \`${domain}/${d.id}\` — the LAW mints the ${(d as { aggregateId?: string }).aggregateId ?? "aggregate"} id inside the sealed`,
220
- ` /// intent; it returns here (\`outcome.created\`) with the new local head. No id in the payload, no query-after.`,
221
- ...(stamps.length > 0 ? [` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`] : []),
233
+ ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the typed [NomosOfferOutcome].${createsDoc}${stampsDoc}`,
222
234
  ...(gateDoc ? [gateDoc] : []),
223
235
  ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload) {`,
224
236
  ...(gateGuard ? [gateGuard] : []),
225
237
  ` final data = payload.toPayloadJson();`,
226
- ...(stamps.length > 0
227
- ? [
228
- ` final now = DateTime.now().toUtc().toIso8601String();`,
229
- ...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
230
- ]
231
- : []),
232
- ` return bridge.offerCreates(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
233
- ` }`,
234
- ``,
235
- );
236
- } else if (stamps.length > 0) {
237
- methods.push(
238
- ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
239
- ` /// Omitted ${stamps.map((s) => `\`${s}\``).join(" / ")} auto-stamp(s) ISO now() at offer.`,
240
- ...(gateDoc ? [gateDoc] : []),
241
- ` Future<String> ${method}(${payloadClass} payload) {`,
242
- ...(gateGuard ? [gateGuard] : []),
243
- ` final data = payload.toPayloadJson();`,
244
238
  ` final now = DateTime.now().toUtc().toIso8601String();`,
245
239
  ...stamps.map((s) => ` data.putIfAbsent(${dartStr(s)}, () => now);`),
246
- ` return bridge.offerDirective(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
240
+ ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: data, actor: actor, domainHash: domainHash);`,
247
241
  ` }`,
248
242
  ``,
249
243
  );
250
244
  } else if (gateGuard) {
251
245
  // A gated directive with no `…At` sugar still needs a block body for the throw guard.
252
246
  methods.push(
253
- ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
247
+ ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the typed [NomosOfferOutcome].${createsDoc}`,
254
248
  gateDoc,
255
- ` Future<String> ${method}(${payloadClass} payload) {`,
249
+ ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload) {`,
256
250
  gateGuard,
257
- ` return bridge.offerDirective(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
251
+ ` return bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
258
252
  ` }`,
259
253
  ``,
260
254
  );
261
255
  } else {
262
256
  methods.push(
263
- ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
264
- ` Future<String> ${method}(${payloadClass} payload) =>`,
265
- ` bridge.offerDirective(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
257
+ ` /// Author \`${domain}/${d.id}\` locally under the installed law; returns the typed [NomosOfferOutcome].${createsDoc}`,
258
+ ...(gateDoc ? [gateDoc] : []),
259
+ ` Future<NomosOfferOutcome> ${method}(${payloadClass} payload) =>`,
260
+ ` bridge.offerOutcome(domain: ${dartStr(domain)}, directiveId: ${dartStr(d.id)}, payload: payload.toPayloadJson(), actor: actor, domainHash: domainHash);`,
266
261
  ``,
267
262
  );
268
263
  }
@@ -345,6 +340,55 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
345
340
  );
346
341
  }
347
342
 
343
+ // ── activity feed → typed reactive + one-shot (declared `.activity(...)`) ──
344
+ if (opts.emitActivity === true) {
345
+ methods.push(
346
+ ` /// THE ACTIVITY FEED (architecture/activity_descriptors.md): fold the committed intent log into typed`,
347
+ ` /// [ActivityEvent]s (declared \`.activity(...)\`). Re-emits on every new committed intent; a directive`,
348
+ ` /// with no declared activity is dropped. Cancel the subscription to stop.`,
349
+ ` Stream<List<ActivityEvent>> watchActivity() =>`,
350
+ ` bridge.watchActivity().map((rows) => rows`,
351
+ ` .map((r) => renderActivity((r as Map).cast<String, dynamic>()))`,
352
+ ` .whereType<ActivityEvent>()`,
353
+ ` .toList());`,
354
+ ``,
355
+ ` /// One-shot: the current committed activity log, folded to typed [ActivityEvent]s (declared \`.activity(...)\`).`,
356
+ ` Future<List<ActivityEvent>> activityLog({String? base, int? limit}) async =>`,
357
+ ` (await bridge.activityLog(base: base, limit: limit))`,
358
+ ` .map((r) => renderActivity(r.cast<String, dynamic>()))`,
359
+ ` .whereType<ActivityEvent>()`,
360
+ ` .toList();`,
361
+ ``,
362
+ ` /// NARRATIVE (for an in-app LLM): the workspace story — the activity log folded to typed events + a`,
363
+ ` /// plain-English [ActivityNarrative.transcript] grounded in the VERIFIED log. Filter by [actor] /`,
364
+ ` /// [category] / [since] (ISO occurredAt lower bound); [limit] caps the rows pulled. Newest-first.`,
365
+ ` Future<ActivityNarrative> describe({String? since, String? actor, String? category, int? limit}) async {`,
366
+ ` final raw = await bridge.activityLog(limit: limit);`,
367
+ ` var events = raw`,
368
+ ` .map((r) => renderActivity(r.cast<String, dynamic>()))`,
369
+ ` .whereType<ActivityEvent>()`,
370
+ ` .toList();`,
371
+ ` if (actor != null) events = events.where((e) => e.actorSubject == actor).toList();`,
372
+ ` if (category != null) events = events.where((e) => e.category == category).toList();`,
373
+ ` if (since != null) events = events.where((e) => e.occurredAt.compareTo(since) >= 0).toList();`,
374
+ ` return activityNarrative(events);`,
375
+ ` }`,
376
+ ``,
377
+ ` /// NARRATIVE for ONE thing: the story of the aggregate [aggregateId] — events whose declared refs`,
378
+ ` /// point at it (only directives declaring a ref to the aggregate appear in its story) + a transcript.`,
379
+ ` Future<ActivityNarrative> describeHistory(String aggregateId, {int? limit}) async {`,
380
+ ` final raw = await bridge.activityLog(limit: limit);`,
381
+ ` final events = raw`,
382
+ ` .map((r) => renderActivity(r.cast<String, dynamic>()))`,
383
+ ` .whereType<ActivityEvent>()`,
384
+ ` .where((e) => e.refs.values.any((ref) => ref.id == aggregateId))`,
385
+ ` .toList();`,
386
+ ` return activityNarrative(events);`,
387
+ ` }`,
388
+ ``,
389
+ );
390
+ }
391
+
348
392
  return [
349
393
  `// AUTO-GENERATED by nomos-compile — typed ASYNC Dart client for domain "${domain}".`,
350
394
  `// Drives the REAL githolon over a NomosBridge (the WebView runner). Regenerated every compile; do`,
@@ -368,6 +412,9 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
368
412
  // Layered-packages mode: the sibling types live in their OWN package — one barrel import
369
413
  // replaces both the relative types import and the relative framework import.
370
414
  ...(opts.typesImportUri !== undefined ? [`import '${opts.typesImportUri}';`] : [`import '${mod.name}.dart';`]),
415
+ // ACTIVITY FEED (flat mode): the shared pure `renderActivity` lives in the sibling `activity_registry.dart`
416
+ // (in layered mode it resolves through the types-package barrel above). Only when the package opts in.
417
+ ...(opts.emitActivity === true && opts.activityImportUri !== undefined ? [`import '${opts.activityImportUri}';`] : []),
371
418
  // Framework-shared aggregate types (RelationTupleReadModel, …) live in the ONE shared file — import
372
419
  // them (the by-id accessors reference them) and re-export them (once per package, Dart de-dups).
373
420
  ...(opts.typesImportUri === undefined && opts.frameworkImportUri !== undefined ? [`import '${opts.frameworkImportUri}';`] : []),
@@ -375,7 +422,7 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
375
422
  `// client file gives an app the whole typed surface.`,
376
423
  ...(opts.typesImportUri !== undefined ? [`export '${opts.typesImportUri}';`] : [`export '${mod.name}.dart';`]),
377
424
  ...(opts.typesImportUri === undefined && opts.frameworkImportUri !== undefined ? [`export '${opts.frameworkImportUri}';`] : []),
378
- `export '${supportImport}' show NomosBridge, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion, NomosCompat, NomosCompatMember${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore, NomosEncryption, NomosEpochResult, EncryptionMemberKeyMissing, EncryptionRosterMismatch" : ""};`,
425
+ `export '${supportImport}' show NomosBridge, NomosSession, BridgeTransport, NomosBridgeError, MissingOfferAuthority, kWebviewBridgeVersion, NomosCompat, NomosCompatMember, NomosOfferOutcome, NomosCreated, NomosRef${encPresent ? ", NomosScopeKey, NomosKeyStore, EncryptionKeyUnavailable, WrappedKeyStore, NomosEncryption, NomosEpochResult, EncryptionMemberKeyMissing, EncryptionRosterMismatch" : ""};`,
379
426
  ``,
380
427
  `/// sha256 of the deployed \`.package.usda\` — the installed law this client offers under.`,
381
428
  `const String ${hashConst} = ${dartStr(opts.domainHash)};`,
@@ -425,6 +472,13 @@ export function generateDartBridgeClient(mod: DomainModule, opts: DartBridgeClie
425
472
  : []),
426
473
  ` ${className}(this.bridge, {this.domainHash = ${hashConst}, this.actor${encPresent ? ", this.keyStore" : ""}});`,
427
474
  ``,
475
+ ` /// The session workspace this client is bound to (from the [NomosSession] handle, when constructed with`,
476
+ ` /// one) — the \`workspace\` of every read row's canonical [NomosRef]. '' on a bare (non-session) bridge.`,
477
+ ` String get _ws {`,
478
+ ` final b = bridge;`,
479
+ ` return b is NomosSession ? b.workspace : '';`,
480
+ ` }`,
481
+ ``,
428
482
  ...(encPresent ? dartEncClassMembers(encMap!) : []),
429
483
  // THE OWNER CEREMONY (slice 2): grantAndWrap / revokeAndRotate / rotate — the operator-blind key-epoch
430
484
  // handshake as one call. Bind `actor` (the owner) to author the fan-out. Only for a domain with a scope.
package/src/codegen_ts.ts CHANGED
@@ -40,6 +40,7 @@ import type { AggregateHandle } from "./aggregate.js";
40
40
  import type { Field, ValueObjectDescriptor, ValueObjectFieldDescriptor } from "./fields.js";
41
41
  import type { Directive } from "./directive.js";
42
42
  import type { DomainModule } from "./codegen_dart.js";
43
+ import { collectActivityDescriptors, type PackageActivityEntry } from "./codegen_dart.js";
43
44
  import type { NomosCompat } from "./compat_manifest.js";
44
45
  import type { QueryDecl } from "./query.js";
45
46
  import { voMetaOf, collectPayloadValueObjectDescriptors } from "./value_object.js";
@@ -665,6 +666,102 @@ export interface TsClientOptions {
665
666
  readonly compat?: NomosCompat;
666
667
  }
667
668
 
669
+ /** TS string literal (JSON.stringify covers escaping). */
670
+ const _actLit = (s: string): string => JSON.stringify(s);
671
+
672
+ /** Emit the ACTIVITY-FEED block (architecture/activity_descriptors.md): the `ActivityEvent`/`ActivityRef`/
673
+ * `ActivityRawRow` runtime types, a dotted payload-path getter, the per-directive `renderActivity` registry,
674
+ * and the shared template renderer — inline in the generated `.client.ts` (mirrors the ForeignRef placement).
675
+ * Empty when no directive declares `.activity(...)` — an activity-free package stays BYTE-IDENTICAL. */
676
+ function emitTsActivityBlock(entries: readonly PackageActivityEntry[]): string[] {
677
+ if (entries.length === 0) return [];
678
+ const arm = (e: PackageActivityEntry): string[] => {
679
+ const { directiveId, desc } = e;
680
+ const params = desc.params.map((p) => `${_actLit(p.name)}: _activityPayloadPath(row.payload, ${_actLit(p.from)})`).join(", ");
681
+ const refs = desc.refs
682
+ .map((r) => {
683
+ // A `from: "@created"` ref reads the id the LAW MINTED (the row's `created` list, first type match);
684
+ // any other `from` is a payload path. Every ref carries the session workspace (the row's `workspace`).
685
+ const id = r.from === "@created"
686
+ ? `_activityCreatedId(row.created, ${_actLit(r.type)})`
687
+ : `_activityPayloadPath(row.payload, ${_actLit(r.from)})`;
688
+ return `${_actLit(r.name)}: { workspace: row.workspace ?? "", type: ${_actLit(r.type)}, id: ${id} }`;
689
+ })
690
+ .join(", ");
691
+ return [
692
+ ` ${_actLit(directiveId)}: (row: ActivityRawRow): ActivityEvent => {`,
693
+ ` const params: Record<string, string> = { ${params} };`,
694
+ ` const vars = { actor: row.actorSubject, ...params };`,
695
+ ` return {`,
696
+ ` eventId: row.intentId, kind: ${_actLit(desc.kind)}, actorSubject: row.actorSubject, occurredAt: row.occurredAt,`,
697
+ ` title: _renderActivityTemplate(${_actLit(desc.title)}, vars), message: _renderActivityTemplate(${_actLit(desc.message)}, vars),`,
698
+ ` titleKey: ${_actLit(desc.titleKey)}, messageKey: ${_actLit(desc.messageKey)},`,
699
+ ` params, refs: { ${refs} },`,
700
+ ...(desc.category !== undefined ? [` category: ${_actLit(desc.category)},`] : []),
701
+ ...(desc.icon !== undefined ? [` icon: ${_actLit(desc.icon)},`] : []),
702
+ ` severity: ${_actLit(desc.severity)}, domain: row.domain, directiveId: ${_actLit(directiveId)},`,
703
+ ` };`,
704
+ ` },`,
705
+ ];
706
+ };
707
+ return [
708
+ `// ── activity feed (architecture/activity_descriptors.md) — typed rows folded from the intent log ──`,
709
+ `/** A declared navigation target on an activity row: the canonical [NomosRef] triple (workspace + type +`,
710
+ ` * resolved id). A \`from: "@created"\` ref resolves the id the LAW minted; others a payload path. */`,
711
+ `export interface ActivityRef { workspace: string; type: string; id: string }`,
712
+ `/** A rendered activity row — produced by \`renderActivity\` from a directive's declared \`.activity(...)\`. */`,
713
+ `export interface ActivityEvent {`,
714
+ ` eventId: string; kind: string; actorSubject: string; occurredAt: string;`,
715
+ ` title: string; message: string; titleKey: string; messageKey: string;`,
716
+ ` params: Record<string, string>; refs: Record<string, ActivityRef>;`,
717
+ ` category?: string; icon?: string;`,
718
+ ` severity: "info" | "success" | "warning" | "critical";`,
719
+ ` domain: string; directiveId: string;`,
720
+ `}`,
721
+ `/** The runtime's per-committed-intent RAW ROW (the shape \`holon.watchActivity\`/\`activityLog\` yield). */`,
722
+ `export interface ActivityRawRow { intentId: string; domain: string; directiveId: string; actorSubject: string; occurredAt: string; payload: Record<string, unknown>; created?: { type: string; id: string }[]; workspace?: string }`,
723
+ `/** A NARRATIVE over the activity log (for an in-app LLM): the filtered events + a plain-English \`transcript\``,
724
+ ` * (ONE LINE PER EVENT, oldest-first, \`"<occurredAt> — <title>: <message>"\`) — grounding text over the`,
725
+ ` * VERIFIED log, never raw payloads. Produced by the client's \`describe\` / \`describeHistory\`. */`,
726
+ `export interface ActivityNarrative { events: ActivityEvent[]; transcript: string }`,
727
+ `/** Build the LLM-ready transcript: one line per event, OLDEST-FIRST (the log yields newest-first). */`,
728
+ `function _activityTranscript(events: readonly ActivityEvent[]): string {`,
729
+ " return [...events].reverse().map((e) => `${e.occurredAt} — ${e.title}: ${e.message}`).join(\"\\n\");",
730
+ `}`,
731
+ `/** Read a dotted path off a decoded intent payload, coerced to string ("" when absent/null). */`,
732
+ `function _activityPayloadPath(obj: unknown, path: string): string {`,
733
+ ` let cur: unknown = obj;`,
734
+ ` for (const seg of path.split(".")) {`,
735
+ ` if (cur === null || typeof cur !== "object") return "";`,
736
+ ` cur = (cur as Record<string, unknown>)[seg];`,
737
+ ` }`,
738
+ ` return cur === null || cur === undefined ? "" : String(cur);`,
739
+ `}`,
740
+ `/** The id the LAW minted for a \`from: "@created"\` ref — the first entry of the row's \`created\` list whose`,
741
+ ` * type matches the declared aggregate (the runtime type is uuid-stripped + may be namespaced, so match on`,
742
+ ` * equality OR \`<domain>.<Type>\` suffix). "" when the holon returned no created list (era-safe). */`,
743
+ `function _activityCreatedId(created: { type: string; id: string }[] | undefined, declared: string): string {`,
744
+ ` if (created === undefined) return "";`,
745
+ ` for (const c of created) if (c.type === declared || c.type.endsWith("." + declared)) return c.id;`,
746
+ ` return "";`,
747
+ `}`,
748
+ `/** Interpolate \`{name}\` placeholders in a template from vars (a missing var renders empty). */`,
749
+ `function _renderActivityTemplate(template: string, vars: Record<string, string>): string {`,
750
+ ` return template.replace(/\\{([a-zA-Z0-9_]+)\\}/g, (_m, name: string) => vars[name] ?? "");`,
751
+ `}`,
752
+ `const _ACTIVITY_REGISTRY: Record<string, (row: ActivityRawRow) => ActivityEvent> = {`,
753
+ ...entries.flatMap(arm),
754
+ `};`,
755
+ `/** Fold ONE committed-intent raw row into a typed [ActivityEvent], or null when the directive declares`,
756
+ ` * no \`.activity(...)\`. Pure — the kernel/ledger stays the audit truth; this is a client-side projection. */`,
757
+ `export function renderActivity(row: ActivityRawRow): ActivityEvent | null {`,
758
+ ` const f = _ACTIVITY_REGISTRY[row.directiveId];`,
759
+ ` return f === undefined ? null : f(row);`,
760
+ `}`,
761
+ ``,
762
+ ];
763
+ }
764
+
668
765
  /** TRUE when any module declares a PACKED workspace taxonomy (the routed-holon shape).
669
766
  * Gates every slice-4 paged-surface emission: taxonomy-free clients stay BYTE-IDENTICAL. */
670
767
  function anyPackedTaxonomy(modules: readonly DomainModule[]): boolean {
@@ -690,6 +787,10 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
690
787
  );
691
788
  const encPresent = encMap !== null;
692
789
  const encConst = encryptionConstName(opts.packageName);
790
+ // ACTIVITY FEED (architecture/activity_descriptors.md): the package's declared `.activity(...)` descriptors.
791
+ // Empty ⇒ NOTHING activity-related emits (byte-identical to a pre-activity compile).
792
+ const activityEntries = collectActivityDescriptors(modules);
793
+ const activityPresent = activityEntries.length > 0;
693
794
  const out: string[] = [];
694
795
  // The PACKAGE-LEVEL recursive-type registry: every directive payload's `z.lazy` knots mint
695
796
  // named recursive type aliases into it (deduped by lazy identity across directives); the decls
@@ -728,8 +829,46 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
728
829
  ``,
729
830
  ]
730
831
  : []),
731
- `/** One projection row: the aggregate's wire type, its id, and the folded fields. */`,
732
- `export interface Row<T> { type: string; id: string; data: T }`,
832
+ `/** THE ONE REFERENCE (architecture/nomos_ref.md) — the canonical, workspace-qualified identity of an`,
833
+ ` * aggregate instance: \`{ workspace, type, id }\`, with the stable interop string`,
834
+ ` * \`nomos://<workspace>/<type>/<id>\`. Carried on every read \`Row.ref\` + write outcome + activity ref. */`,
835
+ `export interface NomosRef { workspace: string; type: string; id: string }`,
836
+ `/** The stable \`nomos://<workspace>/<type>/<id>\` string form (each segment URI-encoded — a workspace/`,
837
+ ` * type/id containing \`/\` or spaces round-trips). Survives URLs, nav params, JSON, logs. */`,
838
+ `export function nomosRefUri(ref: NomosRef): string {`,
839
+ ` return "nomos://" + encodeURIComponent(ref.workspace) + "/" + encodeURIComponent(ref.type) + "/" + encodeURIComponent(ref.id);`,
840
+ `}`,
841
+ `/** Parse a \`nomos://<workspace>/<type>/<id>\` string back into a [NomosRef] (the inverse of [nomosRefUri]);`,
842
+ ` * throws on anything else. */`,
843
+ `export function parseNomosRef(uri: string): NomosRef {`,
844
+ " const m = /^nomos:\\/\\/([^/]+)\\/([^/]+)\\/([^/]+)$/.exec(uri);",
845
+ ` if (m === null) throw new Error("parseNomosRef: expected nomos://<workspace>/<type>/<id>: " + uri);`,
846
+ ` return { workspace: decodeURIComponent(m[1]!), type: decodeURIComponent(m[2]!), id: decodeURIComponent(m[3]!) };`,
847
+ `}`,
848
+ `/** One projection row: the aggregate's wire type, its id, the folded fields, and the canonical [NomosRef]`,
849
+ ` * (composed at decode from the session workspace + the row's type/id — additive, the \`data\` is untouched). */`,
850
+ `export interface Row<T> { type: string; id: string; data: T; ref: NomosRef }`,
851
+ `/** Stamp each row's canonical [NomosRef] from the session \`ws\` + the row's type/id (additive — already-`,
852
+ ` * stamped rows pass through). */`,
853
+ `function _stampRefs<T>(ws: string, rows: Row<T>[]): Row<T>[] {`,
854
+ ` return rows.map((r) => (r.ref !== undefined ? r : { ...r, ref: { workspace: ws, type: r.type, id: r.id } }));`,
855
+ `}`,
856
+ `function _stampRefsP<T>(ws: string, p: Promise<Row<T>[]>): Promise<Row<T>[]> { return p.then((rows) => _stampRefs(ws, rows)); }`,
857
+ `/** THE TYPED WRITE OUTCOME (architecture/nomos_ref.md) — every generated directive method returns this.`,
858
+ ` * \`created\` is EVERY aggregate the plan minted (typed [NomosRef]s, composed with the session workspace;`,
859
+ ` * \`[]\` on a holon too old to return them — never a fake id). \`target\` is the addressed instance for a`,
860
+ ` * front-door-mint lane (statically known); omitted otherwise. \`head\` is the new local head. */`,
861
+ `export interface NomosOutcome { head: string; created: NomosRef[]; target?: NomosRef }`,
862
+ `/** Fold a holon offer result (a bare head, or \`{ head, created }\` under returnCreated) into a [NomosOutcome]`,
863
+ ` * — composing each created \`{type,id}\` with the session \`ws\`. \`target\` rides through when the caller`,
864
+ ` * statically knows the addressed instance (the front-door mint). */`,
865
+ `async function _outcome(ws: string, r: Promise<string | { head: string; created?: { type: string; id: string }[] }>, target?: NomosRef): Promise<NomosOutcome> {`,
866
+ ` const v = await r;`,
867
+ ` const obj = typeof v === "object" && v !== null;`,
868
+ ` const head = obj ? v.head : String(v);`,
869
+ ` const created = (obj ? v.created ?? [] : []).map((c) => ({ workspace: ws, type: c.type, id: c.id }));`,
870
+ ` return target !== undefined ? { head, created, target } : { head, created };`,
871
+ `}`,
733
872
  // MULTI-WORKSPACE LOCATORS (t.foreignRef): a TYPED cross-workspace edge — the foreign aggregate's id +
734
873
  // the foreign workspace id (from a sibling field). NOT an atomic FK: it does not clone/open the other
735
874
  // holon; `resolve`/`watch` open it ON DEMAND over a realm (openRealm's `.workspace(ws)` surface).
@@ -814,6 +953,8 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
814
953
  ` offerDirective(domain: string, directiveId: string, payload: unknown, opts?: { domainHash?: string; actor?: string; returnCreated?: boolean }): Promise<string>;`,
815
954
  ` /** Opaque-bytes offer — retained for carriers that already hold an encoded envelope (back-compat). */`,
816
955
  ` offer(intentBytes: string | Uint8Array): Promise<string>;`,
956
+ ` /** The workspace this holon is bound to — the \`workspace\` of every [NomosRef] this client composes. */`,
957
+ ` workspace: string;`,
817
958
  ...(encPresent
818
959
  ? [
819
960
  ` /** \`keys\` (E2E sealing): the reader's held scope keys, injected so encrypted fields decrypt;`,
@@ -853,6 +994,16 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
853
994
  ` deadLetters(): Promise<{ id?: string; oid: string; source: string; error: string; domain?: string | null; directiveId?: string | null; at: string }[]>;`,
854
995
  ` retryDeadLetter(id: string): Promise<{ ok: boolean; head?: string; alreadyOnMain?: boolean; error?: string }>;`,
855
996
  ` discardDeadLetter(id: string): Promise<{ ok: boolean; removed: number }>;`,
997
+ ...(activityPresent
998
+ ? [
999
+ ` /** THE ACTIVITY FEED (declared \`.activity(...)\`): subscribe to per-committed-intent RAW ROWS`,
1000
+ ` * (the runtime folds the intent log). \`cb\` fires with the current rows + on every new intent;`,
1001
+ ` * returns an unsubscribe fn. The generated client maps these through \`renderActivity\`. */`,
1002
+ ` watchActivity(cb: (rows: ActivityRawRow[]) => void, opts?: { base?: string }): () => void;`,
1003
+ ` /** One-shot: the current committed activity log as RAW ROWS (folded to typed rows by the client). */`,
1004
+ ` activityLog(opts?: { base?: string; limit?: number }): Promise<ActivityRawRow[]>;`,
1005
+ ]
1006
+ : []),
856
1007
  ` /** THE COMPAT SUPERPOWER (#72b) — OPTIONAL: a holon from @githolon/client's connect() attaches it.`,
857
1008
  ` * The factory hands over this client's baked compat manifest + structural interface (the requirement),`,
858
1009
  ` * and the holon computes the LIVE per-member verdict on \`holon.compat\`. A KNOWN-incompatible directive`,
@@ -1246,8 +1397,12 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1246
1397
  const foreignRefFields: { agg: string; field: string; workspaceField: string; target: string }[] = [];
1247
1398
  // wrap a read accessor return expression through the foreignRef row-hydrator (identity + byte-identical
1248
1399
  // when the package declares no foreignRef, so non-foreignRef packages are unchanged).
1249
- const wrapP = (expr: string): string => (foreignRefFields.length > 0 ? `_hydrateP(${expr})` : expr);
1250
- const wrapRows = (expr: string): string => (foreignRefFields.length > 0 ? `_hydrateRows(${expr})` : expr);
1400
+ // Every read accessor's rows get their canonical `.ref` stamped from `holon.workspace` (additive); a
1401
+ // foreignRef-declaring package ALSO hydrates its foreignRef fields into typed locators first.
1402
+ const wrapP = (expr: string): string =>
1403
+ `_stampRefsP(holon.workspace, ${foreignRefFields.length > 0 ? `_hydrateP(${expr})` : expr})`;
1404
+ const wrapRows = (expr: string): string =>
1405
+ `_stampRefs(holon.workspace, ${foreignRefFields.length > 0 ? `_hydrateRows(${expr})` : expr})`;
1251
1406
  for (const [typeId, agg] of [...aggByType].sort(([a], [b]) => a.localeCompare(b))) {
1252
1407
  out.push(`export interface ${typeId}Data {`);
1253
1408
  for (const [name, field] of Object.entries(agg.fields)) {
@@ -1489,6 +1644,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1489
1644
  }
1490
1645
  }
1491
1646
 
1647
+ // ── activity feed (declared `.activity(...)`) — types + `renderActivity` registry (empty ⇒ nothing) ──
1648
+ out.push(...emitTsActivityBlock(activityEntries));
1649
+
1492
1650
  // ── payload types + per-domain client factories ──
1493
1651
  const usedPayloadNames = new Set<string>();
1494
1652
  for (const mod of modules) {
@@ -1565,6 +1723,9 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1565
1723
  `export type ${payloadName} = ${tsTypeOfPayload(schema, stamps, mint ? [mint.field] : [], placed, lazyReg, d.id)};`,
1566
1724
  ``,
1567
1725
  );
1726
+ // THE ONE WRITE OUTCOME (architecture/nomos_ref.md): EVERY directive method returns Promise<NomosOutcome>
1727
+ // — `created` (typed refs of all minted aggregates, from returnCreated) + `head`, plus `target` for the
1728
+ // front-door-mint lane (the one statically-known addressed instance). One async block covers every case.
1568
1729
  if (mint !== undefined) {
1569
1730
  const mintOpts =
1570
1731
  mint.homeKeyField !== undefined
@@ -1577,65 +1738,31 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1577
1738
  : `{ ...${pv}, ${mint.field} }`;
1578
1739
  methods.push(
1579
1740
  ` /** Author \`${domain}/${d.id}\`: the front-door MINTS \`${mint.field}\` (route-tagged) when omitted`,
1580
- ` * — ids stay internal plumbing — and returns it with the new local head.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
1581
- ` ${d.id}: async (${paramSig}): Promise<{ head: string; ${mint.field}: string }> => {`,
1741
+ ` * — ids stay internal plumbing — and returns the outcome (\`target\` = the minted ${dd.aggregateId ?? "aggregate"}).${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
1742
+ ` ${d.id}: async (${paramSig}): Promise<NomosOutcome> => {`,
1582
1743
  ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1583
1744
  ...(sealPrelude ? [sealPrelude] : []),
1584
1745
  ` const ${mint.field} = payload.${mint.field} ?? (await (holon as NomosRoutedHolon).mint(${JSON.stringify(mint.mintType)}${mintOpts}));`,
1585
- ` const head = await holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, { domainHash, actor });`,
1586
- ` return { head, ${mint.field} };`,
1746
+ ` return _outcome(holon.workspace, holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${body}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>, { workspace: holon.workspace, type: ${JSON.stringify(dd.aggregateId ?? mint.mintType)}, id: ${mint.field} });`,
1587
1747
  ` },`,
1588
1748
  );
1589
1749
  continue;
1590
1750
  }
1591
1751
  const offerPayload = stamps.length ? `stampNow(${pv}, ${JSON.stringify(stamps)})` : pv;
1592
- if (d.marker === "creates") {
1593
- // LAW-MINTED ID RETURN: a `.creates` plan mints its aggregate id inside the sealed intent (the payload
1594
- // carries none). The holon hands the minted ids back on the offer (`returnCreated`), so the create
1595
- // method returns the typed id with NO query-after. Older holons (pre-returnCreated) answer a bare head
1596
- // string — degrade to id:"" rather than refuse (structural binding, era-safe).
1597
- methods.push(
1598
- ` /** Author \`${domain}/${d.id}\`: the LAW mints the ${d.aggregateId ?? "aggregate"} id inside the sealed intent`,
1599
- ` * and it returns here with the new local head — no id in the payload, no query-after.${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
1600
- ` ${d.id}: async (${paramSig}): Promise<{ head: string; id: string }> => {`,
1601
- ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1602
- ...(sealPrelude ? [sealPrelude] : []),
1603
- ` const r = await (holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>);`,
1604
- ` return typeof r === "object" && r !== null ? { head: r.head, id: r.created?.[0]?.id ?? "" } : { head: String(r), id: "" };`,
1605
- ` },`,
1606
- );
1607
- continue;
1608
- }
1609
- const offerCall = `holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor })`;
1610
- if (wantSeal) {
1611
- // SEALING directive (optionally also gated): an ASYNC block — seal the encrypted fields via the
1612
- // bound { keyStore } (+ throw MissingOfferAuthority first if gated) before dispatch.
1613
- methods.push(
1614
- ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
1615
- ` * Encrypted field(s) are SEALED via the bound { keyStore } before dispatch (pass { seal: false } to skip).${gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`).` : ""} */`,
1616
- ` ${d.id}: async (${paramSig}): Promise<string> => {`,
1617
- ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1618
- sealPrelude,
1619
- ` return ${offerCall};`,
1620
- ` },`,
1621
- );
1622
- } else if (gateGuard) {
1623
- // GATED directive: a block body that throws MissingOfferAuthority when no actor is bound — an
1624
- // actorless gated offer can't be constructed (the war: the gate is structural, not just kernel-side).
1625
- methods.push(
1626
- ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head.`,
1627
- ` * GATED: needs \`{ actor }\` (the \`${gate!.relation}\` relation) — throws MissingOfferAuthority otherwise. */`,
1628
- ` ${d.id}: (payload: ${payloadName}): Promise<string> => {`,
1629
- gateGuard.replace(/\n$/, ""),
1630
- ` return ${offerCall};`,
1631
- ` },`,
1632
- );
1633
- } else {
1634
- methods.push(
1635
- ` /** Author \`${domain}/${d.id}\` locally under the installed law; returns the new local head. */`,
1636
- ` ${d.id}: (payload: ${payloadName}) => ${offerCall},`,
1637
- );
1638
- }
1752
+ const outcomeDoc =
1753
+ d.marker === "creates"
1754
+ ? `Author \`${domain}/${d.id}\`: the LAW mints the ${d.aggregateId ?? "aggregate"} id inside the sealed intent; it rides back on \`outcome.created\` — no id in the payload, no query-after.`
1755
+ : `Author \`${domain}/${d.id}\` locally under the installed law; returns the typed [NomosOutcome] (\`head\` + any \`created\` refs).`;
1756
+ const sealDoc = wantSeal ? " Encrypted field(s) are SEALED via the bound { keyStore } before dispatch (pass { seal: false } to skip)." : "";
1757
+ const gateDoc = gate ? ` GATED: needs \`{ actor }\` (\`${gate.relation}\`) — throws MissingOfferAuthority otherwise.` : "";
1758
+ methods.push(
1759
+ ` /** ${outcomeDoc}${sealDoc}${gateDoc} */`,
1760
+ ` ${d.id}: async (${paramSig}): Promise<NomosOutcome> => {`,
1761
+ ...(gateGuard ? [gateGuard.replace(/\n$/, "")] : []),
1762
+ ...(sealPrelude ? [sealPrelude] : []),
1763
+ ` return _outcome(holon.workspace, holon.offerDirective(${JSON.stringify(domain)}, ${JSON.stringify(d.id)}, ${offerPayload}, { domainHash, actor, returnCreated: true }) as Promise<string | { head: string; created?: { type: string; id: string }[] }>);`,
1764
+ ` },`,
1765
+ );
1639
1766
  }
1640
1767
 
1641
1768
  for (const sp of (mod.spatials ?? []) as { id: string; of: string; on: string }[]) {
@@ -1708,9 +1835,39 @@ export function generateTsClient(modules: readonly DomainModule[], opts: TsClien
1708
1835
  : ` list${agg.id}s: () => ${wrapP(`holon.query("aggregatesByType", { __type: ${JSON.stringify(agg.id)} }) as Promise<Row<${agg.id}Data>[]>`)},`,
1709
1836
  ` /** Watch one ${agg.id} by aggregate id (local reactive read; returns stop()). */`,
1710
1837
  ` watch${agg.id}ById: (id: string, cb: (rows: Row<${agg.id}Data>[]) => void, opts?: { intervalMs?: number }) =>`,
1711
- foreignRefFields.length > 0
1712
- ? ` holon.watchById(id, ((rows: Row<${agg.id}Data>[]) => cb(_hydrateRows(rows))) as (rows: unknown) => void, opts),`
1713
- : ` holon.watchById(id, cb as (rows: unknown) => void, opts),`,
1838
+ ` holon.watchById(id, ((rows: Row<${agg.id}Data>[]) => cb(${wrapRows("rows")})) as (rows: unknown) => void, opts),`,
1839
+ );
1840
+ }
1841
+
1842
+ if (activityPresent) {
1843
+ methods.push(
1844
+ ` /** THE ACTIVITY FEED (declared \`.activity(...)\`): typed activity rows folded from the intent log.`,
1845
+ ` * \`cb\` fires with the current rows + on every new committed intent; returns stop(). Directives with`,
1846
+ ` * no declared activity are dropped. */`,
1847
+ ` watchActivity: (cb: (events: ActivityEvent[]) => void, opts?: { base?: string }) =>`,
1848
+ ` holon.watchActivity((rows) => cb(rows.map(renderActivity).filter((e): e is ActivityEvent => e !== null)), opts),`,
1849
+ ` /** One-shot: the current committed activity log, folded to typed [ActivityEvent]s. */`,
1850
+ ` activityLog: async (opts?: { base?: string; limit?: number }): Promise<ActivityEvent[]> =>`,
1851
+ ` (await holon.activityLog(opts)).map(renderActivity).filter((e): e is ActivityEvent => e !== null),`,
1852
+ ` /** NARRATIVE (for an in-app LLM): the workspace story — the activity log folded to typed events +`,
1853
+ ` * a plain-English \`transcript\` grounded in the VERIFIED log. Filter by \`actor\` / \`category\` /`,
1854
+ ` * \`since\` (ISO occurredAt lower bound); \`limit\` caps the rows pulled. Events are newest-first. */`,
1855
+ ` describe: async (opts: { since?: string; actor?: string; category?: string; limit?: number } = {}): Promise<ActivityNarrative> => {`,
1856
+ ` const raw = await holon.activityLog(opts.limit !== undefined ? { limit: opts.limit } : {});`,
1857
+ ` let events = raw.map(renderActivity).filter((e): e is ActivityEvent => e !== null);`,
1858
+ ` if (opts.actor !== undefined) events = events.filter((e) => e.actorSubject === opts.actor);`,
1859
+ ` if (opts.category !== undefined) events = events.filter((e) => e.category === opts.category);`,
1860
+ ` if (opts.since !== undefined) events = events.filter((e) => e.occurredAt >= opts.since!);`,
1861
+ ` return { events, transcript: _activityTranscript(events) };`,
1862
+ ` },`,
1863
+ ` /** NARRATIVE for ONE thing: the story of the aggregate \`aggregateId\` — events whose declared refs`,
1864
+ ` * point at it (only directives declaring a ref to the aggregate appear in its story) + a transcript. */`,
1865
+ ` describeHistory: async (aggregateId: string, opts: { limit?: number } = {}): Promise<ActivityNarrative> => {`,
1866
+ ` const raw = await holon.activityLog(opts.limit !== undefined ? { limit: opts.limit } : {});`,
1867
+ ` const events = raw.map(renderActivity).filter((e): e is ActivityEvent => e !== null)`,
1868
+ ` .filter((e) => Object.values(e.refs).some((r) => r.id === aggregateId));`,
1869
+ ` return { events, transcript: _activityTranscript(events) };`,
1870
+ ` },`,
1714
1871
  );
1715
1872
  }
1716
1873