synomem 0.9.6 → 0.10.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.
Files changed (118) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -3
  3. package/dist/actor-directory.d.ts +34 -0
  4. package/dist/actor-directory.d.ts.map +1 -0
  5. package/dist/actor-directory.js +52 -0
  6. package/dist/actor-directory.js.map +1 -0
  7. package/dist/backend.d.ts +3 -0
  8. package/dist/backend.d.ts.map +1 -1
  9. package/dist/backend.js +26 -3
  10. package/dist/backend.js.map +1 -1
  11. package/dist/bookmarks.d.ts +30 -0
  12. package/dist/bookmarks.d.ts.map +1 -0
  13. package/dist/bookmarks.js +87 -0
  14. package/dist/bookmarks.js.map +1 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +291 -61
  17. package/dist/cli.js.map +1 -1
  18. package/dist/client.d.ts +115 -4
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +854 -138
  21. package/dist/client.js.map +1 -1
  22. package/dist/cursors.d.ts +30 -0
  23. package/dist/cursors.d.ts.map +1 -0
  24. package/dist/cursors.js +101 -0
  25. package/dist/cursors.js.map +1 -0
  26. package/dist/errors.d.ts +1 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +2 -0
  29. package/dist/errors.js.map +1 -1
  30. package/dist/import.d.ts +605 -39
  31. package/dist/import.d.ts.map +1 -1
  32. package/dist/index.d.ts +19 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +11 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp/index.d.ts +1 -1
  37. package/dist/mcp/index.d.ts.map +1 -1
  38. package/dist/mcp/index.js +329 -22
  39. package/dist/mcp/index.js.map +1 -1
  40. package/dist/mutation-receipts.d.ts +8 -0
  41. package/dist/mutation-receipts.d.ts.map +1 -0
  42. package/dist/mutation-receipts.js +2 -0
  43. package/dist/mutation-receipts.js.map +1 -0
  44. package/dist/notifications.d.ts +73 -0
  45. package/dist/notifications.d.ts.map +1 -0
  46. package/dist/notifications.js +309 -0
  47. package/dist/notifications.js.map +1 -0
  48. package/dist/participation-repository.d.ts +46 -0
  49. package/dist/participation-repository.d.ts.map +1 -0
  50. package/dist/participation-repository.js +296 -0
  51. package/dist/participation-repository.js.map +1 -0
  52. package/dist/participation.d.ts +97 -0
  53. package/dist/participation.d.ts.map +1 -0
  54. package/dist/participation.js +10 -0
  55. package/dist/participation.js.map +1 -0
  56. package/dist/policy.d.ts +31 -0
  57. package/dist/policy.d.ts.map +1 -0
  58. package/dist/policy.js +71 -0
  59. package/dist/policy.js.map +1 -0
  60. package/dist/ports/repository.d.ts +23 -0
  61. package/dist/ports/repository.d.ts.map +1 -1
  62. package/dist/profiles.d.ts +12 -0
  63. package/dist/profiles.d.ts.map +1 -1
  64. package/dist/profiles.js +2 -1
  65. package/dist/profiles.js.map +1 -1
  66. package/dist/projections.d.ts.map +1 -1
  67. package/dist/projections.js +29 -11
  68. package/dist/projections.js.map +1 -1
  69. package/dist/remote.d.ts +79 -0
  70. package/dist/remote.d.ts.map +1 -1
  71. package/dist/remote.js +65 -6
  72. package/dist/remote.js.map +1 -1
  73. package/dist/schemas.d.ts +746 -58
  74. package/dist/schemas.d.ts.map +1 -1
  75. package/dist/schemas.js +114 -16
  76. package/dist/schemas.js.map +1 -1
  77. package/dist/search.d.ts +25 -0
  78. package/dist/search.d.ts.map +1 -0
  79. package/dist/search.js +2 -0
  80. package/dist/search.js.map +1 -0
  81. package/dist/service.d.ts +89 -0
  82. package/dist/service.d.ts.map +1 -1
  83. package/dist/storage.d.ts +38 -3
  84. package/dist/storage.d.ts.map +1 -1
  85. package/dist/storage.js +494 -365
  86. package/dist/storage.js.map +1 -1
  87. package/dist/types.d.ts +65 -34
  88. package/dist/types.d.ts.map +1 -1
  89. package/docs/cli.md +6 -0
  90. package/docs/human-participation.md +20 -0
  91. package/docs/mcp.md +2 -0
  92. package/docs/storage-format.md +4 -15
  93. package/openapi/synomem-v1.yaml +187 -10
  94. package/package.json +2 -1
  95. package/skills/synomem/SKILL.md +2 -0
  96. package/src/actor-directory.ts +87 -0
  97. package/src/backend.ts +29 -3
  98. package/src/bookmarks.ts +127 -0
  99. package/src/cli.ts +941 -546
  100. package/src/client.ts +1143 -279
  101. package/src/cursors.ts +132 -0
  102. package/src/errors.ts +2 -0
  103. package/src/index.ts +50 -0
  104. package/src/mcp/index.ts +412 -96
  105. package/src/mutation-receipts.ts +7 -0
  106. package/src/notifications.ts +483 -0
  107. package/src/participation-repository.ts +406 -0
  108. package/src/participation.ts +103 -0
  109. package/src/policy.ts +109 -0
  110. package/src/ports/repository.ts +33 -0
  111. package/src/profiles.ts +2 -1
  112. package/src/projections.ts +49 -48
  113. package/src/remote.ts +227 -37
  114. package/src/schemas.ts +116 -16
  115. package/src/search.ts +24 -0
  116. package/src/service.ts +122 -8
  117. package/src/storage.ts +1039 -697
  118. package/src/types.ts +67 -32
package/src/client.ts CHANGED
@@ -1,3 +1,33 @@
1
+ import { actorDirectoryInput } from './actor-directory.js';
2
+ import type { ActorDirectoryInput, ActorProfileInput } from './actor-directory.js';
3
+ import type { BookmarkInput } from './bookmarks.js';
4
+ import type { SearchInput, SearchPage } from './search.js';
5
+ import { z } from 'zod';
6
+ import type { NotificationInput } from './notifications.js';
7
+ import type {
8
+ ReplyCreateInput,
9
+ ReplyDeleteInput,
10
+ ReplyRecord,
11
+ ReactionSetInput,
12
+ ThreadInput,
13
+ } from './participation.js';
14
+ import {
15
+ threadInputSchema,
16
+ threadSubscriptionSchema,
17
+ replyCreateSchema,
18
+ replyDeleteSchema,
19
+ reactionSetSchema,
20
+ } from './schemas.js';
21
+ import { AsyncLocalStorage } from 'node:async_hooks';
22
+ import { cursorFilter } from './cursors.js';
23
+ import {
24
+ isRecordAdministrator,
25
+ localOwnerAuthority,
26
+ overseesAgent,
27
+ resolveAuthority,
28
+ sameActor,
29
+ } from './policy.js';
30
+ import type { ActorRef, AddressableActor, RecordAuthority } from './policy.js';
1
31
  import { accessSync, constants as fsConstants, existsSync, lstatSync } from 'node:fs';
2
32
  import { join, relative, resolve } from 'node:path';
3
33
  import { ulid } from 'ulid';
@@ -16,6 +46,8 @@ import {
16
46
  } from './projections.js';
17
47
  import {
18
48
  actorSchema,
49
+ eventSchema,
50
+ overrideTaskDecisionSchema,
19
51
  agentLookupSchema,
20
52
  bindRuntimeSchema,
21
53
  createAgentSchema,
@@ -79,6 +111,7 @@ import type {
79
111
  UpdateTaskInput,
80
112
  UpdateTodoInput,
81
113
  TaskRecord,
114
+ OverrideTaskDecisionInput,
82
115
  TodoRecord,
83
116
  ItemListInput,
84
117
  ItemRecord,
@@ -103,7 +136,6 @@ import type {
103
136
  Page,
104
137
  UpdateAgentInput,
105
138
  } from './types.js';
106
-
107
139
  export interface SynomemCoreOptions {
108
140
  repository: SynomemRepository;
109
141
  projectionWriter: ProjectionWriter;
@@ -111,10 +143,10 @@ export interface SynomemCoreOptions {
111
143
  clock?: () => Date;
112
144
  idGenerator?: () => string;
113
145
  signal?: AbortSignal;
114
- /** Explicit administrator authority. Defaults to true only for local-style human actors. */
115
- administrative?: boolean;
146
+ /** Authority from the trusted adapter; defaults to no administration. */
147
+ authority?: RecordAuthority;
148
+ canWrite?: boolean;
116
149
  }
117
-
118
150
  export class SynomemCore implements SynomemDomainService {
119
151
  /**
120
152
  * Mutable because an agent actor is resolved to its canonical identity on
@@ -126,9 +158,319 @@ export class SynomemCore implements SynomemDomainService {
126
158
  private readonly clock: () => Date;
127
159
  private readonly idGenerator: () => string;
128
160
  private readonly signal?: AbortSignal;
129
- private readonly administrative: boolean;
161
+ protected administrative: boolean;
162
+ private writeAllowed: boolean;
163
+ private readonly mutationContext = new AsyncLocalStorage<{
164
+ key?: string;
165
+ projections: Set<string>;
166
+ }>();
167
+ protected authority: RecordAuthority;
130
168
  private initialized = false;
131
-
169
+ readonly bookmarks = {
170
+ list: (input: BookmarkInput = {}) => {
171
+ const parsed = this.validate(() =>
172
+ z
173
+ .object({
174
+ after: z.string().max(4096).optional(),
175
+ limit: z.number().int().min(1).max(100).optional(),
176
+ })
177
+ .strict()
178
+ .parse(input),
179
+ );
180
+ return this.repository.bookmarks.list(parsed, this.actor);
181
+ },
182
+ has: async (rootId: string) => {
183
+ await this.getItem(rootId);
184
+ return { saved: await this.repository.bookmarks.has(rootId, this.boundRef()) };
185
+ },
186
+ set: async (input: { rootId: string; present: boolean }) => {
187
+ const parsed = this.validate(() =>
188
+ z
189
+ .object({ rootId: z.string().length(26), present: z.boolean() })
190
+ .strict()
191
+ .parse(input),
192
+ );
193
+ if (!this.writeAllowed)
194
+ throw new SynomemError('READ_ONLY', 'This binding cannot change saved records.');
195
+ await this.repository.transaction(async () => {
196
+ await this.resolveActor(this.boundRef(), true);
197
+ await this.getItem(parsed.rootId);
198
+ await this.repository.bookmarks.set(parsed.rootId, this.boundRef(), parsed.present);
199
+ });
200
+ return { saved: parsed.present };
201
+ },
202
+ };
203
+ readonly notifications = {
204
+ list: async (input: NotificationInput = {}) => {
205
+ const parsed = this.validate(() =>
206
+ z
207
+ .object({
208
+ after: z.string().max(4096).optional(),
209
+ limit: z.number().int().min(1).max(100).optional(),
210
+ view: z.enum(['all', 'unread', 'action_required', 'default']).optional(),
211
+ })
212
+ .strict()
213
+ .parse(input),
214
+ );
215
+ if (this.writeAllowed)
216
+ await this.repository.transaction(() => this.repository.notifications.drain());
217
+ const page = await this.repository.notifications.list(parsed, this.actor);
218
+ return page;
219
+ },
220
+ read: (id: string) =>
221
+ this.personalWrite(() => this.repository.notifications.read(id, this.boundRef())),
222
+ dismiss: (id: string) =>
223
+ this.personalWrite(() => this.repository.notifications.dismiss(id, this.boundRef())),
224
+ readThrough: (through: string) =>
225
+ this.personalWrite(() => this.repository.notifications.readThrough(through, this.boundRef())),
226
+ };
227
+ readonly replies = {
228
+ changes: async (input: ThreadInput) => {
229
+ const parsed = this.validate(() => threadInputSchema.parse(input));
230
+ await this.getItem(parsed.rootId);
231
+ return this.repository.participation.changes(parsed, this.actor);
232
+ },
233
+ create: (input: ReplyCreateInput) =>
234
+ this.mutate('replies.create', input, () => this.createReply(input)),
235
+ get: (id: string) => this.getReply(id),
236
+ delete: (input: ReplyDeleteInput) =>
237
+ this.mutate('replies.delete', input, () => this.deleteReply(input)),
238
+ };
239
+ readonly threads = {
240
+ read: async (input: { rootId: string; through: string }) => {
241
+ const parsed = this.validate(() =>
242
+ z
243
+ .object({ rootId: z.string().length(26), through: z.string().max(4096) })
244
+ .strict()
245
+ .parse(input),
246
+ );
247
+ await this.personalWrite(async () => {
248
+ await this.getItem(parsed.rootId);
249
+ await this.repository.participation.read(parsed.rootId, parsed.through, this.boundRef());
250
+ });
251
+ },
252
+ get: async (input: ThreadInput) => {
253
+ const parsed = this.validate(() => threadInputSchema.parse(input));
254
+ await this.getItem(parsed.rootId);
255
+ return this.repository.participation.thread(parsed, this.actor);
256
+ },
257
+ subscription: async (input: { rootId: string; following: boolean; muted: boolean }) => {
258
+ const parsed = this.validate(() => threadSubscriptionSchema.parse(input));
259
+ await this.personalWrite(async () => {
260
+ await this.getItem(parsed.rootId);
261
+ await this.repository.participation.subscription(
262
+ parsed.rootId,
263
+ this.boundRef(),
264
+ parsed.following,
265
+ parsed.muted,
266
+ );
267
+ });
268
+ },
269
+ };
270
+ readonly reactions = {
271
+ set: (input: ReactionSetInput) =>
272
+ this.mutate('reactions.set', input, () => this.setReaction(input)),
273
+ get: async (targetId: string) => {
274
+ await this.reactionRoot(targetId);
275
+ return this.repository.participation.reactions(targetId, this.boundRef());
276
+ },
277
+ };
278
+ private allowedActions(record: ItemRecord): Record<string, boolean> {
279
+ const event = record.event;
280
+ const active =
281
+ record.status !== 'archived' &&
282
+ (!('revocationStatus' in record) || record.revocationStatus !== 'revoked');
283
+ const owner = 'owner' in event ? event.owner : event.actor;
284
+ const participant =
285
+ 'assignee' in event &&
286
+ (this.canManage(event.assignee) || this.canManage(event.actor as ActorRef));
287
+ const managed =
288
+ event.type === 'memo.sent'
289
+ ? this.canManage(event.recipient)
290
+ : event.type === 'task.created'
291
+ ? participant
292
+ : this.canManage(owner as ActorRef);
293
+ const flags: Record<string, boolean> = {
294
+ reply: active,
295
+ edit:
296
+ active &&
297
+ managed &&
298
+ ['post.created', 'note.created', 'task.created', 'todo.created'].includes(event.type),
299
+ archive: active && managed && !['task.created', 'kudos.given'].includes(event.type),
300
+ moderateReplies: this.canModerateRoot(record),
301
+ acknowledge:
302
+ event.type === 'kudos.given'
303
+ ? active && sameActor(this.actor, event.recipient) && record.status === 'unacknowledged'
304
+ : event.type === 'post.created'
305
+ ? active &&
306
+ 'acknowledgments' in record &&
307
+ !record.acknowledgments.some((entry) => sameActor(this.actor, entry.actor))
308
+ : false,
309
+ withdrawAcknowledgment:
310
+ event.type === 'post.created' &&
311
+ active &&
312
+ 'acknowledgments' in record &&
313
+ record.acknowledgments.some((entry) => sameActor(this.actor, entry.actor)),
314
+ readMemo:
315
+ event.type === 'memo.sent' &&
316
+ active &&
317
+ sameActor(this.actor, event.recipient) &&
318
+ record.status === 'unread',
319
+ revoke: event.type === 'kudos.given' && active && this.canManage(event.actor as ActorRef),
320
+ };
321
+ if (event.type === 'task.created')
322
+ Object.assign(flags, {
323
+ accept: record.status === 'assigned' && this.canManage(event.assignee),
324
+ reject: record.status === 'assigned' && this.canManage(event.assignee),
325
+ complete: record.status === 'open' && participant,
326
+ reopen: ['completed', 'canceled'].includes(record.status) && participant,
327
+ cancel: ['assigned', 'open'].includes(record.status) && participant,
328
+ overrideDecision:
329
+ ['open', 'rejected'].includes(record.status) &&
330
+ this.actor.kind === 'human' &&
331
+ (this.administrative ||
332
+ (event.assignee.kind === 'agent' &&
333
+ overseesAgent(this.actor, this.authority, event.assignee.id)) ||
334
+ (event.actor.kind === 'agent' &&
335
+ overseesAgent(this.actor, this.authority, event.actor.id))),
336
+ });
337
+ if (event.type === 'todo.created')
338
+ Object.assign(flags, {
339
+ complete: record.status === 'open' && managed,
340
+ reopen: ['completed', 'canceled'].includes(record.status) && managed,
341
+ cancel: record.status === 'open' && managed,
342
+ });
343
+ return Object.fromEntries(
344
+ Object.entries(flags).map(([name, allowed]) => [name, this.writeAllowed && allowed]),
345
+ );
346
+ }
347
+ private async decorateRecord<T extends ItemRecord>(record: T): Promise<T> {
348
+ return {
349
+ ...record,
350
+ lifecycleVersion: (await this.repository.nextAggregateVersion(record.event.id)) - 1,
351
+ allowedActions: this.allowedActions(record),
352
+ };
353
+ }
354
+ private async getReply(id: string): Promise<ReplyRecord> {
355
+ const reply = await this.repository.participation.getReply(id);
356
+ if (!reply) throw new SynomemError('ITEM_NOT_FOUND', 'Unknown reply.');
357
+ await this.getItem(reply.rootId);
358
+ return reply;
359
+ }
360
+ private async createReply(input: ReplyCreateInput): Promise<ReplyRecord> {
361
+ await this.repository.assertEventCompatibility();
362
+ const parsed = this.validate(() => replyCreateSchema.parse(input));
363
+ const root = await this.getItem(parsed.rootId);
364
+ if (
365
+ root.status === 'archived' ||
366
+ ('revocationStatus' in root && root.revocationStatus === 'revoked')
367
+ )
368
+ throw new SynomemError('MUTATION_FORBIDDEN', 'This root no longer accepts replies.');
369
+ if (parsed.parentId) {
370
+ const parent = await this.getReply(parsed.parentId);
371
+ if (parent.rootId !== parsed.rootId)
372
+ throw new SynomemError('INVALID_INPUT', 'The parent must belong to this thread.');
373
+ }
374
+ const mentions: ActorRef[] = [];
375
+ for (const supplied of parsed.mentions ?? []) {
376
+ const target = await this.resolveActor(supplied, true);
377
+ const ref = { kind: target.kind, id: target.id };
378
+ if (!(await this.repository.canActorReadItem(parsed.rootId, ref)))
379
+ throw new SynomemError('INVALID_INPUT', 'Mention targets must already have access.');
380
+ if (!mentions.some((actor) => sameActor(actor, ref))) mentions.push(ref);
381
+ }
382
+ await this.repository.participation.reserveBudget('reply', this.boundRef(), this.now());
383
+ const id = this.nextId();
384
+ await this.repository.insertEvent({
385
+ ...this.eventBase(id, 1, id),
386
+ type: 'reply.created',
387
+ rootId: parsed.rootId,
388
+ parentId: parsed.parentId ?? null,
389
+ body: parsed.body,
390
+ mentions,
391
+ });
392
+ return this.getReply(id);
393
+ }
394
+ private canModerateRoot(root: ItemRecord): boolean {
395
+ if (this.actor.kind !== 'human') return false;
396
+ const event = root.event;
397
+ if (this.administrative || sameActor(event.actor, this.actor)) return true;
398
+ const targets = [
399
+ event.actor,
400
+ ...('recipient' in event ? [event.recipient] : []),
401
+ ...('owner' in event ? [event.owner] : []),
402
+ ...('assignee' in event ? [event.assignee] : []),
403
+ ];
404
+ return targets.some(
405
+ (ref) => ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id),
406
+ );
407
+ }
408
+ private async deleteReply(input: ReplyDeleteInput): Promise<ReplyRecord> {
409
+ await this.repository.assertEventCompatibility();
410
+ const parsed = this.validate(() => replyDeleteSchema.parse(input));
411
+ const reply = await this.getReply(parsed.replyId);
412
+ const root = await this.getItem(reply.rootId);
413
+ const own = sameActor(reply.author, this.actor);
414
+ if (!own && !this.canModerateRoot(root))
415
+ throw new SynomemError(
416
+ 'MUTATION_FORBIDDEN',
417
+ 'Only the reply author or an authorized human moderator may delete it.',
418
+ );
419
+ if (!own && !parsed.reason)
420
+ throw new SynomemError('INVALID_INPUT', 'Moderation requires a reason.');
421
+ if (reply.version !== parsed.expectedVersion)
422
+ throw new SynomemError('REVISION_CONFLICT', 'The reply changed. Refresh before deleting.');
423
+ if (reply.deleted) return reply;
424
+ await this.repository.insertEvent({
425
+ ...this.eventBase(reply.id, reply.version + 1),
426
+ ...(!own ? this.intervention(reply.author as ActorRef, parsed.reason) : {}),
427
+ type: 'reply.deleted',
428
+ replyId: reply.id,
429
+ rootId: reply.rootId,
430
+ ...(parsed.reason ? { reason: parsed.reason } : {}),
431
+ });
432
+ return this.getReply(reply.id);
433
+ }
434
+ private async reactionRoot(targetId: string): Promise<string> {
435
+ const reply = await this.repository.participation.getReply(targetId);
436
+ if (reply) {
437
+ await this.getItem(reply.rootId);
438
+ if (reply.deleted)
439
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Deleted replies cannot receive reactions.');
440
+ return reply.rootId;
441
+ }
442
+ await this.getItem(targetId);
443
+ return targetId;
444
+ }
445
+ private async setReaction(input: ReactionSetInput) {
446
+ await this.repository.assertEventCompatibility();
447
+ const parsed = this.validate(() => reactionSetSchema.parse(input));
448
+ const rootId = await this.reactionRoot(parsed.targetId);
449
+ const actor = this.boundRef();
450
+ const present = await this.repository.participation.hasReaction(
451
+ parsed.targetId,
452
+ actor,
453
+ parsed.code,
454
+ );
455
+ if (present !== parsed.present) {
456
+ await this.repository.participation.reserveBudget('reaction', actor, this.now());
457
+ const id = this.nextId();
458
+ await this.repository.insertEvent({
459
+ ...this.eventBase(id, 1, id),
460
+ type: parsed.present ? 'reaction.added' : 'reaction.removed',
461
+ rootId,
462
+ targetId: parsed.targetId,
463
+ code: parsed.code,
464
+ });
465
+ }
466
+ return this.repository.participation.reactions(parsed.targetId, actor);
467
+ }
468
+ async search(input: SearchInput): Promise<SearchPage> {
469
+ this.checkAbort();
470
+ if (!this.repository.search)
471
+ throw new SynomemError('UNSUPPORTED_BACKEND', 'Search is available on hosted workspaces.');
472
+ return this.repository.search(input, this.actor);
473
+ }
132
474
  readonly agents = {
133
475
  create: (input: CreateAgentInput) => this.createAgent(input),
134
476
  update: (id: string, changes: UpdateAgentInput) => this.updateAgent(id, changes),
@@ -145,7 +487,6 @@ export class SynomemCore implements SynomemDomainService {
145
487
  bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
146
488
  unbindRuntime: (bindingId: string) => this.unbindRuntime(bindingId),
147
489
  };
148
-
149
490
  readonly topics = {
150
491
  create: (input: CreateTopicInput) => this.createTopic(input),
151
492
  update: (idOrAlias: string, changes: UpdateTopicInput) =>
@@ -156,91 +497,147 @@ export class SynomemCore implements SynomemDomainService {
156
497
  archive: (idOrAlias: string) => this.setTopicStatus(idOrAlias, 'archived'),
157
498
  restore: (idOrAlias: string) => this.setTopicStatus(idOrAlias, 'active'),
158
499
  };
159
-
160
500
  readonly kudos = {
161
- give: (input: GiveKudosInput) => this.giveKudos(input),
501
+ give: (input: GiveKudosInput) => this.mutate('kudos.give', input, () => this.giveKudos(input)),
162
502
  list: (input: KudosListInput = {}) => this.listKudos(input),
163
503
  changes: (input: KudosChangesInput = {}) => this.listKudosChanges(input),
164
504
  get: (id: string) => this.getKudos(id),
165
- acknowledge: (input: { kudosId: string; note?: string }) => this.acknowledgeKudos(input),
166
- revoke: (input: { kudosId: string; reason: string; administrative?: boolean }) =>
167
- this.revokeKudos(input),
505
+ acknowledge: (input: {
506
+ expectedVersion: number;
507
+ kudosId: string;
508
+ note?: string;
509
+ idempotencyKey?: string;
510
+ }) => this.mutate('kudos.acknowledge', input, () => this.acknowledgeKudos(input)),
511
+ revoke: (input: {
512
+ expectedVersion: number;
513
+ kudosId: string;
514
+ reason: string;
515
+ administrative?: boolean;
516
+ idempotencyKey?: string;
517
+ }) => this.mutate('kudos.revoke', input, () => this.revokeKudos(input)),
168
518
  };
169
-
170
519
  readonly memos = {
171
- send: (input: SendMemoInput) => this.sendMemo(input),
520
+ send: (input: SendMemoInput) => this.mutate('memos.send', input, () => this.sendMemo(input)),
172
521
  list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
173
522
  this.listItems({ ...input, kinds: ['memo'] }),
174
523
  get: (id: string) => this.getMemo(id),
175
- read: (input: { memoId: string; idempotencyKey?: string }) => this.readMemo(input),
176
- archive: (input: { memoId: string; idempotencyKey?: string }) => this.archiveMemo(input),
524
+ read: (input: { expectedVersion: number; memoId: string; idempotencyKey?: string }) =>
525
+ this.mutate('memos.read', input, () => this.readMemo(input)),
526
+ archive: (input: { expectedVersion: number; memoId: string; idempotencyKey?: string }) =>
527
+ this.mutate('memos.archive', input, () => this.archiveMemo(input)),
177
528
  };
178
-
179
529
  readonly posts = {
180
- create: (input: CreatePostInput) => this.createPost(input),
530
+ create: (input: CreatePostInput) =>
531
+ this.mutate('posts.create', input, () => this.createPost(input)),
181
532
  list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
182
533
  this.listItems({ ...input, kinds: ['post'] }),
183
534
  get: (id: string) => this.getPost(id),
184
- update: (input: UpdatePostInput) => this.updatePost(input),
185
- archive: (input: { postId: string; reason?: string; idempotencyKey?: string }) =>
186
- this.archivePost(input),
535
+ update: (input: UpdatePostInput) =>
536
+ this.mutate('posts.update', input, () => this.updatePost(input)),
537
+ archive: (input: {
538
+ expectedVersion: number;
539
+ postId: string;
540
+ reason?: string;
541
+ idempotencyKey?: string;
542
+ }) => this.mutate('posts.archive', input, () => this.archivePost(input)),
187
543
  /*
188
544
  * Acknowledging is an explicit call and always speaks for the caller alone.
189
545
  * There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
190
546
  * is one actor saying "I have seen this", and reading a post must never
191
547
  * append one, or the roster stops meaning anything.
192
548
  */
193
- acknowledge: (input: { postId: string; note?: string; idempotencyKey?: string }) =>
194
- this.acknowledgePost(input),
195
- withdrawAcknowledgment: (input: { postId: string; reason?: string }) =>
196
- this.withdrawPostAcknowledgment(input),
549
+ acknowledge: (input: {
550
+ expectedVersion: number;
551
+ postId: string;
552
+ note?: string;
553
+ idempotencyKey?: string;
554
+ }) => this.mutate('posts.acknowledge', input, () => this.acknowledgePost(input)),
555
+ withdrawAcknowledgment: (input: {
556
+ expectedVersion: number;
557
+ postId: string;
558
+ reason?: string;
559
+ idempotencyKey?: string;
560
+ }) =>
561
+ this.mutate('posts.withdrawAcknowledgment', input, () =>
562
+ this.withdrawPostAcknowledgment(input),
563
+ ),
197
564
  roster: (postId: string) => this.postRoster(postId),
198
565
  };
199
-
200
566
  readonly notes = {
201
- create: (input: CreateNoteInput) => this.createNote(input),
567
+ create: (input: CreateNoteInput) =>
568
+ this.mutate('notes.create', input, () => this.createNote(input)),
202
569
  list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
203
570
  this.listItems({ ...input, kinds: ['note'] }),
204
571
  get: (id: string) => this.getNote(id),
205
- revise: (input: ReviseNoteInput) => this.reviseNote(input),
206
- archive: (input: { noteId: string; idempotencyKey?: string }) => this.archiveNote(input),
572
+ revise: (input: ReviseNoteInput) =>
573
+ this.mutate('notes.revise', input, () => this.reviseNote(input)),
574
+ archive: (input: { expectedVersion: number; noteId: string; idempotencyKey?: string }) =>
575
+ this.mutate('notes.archive', input, () => this.archiveNote(input)),
207
576
  };
208
-
209
577
  readonly tasks = {
210
- create: (input: CreateTaskInput) => this.createTask(input),
578
+ overrideDecision: (input: OverrideTaskDecisionInput) =>
579
+ this.mutate('tasks.overrideDecision', input, () => this.overrideTaskDecision(input)),
580
+ create: (input: CreateTaskInput) =>
581
+ this.mutate('tasks.create', input, () => this.createTask(input)),
211
582
  list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
212
583
  this.listItems({ ...input, kinds: ['task'] }),
213
584
  get: (id: string) => this.getTask(id),
214
- update: (input: UpdateTaskInput) => this.updateTask(input),
585
+ update: (input: UpdateTaskInput) =>
586
+ this.mutate('tasks.update', input, () => this.updateTask(input)),
215
587
  // A response is optional when accepting and required when rejecting: a
216
588
  // refusal without a reason leaves the assigner unable to act on it.
217
- accept: (input: { taskId: string; response?: string; idempotencyKey?: string }) =>
218
- this.acceptTask(input),
219
- reject: (input: { taskId: string; response: string; idempotencyKey?: string }) =>
220
- this.rejectTask(input),
221
- complete: (input: { taskId: string; note?: string; idempotencyKey?: string }) =>
222
- this.completeTask(input),
223
- reopen: (input: { taskId: string; idempotencyKey?: string }) => this.reopenTask(input),
224
- cancel: (input: { taskId: string; reason?: string; idempotencyKey?: string }) =>
225
- this.cancelTask(input),
589
+ accept: (input: {
590
+ expectedVersion: number;
591
+ taskId: string;
592
+ response?: string;
593
+ idempotencyKey?: string;
594
+ }) => this.mutate('tasks.accept', input, () => this.acceptTask(input)),
595
+ reject: (input: {
596
+ expectedVersion: number;
597
+ taskId: string;
598
+ response: string;
599
+ idempotencyKey?: string;
600
+ }) => this.mutate('tasks.reject', input, () => this.rejectTask(input)),
601
+ complete: (input: {
602
+ expectedVersion: number;
603
+ taskId: string;
604
+ note?: string;
605
+ idempotencyKey?: string;
606
+ }) => this.mutate('tasks.complete', input, () => this.completeTask(input)),
607
+ reopen: (input: { expectedVersion: number; taskId: string; idempotencyKey?: string }) =>
608
+ this.mutate('tasks.reopen', input, () => this.reopenTask(input)),
609
+ cancel: (input: {
610
+ expectedVersion: number;
611
+ taskId: string;
612
+ reason?: string;
613
+ idempotencyKey?: string;
614
+ }) => this.mutate('tasks.cancel', input, () => this.cancelTask(input)),
226
615
  };
227
-
228
616
  readonly todos = {
229
- create: (input: CreateTodoInput) => this.createTodo(input),
617
+ create: (input: CreateTodoInput) =>
618
+ this.mutate('todos.create', input, () => this.createTodo(input)),
230
619
  list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
231
620
  this.listItems({ ...input, kinds: ['todo'] }),
232
621
  get: (id: string) => this.getTodo(id),
233
- update: (input: UpdateTodoInput) => this.updateTodo(input),
234
- complete: (input: { todoId: string; note?: string; idempotencyKey?: string }) =>
235
- this.todoTransition(input, 'todo.completed'),
236
- reopen: (input: { todoId: string; idempotencyKey?: string }) =>
237
- this.todoTransition(input, 'todo.reopened'),
238
- cancel: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
239
- this.todoTransition(input, 'todo.canceled'),
240
- archive: (input: { todoId: string; idempotencyKey?: string }) =>
241
- this.todoTransition(input, 'todo.archived'),
622
+ update: (input: UpdateTodoInput) =>
623
+ this.mutate('todos.update', input, () => this.updateTodo(input)),
624
+ complete: (input: {
625
+ expectedVersion: number;
626
+ todoId: string;
627
+ note?: string;
628
+ idempotencyKey?: string;
629
+ }) => this.mutate('todos.complete', input, () => this.todoTransition(input, 'todo.completed')),
630
+ reopen: (input: { expectedVersion: number; todoId: string; idempotencyKey?: string }) =>
631
+ this.mutate('todos.reopen', input, () => this.todoTransition(input, 'todo.reopened')),
632
+ cancel: (input: {
633
+ expectedVersion: number;
634
+ todoId: string;
635
+ reason?: string;
636
+ idempotencyKey?: string;
637
+ }) => this.mutate('todos.cancel', input, () => this.todoTransition(input, 'todo.canceled')),
638
+ archive: (input: { expectedVersion: number; todoId: string; idempotencyKey?: string }) =>
639
+ this.mutate('todos.archive', input, () => this.todoTransition(input, 'todo.archived')),
242
640
  };
243
-
244
641
  /**
245
642
  * Unanswered and overdue discovery.
246
643
  *
@@ -255,13 +652,15 @@ export class SynomemCore implements SynomemDomainService {
255
652
  * optionally only those older than a given age or instant.
256
653
  */
257
654
  unanswered: (
258
- input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & { olderThanHours?: number } = {},
655
+ input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & {
656
+ olderThanHours?: number;
657
+ } = {},
259
658
  ) => {
260
659
  const { olderThanHours, awaitingSince, ...rest } = input;
261
660
  const since =
262
661
  awaitingSince ??
263
662
  (olderThanHours !== undefined
264
- ? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
663
+ ? new Date(Date.now() - olderThanHours * 3600000).toISOString()
265
664
  : undefined);
266
665
  return this.listItems({
267
666
  ...rest,
@@ -270,18 +669,20 @@ export class SynomemCore implements SynomemDomainService {
270
669
  });
271
670
  },
272
671
  /** Open work whose deadline has passed. Defaults to "now". */
273
- overdue: (input: Omit<ItemListInput, 'overdueAsOf'> & { asOf?: string } = {}) => {
672
+ overdue: (
673
+ input: Omit<ItemListInput, 'overdueAsOf'> & {
674
+ asOf?: string;
675
+ } = {},
676
+ ) => {
274
677
  const { asOf, ...rest } = input;
275
678
  return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
276
679
  },
277
680
  };
278
-
279
681
  readonly items = {
280
682
  list: (input: ItemListInput = {}) => this.listItems(input),
281
683
  get: (id: string) => this.getItem(id),
282
684
  changes: (input: ChangesInput = {}) => this.listItemChanges(input),
283
685
  };
284
-
285
686
  constructor(options: SynomemCoreOptions) {
286
687
  try {
287
688
  this.actor = actorSchema.parse(options.actor ?? { kind: 'system', id: 'workspace' });
@@ -291,17 +692,39 @@ export class SynomemCore implements SynomemDomainService {
291
692
  this.clock = options.clock ?? (() => new Date());
292
693
  this.idGenerator = options.idGenerator ?? (() => ulid(this.clock().getTime()));
293
694
  this.signal = options.signal;
294
- this.administrative = options.administrative ?? this.actor.kind === 'human';
695
+ this.authority = resolveAuthority(options.authority);
696
+ this.writeAllowed = options.canWrite ?? true;
697
+ this.administrative = isRecordAdministrator(this.actor, this.authority);
295
698
  this.repository = options.repository;
296
699
  this.projectionWriter = options.projectionWriter;
297
700
  }
298
-
701
+ /** Trusted adapters refresh live authority after acquiring the workspace write boundary. Never exposed as a tool/input. */
702
+ setAuthority(authority: RecordAuthority, canWrite = this.writeAllowed): void {
703
+ this.writeAllowed = canWrite;
704
+ this.authority = resolveAuthority(authority);
705
+ this.administrative = isRecordAdministrator(this.actor, this.authority);
706
+ this.repository.setAuthority(this.authority);
707
+ }
299
708
  async init(): Promise<void> {
300
709
  this.checkAbort();
301
710
  if (this.initialized) return;
302
711
  await this.repository.init();
303
712
  this.initialized = true;
304
-
713
+ if (
714
+ this.actor.kind === 'human' &&
715
+ this.authority.mode === 'local-owner' &&
716
+ !(await this.repository.getActor({ kind: 'human', id: this.actor.id }))
717
+ ) {
718
+ await this.repository.transaction(() =>
719
+ this.repository.registerHuman({
720
+ kind: 'human',
721
+ id: this.actor.id,
722
+ handle: this.actor.id,
723
+ displayName: this.actor.displayName ?? this.actor.id,
724
+ status: 'active',
725
+ }),
726
+ );
727
+ }
305
728
  /*
306
729
  * An agent actor is resolved to its canonical identity here.
307
730
  *
@@ -326,23 +749,393 @@ export class SynomemCore implements SynomemDomainService {
326
749
  }
327
750
  }
328
751
  }
329
-
330
752
  async close(): Promise<void> {
331
753
  await this.repository.close();
332
754
  this.initialized = false;
333
755
  }
334
-
756
+ readonly actors = {
757
+ profile: async (input: ActorProfileInput) => {
758
+ const actor = await this.resolveActor(input.target);
759
+ return {
760
+ actor,
761
+ counts: await this.repository.actorCounts(actor, this.actor),
762
+ authored: await this.listItems({
763
+ actorKind: actor.kind,
764
+ actorId: actor.id,
765
+ cursor: input.after,
766
+ limit: input.limit,
767
+ }),
768
+ };
769
+ },
770
+ list: async (input: ActorDirectoryInput = {}) => {
771
+ this.checkAbort();
772
+ return await this.repository.listActors(actorDirectoryInput(input), this.actor);
773
+ },
774
+ get: (ref: ActorRef) => this.resolveActor(ref),
775
+ registerHuman: async (input: { id: string; handle: string; displayName: string }) => {
776
+ if (this.actor.kind !== 'human' || this.authority.mode !== 'local-owner')
777
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the local owner may register a human.');
778
+ const ref = actorSchema.parse({
779
+ kind: 'human',
780
+ id: input.id,
781
+ displayName: input.displayName,
782
+ });
783
+ const handle = agentLookupSchema.parse(input.handle);
784
+ await this.repository.registerHuman({
785
+ kind: 'human',
786
+ id: ref.id,
787
+ handle,
788
+ displayName: input.displayName,
789
+ status: 'active',
790
+ });
791
+ return this.resolveActor({ kind: 'human', id: ref.id });
792
+ },
793
+ };
794
+ private async resolveActor(ref?: ActorRef, requireActive = false): Promise<AddressableActor> {
795
+ if (!ref || (ref.kind !== 'human' && ref.kind !== 'agent'))
796
+ throw new SynomemError('INVALID_INPUT', 'An actor reference is required.');
797
+ const actor = await this.repository.getActor(ref);
798
+ if (!actor) throw new SynomemError('AGENT_NOT_FOUND', `Unknown ${ref.kind}: ${ref.id}`);
799
+ if (requireActive && actor.status !== 'active')
800
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Inactive actors cannot receive new work.');
801
+ return actor;
802
+ }
803
+ private boundRef(): ActorRef {
804
+ if (this.actor.kind === 'system')
805
+ throw new SynomemError('MUTATION_FORBIDDEN', 'A human or agent identity is required.');
806
+ return { kind: this.actor.kind, id: this.actor.id };
807
+ }
808
+ private canManage(ref: ActorRef): boolean {
809
+ return (
810
+ sameActor(ref, this.actor) ||
811
+ this.administrative ||
812
+ (ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id))
813
+ );
814
+ }
815
+ private intervention(ref: ActorRef, reason?: string): Pick<SynomemEvent, 'intervention'> {
816
+ if (this.actor.kind !== 'human' || sameActor(this.actor, ref) || !this.canManage(ref))
817
+ return {};
818
+ const basis =
819
+ this.authority.mode === 'local-owner'
820
+ ? 'local_owner'
821
+ : this.authority.organizationAdministrator
822
+ ? 'organization_admin'
823
+ : this.authority.workspaceAdministrator
824
+ ? 'workspace_admin'
825
+ : 'operator';
826
+ return { intervention: { basis, ...(reason ? { reason } : {}) } };
827
+ }
828
+ private async syncActor(ref: ActorRef): Promise<void> {
829
+ if (ref.kind === 'agent') {
830
+ const context = this.mutationContext.getStore();
831
+ if (context) context.projections.add(ref.id);
832
+ else await this.projectionWriter.syncAgent(ref.id);
833
+ }
834
+ }
835
+ private async normalizeMutation(
836
+ operation: string,
837
+ input: object,
838
+ ): Promise<Record<string, unknown>> {
839
+ const schemas: Record<string, { parse: (value: unknown) => unknown }> = {
840
+ 'kudos.give': giveKudosSchema,
841
+ 'replies.create': replyCreateSchema,
842
+ 'replies.delete': replyDeleteSchema,
843
+ 'reactions.set': reactionSetSchema,
844
+ 'memos.send': sendMemoSchema,
845
+ 'notes.create': createNoteSchema,
846
+ 'notes.revise': reviseNoteSchema,
847
+ 'posts.create': createPostSchema,
848
+ 'posts.update': updatePostSchema,
849
+ 'tasks.create': createTaskSchema,
850
+ 'tasks.update': updateTaskSchema,
851
+ 'tasks.overrideDecision': overrideTaskDecisionSchema,
852
+ 'todos.create': createTodoSchema,
853
+ 'todos.update': updateTodoSchema,
854
+ };
855
+ const value = this.validate(
856
+ () =>
857
+ schemas[operation]?.parse(
858
+ operation === 'kudos.give'
859
+ ? {
860
+ ...input,
861
+ visibility:
862
+ (input as GiveKudosInput).visibility ?? this.repository.config.defaultVisibility,
863
+ }
864
+ : input,
865
+ ) ?? input,
866
+ ) as Record<string, unknown>;
867
+ const normalized = { ...value };
868
+ delete normalized.idempotencyKey;
869
+ for (const field of ['recipient', 'owner', 'assignee']) {
870
+ const supplied = normalized[field] as ActorRef | undefined;
871
+ if (supplied) {
872
+ const actor = await this.resolveActor(supplied);
873
+ normalized[field] = { kind: actor.kind, id: actor.id };
874
+ }
875
+ }
876
+ for (const field of [
877
+ 'title',
878
+ 'body',
879
+ 'subject',
880
+ 'reason',
881
+ 'response',
882
+ 'note',
883
+ 'details',
884
+ 'description',
885
+ ])
886
+ if (typeof normalized[field] === 'string') normalized[field] = normalized[field].trim();
887
+ for (const field of ['tags', 'topicIds'])
888
+ if (
889
+ field in normalized ||
890
+ operation.endsWith('.create') ||
891
+ ['kudos.give', 'memos.send'].includes(operation)
892
+ )
893
+ normalized[field] = [...new Set((normalized[field] ?? []) as string[])].sort();
894
+ if (['kudos.give', 'memos.send', 'tasks.create'].includes(operation))
895
+ normalized.visibility ??= this.repository.config.defaultVisibility;
896
+ if (['notes.create', 'todos.create'].includes(operation)) normalized.owner ??= this.boundRef();
897
+ if (operation === 'tasks.create') normalized.assignee ??= this.boundRef();
898
+ if (['tasks.create', 'todos.create'].includes(operation)) normalized.priority ??= 3;
899
+ if (['replies.create', 'posts.create', 'posts.update'].includes(operation)) {
900
+ if (operation === 'replies.create') normalized.parentId ??= null;
901
+ if (operation === 'posts.update' && normalized.mentions === undefined) return normalized;
902
+ const mentions: ActorRef[] = [];
903
+ for (const supplied of (normalized.mentions ?? []) as ActorRef[]) {
904
+ let actor: AddressableActor;
905
+ try {
906
+ actor = await this.resolveActor(supplied, true);
907
+ } catch (error) {
908
+ if (
909
+ error instanceof SynomemError &&
910
+ ['AGENT_NOT_FOUND', 'INVALID_INPUT'].includes(error.code)
911
+ )
912
+ throw new SynomemError(
913
+ 'INVALID_INPUT',
914
+ 'Mention targets must be active and accessible.',
915
+ );
916
+ throw error;
917
+ }
918
+ if (!mentions.some((ref) => sameActor(ref, actor)))
919
+ mentions.push({ kind: actor.kind, id: actor.id });
920
+ }
921
+ normalized.mentions = mentions.sort((a, b) =>
922
+ `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`),
923
+ );
924
+ }
925
+ return normalized;
926
+ }
927
+ private async authorizeReceipt(operation: string, result: unknown): Promise<void> {
928
+ if (operation.startsWith('replies.')) {
929
+ const reply = await this.getReply((result as ReplyRecord).id);
930
+ if (
931
+ operation === 'replies.delete' &&
932
+ !sameActor(reply.author, this.actor) &&
933
+ !this.canModerateRoot(await this.getItem(reply.rootId))
934
+ )
935
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Moderation authority was removed.');
936
+ return;
937
+ }
938
+ if (operation === 'reactions.set') {
939
+ await this.reactionRoot((result as { targetId: string }).targetId);
940
+ return;
941
+ }
942
+ const candidate = result as { record?: { event?: { id?: string } }; event?: { id?: string } };
943
+ const id = candidate.record?.event?.id ?? candidate.event?.id;
944
+ if (!id)
945
+ throw new SynomemError(
946
+ 'IDEMPOTENCY_EXPIRED',
947
+ 'The cached mutation result cannot be reconstructed.',
948
+ );
949
+ const record = await this.getItem(id);
950
+ const event = record.event;
951
+ if (operation.startsWith('notes.')) this.assertNoteOwner(record as NoteRecord);
952
+ else if (operation.startsWith('todos.')) this.assertTodoOwner(record as TodoRecord);
953
+ else if (operation.startsWith('tasks.') && operation !== 'tasks.create') {
954
+ if (['tasks.accept', 'tasks.reject'].includes(operation))
955
+ this.assertTaskAssignee(record as TaskRecord);
956
+ else this.assertTaskParticipant(record as TaskRecord);
957
+ if (
958
+ operation === 'tasks.overrideDecision' &&
959
+ (this.actor.kind !== 'human' ||
960
+ !(
961
+ this.administrative ||
962
+ ((record as TaskRecord).event.assignee.kind === 'agent' &&
963
+ overseesAgent(
964
+ this.actor,
965
+ this.authority,
966
+ (record as TaskRecord).event.assignee.id,
967
+ )) ||
968
+ (event.actor.kind === 'agent' &&
969
+ overseesAgent(this.actor, this.authority, event.actor.id))
970
+ ))
971
+ )
972
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only a human overseer may override a task.');
973
+ } else if (
974
+ operation.startsWith('posts.') &&
975
+ ['posts.update', 'posts.archive'].includes(operation)
976
+ )
977
+ this.assertPostAuthor(record as PostRecord);
978
+ else if (operation === 'memos.read')
979
+ this.assertRecipient((record as MemoRecord).event.recipient, 'mark this memo read');
980
+ else if (operation === 'memos.archive')
981
+ this.assertRecipient((record as MemoRecord).event.recipient, 'archive this memo', true);
982
+ else if (
983
+ operation === 'kudos.acknowledge' &&
984
+ !sameActor(this.actor, (record as KudosRecord).event.recipient)
985
+ )
986
+ throw new SynomemError(
987
+ 'ACKNOWLEDGMENT_FORBIDDEN',
988
+ 'Only the recipient may acknowledge kudos.',
989
+ );
990
+ else if (operation === 'kudos.revoke' && !this.canManage(event.actor as ActorRef))
991
+ throw new SynomemError(
992
+ 'REVOCATION_FORBIDDEN',
993
+ 'Only the author or an authorized overseer may revoke kudos.',
994
+ );
995
+ for (const field of ['recipient', 'owner', 'assignee'] as const)
996
+ if (field in event) {
997
+ const ref = (event as unknown as Record<string, ActorRef>)[field]!;
998
+ await this.syncActor(ref);
999
+ }
1000
+ }
1001
+ private personalWrite<T>(operation: () => Promise<T>): Promise<T> {
1002
+ this.checkAbort();
1003
+ if (!this.writeAllowed)
1004
+ throw new SynomemError('READ_ONLY', 'This binding cannot change personal state.');
1005
+ return this.repository.transaction(async () => {
1006
+ await this.resolveActor(this.boundRef(), true);
1007
+ return operation();
1008
+ });
1009
+ }
1010
+ private async mutate<T>(operation: string, input: object, action: () => Promise<T>): Promise<T> {
1011
+ this.checkAbort();
1012
+ if (!this.writeAllowed)
1013
+ throw new SynomemError('READ_ONLY', 'This binding cannot write records.');
1014
+ const rawKey = (input as { idempotencyKey?: unknown }).idempotencyKey;
1015
+ if (
1016
+ rawKey !== undefined &&
1017
+ (typeof rawKey !== 'string' || !rawKey.trim() || rawKey.trim().length > 200)
1018
+ )
1019
+ throw new SynomemError('INVALID_INPUT', 'Idempotency keys must contain 1–200 characters.');
1020
+ const key = typeof rawKey === 'string' ? rawKey.trim() : undefined;
1021
+ const state = { ...(key ? { key } : {}), projections: new Set<string>() };
1022
+ const result = await this.mutationContext.run(state, () =>
1023
+ this.repository.transaction(async () => {
1024
+ if (this.actor.kind !== 'system') await this.resolveActor(this.boundRef(), true);
1025
+ const normalized = await this.normalizeMutation(operation, input);
1026
+ const requestHash = cursorFilter({ operation, request: normalized });
1027
+ const keyHash = key ? cursorFilter(key) : undefined;
1028
+ const prior = keyHash
1029
+ ? await this.repository.getMutationReceipt(this.actor.kind, this.actor.id, keyHash)
1030
+ : undefined;
1031
+ if (prior) {
1032
+ if (prior.operation !== operation || prior.requestHash !== requestHash)
1033
+ throw new SynomemError(
1034
+ 'IDEMPOTENCY_CONFLICT',
1035
+ 'This key was already used for a different request.',
1036
+ );
1037
+ if (!prior.resultJson)
1038
+ throw new SynomemError(
1039
+ 'IDEMPOTENCY_EXPIRED',
1040
+ 'The original response expired. This request will not execute again.',
1041
+ );
1042
+ const response = JSON.parse(prior.resultJson) as T;
1043
+ await this.authorizeReceipt(operation, response);
1044
+ if (operation === 'replies.create' || operation === 'replies.delete') {
1045
+ const current = await this.getReply((response as ReplyRecord).id);
1046
+ if (current.deleted)
1047
+ return { ...response, body: undefined, mentions: undefined, deleted: true };
1048
+ }
1049
+ if (response && typeof response === 'object' && 'created' in response)
1050
+ return { ...response, created: false, deduplicated: true };
1051
+ return response;
1052
+ }
1053
+ if (
1054
+ key &&
1055
+ (await this.repository.getEventByIdempotency(this.actor.kind, this.actor.id, key))
1056
+ )
1057
+ throw new SynomemError(
1058
+ 'IDEMPOTENCY_EXPIRED',
1059
+ 'The original request receipt is unavailable. This key will not execute again.',
1060
+ );
1061
+ const lifecycleOperations = new Set([
1062
+ 'kudos.acknowledge',
1063
+ 'kudos.revoke',
1064
+ 'memos.read',
1065
+ 'memos.archive',
1066
+ 'posts.archive',
1067
+ 'posts.acknowledge',
1068
+ 'posts.withdrawAcknowledgment',
1069
+ 'notes.archive',
1070
+ 'tasks.accept',
1071
+ 'tasks.reject',
1072
+ 'tasks.complete',
1073
+ 'tasks.reopen',
1074
+ 'tasks.cancel',
1075
+ 'todos.complete',
1076
+ 'todos.reopen',
1077
+ 'todos.cancel',
1078
+ 'todos.archive',
1079
+ ]);
1080
+ if (lifecycleOperations.has(operation)) {
1081
+ const supplied = (input as { expectedVersion?: unknown }).expectedVersion;
1082
+ if (!Number.isSafeInteger(supplied) || (supplied as number) < 1)
1083
+ throw new SynomemError(
1084
+ 'INVALID_INPUT',
1085
+ 'expectedVersion must be the current positive lifecycleVersion.',
1086
+ );
1087
+ const rootId = normalized[
1088
+ (operation.startsWith('kudos.')
1089
+ ? 'kudos'
1090
+ : operation.split('.')[0]!.replace(/s$/, '')) + 'Id'
1091
+ ] as string;
1092
+ const current = await this.getItem(rootId);
1093
+ await this.authorizeReceipt(operation, current);
1094
+ if (current.lifecycleVersion !== supplied)
1095
+ throw new SynomemError(
1096
+ 'REVISION_CONFLICT',
1097
+ 'The record changed. Refresh before applying this action.',
1098
+ );
1099
+ }
1100
+ const response = await action();
1101
+ if (keyHash) {
1102
+ const resultJson = JSON.stringify(response);
1103
+ if (Buffer.byteLength(resultJson) > 262144)
1104
+ throw new SynomemError(
1105
+ 'INVALID_INPUT',
1106
+ 'The mutation response exceeds the receipt budget.',
1107
+ );
1108
+ const eventId = (
1109
+ key
1110
+ ? await this.repository.getEventByIdempotency(this.actor.kind, this.actor.id, key)
1111
+ : undefined
1112
+ )?.id;
1113
+ await this.repository.insertMutationReceipt(this.actor.kind, this.actor.id, keyHash, {
1114
+ operation,
1115
+ requestHash,
1116
+ resultJson,
1117
+ ...(eventId ? { eventId } : {}),
1118
+ createdAt: this.now(),
1119
+ });
1120
+ }
1121
+ return response;
1122
+ }),
1123
+ );
1124
+ for (const id of state.projections) await this.projectionWriter.syncAgent(id);
1125
+ return result;
1126
+ }
335
1127
  private now(): string {
336
1128
  return this.clock().toISOString();
337
1129
  }
338
-
339
1130
  private nextId(): string {
340
1131
  return this.idGenerator();
341
1132
  }
342
-
343
1133
  private eventBase(aggregateId: string, aggregateVersion: number, id = this.nextId()) {
344
1134
  return {
345
- schemaVersion: 1 as const,
1135
+ schemaVersion: 2 as const,
1136
+ ...(this.mutationContext.getStore()?.key
1137
+ ? { idempotencyKey: this.mutationContext.getStore()!.key }
1138
+ : {}),
346
1139
  id,
347
1140
  workspaceId: this.repository.config.workspaceId,
348
1141
  aggregateId,
@@ -351,11 +1144,9 @@ export class SynomemCore implements SynomemDomainService {
351
1144
  actor: this.actor,
352
1145
  };
353
1146
  }
354
-
355
1147
  protected checkAbort(): void {
356
1148
  this.signal?.throwIfAborted();
357
1149
  }
358
-
359
1150
  private validate<T>(operation: () => T): T {
360
1151
  try {
361
1152
  return operation();
@@ -363,7 +1154,6 @@ export class SynomemCore implements SynomemDomainService {
363
1154
  throw asSynomemError(error);
364
1155
  }
365
1156
  }
366
-
367
1157
  private async createAgent(input: CreateAgentInput): Promise<AgentProfile> {
368
1158
  this.checkAbort();
369
1159
  await this.repository.assertEventCompatibility();
@@ -409,7 +1199,6 @@ export class SynomemCore implements SynomemDomainService {
409
1199
  await this.projectionWriter.syncAgent(profile.id);
410
1200
  return profile;
411
1201
  }
412
-
413
1202
  private async updateAgent(idOrAlias: string, changes: UpdateAgentInput): Promise<AgentProfile> {
414
1203
  this.checkAbort();
415
1204
  await this.repository.assertEventCompatibility();
@@ -467,7 +1256,6 @@ export class SynomemCore implements SynomemDomainService {
467
1256
  await this.projectionWriter.syncAgent(updated.id);
468
1257
  return updated;
469
1258
  }
470
-
471
1259
  /**
472
1260
  * Archiving stops an agent acting without erasing it.
473
1261
  *
@@ -499,21 +1287,18 @@ export class SynomemCore implements SynomemDomainService {
499
1287
  await this.projectionWriter.syncAgent(updated.id);
500
1288
  return updated;
501
1289
  }
502
-
503
1290
  /** Adds aliases without disturbing the ones already there. */
504
1291
  private async addAgentAliases(idOrAlias: string, add: string[]): Promise<AgentProfile> {
505
1292
  const existing = await this.getAgent(idOrAlias);
506
1293
  const merged = [...new Set([...(existing.aliases ?? []), ...add])].sort();
507
1294
  return await this.updateAgent(existing.id, { aliases: merged });
508
1295
  }
509
-
510
1296
  private async removeAgentAliases(idOrAlias: string, remove: string[]): Promise<AgentProfile> {
511
1297
  const existing = await this.getAgent(idOrAlias);
512
1298
  const drop = new Set(remove.map((alias) => alias.trim().toLowerCase()));
513
1299
  const kept = (existing.aliases ?? []).filter((alias) => !drop.has(alias));
514
1300
  return await this.updateAgent(existing.id, { aliases: kept });
515
1301
  }
516
-
517
1302
  private async getAgent(idOrAlias: string): Promise<AgentProfile> {
518
1303
  this.checkAbort();
519
1304
  this.validate(() => agentLookupSchema.parse(idOrAlias));
@@ -521,12 +1306,10 @@ export class SynomemCore implements SynomemDomainService {
521
1306
  if (!profile) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
522
1307
  return profile;
523
1308
  }
524
-
525
1309
  private async listAgents(): Promise<AgentProfile[]> {
526
1310
  this.checkAbort();
527
1311
  return await this.repository.listAgents();
528
1312
  }
529
-
530
1313
  /**
531
1314
  * Resolves a name without ever choosing between equally valid answers.
532
1315
  *
@@ -545,7 +1328,6 @@ export class SynomemCore implements SynomemDomainService {
545
1328
  candidates: resolved.candidates,
546
1329
  };
547
1330
  }
548
-
549
1331
  private async agentDirectory(): Promise<AgentDirectoryEntry[]> {
550
1332
  this.checkAbort();
551
1333
  const profiles = await this.repository.listAgents();
@@ -558,12 +1340,10 @@ export class SynomemCore implements SynomemDomainService {
558
1340
  }
559
1341
  return entries;
560
1342
  }
561
-
562
1343
  private async listRuntimeBindings(idOrAlias: string): Promise<AgentRuntimeBinding[]> {
563
1344
  const profile = await this.getAgent(idOrAlias);
564
1345
  return await this.repository.listRuntimeBindings(profile.id);
565
1346
  }
566
-
567
1347
  /**
568
1348
  * Records where an agent runs. Re-binding the same runtime and profile
569
1349
  * updates the claim in place rather than accumulating duplicates, because a
@@ -592,12 +1372,10 @@ export class SynomemCore implements SynomemDomainService {
592
1372
  if (!binding) throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
593
1373
  return binding;
594
1374
  }
595
-
596
1375
  private async unbindRuntime(bindingId: string): Promise<boolean> {
597
1376
  this.checkAbort();
598
1377
  return await this.repository.unbindRuntime(bindingId);
599
1378
  }
600
-
601
1379
  /* ---------------------------------------------------------------- topics *
602
1380
  * A topic is a controlled, reusable subject a record can be filed under —
603
1381
  * one canonical display name and a set of aliases, so a stable "Synomem"
@@ -606,7 +1384,6 @@ export class SynomemCore implements SynomemDomainService {
606
1384
  * an administrator renames or archives it, matching how an agent's own
607
1385
  * identity is managed.
608
1386
  */
609
-
610
1387
  private async createTopic(input: CreateTopicInput): Promise<Topic> {
611
1388
  this.checkAbort();
612
1389
  await this.repository.assertEventCompatibility();
@@ -641,7 +1418,6 @@ export class SynomemCore implements SynomemDomainService {
641
1418
  });
642
1419
  return topic;
643
1420
  }
644
-
645
1421
  private async updateTopicRecord(idOrAlias: string, changes: UpdateTopicInput): Promise<Topic> {
646
1422
  this.checkAbort();
647
1423
  await this.repository.assertEventCompatibility();
@@ -683,7 +1459,6 @@ export class SynomemCore implements SynomemDomainService {
683
1459
  });
684
1460
  return updated;
685
1461
  }
686
-
687
1462
  private async setTopicStatus(idOrAlias: string, status: 'active' | 'archived'): Promise<Topic> {
688
1463
  this.checkAbort();
689
1464
  await this.repository.assertEventCompatibility();
@@ -704,7 +1479,6 @@ export class SynomemCore implements SynomemDomainService {
704
1479
  });
705
1480
  return updated;
706
1481
  }
707
-
708
1482
  private async getTopicRecord(idOrAlias: string): Promise<Topic> {
709
1483
  this.checkAbort();
710
1484
  this.validate(() => topicLookupSchema.parse(idOrAlias));
@@ -712,20 +1486,17 @@ export class SynomemCore implements SynomemDomainService {
712
1486
  if (!topic) throw new SynomemError('TOPIC_NOT_FOUND', `Unknown topic: ${idOrAlias}`);
713
1487
  return topic;
714
1488
  }
715
-
716
1489
  private async listTopicsRecord(input: TopicListInput): Promise<Topic[]> {
717
1490
  this.checkAbort();
718
1491
  const parsed = this.validate(() => topicListInputSchema.parse(input));
719
1492
  return await this.repository.listTopics(parsed.status);
720
1493
  }
721
-
722
1494
  private async resolveTopicRecord(query: string): Promise<TopicResolution> {
723
1495
  this.checkAbort();
724
1496
  const trimmed = this.validate(() => topicLookupSchema.parse(query));
725
1497
  const resolved = await this.repository.resolveTopic(trimmed);
726
1498
  return { query: trimmed, ...resolved };
727
1499
  }
728
-
729
1500
  /**
730
1501
  * Every topic in `topicIds` must already exist and be active — a record
731
1502
  * cannot be filed under a subject that does not exist, or one somebody
@@ -739,7 +1510,6 @@ export class SynomemCore implements SynomemDomainService {
739
1510
  }
740
1511
  }
741
1512
  }
742
-
743
1513
  private async giveKudos(input: GiveKudosInput): Promise<GiveKudosResult> {
744
1514
  this.checkAbort();
745
1515
  await this.repository.assertEventCompatibility();
@@ -750,21 +1520,20 @@ export class SynomemCore implements SynomemDomainService {
750
1520
  }),
751
1521
  );
752
1522
  await this.assertTopicsExist(parsed.topicIds);
753
- const recipient = await this.repository.getAgent(parsed.recipientAgentId);
1523
+ const recipient = await this.resolveActor(parsed.recipient, true);
754
1524
  if (!recipient) {
755
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
1525
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipient?.id}`);
756
1526
  }
757
1527
  if (
758
1528
  !this.repository.config.allowSelfAwards &&
759
1529
  this.actor.kind !== 'human' &&
760
- this.actor.id === recipient.id
1530
+ sameActor(this.actor, recipient)
761
1531
  ) {
762
1532
  throw new SynomemError(
763
1533
  'SELF_AWARD_FORBIDDEN',
764
1534
  'Non-human actors cannot award kudos to a matching agent identity.',
765
1535
  );
766
1536
  }
767
-
768
1537
  const outcome = await this.repository.transaction(async () => {
769
1538
  const prior = await this.priorMutation(parsed.idempotencyKey, 'kudos.given');
770
1539
  if (prior?.type === 'kudos.given') return { event: prior, created: false };
@@ -772,7 +1541,7 @@ export class SynomemCore implements SynomemDomainService {
772
1541
  const event: KudosGivenEvent = {
773
1542
  ...this.eventBase(id, 1, id),
774
1543
  type: 'kudos.given',
775
- recipientAgentId: recipient.id,
1544
+ recipient: { kind: recipient.kind, id: recipient.id },
776
1545
  recipientDisplayName: recipient.displayName,
777
1546
  title: parsed.title,
778
1547
  reason: parsed.reason,
@@ -787,48 +1556,47 @@ export class SynomemCore implements SynomemDomainService {
787
1556
  await this.repository.insertEvent(event);
788
1557
  return { event, created: true };
789
1558
  });
790
- if (outcome.created) await this.projectionWriter.syncAgent(recipient.id);
1559
+ if (outcome.created) await this.syncActor(recipient);
791
1560
  const record = await this.getKudosRecord(outcome.event.id);
792
1561
  return { record, created: outcome.created, deduplicated: !outcome.created };
793
1562
  }
794
-
795
1563
  private async getKudosRecord(id: string): Promise<KudosRecord> {
796
1564
  await this.requireVisibleItem(id, 'kudos');
797
1565
  const record = recordsFromEvents(await this.repository.getReadableSynomemEvents(id))[0];
798
1566
  if (!record) throw new SynomemError('KUDOS_NOT_FOUND', `Unknown kudos: ${id}`);
799
- return record;
1567
+ return this.decorateRecord(record);
800
1568
  }
801
-
802
1569
  private async getKudos(id: string): Promise<KudosRecord> {
803
1570
  this.checkAbort();
804
1571
  return await this.getKudosRecord(id);
805
1572
  }
806
-
807
1573
  private async listKudos(input: KudosListInput): Promise<Page<KudosSummary>> {
808
1574
  this.checkAbort();
809
1575
  const filters = this.validate(() => listInputSchema.parse(input));
810
- const recipient = filters.recipientAgentId
811
- ? await this.repository.getAgent(filters.recipientAgentId)
1576
+ const recipient = filters.recipient?.id
1577
+ ? await this.resolveActor(filters.recipient)
812
1578
  : undefined;
813
- if (filters.recipientAgentId && !recipient) {
814
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipientAgentId}`);
1579
+ if (filters.recipient?.id && !recipient) {
1580
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipient?.id}`);
815
1581
  }
816
1582
  return await this.repository.listKudosSummaries(
817
1583
  {
818
1584
  ...filters,
819
- ...(recipient ? { recipientAgentId: recipient.id } : {}),
1585
+ ...(recipient ? { recipient: { kind: recipient.kind, id: recipient.id } } : {}),
820
1586
  },
821
1587
  this.actor,
822
1588
  );
823
1589
  }
824
-
825
1590
  private async listKudosChanges(input: KudosChangesInput): Promise<ChangePage> {
826
1591
  this.checkAbort();
827
1592
  const parsed = this.validate(() => changesInputSchema.parse(input));
828
1593
  return await this.repository.listKudosChanges(parsed.after, parsed.limit, this.actor);
829
1594
  }
830
-
831
- private async acknowledgeKudos(input: { kudosId: string; note?: string }): Promise<KudosRecord> {
1595
+ private async acknowledgeKudos(input: {
1596
+ expectedVersion: number;
1597
+ kudosId: string;
1598
+ note?: string;
1599
+ }): Promise<KudosRecord> {
832
1600
  this.checkAbort();
833
1601
  await this.repository.assertEventCompatibility();
834
1602
  if (input.note !== undefined && (input.note.trim().length < 1 || input.note.length > 2000)) {
@@ -838,27 +1606,26 @@ export class SynomemCore implements SynomemDomainService {
838
1606
  if (record.acknowledgment) return record;
839
1607
  if (record.revocation)
840
1608
  throw new SynomemError('INVALID_INPUT', 'Revoked kudos cannot be acknowledged.');
841
- const isRecipient =
842
- this.actor.kind === 'agent' && this.actor.id === record.event.recipientAgentId;
843
- if (!this.administrative && !isRecipient) {
1609
+ const isRecipient = sameActor(this.actor, record.event.recipient);
1610
+ if (!isRecipient) {
844
1611
  throw new SynomemError(
845
1612
  'ACKNOWLEDGMENT_FORBIDDEN',
846
- 'Only the recipient agent or a human administrator may acknowledge kudos.',
1613
+ 'Only the recipient may acknowledge kudos.',
847
1614
  );
848
1615
  }
849
1616
  const event: KudosAcknowledgedEvent = {
850
1617
  ...this.eventBase(record.event.id, 2),
851
1618
  type: 'kudos.acknowledged',
852
1619
  kudosId: record.event.id,
853
- recipientAgentId: record.event.recipientAgentId,
1620
+ recipient: record.event.recipient,
854
1621
  ...(input.note ? { note: input.note.trim() } : {}),
855
1622
  };
856
1623
  await this.repository.transaction(() => this.repository.insertEvent(event));
857
- await this.projectionWriter.syncAgent(record.event.recipientAgentId);
1624
+ await this.syncActor(record.event.recipient);
858
1625
  return await this.getKudosRecord(input.kudosId);
859
1626
  }
860
-
861
1627
  private async revokeKudos(input: {
1628
+ expectedVersion: number;
862
1629
  kudosId: string;
863
1630
  reason: string;
864
1631
  administrative?: boolean;
@@ -880,24 +1647,24 @@ export class SynomemCore implements SynomemDomainService {
880
1647
  );
881
1648
  }
882
1649
  const administrative = this.administrative && !isOriginalActor;
883
- if (!isOriginalActor && !this.administrative) {
1650
+ if (!this.canManage(record.event.actor as ActorRef)) {
884
1651
  throw new SynomemError(
885
1652
  'REVOCATION_FORBIDDEN',
886
- 'Only the original actor or an administrator may revoke kudos.',
1653
+ 'Only the author or an authorized overseer may revoke kudos.',
887
1654
  );
888
1655
  }
889
1656
  const event: KudosRevokedEvent = {
890
1657
  ...this.eventBase(record.event.id, record.acknowledgment ? 3 : 2),
1658
+ ...this.intervention(record.event.actor as ActorRef, reason),
891
1659
  type: 'kudos.revoked',
892
1660
  kudosId: record.event.id,
893
1661
  reason,
894
1662
  mode: administrative && !isOriginalActor ? 'administrative' : 'actor-requested',
895
1663
  };
896
1664
  await this.repository.transaction(() => this.repository.insertEvent(event));
897
- await this.projectionWriter.syncAgent(record.event.recipientAgentId);
1665
+ await this.syncActor(record.event.recipient);
898
1666
  return await this.getKudosRecord(input.kudosId);
899
1667
  }
900
-
901
1668
  private async priorMutation(
902
1669
  idempotencyKey: string | undefined,
903
1670
  expectedType: SynomemEvent['type'],
@@ -916,19 +1683,20 @@ export class SynomemCore implements SynomemDomainService {
916
1683
  }
917
1684
  return prior;
918
1685
  }
919
-
920
1686
  private canViewItem(summary: ItemSummary): boolean {
1687
+ if (summary.kind === 'note' || summary.kind === 'todo')
1688
+ return !!summary.owner && this.canManage(summary.owner);
921
1689
  return (
922
1690
  this.administrative ||
923
1691
  summary.visibility !== 'private' ||
924
- (summary.actor.kind === this.actor.kind && summary.actor.id === this.actor.id) ||
925
- (this.actor.kind === 'agent' &&
926
- (summary.recipientAgentId === this.actor.id ||
927
- summary.ownerAgentId === this.actor.id ||
928
- summary.assigneeAgentId === this.actor.id))
1692
+ [summary.actor, summary.recipient, summary.assignee].some(
1693
+ (ref) =>
1694
+ !!ref &&
1695
+ (sameActor(ref, this.actor) ||
1696
+ (ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id))),
1697
+ )
929
1698
  );
930
1699
  }
931
-
932
1700
  private async requireVisibleItem(id: string, kind: ItemSummary['kind']): Promise<ItemSummary> {
933
1701
  const summary = await this.repository.getItemSummary(id);
934
1702
  if (!summary || summary.kind !== kind) {
@@ -950,27 +1718,24 @@ export class SynomemCore implements SynomemDomainService {
950
1718
  }
951
1719
  return summary;
952
1720
  }
953
-
954
1721
  private async getMemoRecord(id: string): Promise<MemoRecord> {
955
1722
  await this.requireVisibleItem(id, 'memo');
956
1723
  const record = memoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
957
1724
  if (!record) throw new SynomemError('MEMO_NOT_FOUND', `Unknown memo: ${id}`);
958
- return record;
1725
+ return this.decorateRecord(record);
959
1726
  }
960
-
961
1727
  private async getMemo(id: string): Promise<MemoRecord> {
962
1728
  this.checkAbort();
963
1729
  return await this.getMemoRecord(id);
964
1730
  }
965
-
966
1731
  private async sendMemo(input: SendMemoInput): Promise<SendMemoResult> {
967
1732
  this.checkAbort();
968
1733
  await this.repository.assertEventCompatibility();
969
1734
  const parsed = this.validate(() => sendMemoSchema.parse(input));
970
1735
  await this.assertTopicsExist(parsed.topicIds);
971
- const recipient = await this.repository.getAgent(parsed.recipientAgentId);
1736
+ const recipient = await this.resolveActor(parsed.recipient, true);
972
1737
  if (!recipient)
973
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
1738
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipient?.id}`);
974
1739
  const outcome = await this.repository.transaction(async () => {
975
1740
  const prior = await this.priorMutation(parsed.idempotencyKey, 'memo.sent');
976
1741
  if (prior?.type === 'memo.sent') return { id: prior.id, created: false };
@@ -978,7 +1743,7 @@ export class SynomemCore implements SynomemDomainService {
978
1743
  const event: SynomemEvent = {
979
1744
  ...this.eventBase(id, 1, id),
980
1745
  type: 'memo.sent',
981
- recipientAgentId: recipient.id,
1746
+ recipient: { kind: recipient.kind, id: recipient.id },
982
1747
  recipientDisplayName: recipient.displayName,
983
1748
  subject: parsed.subject,
984
1749
  body: parsed.body,
@@ -992,29 +1757,24 @@ export class SynomemCore implements SynomemDomainService {
992
1757
  await this.repository.insertEvent(event);
993
1758
  return { id, created: true };
994
1759
  });
995
- if (outcome.created) await this.projectionWriter.syncAgent(recipient.id);
1760
+ if (outcome.created) await this.syncActor(recipient);
996
1761
  return {
997
1762
  record: await this.getMemoRecord(outcome.id),
998
1763
  created: outcome.created,
999
1764
  deduplicated: !outcome.created,
1000
1765
  };
1001
1766
  }
1002
-
1003
- private assertRecipient(recipientAgentId: string, operation: string): void {
1004
- if (
1005
- !this.administrative &&
1006
- !(this.actor.kind === 'agent' && this.actor.id === recipientAgentId)
1007
- ) {
1008
- throw new SynomemError(
1009
- 'MUTATION_FORBIDDEN',
1010
- `Only the recipient agent or a human administrator may ${operation}.`,
1011
- );
1012
- }
1767
+ private assertRecipient(recipient: ActorRef, operation: string, oversight = false): void {
1768
+ if (!(oversight ? this.canManage(recipient) : sameActor(this.actor, recipient)))
1769
+ throw new SynomemError('MUTATION_FORBIDDEN', `Only the recipient may ${operation}.`);
1013
1770
  }
1014
-
1015
- private async readMemo(input: { memoId: string; idempotencyKey?: string }): Promise<MemoRecord> {
1771
+ private async readMemo(input: {
1772
+ expectedVersion: number;
1773
+ memoId: string;
1774
+ idempotencyKey?: string;
1775
+ }): Promise<MemoRecord> {
1016
1776
  const record = await this.getMemoRecord(input.memoId);
1017
- this.assertRecipient(record.event.recipientAgentId, 'mark this memo read');
1777
+ this.assertRecipient(record.event.recipient, 'mark this memo read');
1018
1778
  if (record.read) return record;
1019
1779
  await this.repository.transaction(async () => {
1020
1780
  const prior = await this.priorMutation(input.idempotencyKey, 'memo.read');
@@ -1026,53 +1786,62 @@ export class SynomemCore implements SynomemDomainService {
1026
1786
  ),
1027
1787
  type: 'memo.read',
1028
1788
  memoId: record.event.id,
1029
- recipientAgentId: record.event.recipientAgentId,
1789
+ recipient: record.event.recipient,
1030
1790
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
1031
1791
  };
1032
1792
  await this.repository.insertEvent(event);
1033
1793
  });
1034
- await this.projectionWriter.syncAgent(record.event.recipientAgentId);
1794
+ await this.syncActor(record.event.recipient);
1035
1795
  return await this.getMemoRecord(input.memoId);
1036
1796
  }
1037
-
1038
1797
  private async archiveMemo(input: {
1798
+ expectedVersion: number;
1039
1799
  memoId: string;
1040
1800
  idempotencyKey?: string;
1041
1801
  }): Promise<MemoRecord> {
1042
1802
  const record = await this.getMemoRecord(input.memoId);
1043
- this.assertRecipient(record.event.recipientAgentId, 'archive this memo');
1803
+ this.assertRecipient(record.event.recipient, 'archive this memo', true);
1044
1804
  if (record.archived) return record;
1045
1805
  await this.repository.transaction(async () => {
1046
1806
  const prior = await this.priorMutation(input.idempotencyKey, 'memo.archived');
1047
1807
  if (prior) return;
1048
1808
  const event: SynomemEvent = {
1809
+ ...this.intervention(record.event.recipient),
1049
1810
  ...this.eventBase(
1050
1811
  record.event.id,
1051
1812
  await this.repository.nextAggregateVersion(record.event.id),
1052
1813
  ),
1053
1814
  type: 'memo.archived',
1054
1815
  memoId: record.event.id,
1055
- recipientAgentId: record.event.recipientAgentId,
1816
+ recipient: record.event.recipient,
1056
1817
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
1057
1818
  };
1058
1819
  await this.repository.insertEvent(event);
1059
1820
  });
1060
- await this.projectionWriter.syncAgent(record.event.recipientAgentId);
1821
+ await this.syncActor(record.event.recipient);
1061
1822
  return await this.getMemoRecord(input.memoId);
1062
1823
  }
1063
-
1064
1824
  private async getPostRecord(id: string): Promise<PostRecord> {
1065
1825
  await this.requireVisibleItem(id, 'post');
1066
1826
  const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1067
1827
  if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
1068
- return record;
1828
+ return this.decorateRecord(record);
1069
1829
  }
1070
-
1071
1830
  private async getPost(id: string): Promise<PostRecord> {
1072
1831
  this.checkAbort();
1073
1832
  return await this.getPostRecord(id);
1074
1833
  }
1075
-
1834
+ private async canonicalPostMentions(input: ActorRef[]): Promise<ActorRef[]> {
1835
+ const mentions: ActorRef[] = [];
1836
+ for (const reference of input) {
1837
+ const actor = await this.resolveActor(reference, true);
1838
+ const ref = { kind: actor.kind, id: actor.id };
1839
+ if (!mentions.some((mention) => sameActor(mention, ref))) mentions.push(ref);
1840
+ }
1841
+ return mentions.sort((left, right) =>
1842
+ `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`),
1843
+ );
1844
+ }
1076
1845
  private async createPost(input: CreatePostInput): Promise<{
1077
1846
  record: PostRecord;
1078
1847
  created: boolean;
@@ -1082,11 +1851,9 @@ export class SynomemCore implements SynomemDomainService {
1082
1851
  await this.repository.assertEventCompatibility();
1083
1852
  const parsed = this.validate(() => createPostSchema.parse(input));
1084
1853
  await this.assertTopicsExist(parsed.topicIds);
1085
-
1086
- // A reply inherits its parent's workspace by construction, and cannot name
1087
- // a different target — there is no target to name.
1088
- if (parsed.replyTo) await this.requireVisibleItem(parsed.replyTo, 'post');
1089
-
1854
+ const mentions = parsed.mentions
1855
+ ? await this.canonicalPostMentions(parsed.mentions)
1856
+ : undefined;
1090
1857
  const outcome = await this.repository.transaction(async () => {
1091
1858
  const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
1092
1859
  if (prior?.type === 'post.created') return { id: prior.id, created: false };
@@ -1096,9 +1863,9 @@ export class SynomemCore implements SynomemDomainService {
1096
1863
  type: 'post.created',
1097
1864
  title: parsed.title,
1098
1865
  body: parsed.body,
1866
+ mentions: mentions ?? [],
1099
1867
  tags: [...new Set(parsed.tags ?? [])].sort(),
1100
1868
  topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
1101
- ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
1102
1869
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1103
1870
  ...(parsed.source ? { source: parsed.source } : {}),
1104
1871
  ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
@@ -1112,19 +1879,25 @@ export class SynomemCore implements SynomemDomainService {
1112
1879
  deduplicated: !outcome.created,
1113
1880
  };
1114
1881
  }
1115
-
1116
1882
  /** Only the author edits a post. Everyone else responds to it. */
1117
1883
  private assertPostAuthor(record: PostRecord): void {
1118
- if (this.administrative) return;
1884
+ if (
1885
+ this.administrative ||
1886
+ (record.event.actor.kind === 'agent' &&
1887
+ overseesAgent(this.actor, this.authority, record.event.actor.id))
1888
+ )
1889
+ return;
1119
1890
  if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
1120
1891
  throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
1121
1892
  }
1122
1893
  }
1123
-
1124
1894
  private async updatePost(input: UpdatePostInput): Promise<PostRecord> {
1125
1895
  this.checkAbort();
1126
1896
  const parsed = this.validate(() => updatePostSchema.parse(input));
1127
1897
  await this.assertTopicsExist(parsed.topicIds);
1898
+ const mentions = parsed.mentions
1899
+ ? await this.canonicalPostMentions(parsed.mentions)
1900
+ : undefined;
1128
1901
  const record = await this.getPostRecord(parsed.postId);
1129
1902
  this.assertPostAuthor(record);
1130
1903
  if (record.status === 'archived') {
@@ -1138,11 +1911,13 @@ export class SynomemCore implements SynomemDomainService {
1138
1911
  }
1139
1912
  await this.repository.transaction(async () => {
1140
1913
  const event: SynomemEvent = {
1914
+ ...this.intervention(record.event.actor as ActorRef),
1141
1915
  ...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
1142
1916
  type: 'post.edited',
1143
1917
  postId: parsed.postId,
1144
1918
  title: parsed.title ?? record.title,
1145
1919
  body: parsed.body ?? record.body,
1920
+ mentions: mentions ?? record.mentions ?? [],
1146
1921
  tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
1147
1922
  topicIds: [...new Set(parsed.topicIds ?? record.topicIds ?? [])].sort(),
1148
1923
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
@@ -1151,8 +1926,8 @@ export class SynomemCore implements SynomemDomainService {
1151
1926
  });
1152
1927
  return await this.getPostRecord(parsed.postId);
1153
1928
  }
1154
-
1155
1929
  private async archivePost(input: {
1930
+ expectedVersion: number;
1156
1931
  postId: string;
1157
1932
  reason?: string;
1158
1933
  idempotencyKey?: string;
@@ -1163,6 +1938,7 @@ export class SynomemCore implements SynomemDomainService {
1163
1938
  if (record.status !== 'archived') {
1164
1939
  await this.repository.transaction(async () => {
1165
1940
  const event: SynomemEvent = {
1941
+ ...this.intervention(record.event.actor as ActorRef),
1166
1942
  ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
1167
1943
  type: 'post.archived',
1168
1944
  postId: input.postId,
@@ -1174,8 +1950,8 @@ export class SynomemCore implements SynomemDomainService {
1174
1950
  }
1175
1951
  return await this.getPostRecord(input.postId);
1176
1952
  }
1177
-
1178
1953
  private async acknowledgePost(input: {
1954
+ expectedVersion: number;
1179
1955
  postId: string;
1180
1956
  note?: string;
1181
1957
  idempotencyKey?: string;
@@ -1201,10 +1977,11 @@ export class SynomemCore implements SynomemDomainService {
1201
1977
  }
1202
1978
  return await this.getPostRecord(input.postId);
1203
1979
  }
1204
-
1205
1980
  private async withdrawPostAcknowledgment(input: {
1981
+ expectedVersion: number;
1206
1982
  postId: string;
1207
1983
  reason?: string;
1984
+ idempotencyKey?: string;
1208
1985
  }): Promise<PostRecord> {
1209
1986
  this.checkAbort();
1210
1987
  const record = await this.getPostRecord(input.postId);
@@ -1224,7 +2001,6 @@ export class SynomemCore implements SynomemDomainService {
1224
2001
  }
1225
2002
  return await this.getPostRecord(input.postId);
1226
2003
  }
1227
-
1228
2004
  private async postRoster(postId: string): Promise<PostRoster> {
1229
2005
  this.checkAbort();
1230
2006
  await this.requireVisibleItem(postId, 'post');
@@ -1232,40 +2008,36 @@ export class SynomemCore implements SynomemDomainService {
1232
2008
  if (!roster) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
1233
2009
  return roster;
1234
2010
  }
1235
-
1236
2011
  private async getNoteRecord(id: string): Promise<NoteRecord> {
1237
2012
  await this.requireVisibleItem(id, 'note');
1238
2013
  const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1239
2014
  if (!record) throw new SynomemError('NOTE_NOT_FOUND', `Unknown note: ${id}`);
1240
- return record;
2015
+ return this.decorateRecord(record);
1241
2016
  }
1242
2017
  private async getNote(id: string): Promise<NoteRecord> {
1243
2018
  this.checkAbort();
1244
2019
  return await this.getNoteRecord(id);
1245
2020
  }
1246
-
1247
2021
  private async createNote(input: CreateNoteInput): Promise<CreateNoteResult> {
1248
2022
  this.checkAbort();
1249
2023
  await this.repository.assertEventCompatibility();
1250
2024
  const parsed = this.validate(() => createNoteSchema.parse(input));
1251
2025
  await this.assertTopicsExist(parsed.topicIds);
1252
- const ownerId =
1253
- parsed.ownerAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
1254
- if (!ownerId)
1255
- throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify ownerAgentId.');
1256
- const owner = await this.repository.getAgent(ownerId);
1257
- if (!owner) throw new SynomemError('AGENT_NOT_FOUND', `Unknown note owner: ${ownerId}`);
1258
- if (!this.administrative && (this.actor.kind !== 'agent' || this.actor.id !== owner.id)) {
1259
- throw new SynomemError('MUTATION_FORBIDDEN', 'Agents may create notes only for themselves.');
1260
- }
2026
+ const owner = await this.resolveActor(parsed.owner ?? this.boundRef(), true);
2027
+ if (!this.canManage(owner))
2028
+ throw new SynomemError(
2029
+ 'MUTATION_FORBIDDEN',
2030
+ 'Only the owner or an authorized overseer may create private memory.',
2031
+ );
1261
2032
  const outcome = await this.repository.transaction(async () => {
1262
2033
  const prior = await this.priorMutation(parsed.idempotencyKey, 'note.created');
1263
2034
  if (prior?.type === 'note.created') return { id: prior.id, created: false };
1264
2035
  const id = this.nextId();
1265
2036
  const event: SynomemEvent = {
1266
2037
  ...this.eventBase(id, 1, id),
2038
+ ...this.intervention({ kind: owner.kind, id: owner.id }),
1267
2039
  type: 'note.created',
1268
- ownerAgentId: owner.id,
2040
+ owner: { kind: owner.kind, id: owner.id },
1269
2041
  ownerDisplayName: owner.displayName,
1270
2042
  title: parsed.title,
1271
2043
  body: parsed.body,
@@ -1279,26 +2051,20 @@ export class SynomemCore implements SynomemDomainService {
1279
2051
  await this.repository.insertEvent(event);
1280
2052
  return { id, created: true };
1281
2053
  });
1282
- if (outcome.created) await this.projectionWriter.syncAgent(owner.id);
2054
+ if (outcome.created) await this.syncActor(owner);
1283
2055
  return {
1284
2056
  record: await this.getNoteRecord(outcome.id),
1285
2057
  created: outcome.created,
1286
2058
  deduplicated: !outcome.created,
1287
2059
  };
1288
2060
  }
1289
-
1290
2061
  private assertNoteOwner(record: NoteRecord): void {
1291
- if (
1292
- !this.administrative &&
1293
- !(this.actor.kind === 'agent' && this.actor.id === record.event.ownerAgentId)
1294
- ) {
2062
+ if (!this.canManage(record.event.owner))
1295
2063
  throw new SynomemError(
1296
2064
  'MUTATION_FORBIDDEN',
1297
- 'Only the note owner or a human administrator may change it.',
2065
+ 'Only the owner or an authorized overseer may change private memory.',
1298
2066
  );
1299
- }
1300
2067
  }
1301
-
1302
2068
  private async reviseNote(input: ReviseNoteInput): Promise<NoteRecord> {
1303
2069
  const parsed = this.validate(() => reviseNoteSchema.parse(input));
1304
2070
  await this.assertTopicsExist(parsed.topicIds);
@@ -1323,6 +2089,7 @@ export class SynomemCore implements SynomemDomainService {
1323
2089
  'The note changed before this revision was stored.',
1324
2090
  );
1325
2091
  const event: SynomemEvent = {
2092
+ ...this.intervention(record.event.owner),
1326
2093
  ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
1327
2094
  type: 'note.revised',
1328
2095
  noteId: record.event.id,
@@ -1337,11 +2104,11 @@ export class SynomemCore implements SynomemDomainService {
1337
2104
  };
1338
2105
  await this.repository.insertEvent(event);
1339
2106
  });
1340
- await this.projectionWriter.syncAgent(record.event.ownerAgentId);
2107
+ await this.syncActor(record.event.owner);
1341
2108
  return await this.getNoteRecord(parsed.noteId);
1342
2109
  }
1343
-
1344
2110
  private async archiveNote(input: {
2111
+ expectedVersion: number;
1345
2112
  noteId: string;
1346
2113
  idempotencyKey?: string;
1347
2114
  }): Promise<NoteRecord> {
@@ -1352,6 +2119,7 @@ export class SynomemCore implements SynomemDomainService {
1352
2119
  const prior = await this.priorMutation(input.idempotencyKey, 'note.archived');
1353
2120
  if (prior) return;
1354
2121
  const event: SynomemEvent = {
2122
+ ...this.intervention(record.event.owner),
1355
2123
  ...this.eventBase(
1356
2124
  record.event.id,
1357
2125
  await this.repository.nextAggregateVersion(record.event.id),
@@ -1362,51 +2130,40 @@ export class SynomemCore implements SynomemDomainService {
1362
2130
  };
1363
2131
  await this.repository.insertEvent(event);
1364
2132
  });
1365
- await this.projectionWriter.syncAgent(record.event.ownerAgentId);
2133
+ await this.syncActor(record.event.owner);
1366
2134
  return await this.getNoteRecord(input.noteId);
1367
2135
  }
1368
-
1369
2136
  private async getTaskRecord(id: string): Promise<TaskRecord> {
1370
2137
  await this.requireVisibleItem(id, 'task');
1371
2138
  const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1372
2139
  if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
1373
- return record;
2140
+ return this.decorateRecord(record);
1374
2141
  }
1375
2142
  private async getTask(id: string): Promise<TaskRecord> {
1376
2143
  this.checkAbort();
1377
2144
  return await this.getTaskRecord(id);
1378
2145
  }
1379
-
1380
2146
  private async createTask(input: CreateTaskInput): Promise<CreateTaskResult> {
1381
2147
  this.checkAbort();
1382
2148
  await this.repository.assertEventCompatibility();
1383
2149
  const parsed = this.validate(() => createTaskSchema.parse(input));
1384
2150
  await this.assertTopicsExist(parsed.topicIds);
1385
- const assigneeId =
1386
- parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
1387
- if (!assigneeId)
1388
- throw new SynomemError(
1389
- 'INVALID_INPUT',
1390
- 'A human or system actor must specify assigneeAgentId.',
1391
- );
1392
- const assignee = await this.repository.getAgent(assigneeId);
1393
- if (!assignee)
1394
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
2151
+ const assignee = await this.resolveActor(parsed.assignee ?? this.boundRef(), true);
1395
2152
  if (
1396
2153
  this.actor.kind === 'agent' &&
1397
- this.actor.id !== assignee.id &&
2154
+ !sameActor(this.actor, assignee) &&
1398
2155
  !this.repository.config.allowCrossAgentTasks
1399
2156
  )
1400
- throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
2157
+ throw new SynomemError('POLICY_FORBIDDEN', 'Cross-actor task assignment is disabled.');
1401
2158
  const outcome = await this.repository.transaction(async () => {
1402
2159
  const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
1403
2160
  if (prior?.type === 'task.created') return { id: prior.id, created: false };
1404
2161
  const id = this.nextId();
1405
- const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
2162
+ const requiresAcceptance = !sameActor(this.actor, assignee);
1406
2163
  const event: SynomemEvent = {
1407
2164
  ...this.eventBase(id, 1, id),
1408
2165
  type: 'task.created',
1409
- assigneeAgentId: assignee.id,
2166
+ assignee: { kind: assignee.kind, id: assignee.id },
1410
2167
  assigneeDisplayName: assignee.displayName,
1411
2168
  title: parsed.title,
1412
2169
  ...(parsed.description !== undefined ? { description: parsed.description } : {}),
@@ -1423,38 +2180,27 @@ export class SynomemCore implements SynomemDomainService {
1423
2180
  await this.repository.insertEvent(event);
1424
2181
  return { id, created: true };
1425
2182
  });
1426
- if (outcome.created) await this.projectionWriter.syncAgent(assignee.id);
2183
+ if (outcome.created) await this.syncActor(assignee);
1427
2184
  return {
1428
2185
  record: await this.getTaskRecord(outcome.id),
1429
2186
  created: outcome.created,
1430
2187
  deduplicated: !outcome.created,
1431
2188
  };
1432
2189
  }
1433
-
1434
2190
  private assertTaskParticipant(record: TaskRecord): void {
1435
- const isCreator =
1436
- record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
1437
- const isAssignee =
1438
- this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId;
1439
- if (!this.administrative && !isCreator && !isAssignee)
2191
+ if (!this.canManage(record.event.assignee) && !this.canManage(record.event.actor as ActorRef))
1440
2192
  throw new SynomemError(
1441
2193
  'MUTATION_FORBIDDEN',
1442
- 'Only the task creator, assignee, or a human administrator may change it.',
2194
+ 'Only task participants or authorized overseers may change it.',
1443
2195
  );
1444
2196
  }
1445
-
1446
2197
  private assertTaskAssignee(record: TaskRecord): void {
1447
- if (
1448
- !this.administrative &&
1449
- !(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)
1450
- ) {
2198
+ if (!this.canManage(record.event.assignee))
1451
2199
  throw new SynomemError(
1452
2200
  'MUTATION_FORBIDDEN',
1453
- 'Only the assigned agent or a human administrator may accept or reject this task.',
2201
+ 'Only the assignee or an authorized overseer may decide this task.',
1454
2202
  );
1455
- }
1456
2203
  }
1457
-
1458
2204
  private async updateTask(input: UpdateTaskInput): Promise<TaskRecord> {
1459
2205
  const parsed = this.validate(() => updateTaskSchema.parse(input));
1460
2206
  await this.assertTopicsExist(parsed.topicIds);
@@ -1480,6 +2226,7 @@ export class SynomemCore implements SynomemDomainService {
1480
2226
  );
1481
2227
  const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
1482
2228
  const event: SynomemEvent = {
2229
+ ...this.intervention(record.event.assignee),
1483
2230
  ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
1484
2231
  type: 'task.updated',
1485
2232
  taskId: record.event.id,
@@ -1500,12 +2247,57 @@ export class SynomemCore implements SynomemDomainService {
1500
2247
  };
1501
2248
  await this.repository.insertEvent(event);
1502
2249
  });
1503
- await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
2250
+ await this.syncActor(record.event.assignee);
1504
2251
  return await this.getTaskRecord(parsed.taskId);
1505
2252
  }
1506
-
2253
+ private async overrideTaskDecision(input: OverrideTaskDecisionInput): Promise<TaskRecord> {
2254
+ this.checkAbort();
2255
+ const parsed = this.validate(() => overrideTaskDecisionSchema.parse(input));
2256
+ await this.repository.transaction(async () => {
2257
+ const record = await this.getTaskRecord(parsed.taskId);
2258
+ if (
2259
+ this.actor.kind !== 'human' ||
2260
+ !(
2261
+ this.administrative ||
2262
+ (record.event.assignee.kind === 'agent' &&
2263
+ overseesAgent(this.actor, this.authority, record.event.assignee.id)) ||
2264
+ (record.event.actor.kind === 'agent' &&
2265
+ overseesAgent(this.actor, this.authority, record.event.actor.id))
2266
+ )
2267
+ )
2268
+ throw new SynomemError(
2269
+ 'MUTATION_FORBIDDEN',
2270
+ 'Only an authorized human overseer may override a decision.',
2271
+ );
2272
+ if (record.current.version !== parsed.expectedVersion)
2273
+ throw new SynomemError(
2274
+ 'REVISION_CONFLICT',
2275
+ 'The task changed. Reload it before overriding.',
2276
+ );
2277
+ if (record.status !== 'open' && record.status !== 'rejected')
2278
+ throw new SynomemError(
2279
+ 'INVALID_INPUT',
2280
+ 'Only accepted/open or rejected decisions can be overridden.',
2281
+ );
2282
+ if (record.status === parsed.nextStatus)
2283
+ throw new SynomemError('INVALID_INPUT', 'The requested decision is already in effect.');
2284
+ const event: SynomemEvent = {
2285
+ ...this.eventBase(parsed.taskId, parsed.expectedVersion + 1),
2286
+ ...this.intervention(record.event.assignee, parsed.reason),
2287
+ type: 'task.decision_overridden',
2288
+ taskId: parsed.taskId,
2289
+ previousStatus: record.status,
2290
+ nextStatus: parsed.nextStatus,
2291
+ reason: parsed.reason,
2292
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
2293
+ };
2294
+ await this.repository.insertEvent(event);
2295
+ });
2296
+ return this.getTaskRecord(parsed.taskId);
2297
+ }
1507
2298
  private async taskTransition(
1508
2299
  input: {
2300
+ expectedVersion: number;
1509
2301
  taskId: string;
1510
2302
  idempotencyKey?: string;
1511
2303
  note?: string;
@@ -1549,6 +2341,7 @@ export class SynomemCore implements SynomemDomainService {
1549
2341
  const prior = await this.priorMutation(input.idempotencyKey, type);
1550
2342
  if (prior) return;
1551
2343
  const base = {
2344
+ ...this.intervention(record.event.assignee),
1552
2345
  ...this.eventBase(
1553
2346
  record.event.id,
1554
2347
  await this.repository.nextAggregateVersion(record.event.id),
@@ -1572,25 +2365,44 @@ export class SynomemCore implements SynomemDomainService {
1572
2365
  : { ...base, type };
1573
2366
  await this.repository.insertEvent(event);
1574
2367
  });
1575
- await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
2368
+ await this.syncActor(record.event.assignee);
1576
2369
  return await this.getTaskRecord(input.taskId);
1577
2370
  }
1578
- private completeTask(input: { taskId: string; note?: string; idempotencyKey?: string }) {
2371
+ private completeTask(input: {
2372
+ expectedVersion: number;
2373
+ taskId: string;
2374
+ note?: string;
2375
+ idempotencyKey?: string;
2376
+ }) {
1579
2377
  return this.taskTransition(input, 'task.completed');
1580
2378
  }
1581
- private acceptTask(input: { taskId: string; response?: string; idempotencyKey?: string }) {
2379
+ private acceptTask(input: {
2380
+ expectedVersion: number;
2381
+ taskId: string;
2382
+ response?: string;
2383
+ idempotencyKey?: string;
2384
+ }) {
1582
2385
  return this.taskTransition(input, 'task.accepted');
1583
2386
  }
1584
- private rejectTask(input: { taskId: string; response: string; idempotencyKey?: string }) {
2387
+ private rejectTask(input: {
2388
+ expectedVersion: number;
2389
+ taskId: string;
2390
+ response: string;
2391
+ idempotencyKey?: string;
2392
+ }) {
1585
2393
  return this.taskTransition(input, 'task.rejected');
1586
2394
  }
1587
- private reopenTask(input: { taskId: string; idempotencyKey?: string }) {
2395
+ private reopenTask(input: { expectedVersion: number; taskId: string; idempotencyKey?: string }) {
1588
2396
  return this.taskTransition(input, 'task.reopened');
1589
2397
  }
1590
- private cancelTask(input: { taskId: string; reason?: string; idempotencyKey?: string }) {
2398
+ private cancelTask(input: {
2399
+ expectedVersion: number;
2400
+ taskId: string;
2401
+ reason?: string;
2402
+ idempotencyKey?: string;
2403
+ }) {
1591
2404
  return this.taskTransition(input, 'task.canceled');
1592
2405
  }
1593
-
1594
2406
  /* ----------------------------------------------------------------- todos *
1595
2407
  * A Todo is a private reminder an agent creates for itself. There is no
1596
2408
  * assignee, no acceptance, and no visibility choice: it belongs to its author
@@ -1598,19 +2410,26 @@ export class SynomemCore implements SynomemDomainService {
1598
2410
  * rather than relying on a visibility filter, so a Todo cannot be reached by
1599
2411
  * guessing its id.
1600
2412
  */
1601
-
1602
2413
  private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
1603
2414
  this.checkAbort();
1604
2415
  await this.repository.assertEventCompatibility();
1605
2416
  const parsed = this.validate(() => createTodoSchema.parse(input));
1606
2417
  await this.assertTopicsExist(parsed.topicIds);
2418
+ const owner = await this.resolveActor(parsed.owner ?? this.boundRef(), true);
2419
+ if (!this.canManage(owner))
2420
+ throw new SynomemError(
2421
+ 'MUTATION_FORBIDDEN',
2422
+ 'Only the owner or an authorized overseer may create private memory.',
2423
+ );
1607
2424
  const outcome = await this.repository.transaction(async () => {
1608
2425
  const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
1609
2426
  if (prior?.type === 'todo.created') return { id: prior.id, created: false };
1610
2427
  const id = this.nextId();
1611
2428
  const event: SynomemEvent = {
1612
2429
  ...this.eventBase(id, 1, id),
2430
+ ...this.intervention({ kind: owner.kind, id: owner.id }),
1613
2431
  type: 'todo.created',
2432
+ owner: { kind: owner.kind, id: owner.id },
1614
2433
  title: parsed.title,
1615
2434
  ...(parsed.details !== undefined ? { details: parsed.details } : {}),
1616
2435
  priority: parsed.priority ?? 3,
@@ -1630,14 +2449,12 @@ export class SynomemCore implements SynomemDomainService {
1630
2449
  deduplicated: !outcome.created,
1631
2450
  };
1632
2451
  }
1633
-
1634
2452
  private async getTodoRecord(id: string): Promise<TodoRecord> {
1635
2453
  const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1636
2454
  if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
1637
2455
  this.assertTodoOwner(record);
1638
- return record;
2456
+ return this.decorateRecord(record);
1639
2457
  }
1640
-
1641
2458
  /**
1642
2459
  * Todos are owner-only for every ordinary actor — the same rule notes
1643
2460
  * already follow (`assertNoteOwner`): an organization owner or admin is the
@@ -1650,20 +2467,16 @@ export class SynomemCore implements SynomemDomainService {
1650
2467
  * on top of a permission that had already been granted.
1651
2468
  */
1652
2469
  private assertTodoOwner(record: TodoRecord): void {
1653
- const owner = record.event.actor;
1654
- if (!this.administrative && (owner.kind !== this.actor.kind || owner.id !== this.actor.id)) {
2470
+ if (!this.canManage(record.event.owner))
1655
2471
  throw new SynomemError(
1656
2472
  'MUTATION_FORBIDDEN',
1657
- 'A todo is private to the actor who created it.',
2473
+ 'Only the owner or an authorized overseer may change private memory.',
1658
2474
  );
1659
- }
1660
2475
  }
1661
-
1662
2476
  private async getTodo(id: string): Promise<TodoRecord> {
1663
2477
  this.checkAbort();
1664
2478
  return await this.getTodoRecord(id);
1665
2479
  }
1666
-
1667
2480
  private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
1668
2481
  this.checkAbort();
1669
2482
  const parsed = this.validate(() => updateTodoSchema.parse(input));
@@ -1680,6 +2493,7 @@ export class SynomemCore implements SynomemDomainService {
1680
2493
  const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
1681
2494
  if (prior) return;
1682
2495
  const event: SynomemEvent = {
2496
+ ...this.intervention(record.event.owner),
1683
2497
  ...this.eventBase(
1684
2498
  record.event.id,
1685
2499
  await this.repository.nextAggregateVersion(record.event.id),
@@ -1700,12 +2514,18 @@ export class SynomemCore implements SynomemDomainService {
1700
2514
  });
1701
2515
  return await this.getTodoRecord(parsed.todoId);
1702
2516
  }
1703
-
1704
2517
  private async todoTransition(
1705
- input: { todoId: string; idempotencyKey?: string; note?: string; reason?: string },
2518
+ input: {
2519
+ expectedVersion: number;
2520
+ todoId: string;
2521
+ idempotencyKey?: string;
2522
+ note?: string;
2523
+ reason?: string;
2524
+ },
1706
2525
  type: 'todo.completed' | 'todo.reopened' | 'todo.canceled' | 'todo.archived',
1707
2526
  ): Promise<TodoRecord> {
1708
2527
  const record = await this.getTodoRecord(input.todoId);
2528
+ this.assertTodoOwner(record);
1709
2529
  if (type === 'todo.completed' && record.status !== 'open') {
1710
2530
  if (record.status === 'completed') return record;
1711
2531
  throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
@@ -1716,7 +2536,6 @@ export class SynomemCore implements SynomemDomainService {
1716
2536
  throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
1717
2537
  }
1718
2538
  if (type === 'todo.archived' && record.status === 'archived') return record;
1719
-
1720
2539
  await this.repository.transaction(async () => {
1721
2540
  const prior = await this.priorMutation(input.idempotencyKey, type);
1722
2541
  if (prior) return;
@@ -1738,7 +2557,6 @@ export class SynomemCore implements SynomemDomainService {
1738
2557
  });
1739
2558
  return await this.getTodoRecord(input.todoId);
1740
2559
  }
1741
-
1742
2560
  /**
1743
2561
  * Turns an agent name in a filter into the canonical ID the records hold.
1744
2562
  *
@@ -1755,16 +2573,15 @@ export class SynomemCore implements SynomemDomainService {
1755
2573
  const resolved = await this.repository.resolveAgent(name);
1756
2574
  return resolved.match?.id ?? name;
1757
2575
  }
1758
-
1759
2576
  private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
1760
2577
  this.checkAbort();
1761
2578
  const parsed = this.validate(() => itemListInputSchema.parse(input));
1762
2579
  const resolved = {
1763
2580
  ...parsed,
1764
- ...(parsed.participantAgentId
1765
- ? { participantAgentId: await this.canonicalAgentId(parsed.participantAgentId) }
2581
+ ...(parsed.participant ? { participant: await this.resolveActor(parsed.participant) } : {}),
2582
+ ...(parsed.actorId && parsed.actorKind === 'agent'
2583
+ ? { actorId: await this.canonicalAgentId(parsed.actorId) }
1766
2584
  : {}),
1767
- ...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
1768
2585
  };
1769
2586
  return await this.repository.listItemSummaries(resolved, this.actor);
1770
2587
  }
@@ -1800,7 +2617,6 @@ export class SynomemCore implements SynomemDomainService {
1800
2617
  if (summary.kind === 'todo') return this.getTodo(id);
1801
2618
  return this.getTask(id);
1802
2619
  }
1803
-
1804
2620
  async stats(input: KudosListInput = {}): Promise<KudosStats> {
1805
2621
  this.checkAbort();
1806
2622
  const parsed = this.validate(() => listInputSchema.parse(input));
@@ -1808,11 +2624,11 @@ export class SynomemCore implements SynomemDomainService {
1808
2624
  void _cursor;
1809
2625
  void _limit;
1810
2626
  void _offset;
1811
- const recipient = filters.recipientAgentId
1812
- ? await this.repository.getAgent(filters.recipientAgentId)
2627
+ const recipient = filters.recipient?.id
2628
+ ? await this.resolveActor(filters.recipient)
1813
2629
  : undefined;
1814
- if (filters.recipientAgentId && !recipient) {
1815
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipientAgentId}`);
2630
+ if (filters.recipient?.id && !recipient) {
2631
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipient?.id}`);
1816
2632
  }
1817
2633
  const records: KudosSummary[] = [];
1818
2634
  let cursor: string | undefined;
@@ -1822,7 +2638,7 @@ export class SynomemCore implements SynomemDomainService {
1822
2638
  const page = await this.repository.listKudosSummaries(
1823
2639
  {
1824
2640
  ...filters,
1825
- ...(recipient ? { recipientAgentId: recipient.id } : {}),
2641
+ ...(recipient ? { recipient: { kind: recipient.kind, id: recipient.id } } : {}),
1826
2642
  limit: 50,
1827
2643
  offset: 0,
1828
2644
  ...(cursor ? { cursor } : {}),
@@ -1848,7 +2664,7 @@ export class SynomemCore implements SynomemDomainService {
1848
2664
  byTag: {},
1849
2665
  };
1850
2666
  for (const record of includedRecords) {
1851
- stats.byAgent[record.recipientAgentId] = (stats.byAgent[record.recipientAgentId] ?? 0) + 1;
2667
+ stats.byAgent[record.recipient?.id] = (stats.byAgent[record.recipient?.id] ?? 0) + 1;
1852
2668
  const actor = `${record.actor.kind}:${record.actor.id}`;
1853
2669
  stats.byActor[actor] = (stats.byActor[actor] ?? 0) + 1;
1854
2670
  for (const tag of record.tags) stats.byTag[tag] = (stats.byTag[tag] ?? 0) + 1;
@@ -1856,26 +2672,25 @@ export class SynomemCore implements SynomemDomainService {
1856
2672
  return stats;
1857
2673
  }
1858
2674
  }
1859
-
1860
2675
  /**
1861
2676
  * The schema version this package writes and expects. Named rather than
1862
2677
  * repeated as a literal because it is asserted in three places, and a doctor
1863
2678
  * check that silently lags the migration runner reports a healthy database as
1864
2679
  * broken.
1865
2680
  */
1866
- const CURRENT_SCHEMA_VERSION = 8;
1867
- const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8];
1868
-
2681
+ const CURRENT_SCHEMA_VERSION = 9;
2682
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
1869
2683
  export class SynomemClient extends SynomemCore implements SynomemService {
1870
2684
  readonly home: string;
1871
2685
  readonly storage: SynomemStorage;
1872
2686
  readonly projections: ProjectionManager;
1873
-
1874
2687
  constructor(options: SynomemClientOptions = {}) {
1875
2688
  const home = resolveHome(options.home);
1876
2689
  const storage = new SynomemStorage({
1877
2690
  home,
2691
+ ...(options.actor ? { actor: options.actor } : {}),
1878
2692
  readOnly: options.readOnly ?? false,
2693
+ ...(options.authority ? { authority: options.authority } : {}),
1879
2694
  ...(options.config ? { config: options.config } : {}),
1880
2695
  });
1881
2696
  const projections = new ProjectionManager(storage);
@@ -1883,16 +2698,16 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1883
2698
  repository: storage,
1884
2699
  projectionWriter: projections,
1885
2700
  ...(options.actor ? { actor: options.actor } : {}),
1886
- ...(options.administrative !== undefined ? { administrative: options.administrative } : {}),
2701
+ ...(options.authority ? { authority: options.authority } : {}),
1887
2702
  ...(options.clock ? { clock: options.clock } : {}),
1888
2703
  ...(options.idGenerator ? { idGenerator: options.idGenerator } : {}),
1889
2704
  ...(options.signal ? { signal: options.signal } : {}),
2705
+ canWrite: !options.readOnly,
1890
2706
  });
1891
2707
  this.home = home;
1892
2708
  this.storage = storage;
1893
2709
  this.projections = projections;
1894
2710
  }
1895
-
1896
2711
  /*
1897
2712
  * Answers "would a rebuild change anything, and when did one last run?"
1898
2713
  *
@@ -1927,7 +2742,6 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1927
2742
  unexpected: unexpected.slice(0, limit),
1928
2743
  };
1929
2744
  }
1930
-
1931
2745
  async doctor(): Promise<DoctorResult> {
1932
2746
  this.checkAbort();
1933
2747
  const diagnostics: Diagnostic[] = [];
@@ -2055,10 +2869,33 @@ export class SynomemClient extends SynomemCore implements SynomemService {
2055
2869
  }
2056
2870
  return { healthy: !diagnostics.some((item) => item.level === 'error'), diagnostics };
2057
2871
  }
2058
-
2059
2872
  async export(format: 'json' | 'jsonl' | 'markdown'): Promise<string> {
2060
2873
  this.checkAbort();
2061
- const rows = this.storage.rawEventRows();
2874
+ const rows = [] as ReturnType<SynomemStorage['rawEventRows']>;
2875
+ const access = new Map<string, boolean>();
2876
+ for (const row of this.storage.rawEventRows()) {
2877
+ let event: SynomemEvent;
2878
+ try {
2879
+ event = eventSchema.parse(JSON.parse(row.payload));
2880
+ } catch {
2881
+ if (this.administrative) rows.push(row);
2882
+ continue;
2883
+ }
2884
+ const rootId = 'rootId' in event ? event.rootId : event.aggregateId;
2885
+ if (event.type.startsWith('agent.')) {
2886
+ if (this.administrative) rows.push(row);
2887
+ continue;
2888
+ }
2889
+ if (!access.has(rootId))
2890
+ access.set(rootId, this.storage.canActorReadItem(rootId, this.actor as ActorRef));
2891
+ if (!access.get(rootId)) continue;
2892
+ if (
2893
+ event.type === 'reply.created' &&
2894
+ (await this.storage.participation.getReply(event.id))?.deleted
2895
+ )
2896
+ continue;
2897
+ rows.push(row);
2898
+ }
2062
2899
  if (format === 'jsonl') return `${rows.map((row) => row.payload).join('\n')}\n`;
2063
2900
  const exported = rows.map((row) => {
2064
2901
  try {
@@ -2069,7 +2906,8 @@ export class SynomemClient extends SynomemCore implements SynomemService {
2069
2906
  });
2070
2907
  if (format === 'json') return `${JSON.stringify(exported, null, 2)}\n`;
2071
2908
  const scan = this.storage.scanEvents();
2072
- const events = scan.events;
2909
+ const allowedIds = new Set(rows.map((row) => row.id));
2910
+ const events = scan.events.filter((event) => allowedIds.has(event.id));
2073
2911
  const records = recordsFromEvents(events);
2074
2912
  const memos = memoRecordsFromEvents(events);
2075
2913
  const notes = noteRecordsFromEvents(events);
@@ -2097,21 +2935,28 @@ export class SynomemClient extends SynomemCore implements SynomemService {
2097
2935
  ];
2098
2936
  return `${warning}${sections.join('\n\n')}\n`;
2099
2937
  }
2100
-
2101
2938
  async backup(destination: string): Promise<string> {
2102
2939
  this.checkAbort();
2103
2940
  return this.storage.backup(resolve(destination));
2104
2941
  }
2105
-
2106
2942
  async rebuild(): Promise<ProjectionRebuildResult> {
2107
2943
  this.checkAbort();
2108
2944
  return this.projections.rebuild();
2109
2945
  }
2110
-
2111
2946
  async capabilities(): Promise<SynomemServiceCapabilities> {
2112
2947
  this.checkAbort();
2113
2948
  return {
2114
2949
  backend: 'local',
2950
+ participation: {
2951
+ version: 2,
2952
+ replies: true,
2953
+ reactions: true,
2954
+ personalInbox: true,
2955
+ search: false,
2956
+ canWrite: !this.storage.readOnly,
2957
+ administrator: this.administrative,
2958
+ managedAgentIds: [...this.authority.operatedAgentIds],
2959
+ },
2115
2960
  binding: { workspaceId: this.storage.config.workspaceId, actor: this.actor },
2116
2961
  administration: {
2117
2962
  agentCreationViaMcp: this.storage.config.allowAgentCreationViaMcp,
@@ -2121,14 +2966,33 @@ export class SynomemClient extends SynomemCore implements SynomemService {
2121
2966
  projections: { ...this.storage.config.projection },
2122
2967
  };
2123
2968
  }
2124
-
2125
2969
  async info(): Promise<SynomemServiceInfo> {
2126
2970
  this.checkAbort();
2127
2971
  return { backend: 'local', home: this.home, databasePath: this.storage.databasePath };
2128
2972
  }
2129
-
2130
2973
  async getCanonicalEvent(id: string): Promise<SynomemEvent | undefined> {
2131
2974
  this.checkAbort();
2132
- return this.storage.getEvent(id);
2975
+ const event = this.storage.getEvent(id);
2976
+ if (!event) return undefined;
2977
+ if (event.type.startsWith('agent.')) return this.administrative ? event : undefined;
2978
+ const rootId = 'rootId' in event ? event.rootId : event.aggregateId;
2979
+ if (!this.storage.canActorReadItem(rootId, this.actor as ActorRef))
2980
+ throw new SynomemError('POLICY_FORBIDDEN', 'This event is not visible.');
2981
+ if (
2982
+ event.type === 'reply.created' &&
2983
+ (await this.storage.participation.getReply(event.id))?.deleted
2984
+ )
2985
+ return undefined;
2986
+ return event;
2133
2987
  }
2134
2988
  }
2989
+ export function createLocalOwnerClient(
2990
+ options: Omit<SynomemClientOptions, 'authority'> = {},
2991
+ ): SynomemClient {
2992
+ if (options.actor?.kind !== 'human')
2993
+ throw new SynomemError(
2994
+ 'POLICY_FORBIDDEN',
2995
+ 'Local owner administration requires an explicit human actor.',
2996
+ );
2997
+ return new SynomemClient({ ...options, authority: localOwnerAuthority() });
2998
+ }