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/dist/client.js CHANGED
@@ -1,3 +1,9 @@
1
+ import { actorDirectoryInput } from './actor-directory.js';
2
+ import { z } from 'zod';
3
+ import { threadInputSchema, threadSubscriptionSchema, replyCreateSchema, replyDeleteSchema, reactionSetSchema, } from './schemas.js';
4
+ import { AsyncLocalStorage } from 'node:async_hooks';
5
+ import { cursorFilter } from './cursors.js';
6
+ import { isRecordAdministrator, localOwnerAuthority, overseesAgent, resolveAuthority, sameActor, } from './policy.js';
1
7
  import { accessSync, constants as fsConstants, existsSync, lstatSync } from 'node:fs';
2
8
  import { join, relative, resolve } from 'node:path';
3
9
  import { ulid } from 'ulid';
@@ -5,7 +11,7 @@ import { resolveHome } from './config.js';
5
11
  import { asSynomemError, SynomemError } from './errors.js';
6
12
  import { assertNoSymlinkEscape } from './fs-utils.js';
7
13
  import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, postRecordsFromEvents, ProjectionManager, recordsFromEvents, taskRecordsFromEvents, todoRecordsFromEvents, } from './projections.js';
8
- import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createPostSchema, updatePostSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, createTopicSchema, updateTopicSchema, topicListInputSchema, topicLookupSchema, } from './schemas.js';
14
+ import { actorSchema, eventSchema, overrideTaskDecisionSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createPostSchema, updatePostSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, createTopicSchema, updateTopicSchema, topicListInputSchema, topicLookupSchema, } from './schemas.js';
9
15
  import { SynomemStorage } from './storage.js';
10
16
  export class SynomemCore {
11
17
  /**
@@ -19,7 +25,280 @@ export class SynomemCore {
19
25
  idGenerator;
20
26
  signal;
21
27
  administrative;
28
+ writeAllowed;
29
+ mutationContext = new AsyncLocalStorage();
30
+ authority;
22
31
  initialized = false;
32
+ bookmarks = {
33
+ list: (input = {}) => {
34
+ const parsed = this.validate(() => z
35
+ .object({
36
+ after: z.string().max(4096).optional(),
37
+ limit: z.number().int().min(1).max(100).optional(),
38
+ })
39
+ .strict()
40
+ .parse(input));
41
+ return this.repository.bookmarks.list(parsed, this.actor);
42
+ },
43
+ has: async (rootId) => {
44
+ await this.getItem(rootId);
45
+ return { saved: await this.repository.bookmarks.has(rootId, this.boundRef()) };
46
+ },
47
+ set: async (input) => {
48
+ const parsed = this.validate(() => z
49
+ .object({ rootId: z.string().length(26), present: z.boolean() })
50
+ .strict()
51
+ .parse(input));
52
+ if (!this.writeAllowed)
53
+ throw new SynomemError('READ_ONLY', 'This binding cannot change saved records.');
54
+ await this.repository.transaction(async () => {
55
+ await this.resolveActor(this.boundRef(), true);
56
+ await this.getItem(parsed.rootId);
57
+ await this.repository.bookmarks.set(parsed.rootId, this.boundRef(), parsed.present);
58
+ });
59
+ return { saved: parsed.present };
60
+ },
61
+ };
62
+ notifications = {
63
+ list: async (input = {}) => {
64
+ const parsed = this.validate(() => z
65
+ .object({
66
+ after: z.string().max(4096).optional(),
67
+ limit: z.number().int().min(1).max(100).optional(),
68
+ view: z.enum(['all', 'unread', 'action_required', 'default']).optional(),
69
+ })
70
+ .strict()
71
+ .parse(input));
72
+ if (this.writeAllowed)
73
+ await this.repository.transaction(() => this.repository.notifications.drain());
74
+ const page = await this.repository.notifications.list(parsed, this.actor);
75
+ return page;
76
+ },
77
+ read: (id) => this.personalWrite(() => this.repository.notifications.read(id, this.boundRef())),
78
+ dismiss: (id) => this.personalWrite(() => this.repository.notifications.dismiss(id, this.boundRef())),
79
+ readThrough: (through) => this.personalWrite(() => this.repository.notifications.readThrough(through, this.boundRef())),
80
+ };
81
+ replies = {
82
+ changes: async (input) => {
83
+ const parsed = this.validate(() => threadInputSchema.parse(input));
84
+ await this.getItem(parsed.rootId);
85
+ return this.repository.participation.changes(parsed, this.actor);
86
+ },
87
+ create: (input) => this.mutate('replies.create', input, () => this.createReply(input)),
88
+ get: (id) => this.getReply(id),
89
+ delete: (input) => this.mutate('replies.delete', input, () => this.deleteReply(input)),
90
+ };
91
+ threads = {
92
+ read: async (input) => {
93
+ const parsed = this.validate(() => z
94
+ .object({ rootId: z.string().length(26), through: z.string().max(4096) })
95
+ .strict()
96
+ .parse(input));
97
+ await this.personalWrite(async () => {
98
+ await this.getItem(parsed.rootId);
99
+ await this.repository.participation.read(parsed.rootId, parsed.through, this.boundRef());
100
+ });
101
+ },
102
+ get: async (input) => {
103
+ const parsed = this.validate(() => threadInputSchema.parse(input));
104
+ await this.getItem(parsed.rootId);
105
+ return this.repository.participation.thread(parsed, this.actor);
106
+ },
107
+ subscription: async (input) => {
108
+ const parsed = this.validate(() => threadSubscriptionSchema.parse(input));
109
+ await this.personalWrite(async () => {
110
+ await this.getItem(parsed.rootId);
111
+ await this.repository.participation.subscription(parsed.rootId, this.boundRef(), parsed.following, parsed.muted);
112
+ });
113
+ },
114
+ };
115
+ reactions = {
116
+ set: (input) => this.mutate('reactions.set', input, () => this.setReaction(input)),
117
+ get: async (targetId) => {
118
+ await this.reactionRoot(targetId);
119
+ return this.repository.participation.reactions(targetId, this.boundRef());
120
+ },
121
+ };
122
+ allowedActions(record) {
123
+ const event = record.event;
124
+ const active = record.status !== 'archived' &&
125
+ (!('revocationStatus' in record) || record.revocationStatus !== 'revoked');
126
+ const owner = 'owner' in event ? event.owner : event.actor;
127
+ const participant = 'assignee' in event &&
128
+ (this.canManage(event.assignee) || this.canManage(event.actor));
129
+ const managed = event.type === 'memo.sent'
130
+ ? this.canManage(event.recipient)
131
+ : event.type === 'task.created'
132
+ ? participant
133
+ : this.canManage(owner);
134
+ const flags = {
135
+ reply: active,
136
+ edit: active &&
137
+ managed &&
138
+ ['post.created', 'note.created', 'task.created', 'todo.created'].includes(event.type),
139
+ archive: active && managed && !['task.created', 'kudos.given'].includes(event.type),
140
+ moderateReplies: this.canModerateRoot(record),
141
+ acknowledge: event.type === 'kudos.given'
142
+ ? active && sameActor(this.actor, event.recipient) && record.status === 'unacknowledged'
143
+ : event.type === 'post.created'
144
+ ? active &&
145
+ 'acknowledgments' in record &&
146
+ !record.acknowledgments.some((entry) => sameActor(this.actor, entry.actor))
147
+ : false,
148
+ withdrawAcknowledgment: event.type === 'post.created' &&
149
+ active &&
150
+ 'acknowledgments' in record &&
151
+ record.acknowledgments.some((entry) => sameActor(this.actor, entry.actor)),
152
+ readMemo: event.type === 'memo.sent' &&
153
+ active &&
154
+ sameActor(this.actor, event.recipient) &&
155
+ record.status === 'unread',
156
+ revoke: event.type === 'kudos.given' && active && this.canManage(event.actor),
157
+ };
158
+ if (event.type === 'task.created')
159
+ Object.assign(flags, {
160
+ accept: record.status === 'assigned' && this.canManage(event.assignee),
161
+ reject: record.status === 'assigned' && this.canManage(event.assignee),
162
+ complete: record.status === 'open' && participant,
163
+ reopen: ['completed', 'canceled'].includes(record.status) && participant,
164
+ cancel: ['assigned', 'open'].includes(record.status) && participant,
165
+ overrideDecision: ['open', 'rejected'].includes(record.status) &&
166
+ this.actor.kind === 'human' &&
167
+ (this.administrative ||
168
+ (event.assignee.kind === 'agent' &&
169
+ overseesAgent(this.actor, this.authority, event.assignee.id)) ||
170
+ (event.actor.kind === 'agent' &&
171
+ overseesAgent(this.actor, this.authority, event.actor.id))),
172
+ });
173
+ if (event.type === 'todo.created')
174
+ Object.assign(flags, {
175
+ complete: record.status === 'open' && managed,
176
+ reopen: ['completed', 'canceled'].includes(record.status) && managed,
177
+ cancel: record.status === 'open' && managed,
178
+ });
179
+ return Object.fromEntries(Object.entries(flags).map(([name, allowed]) => [name, this.writeAllowed && allowed]));
180
+ }
181
+ async decorateRecord(record) {
182
+ return {
183
+ ...record,
184
+ lifecycleVersion: (await this.repository.nextAggregateVersion(record.event.id)) - 1,
185
+ allowedActions: this.allowedActions(record),
186
+ };
187
+ }
188
+ async getReply(id) {
189
+ const reply = await this.repository.participation.getReply(id);
190
+ if (!reply)
191
+ throw new SynomemError('ITEM_NOT_FOUND', 'Unknown reply.');
192
+ await this.getItem(reply.rootId);
193
+ return reply;
194
+ }
195
+ async createReply(input) {
196
+ await this.repository.assertEventCompatibility();
197
+ const parsed = this.validate(() => replyCreateSchema.parse(input));
198
+ const root = await this.getItem(parsed.rootId);
199
+ if (root.status === 'archived' ||
200
+ ('revocationStatus' in root && root.revocationStatus === 'revoked'))
201
+ throw new SynomemError('MUTATION_FORBIDDEN', 'This root no longer accepts replies.');
202
+ if (parsed.parentId) {
203
+ const parent = await this.getReply(parsed.parentId);
204
+ if (parent.rootId !== parsed.rootId)
205
+ throw new SynomemError('INVALID_INPUT', 'The parent must belong to this thread.');
206
+ }
207
+ const mentions = [];
208
+ for (const supplied of parsed.mentions ?? []) {
209
+ const target = await this.resolveActor(supplied, true);
210
+ const ref = { kind: target.kind, id: target.id };
211
+ if (!(await this.repository.canActorReadItem(parsed.rootId, ref)))
212
+ throw new SynomemError('INVALID_INPUT', 'Mention targets must already have access.');
213
+ if (!mentions.some((actor) => sameActor(actor, ref)))
214
+ mentions.push(ref);
215
+ }
216
+ await this.repository.participation.reserveBudget('reply', this.boundRef(), this.now());
217
+ const id = this.nextId();
218
+ await this.repository.insertEvent({
219
+ ...this.eventBase(id, 1, id),
220
+ type: 'reply.created',
221
+ rootId: parsed.rootId,
222
+ parentId: parsed.parentId ?? null,
223
+ body: parsed.body,
224
+ mentions,
225
+ });
226
+ return this.getReply(id);
227
+ }
228
+ canModerateRoot(root) {
229
+ if (this.actor.kind !== 'human')
230
+ return false;
231
+ const event = root.event;
232
+ if (this.administrative || sameActor(event.actor, this.actor))
233
+ return true;
234
+ const targets = [
235
+ event.actor,
236
+ ...('recipient' in event ? [event.recipient] : []),
237
+ ...('owner' in event ? [event.owner] : []),
238
+ ...('assignee' in event ? [event.assignee] : []),
239
+ ];
240
+ return targets.some((ref) => ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id));
241
+ }
242
+ async deleteReply(input) {
243
+ await this.repository.assertEventCompatibility();
244
+ const parsed = this.validate(() => replyDeleteSchema.parse(input));
245
+ const reply = await this.getReply(parsed.replyId);
246
+ const root = await this.getItem(reply.rootId);
247
+ const own = sameActor(reply.author, this.actor);
248
+ if (!own && !this.canModerateRoot(root))
249
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the reply author or an authorized human moderator may delete it.');
250
+ if (!own && !parsed.reason)
251
+ throw new SynomemError('INVALID_INPUT', 'Moderation requires a reason.');
252
+ if (reply.version !== parsed.expectedVersion)
253
+ throw new SynomemError('REVISION_CONFLICT', 'The reply changed. Refresh before deleting.');
254
+ if (reply.deleted)
255
+ return reply;
256
+ await this.repository.insertEvent({
257
+ ...this.eventBase(reply.id, reply.version + 1),
258
+ ...(!own ? this.intervention(reply.author, parsed.reason) : {}),
259
+ type: 'reply.deleted',
260
+ replyId: reply.id,
261
+ rootId: reply.rootId,
262
+ ...(parsed.reason ? { reason: parsed.reason } : {}),
263
+ });
264
+ return this.getReply(reply.id);
265
+ }
266
+ async reactionRoot(targetId) {
267
+ const reply = await this.repository.participation.getReply(targetId);
268
+ if (reply) {
269
+ await this.getItem(reply.rootId);
270
+ if (reply.deleted)
271
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Deleted replies cannot receive reactions.');
272
+ return reply.rootId;
273
+ }
274
+ await this.getItem(targetId);
275
+ return targetId;
276
+ }
277
+ async setReaction(input) {
278
+ await this.repository.assertEventCompatibility();
279
+ const parsed = this.validate(() => reactionSetSchema.parse(input));
280
+ const rootId = await this.reactionRoot(parsed.targetId);
281
+ const actor = this.boundRef();
282
+ const present = await this.repository.participation.hasReaction(parsed.targetId, actor, parsed.code);
283
+ if (present !== parsed.present) {
284
+ await this.repository.participation.reserveBudget('reaction', actor, this.now());
285
+ const id = this.nextId();
286
+ await this.repository.insertEvent({
287
+ ...this.eventBase(id, 1, id),
288
+ type: parsed.present ? 'reaction.added' : 'reaction.removed',
289
+ rootId,
290
+ targetId: parsed.targetId,
291
+ code: parsed.code,
292
+ });
293
+ }
294
+ return this.repository.participation.reactions(parsed.targetId, actor);
295
+ }
296
+ async search(input) {
297
+ this.checkAbort();
298
+ if (!this.repository.search)
299
+ throw new SynomemError('UNSUPPORTED_BACKEND', 'Search is available on hosted workspaces.');
300
+ return this.repository.search(input, this.actor);
301
+ }
23
302
  agents = {
24
303
  create: (input) => this.createAgent(input),
25
304
  update: (id, changes) => this.updateAgent(id, changes),
@@ -45,65 +324,66 @@ export class SynomemCore {
45
324
  restore: (idOrAlias) => this.setTopicStatus(idOrAlias, 'active'),
46
325
  };
47
326
  kudos = {
48
- give: (input) => this.giveKudos(input),
327
+ give: (input) => this.mutate('kudos.give', input, () => this.giveKudos(input)),
49
328
  list: (input = {}) => this.listKudos(input),
50
329
  changes: (input = {}) => this.listKudosChanges(input),
51
330
  get: (id) => this.getKudos(id),
52
- acknowledge: (input) => this.acknowledgeKudos(input),
53
- revoke: (input) => this.revokeKudos(input),
331
+ acknowledge: (input) => this.mutate('kudos.acknowledge', input, () => this.acknowledgeKudos(input)),
332
+ revoke: (input) => this.mutate('kudos.revoke', input, () => this.revokeKudos(input)),
54
333
  };
55
334
  memos = {
56
- send: (input) => this.sendMemo(input),
335
+ send: (input) => this.mutate('memos.send', input, () => this.sendMemo(input)),
57
336
  list: (input = {}) => this.listItems({ ...input, kinds: ['memo'] }),
58
337
  get: (id) => this.getMemo(id),
59
- read: (input) => this.readMemo(input),
60
- archive: (input) => this.archiveMemo(input),
338
+ read: (input) => this.mutate('memos.read', input, () => this.readMemo(input)),
339
+ archive: (input) => this.mutate('memos.archive', input, () => this.archiveMemo(input)),
61
340
  };
62
341
  posts = {
63
- create: (input) => this.createPost(input),
342
+ create: (input) => this.mutate('posts.create', input, () => this.createPost(input)),
64
343
  list: (input = {}) => this.listItems({ ...input, kinds: ['post'] }),
65
344
  get: (id) => this.getPost(id),
66
- update: (input) => this.updatePost(input),
67
- archive: (input) => this.archivePost(input),
345
+ update: (input) => this.mutate('posts.update', input, () => this.updatePost(input)),
346
+ archive: (input) => this.mutate('posts.archive', input, () => this.archivePost(input)),
68
347
  /*
69
348
  * Acknowledging is an explicit call and always speaks for the caller alone.
70
349
  * There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
71
350
  * is one actor saying "I have seen this", and reading a post must never
72
351
  * append one, or the roster stops meaning anything.
73
352
  */
74
- acknowledge: (input) => this.acknowledgePost(input),
75
- withdrawAcknowledgment: (input) => this.withdrawPostAcknowledgment(input),
353
+ acknowledge: (input) => this.mutate('posts.acknowledge', input, () => this.acknowledgePost(input)),
354
+ withdrawAcknowledgment: (input) => this.mutate('posts.withdrawAcknowledgment', input, () => this.withdrawPostAcknowledgment(input)),
76
355
  roster: (postId) => this.postRoster(postId),
77
356
  };
78
357
  notes = {
79
- create: (input) => this.createNote(input),
358
+ create: (input) => this.mutate('notes.create', input, () => this.createNote(input)),
80
359
  list: (input = {}) => this.listItems({ ...input, kinds: ['note'] }),
81
360
  get: (id) => this.getNote(id),
82
- revise: (input) => this.reviseNote(input),
83
- archive: (input) => this.archiveNote(input),
361
+ revise: (input) => this.mutate('notes.revise', input, () => this.reviseNote(input)),
362
+ archive: (input) => this.mutate('notes.archive', input, () => this.archiveNote(input)),
84
363
  };
85
364
  tasks = {
86
- create: (input) => this.createTask(input),
365
+ overrideDecision: (input) => this.mutate('tasks.overrideDecision', input, () => this.overrideTaskDecision(input)),
366
+ create: (input) => this.mutate('tasks.create', input, () => this.createTask(input)),
87
367
  list: (input = {}) => this.listItems({ ...input, kinds: ['task'] }),
88
368
  get: (id) => this.getTask(id),
89
- update: (input) => this.updateTask(input),
369
+ update: (input) => this.mutate('tasks.update', input, () => this.updateTask(input)),
90
370
  // A response is optional when accepting and required when rejecting: a
91
371
  // refusal without a reason leaves the assigner unable to act on it.
92
- accept: (input) => this.acceptTask(input),
93
- reject: (input) => this.rejectTask(input),
94
- complete: (input) => this.completeTask(input),
95
- reopen: (input) => this.reopenTask(input),
96
- cancel: (input) => this.cancelTask(input),
372
+ accept: (input) => this.mutate('tasks.accept', input, () => this.acceptTask(input)),
373
+ reject: (input) => this.mutate('tasks.reject', input, () => this.rejectTask(input)),
374
+ complete: (input) => this.mutate('tasks.complete', input, () => this.completeTask(input)),
375
+ reopen: (input) => this.mutate('tasks.reopen', input, () => this.reopenTask(input)),
376
+ cancel: (input) => this.mutate('tasks.cancel', input, () => this.cancelTask(input)),
97
377
  };
98
378
  todos = {
99
- create: (input) => this.createTodo(input),
379
+ create: (input) => this.mutate('todos.create', input, () => this.createTodo(input)),
100
380
  list: (input = {}) => this.listItems({ ...input, kinds: ['todo'] }),
101
381
  get: (id) => this.getTodo(id),
102
- update: (input) => this.updateTodo(input),
103
- complete: (input) => this.todoTransition(input, 'todo.completed'),
104
- reopen: (input) => this.todoTransition(input, 'todo.reopened'),
105
- cancel: (input) => this.todoTransition(input, 'todo.canceled'),
106
- archive: (input) => this.todoTransition(input, 'todo.archived'),
382
+ update: (input) => this.mutate('todos.update', input, () => this.updateTodo(input)),
383
+ complete: (input) => this.mutate('todos.complete', input, () => this.todoTransition(input, 'todo.completed')),
384
+ reopen: (input) => this.mutate('todos.reopen', input, () => this.todoTransition(input, 'todo.reopened')),
385
+ cancel: (input) => this.mutate('todos.cancel', input, () => this.todoTransition(input, 'todo.canceled')),
386
+ archive: (input) => this.mutate('todos.archive', input, () => this.todoTransition(input, 'todo.archived')),
107
387
  };
108
388
  /**
109
389
  * Unanswered and overdue discovery.
@@ -122,7 +402,7 @@ export class SynomemCore {
122
402
  const { olderThanHours, awaitingSince, ...rest } = input;
123
403
  const since = awaitingSince ??
124
404
  (olderThanHours !== undefined
125
- ? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
405
+ ? new Date(Date.now() - olderThanHours * 3600000).toISOString()
126
406
  : undefined);
127
407
  return this.listItems({
128
408
  ...rest,
@@ -151,16 +431,36 @@ export class SynomemCore {
151
431
  this.clock = options.clock ?? (() => new Date());
152
432
  this.idGenerator = options.idGenerator ?? (() => ulid(this.clock().getTime()));
153
433
  this.signal = options.signal;
154
- this.administrative = options.administrative ?? this.actor.kind === 'human';
434
+ this.authority = resolveAuthority(options.authority);
435
+ this.writeAllowed = options.canWrite ?? true;
436
+ this.administrative = isRecordAdministrator(this.actor, this.authority);
155
437
  this.repository = options.repository;
156
438
  this.projectionWriter = options.projectionWriter;
157
439
  }
440
+ /** Trusted adapters refresh live authority after acquiring the workspace write boundary. Never exposed as a tool/input. */
441
+ setAuthority(authority, canWrite = this.writeAllowed) {
442
+ this.writeAllowed = canWrite;
443
+ this.authority = resolveAuthority(authority);
444
+ this.administrative = isRecordAdministrator(this.actor, this.authority);
445
+ this.repository.setAuthority(this.authority);
446
+ }
158
447
  async init() {
159
448
  this.checkAbort();
160
449
  if (this.initialized)
161
450
  return;
162
451
  await this.repository.init();
163
452
  this.initialized = true;
453
+ if (this.actor.kind === 'human' &&
454
+ this.authority.mode === 'local-owner' &&
455
+ !(await this.repository.getActor({ kind: 'human', id: this.actor.id }))) {
456
+ await this.repository.transaction(() => this.repository.registerHuman({
457
+ kind: 'human',
458
+ id: this.actor.id,
459
+ handle: this.actor.id,
460
+ displayName: this.actor.displayName ?? this.actor.id,
461
+ status: 'active',
462
+ }));
463
+ }
164
464
  /*
165
465
  * An agent actor is resolved to its canonical identity here.
166
466
  *
@@ -189,6 +489,321 @@ export class SynomemCore {
189
489
  await this.repository.close();
190
490
  this.initialized = false;
191
491
  }
492
+ actors = {
493
+ profile: async (input) => {
494
+ const actor = await this.resolveActor(input.target);
495
+ return {
496
+ actor,
497
+ counts: await this.repository.actorCounts(actor, this.actor),
498
+ authored: await this.listItems({
499
+ actorKind: actor.kind,
500
+ actorId: actor.id,
501
+ cursor: input.after,
502
+ limit: input.limit,
503
+ }),
504
+ };
505
+ },
506
+ list: async (input = {}) => {
507
+ this.checkAbort();
508
+ return await this.repository.listActors(actorDirectoryInput(input), this.actor);
509
+ },
510
+ get: (ref) => this.resolveActor(ref),
511
+ registerHuman: async (input) => {
512
+ if (this.actor.kind !== 'human' || this.authority.mode !== 'local-owner')
513
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the local owner may register a human.');
514
+ const ref = actorSchema.parse({
515
+ kind: 'human',
516
+ id: input.id,
517
+ displayName: input.displayName,
518
+ });
519
+ const handle = agentLookupSchema.parse(input.handle);
520
+ await this.repository.registerHuman({
521
+ kind: 'human',
522
+ id: ref.id,
523
+ handle,
524
+ displayName: input.displayName,
525
+ status: 'active',
526
+ });
527
+ return this.resolveActor({ kind: 'human', id: ref.id });
528
+ },
529
+ };
530
+ async resolveActor(ref, requireActive = false) {
531
+ if (!ref || (ref.kind !== 'human' && ref.kind !== 'agent'))
532
+ throw new SynomemError('INVALID_INPUT', 'An actor reference is required.');
533
+ const actor = await this.repository.getActor(ref);
534
+ if (!actor)
535
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown ${ref.kind}: ${ref.id}`);
536
+ if (requireActive && actor.status !== 'active')
537
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Inactive actors cannot receive new work.');
538
+ return actor;
539
+ }
540
+ boundRef() {
541
+ if (this.actor.kind === 'system')
542
+ throw new SynomemError('MUTATION_FORBIDDEN', 'A human or agent identity is required.');
543
+ return { kind: this.actor.kind, id: this.actor.id };
544
+ }
545
+ canManage(ref) {
546
+ return (sameActor(ref, this.actor) ||
547
+ this.administrative ||
548
+ (ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id)));
549
+ }
550
+ intervention(ref, reason) {
551
+ if (this.actor.kind !== 'human' || sameActor(this.actor, ref) || !this.canManage(ref))
552
+ return {};
553
+ const basis = this.authority.mode === 'local-owner'
554
+ ? 'local_owner'
555
+ : this.authority.organizationAdministrator
556
+ ? 'organization_admin'
557
+ : this.authority.workspaceAdministrator
558
+ ? 'workspace_admin'
559
+ : 'operator';
560
+ return { intervention: { basis, ...(reason ? { reason } : {}) } };
561
+ }
562
+ async syncActor(ref) {
563
+ if (ref.kind === 'agent') {
564
+ const context = this.mutationContext.getStore();
565
+ if (context)
566
+ context.projections.add(ref.id);
567
+ else
568
+ await this.projectionWriter.syncAgent(ref.id);
569
+ }
570
+ }
571
+ async normalizeMutation(operation, input) {
572
+ const schemas = {
573
+ 'kudos.give': giveKudosSchema,
574
+ 'replies.create': replyCreateSchema,
575
+ 'replies.delete': replyDeleteSchema,
576
+ 'reactions.set': reactionSetSchema,
577
+ 'memos.send': sendMemoSchema,
578
+ 'notes.create': createNoteSchema,
579
+ 'notes.revise': reviseNoteSchema,
580
+ 'posts.create': createPostSchema,
581
+ 'posts.update': updatePostSchema,
582
+ 'tasks.create': createTaskSchema,
583
+ 'tasks.update': updateTaskSchema,
584
+ 'tasks.overrideDecision': overrideTaskDecisionSchema,
585
+ 'todos.create': createTodoSchema,
586
+ 'todos.update': updateTodoSchema,
587
+ };
588
+ const value = this.validate(() => schemas[operation]?.parse(operation === 'kudos.give'
589
+ ? {
590
+ ...input,
591
+ visibility: input.visibility ?? this.repository.config.defaultVisibility,
592
+ }
593
+ : input) ?? input);
594
+ const normalized = { ...value };
595
+ delete normalized.idempotencyKey;
596
+ for (const field of ['recipient', 'owner', 'assignee']) {
597
+ const supplied = normalized[field];
598
+ if (supplied) {
599
+ const actor = await this.resolveActor(supplied);
600
+ normalized[field] = { kind: actor.kind, id: actor.id };
601
+ }
602
+ }
603
+ for (const field of [
604
+ 'title',
605
+ 'body',
606
+ 'subject',
607
+ 'reason',
608
+ 'response',
609
+ 'note',
610
+ 'details',
611
+ 'description',
612
+ ])
613
+ if (typeof normalized[field] === 'string')
614
+ normalized[field] = normalized[field].trim();
615
+ for (const field of ['tags', 'topicIds'])
616
+ if (field in normalized ||
617
+ operation.endsWith('.create') ||
618
+ ['kudos.give', 'memos.send'].includes(operation))
619
+ normalized[field] = [...new Set((normalized[field] ?? []))].sort();
620
+ if (['kudos.give', 'memos.send', 'tasks.create'].includes(operation))
621
+ normalized.visibility ??= this.repository.config.defaultVisibility;
622
+ if (['notes.create', 'todos.create'].includes(operation))
623
+ normalized.owner ??= this.boundRef();
624
+ if (operation === 'tasks.create')
625
+ normalized.assignee ??= this.boundRef();
626
+ if (['tasks.create', 'todos.create'].includes(operation))
627
+ normalized.priority ??= 3;
628
+ if (['replies.create', 'posts.create', 'posts.update'].includes(operation)) {
629
+ if (operation === 'replies.create')
630
+ normalized.parentId ??= null;
631
+ if (operation === 'posts.update' && normalized.mentions === undefined)
632
+ return normalized;
633
+ const mentions = [];
634
+ for (const supplied of (normalized.mentions ?? [])) {
635
+ let actor;
636
+ try {
637
+ actor = await this.resolveActor(supplied, true);
638
+ }
639
+ catch (error) {
640
+ if (error instanceof SynomemError &&
641
+ ['AGENT_NOT_FOUND', 'INVALID_INPUT'].includes(error.code))
642
+ throw new SynomemError('INVALID_INPUT', 'Mention targets must be active and accessible.');
643
+ throw error;
644
+ }
645
+ if (!mentions.some((ref) => sameActor(ref, actor)))
646
+ mentions.push({ kind: actor.kind, id: actor.id });
647
+ }
648
+ normalized.mentions = mentions.sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
649
+ }
650
+ return normalized;
651
+ }
652
+ async authorizeReceipt(operation, result) {
653
+ if (operation.startsWith('replies.')) {
654
+ const reply = await this.getReply(result.id);
655
+ if (operation === 'replies.delete' &&
656
+ !sameActor(reply.author, this.actor) &&
657
+ !this.canModerateRoot(await this.getItem(reply.rootId)))
658
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Moderation authority was removed.');
659
+ return;
660
+ }
661
+ if (operation === 'reactions.set') {
662
+ await this.reactionRoot(result.targetId);
663
+ return;
664
+ }
665
+ const candidate = result;
666
+ const id = candidate.record?.event?.id ?? candidate.event?.id;
667
+ if (!id)
668
+ throw new SynomemError('IDEMPOTENCY_EXPIRED', 'The cached mutation result cannot be reconstructed.');
669
+ const record = await this.getItem(id);
670
+ const event = record.event;
671
+ if (operation.startsWith('notes.'))
672
+ this.assertNoteOwner(record);
673
+ else if (operation.startsWith('todos.'))
674
+ this.assertTodoOwner(record);
675
+ else if (operation.startsWith('tasks.') && operation !== 'tasks.create') {
676
+ if (['tasks.accept', 'tasks.reject'].includes(operation))
677
+ this.assertTaskAssignee(record);
678
+ else
679
+ this.assertTaskParticipant(record);
680
+ if (operation === 'tasks.overrideDecision' &&
681
+ (this.actor.kind !== 'human' ||
682
+ !(this.administrative ||
683
+ (record.event.assignee.kind === 'agent' &&
684
+ overseesAgent(this.actor, this.authority, record.event.assignee.id)) ||
685
+ (event.actor.kind === 'agent' &&
686
+ overseesAgent(this.actor, this.authority, event.actor.id)))))
687
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only a human overseer may override a task.');
688
+ }
689
+ else if (operation.startsWith('posts.') &&
690
+ ['posts.update', 'posts.archive'].includes(operation))
691
+ this.assertPostAuthor(record);
692
+ else if (operation === 'memos.read')
693
+ this.assertRecipient(record.event.recipient, 'mark this memo read');
694
+ else if (operation === 'memos.archive')
695
+ this.assertRecipient(record.event.recipient, 'archive this memo', true);
696
+ else if (operation === 'kudos.acknowledge' &&
697
+ !sameActor(this.actor, record.event.recipient))
698
+ throw new SynomemError('ACKNOWLEDGMENT_FORBIDDEN', 'Only the recipient may acknowledge kudos.');
699
+ else if (operation === 'kudos.revoke' && !this.canManage(event.actor))
700
+ throw new SynomemError('REVOCATION_FORBIDDEN', 'Only the author or an authorized overseer may revoke kudos.');
701
+ for (const field of ['recipient', 'owner', 'assignee'])
702
+ if (field in event) {
703
+ const ref = event[field];
704
+ await this.syncActor(ref);
705
+ }
706
+ }
707
+ personalWrite(operation) {
708
+ this.checkAbort();
709
+ if (!this.writeAllowed)
710
+ throw new SynomemError('READ_ONLY', 'This binding cannot change personal state.');
711
+ return this.repository.transaction(async () => {
712
+ await this.resolveActor(this.boundRef(), true);
713
+ return operation();
714
+ });
715
+ }
716
+ async mutate(operation, input, action) {
717
+ this.checkAbort();
718
+ if (!this.writeAllowed)
719
+ throw new SynomemError('READ_ONLY', 'This binding cannot write records.');
720
+ const rawKey = input.idempotencyKey;
721
+ if (rawKey !== undefined &&
722
+ (typeof rawKey !== 'string' || !rawKey.trim() || rawKey.trim().length > 200))
723
+ throw new SynomemError('INVALID_INPUT', 'Idempotency keys must contain 1–200 characters.');
724
+ const key = typeof rawKey === 'string' ? rawKey.trim() : undefined;
725
+ const state = { ...(key ? { key } : {}), projections: new Set() };
726
+ const result = await this.mutationContext.run(state, () => this.repository.transaction(async () => {
727
+ if (this.actor.kind !== 'system')
728
+ await this.resolveActor(this.boundRef(), true);
729
+ const normalized = await this.normalizeMutation(operation, input);
730
+ const requestHash = cursorFilter({ operation, request: normalized });
731
+ const keyHash = key ? cursorFilter(key) : undefined;
732
+ const prior = keyHash
733
+ ? await this.repository.getMutationReceipt(this.actor.kind, this.actor.id, keyHash)
734
+ : undefined;
735
+ if (prior) {
736
+ if (prior.operation !== operation || prior.requestHash !== requestHash)
737
+ throw new SynomemError('IDEMPOTENCY_CONFLICT', 'This key was already used for a different request.');
738
+ if (!prior.resultJson)
739
+ throw new SynomemError('IDEMPOTENCY_EXPIRED', 'The original response expired. This request will not execute again.');
740
+ const response = JSON.parse(prior.resultJson);
741
+ await this.authorizeReceipt(operation, response);
742
+ if (operation === 'replies.create' || operation === 'replies.delete') {
743
+ const current = await this.getReply(response.id);
744
+ if (current.deleted)
745
+ return { ...response, body: undefined, mentions: undefined, deleted: true };
746
+ }
747
+ if (response && typeof response === 'object' && 'created' in response)
748
+ return { ...response, created: false, deduplicated: true };
749
+ return response;
750
+ }
751
+ if (key &&
752
+ (await this.repository.getEventByIdempotency(this.actor.kind, this.actor.id, key)))
753
+ throw new SynomemError('IDEMPOTENCY_EXPIRED', 'The original request receipt is unavailable. This key will not execute again.');
754
+ const lifecycleOperations = new Set([
755
+ 'kudos.acknowledge',
756
+ 'kudos.revoke',
757
+ 'memos.read',
758
+ 'memos.archive',
759
+ 'posts.archive',
760
+ 'posts.acknowledge',
761
+ 'posts.withdrawAcknowledgment',
762
+ 'notes.archive',
763
+ 'tasks.accept',
764
+ 'tasks.reject',
765
+ 'tasks.complete',
766
+ 'tasks.reopen',
767
+ 'tasks.cancel',
768
+ 'todos.complete',
769
+ 'todos.reopen',
770
+ 'todos.cancel',
771
+ 'todos.archive',
772
+ ]);
773
+ if (lifecycleOperations.has(operation)) {
774
+ const supplied = input.expectedVersion;
775
+ if (!Number.isSafeInteger(supplied) || supplied < 1)
776
+ throw new SynomemError('INVALID_INPUT', 'expectedVersion must be the current positive lifecycleVersion.');
777
+ const rootId = normalized[(operation.startsWith('kudos.')
778
+ ? 'kudos'
779
+ : operation.split('.')[0].replace(/s$/, '')) + 'Id'];
780
+ const current = await this.getItem(rootId);
781
+ await this.authorizeReceipt(operation, current);
782
+ if (current.lifecycleVersion !== supplied)
783
+ throw new SynomemError('REVISION_CONFLICT', 'The record changed. Refresh before applying this action.');
784
+ }
785
+ const response = await action();
786
+ if (keyHash) {
787
+ const resultJson = JSON.stringify(response);
788
+ if (Buffer.byteLength(resultJson) > 262144)
789
+ throw new SynomemError('INVALID_INPUT', 'The mutation response exceeds the receipt budget.');
790
+ const eventId = (key
791
+ ? await this.repository.getEventByIdempotency(this.actor.kind, this.actor.id, key)
792
+ : undefined)?.id;
793
+ await this.repository.insertMutationReceipt(this.actor.kind, this.actor.id, keyHash, {
794
+ operation,
795
+ requestHash,
796
+ resultJson,
797
+ ...(eventId ? { eventId } : {}),
798
+ createdAt: this.now(),
799
+ });
800
+ }
801
+ return response;
802
+ }));
803
+ for (const id of state.projections)
804
+ await this.projectionWriter.syncAgent(id);
805
+ return result;
806
+ }
192
807
  now() {
193
808
  return this.clock().toISOString();
194
809
  }
@@ -197,7 +812,10 @@ export class SynomemCore {
197
812
  }
198
813
  eventBase(aggregateId, aggregateVersion, id = this.nextId()) {
199
814
  return {
200
- schemaVersion: 1,
815
+ schemaVersion: 2,
816
+ ...(this.mutationContext.getStore()?.key
817
+ ? { idempotencyKey: this.mutationContext.getStore().key }
818
+ : {}),
201
819
  id,
202
820
  workspaceId: this.repository.config.workspaceId,
203
821
  aggregateId,
@@ -576,13 +1194,13 @@ export class SynomemCore {
576
1194
  visibility: input.visibility ?? this.repository.config.defaultVisibility,
577
1195
  }));
578
1196
  await this.assertTopicsExist(parsed.topicIds);
579
- const recipient = await this.repository.getAgent(parsed.recipientAgentId);
1197
+ const recipient = await this.resolveActor(parsed.recipient, true);
580
1198
  if (!recipient) {
581
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
1199
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipient?.id}`);
582
1200
  }
583
1201
  if (!this.repository.config.allowSelfAwards &&
584
1202
  this.actor.kind !== 'human' &&
585
- this.actor.id === recipient.id) {
1203
+ sameActor(this.actor, recipient)) {
586
1204
  throw new SynomemError('SELF_AWARD_FORBIDDEN', 'Non-human actors cannot award kudos to a matching agent identity.');
587
1205
  }
588
1206
  const outcome = await this.repository.transaction(async () => {
@@ -593,7 +1211,7 @@ export class SynomemCore {
593
1211
  const event = {
594
1212
  ...this.eventBase(id, 1, id),
595
1213
  type: 'kudos.given',
596
- recipientAgentId: recipient.id,
1214
+ recipient: { kind: recipient.kind, id: recipient.id },
597
1215
  recipientDisplayName: recipient.displayName,
598
1216
  title: parsed.title,
599
1217
  reason: parsed.reason,
@@ -609,7 +1227,7 @@ export class SynomemCore {
609
1227
  return { event, created: true };
610
1228
  });
611
1229
  if (outcome.created)
612
- await this.projectionWriter.syncAgent(recipient.id);
1230
+ await this.syncActor(recipient);
613
1231
  const record = await this.getKudosRecord(outcome.event.id);
614
1232
  return { record, created: outcome.created, deduplicated: !outcome.created };
615
1233
  }
@@ -618,7 +1236,7 @@ export class SynomemCore {
618
1236
  const record = recordsFromEvents(await this.repository.getReadableSynomemEvents(id))[0];
619
1237
  if (!record)
620
1238
  throw new SynomemError('KUDOS_NOT_FOUND', `Unknown kudos: ${id}`);
621
- return record;
1239
+ return this.decorateRecord(record);
622
1240
  }
623
1241
  async getKudos(id) {
624
1242
  this.checkAbort();
@@ -627,15 +1245,15 @@ export class SynomemCore {
627
1245
  async listKudos(input) {
628
1246
  this.checkAbort();
629
1247
  const filters = this.validate(() => listInputSchema.parse(input));
630
- const recipient = filters.recipientAgentId
631
- ? await this.repository.getAgent(filters.recipientAgentId)
1248
+ const recipient = filters.recipient?.id
1249
+ ? await this.resolveActor(filters.recipient)
632
1250
  : undefined;
633
- if (filters.recipientAgentId && !recipient) {
634
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipientAgentId}`);
1251
+ if (filters.recipient?.id && !recipient) {
1252
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipient?.id}`);
635
1253
  }
636
1254
  return await this.repository.listKudosSummaries({
637
1255
  ...filters,
638
- ...(recipient ? { recipientAgentId: recipient.id } : {}),
1256
+ ...(recipient ? { recipient: { kind: recipient.kind, id: recipient.id } } : {}),
639
1257
  }, this.actor);
640
1258
  }
641
1259
  async listKudosChanges(input) {
@@ -654,19 +1272,19 @@ export class SynomemCore {
654
1272
  return record;
655
1273
  if (record.revocation)
656
1274
  throw new SynomemError('INVALID_INPUT', 'Revoked kudos cannot be acknowledged.');
657
- const isRecipient = this.actor.kind === 'agent' && this.actor.id === record.event.recipientAgentId;
658
- if (!this.administrative && !isRecipient) {
659
- throw new SynomemError('ACKNOWLEDGMENT_FORBIDDEN', 'Only the recipient agent or a human administrator may acknowledge kudos.');
1275
+ const isRecipient = sameActor(this.actor, record.event.recipient);
1276
+ if (!isRecipient) {
1277
+ throw new SynomemError('ACKNOWLEDGMENT_FORBIDDEN', 'Only the recipient may acknowledge kudos.');
660
1278
  }
661
1279
  const event = {
662
1280
  ...this.eventBase(record.event.id, 2),
663
1281
  type: 'kudos.acknowledged',
664
1282
  kudosId: record.event.id,
665
- recipientAgentId: record.event.recipientAgentId,
1283
+ recipient: record.event.recipient,
666
1284
  ...(input.note ? { note: input.note.trim() } : {}),
667
1285
  };
668
1286
  await this.repository.transaction(() => this.repository.insertEvent(event));
669
- await this.projectionWriter.syncAgent(record.event.recipientAgentId);
1287
+ await this.syncActor(record.event.recipient);
670
1288
  return await this.getKudosRecord(input.kudosId);
671
1289
  }
672
1290
  async revokeKudos(input) {
@@ -684,18 +1302,19 @@ export class SynomemCore {
684
1302
  throw new SynomemError('REVOCATION_FORBIDDEN', 'Only an administrator may request an administrative revocation.');
685
1303
  }
686
1304
  const administrative = this.administrative && !isOriginalActor;
687
- if (!isOriginalActor && !this.administrative) {
688
- throw new SynomemError('REVOCATION_FORBIDDEN', 'Only the original actor or an administrator may revoke kudos.');
1305
+ if (!this.canManage(record.event.actor)) {
1306
+ throw new SynomemError('REVOCATION_FORBIDDEN', 'Only the author or an authorized overseer may revoke kudos.');
689
1307
  }
690
1308
  const event = {
691
1309
  ...this.eventBase(record.event.id, record.acknowledgment ? 3 : 2),
1310
+ ...this.intervention(record.event.actor, reason),
692
1311
  type: 'kudos.revoked',
693
1312
  kudosId: record.event.id,
694
1313
  reason,
695
1314
  mode: administrative && !isOriginalActor ? 'administrative' : 'actor-requested',
696
1315
  };
697
1316
  await this.repository.transaction(() => this.repository.insertEvent(event));
698
- await this.projectionWriter.syncAgent(record.event.recipientAgentId);
1317
+ await this.syncActor(record.event.recipient);
699
1318
  return await this.getKudosRecord(input.kudosId);
700
1319
  }
701
1320
  async priorMutation(idempotencyKey, expectedType) {
@@ -708,13 +1327,13 @@ export class SynomemCore {
708
1327
  return prior;
709
1328
  }
710
1329
  canViewItem(summary) {
1330
+ if (summary.kind === 'note' || summary.kind === 'todo')
1331
+ return !!summary.owner && this.canManage(summary.owner);
711
1332
  return (this.administrative ||
712
1333
  summary.visibility !== 'private' ||
713
- (summary.actor.kind === this.actor.kind && summary.actor.id === this.actor.id) ||
714
- (this.actor.kind === 'agent' &&
715
- (summary.recipientAgentId === this.actor.id ||
716
- summary.ownerAgentId === this.actor.id ||
717
- summary.assigneeAgentId === this.actor.id)));
1334
+ [summary.actor, summary.recipient, summary.assignee].some((ref) => !!ref &&
1335
+ (sameActor(ref, this.actor) ||
1336
+ (ref.kind === 'agent' && overseesAgent(this.actor, this.authority, ref.id)))));
718
1337
  }
719
1338
  async requireVisibleItem(id, kind) {
720
1339
  const summary = await this.repository.getItemSummary(id);
@@ -738,7 +1357,7 @@ export class SynomemCore {
738
1357
  const record = memoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
739
1358
  if (!record)
740
1359
  throw new SynomemError('MEMO_NOT_FOUND', `Unknown memo: ${id}`);
741
- return record;
1360
+ return this.decorateRecord(record);
742
1361
  }
743
1362
  async getMemo(id) {
744
1363
  this.checkAbort();
@@ -749,9 +1368,9 @@ export class SynomemCore {
749
1368
  await this.repository.assertEventCompatibility();
750
1369
  const parsed = this.validate(() => sendMemoSchema.parse(input));
751
1370
  await this.assertTopicsExist(parsed.topicIds);
752
- const recipient = await this.repository.getAgent(parsed.recipientAgentId);
1371
+ const recipient = await this.resolveActor(parsed.recipient, true);
753
1372
  if (!recipient)
754
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
1373
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipient?.id}`);
755
1374
  const outcome = await this.repository.transaction(async () => {
756
1375
  const prior = await this.priorMutation(parsed.idempotencyKey, 'memo.sent');
757
1376
  if (prior?.type === 'memo.sent')
@@ -760,7 +1379,7 @@ export class SynomemCore {
760
1379
  const event = {
761
1380
  ...this.eventBase(id, 1, id),
762
1381
  type: 'memo.sent',
763
- recipientAgentId: recipient.id,
1382
+ recipient: { kind: recipient.kind, id: recipient.id },
764
1383
  recipientDisplayName: recipient.displayName,
765
1384
  subject: parsed.subject,
766
1385
  body: parsed.body,
@@ -775,22 +1394,20 @@ export class SynomemCore {
775
1394
  return { id, created: true };
776
1395
  });
777
1396
  if (outcome.created)
778
- await this.projectionWriter.syncAgent(recipient.id);
1397
+ await this.syncActor(recipient);
779
1398
  return {
780
1399
  record: await this.getMemoRecord(outcome.id),
781
1400
  created: outcome.created,
782
1401
  deduplicated: !outcome.created,
783
1402
  };
784
1403
  }
785
- assertRecipient(recipientAgentId, operation) {
786
- if (!this.administrative &&
787
- !(this.actor.kind === 'agent' && this.actor.id === recipientAgentId)) {
788
- throw new SynomemError('MUTATION_FORBIDDEN', `Only the recipient agent or a human administrator may ${operation}.`);
789
- }
1404
+ assertRecipient(recipient, operation, oversight = false) {
1405
+ if (!(oversight ? this.canManage(recipient) : sameActor(this.actor, recipient)))
1406
+ throw new SynomemError('MUTATION_FORBIDDEN', `Only the recipient may ${operation}.`);
790
1407
  }
791
1408
  async readMemo(input) {
792
1409
  const record = await this.getMemoRecord(input.memoId);
793
- this.assertRecipient(record.event.recipientAgentId, 'mark this memo read');
1410
+ this.assertRecipient(record.event.recipient, 'mark this memo read');
794
1411
  if (record.read)
795
1412
  return record;
796
1413
  await this.repository.transaction(async () => {
@@ -801,17 +1418,17 @@ export class SynomemCore {
801
1418
  ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
802
1419
  type: 'memo.read',
803
1420
  memoId: record.event.id,
804
- recipientAgentId: record.event.recipientAgentId,
1421
+ recipient: record.event.recipient,
805
1422
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
806
1423
  };
807
1424
  await this.repository.insertEvent(event);
808
1425
  });
809
- await this.projectionWriter.syncAgent(record.event.recipientAgentId);
1426
+ await this.syncActor(record.event.recipient);
810
1427
  return await this.getMemoRecord(input.memoId);
811
1428
  }
812
1429
  async archiveMemo(input) {
813
1430
  const record = await this.getMemoRecord(input.memoId);
814
- this.assertRecipient(record.event.recipientAgentId, 'archive this memo');
1431
+ this.assertRecipient(record.event.recipient, 'archive this memo', true);
815
1432
  if (record.archived)
816
1433
  return record;
817
1434
  await this.repository.transaction(async () => {
@@ -819,15 +1436,16 @@ export class SynomemCore {
819
1436
  if (prior)
820
1437
  return;
821
1438
  const event = {
1439
+ ...this.intervention(record.event.recipient),
822
1440
  ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
823
1441
  type: 'memo.archived',
824
1442
  memoId: record.event.id,
825
- recipientAgentId: record.event.recipientAgentId,
1443
+ recipient: record.event.recipient,
826
1444
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
827
1445
  };
828
1446
  await this.repository.insertEvent(event);
829
1447
  });
830
- await this.projectionWriter.syncAgent(record.event.recipientAgentId);
1448
+ await this.syncActor(record.event.recipient);
831
1449
  return await this.getMemoRecord(input.memoId);
832
1450
  }
833
1451
  async getPostRecord(id) {
@@ -835,21 +1453,30 @@ export class SynomemCore {
835
1453
  const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
836
1454
  if (!record)
837
1455
  throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
838
- return record;
1456
+ return this.decorateRecord(record);
839
1457
  }
840
1458
  async getPost(id) {
841
1459
  this.checkAbort();
842
1460
  return await this.getPostRecord(id);
843
1461
  }
1462
+ async canonicalPostMentions(input) {
1463
+ const mentions = [];
1464
+ for (const reference of input) {
1465
+ const actor = await this.resolveActor(reference, true);
1466
+ const ref = { kind: actor.kind, id: actor.id };
1467
+ if (!mentions.some((mention) => sameActor(mention, ref)))
1468
+ mentions.push(ref);
1469
+ }
1470
+ return mentions.sort((left, right) => `${left.kind}:${left.id}`.localeCompare(`${right.kind}:${right.id}`));
1471
+ }
844
1472
  async createPost(input) {
845
1473
  this.checkAbort();
846
1474
  await this.repository.assertEventCompatibility();
847
1475
  const parsed = this.validate(() => createPostSchema.parse(input));
848
1476
  await this.assertTopicsExist(parsed.topicIds);
849
- // A reply inherits its parent's workspace by construction, and cannot name
850
- // a different target — there is no target to name.
851
- if (parsed.replyTo)
852
- await this.requireVisibleItem(parsed.replyTo, 'post');
1477
+ const mentions = parsed.mentions
1478
+ ? await this.canonicalPostMentions(parsed.mentions)
1479
+ : undefined;
853
1480
  const outcome = await this.repository.transaction(async () => {
854
1481
  const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
855
1482
  if (prior?.type === 'post.created')
@@ -860,9 +1487,9 @@ export class SynomemCore {
860
1487
  type: 'post.created',
861
1488
  title: parsed.title,
862
1489
  body: parsed.body,
1490
+ mentions: mentions ?? [],
863
1491
  tags: [...new Set(parsed.tags ?? [])].sort(),
864
1492
  topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
865
- ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
866
1493
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
867
1494
  ...(parsed.source ? { source: parsed.source } : {}),
868
1495
  ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
@@ -878,7 +1505,9 @@ export class SynomemCore {
878
1505
  }
879
1506
  /** Only the author edits a post. Everyone else responds to it. */
880
1507
  assertPostAuthor(record) {
881
- if (this.administrative)
1508
+ if (this.administrative ||
1509
+ (record.event.actor.kind === 'agent' &&
1510
+ overseesAgent(this.actor, this.authority, record.event.actor.id)))
882
1511
  return;
883
1512
  if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
884
1513
  throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
@@ -888,6 +1517,9 @@ export class SynomemCore {
888
1517
  this.checkAbort();
889
1518
  const parsed = this.validate(() => updatePostSchema.parse(input));
890
1519
  await this.assertTopicsExist(parsed.topicIds);
1520
+ const mentions = parsed.mentions
1521
+ ? await this.canonicalPostMentions(parsed.mentions)
1522
+ : undefined;
891
1523
  const record = await this.getPostRecord(parsed.postId);
892
1524
  this.assertPostAuthor(record);
893
1525
  if (record.status === 'archived') {
@@ -898,11 +1530,13 @@ export class SynomemCore {
898
1530
  }
899
1531
  await this.repository.transaction(async () => {
900
1532
  const event = {
1533
+ ...this.intervention(record.event.actor),
901
1534
  ...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
902
1535
  type: 'post.edited',
903
1536
  postId: parsed.postId,
904
1537
  title: parsed.title ?? record.title,
905
1538
  body: parsed.body ?? record.body,
1539
+ mentions: mentions ?? record.mentions ?? [],
906
1540
  tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
907
1541
  topicIds: [...new Set(parsed.topicIds ?? record.topicIds ?? [])].sort(),
908
1542
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
@@ -918,6 +1552,7 @@ export class SynomemCore {
918
1552
  if (record.status !== 'archived') {
919
1553
  await this.repository.transaction(async () => {
920
1554
  const event = {
1555
+ ...this.intervention(record.event.actor),
921
1556
  ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
922
1557
  type: 'post.archived',
923
1558
  postId: input.postId,
@@ -979,7 +1614,7 @@ export class SynomemCore {
979
1614
  const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
980
1615
  if (!record)
981
1616
  throw new SynomemError('NOTE_NOT_FOUND', `Unknown note: ${id}`);
982
- return record;
1617
+ return this.decorateRecord(record);
983
1618
  }
984
1619
  async getNote(id) {
985
1620
  this.checkAbort();
@@ -990,15 +1625,9 @@ export class SynomemCore {
990
1625
  await this.repository.assertEventCompatibility();
991
1626
  const parsed = this.validate(() => createNoteSchema.parse(input));
992
1627
  await this.assertTopicsExist(parsed.topicIds);
993
- const ownerId = parsed.ownerAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
994
- if (!ownerId)
995
- throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify ownerAgentId.');
996
- const owner = await this.repository.getAgent(ownerId);
997
- if (!owner)
998
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown note owner: ${ownerId}`);
999
- if (!this.administrative && (this.actor.kind !== 'agent' || this.actor.id !== owner.id)) {
1000
- throw new SynomemError('MUTATION_FORBIDDEN', 'Agents may create notes only for themselves.');
1001
- }
1628
+ const owner = await this.resolveActor(parsed.owner ?? this.boundRef(), true);
1629
+ if (!this.canManage(owner))
1630
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the owner or an authorized overseer may create private memory.');
1002
1631
  const outcome = await this.repository.transaction(async () => {
1003
1632
  const prior = await this.priorMutation(parsed.idempotencyKey, 'note.created');
1004
1633
  if (prior?.type === 'note.created')
@@ -1006,8 +1635,9 @@ export class SynomemCore {
1006
1635
  const id = this.nextId();
1007
1636
  const event = {
1008
1637
  ...this.eventBase(id, 1, id),
1638
+ ...this.intervention({ kind: owner.kind, id: owner.id }),
1009
1639
  type: 'note.created',
1010
- ownerAgentId: owner.id,
1640
+ owner: { kind: owner.kind, id: owner.id },
1011
1641
  ownerDisplayName: owner.displayName,
1012
1642
  title: parsed.title,
1013
1643
  body: parsed.body,
@@ -1022,7 +1652,7 @@ export class SynomemCore {
1022
1652
  return { id, created: true };
1023
1653
  });
1024
1654
  if (outcome.created)
1025
- await this.projectionWriter.syncAgent(owner.id);
1655
+ await this.syncActor(owner);
1026
1656
  return {
1027
1657
  record: await this.getNoteRecord(outcome.id),
1028
1658
  created: outcome.created,
@@ -1030,10 +1660,8 @@ export class SynomemCore {
1030
1660
  };
1031
1661
  }
1032
1662
  assertNoteOwner(record) {
1033
- if (!this.administrative &&
1034
- !(this.actor.kind === 'agent' && this.actor.id === record.event.ownerAgentId)) {
1035
- throw new SynomemError('MUTATION_FORBIDDEN', 'Only the note owner or a human administrator may change it.');
1036
- }
1663
+ if (!this.canManage(record.event.owner))
1664
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the owner or an authorized overseer may change private memory.');
1037
1665
  }
1038
1666
  async reviseNote(input) {
1039
1667
  const parsed = this.validate(() => reviseNoteSchema.parse(input));
@@ -1052,6 +1680,7 @@ export class SynomemCore {
1052
1680
  parsed.expectedVersion + 1)
1053
1681
  throw new SynomemError('REVISION_CONFLICT', 'The note changed before this revision was stored.');
1054
1682
  const event = {
1683
+ ...this.intervention(record.event.owner),
1055
1684
  ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
1056
1685
  type: 'note.revised',
1057
1686
  noteId: record.event.id,
@@ -1066,7 +1695,7 @@ export class SynomemCore {
1066
1695
  };
1067
1696
  await this.repository.insertEvent(event);
1068
1697
  });
1069
- await this.projectionWriter.syncAgent(record.event.ownerAgentId);
1698
+ await this.syncActor(record.event.owner);
1070
1699
  return await this.getNoteRecord(parsed.noteId);
1071
1700
  }
1072
1701
  async archiveNote(input) {
@@ -1079,6 +1708,7 @@ export class SynomemCore {
1079
1708
  if (prior)
1080
1709
  return;
1081
1710
  const event = {
1711
+ ...this.intervention(record.event.owner),
1082
1712
  ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
1083
1713
  type: 'note.archived',
1084
1714
  noteId: record.event.id,
@@ -1086,7 +1716,7 @@ export class SynomemCore {
1086
1716
  };
1087
1717
  await this.repository.insertEvent(event);
1088
1718
  });
1089
- await this.projectionWriter.syncAgent(record.event.ownerAgentId);
1719
+ await this.syncActor(record.event.owner);
1090
1720
  return await this.getNoteRecord(input.noteId);
1091
1721
  }
1092
1722
  async getTaskRecord(id) {
@@ -1094,7 +1724,7 @@ export class SynomemCore {
1094
1724
  const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1095
1725
  if (!record)
1096
1726
  throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
1097
- return record;
1727
+ return this.decorateRecord(record);
1098
1728
  }
1099
1729
  async getTask(id) {
1100
1730
  this.checkAbort();
@@ -1105,26 +1735,21 @@ export class SynomemCore {
1105
1735
  await this.repository.assertEventCompatibility();
1106
1736
  const parsed = this.validate(() => createTaskSchema.parse(input));
1107
1737
  await this.assertTopicsExist(parsed.topicIds);
1108
- const assigneeId = parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
1109
- if (!assigneeId)
1110
- throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify assigneeAgentId.');
1111
- const assignee = await this.repository.getAgent(assigneeId);
1112
- if (!assignee)
1113
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
1738
+ const assignee = await this.resolveActor(parsed.assignee ?? this.boundRef(), true);
1114
1739
  if (this.actor.kind === 'agent' &&
1115
- this.actor.id !== assignee.id &&
1740
+ !sameActor(this.actor, assignee) &&
1116
1741
  !this.repository.config.allowCrossAgentTasks)
1117
- throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
1742
+ throw new SynomemError('POLICY_FORBIDDEN', 'Cross-actor task assignment is disabled.');
1118
1743
  const outcome = await this.repository.transaction(async () => {
1119
1744
  const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
1120
1745
  if (prior?.type === 'task.created')
1121
1746
  return { id: prior.id, created: false };
1122
1747
  const id = this.nextId();
1123
- const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
1748
+ const requiresAcceptance = !sameActor(this.actor, assignee);
1124
1749
  const event = {
1125
1750
  ...this.eventBase(id, 1, id),
1126
1751
  type: 'task.created',
1127
- assigneeAgentId: assignee.id,
1752
+ assignee: { kind: assignee.kind, id: assignee.id },
1128
1753
  assigneeDisplayName: assignee.displayName,
1129
1754
  title: parsed.title,
1130
1755
  ...(parsed.description !== undefined ? { description: parsed.description } : {}),
@@ -1142,7 +1767,7 @@ export class SynomemCore {
1142
1767
  return { id, created: true };
1143
1768
  });
1144
1769
  if (outcome.created)
1145
- await this.projectionWriter.syncAgent(assignee.id);
1770
+ await this.syncActor(assignee);
1146
1771
  return {
1147
1772
  record: await this.getTaskRecord(outcome.id),
1148
1773
  created: outcome.created,
@@ -1150,16 +1775,12 @@ export class SynomemCore {
1150
1775
  };
1151
1776
  }
1152
1777
  assertTaskParticipant(record) {
1153
- const isCreator = record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
1154
- const isAssignee = this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId;
1155
- if (!this.administrative && !isCreator && !isAssignee)
1156
- throw new SynomemError('MUTATION_FORBIDDEN', 'Only the task creator, assignee, or a human administrator may change it.');
1778
+ if (!this.canManage(record.event.assignee) && !this.canManage(record.event.actor))
1779
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only task participants or authorized overseers may change it.');
1157
1780
  }
1158
1781
  assertTaskAssignee(record) {
1159
- if (!this.administrative &&
1160
- !(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)) {
1161
- throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this task.');
1162
- }
1782
+ if (!this.canManage(record.event.assignee))
1783
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assignee or an authorized overseer may decide this task.');
1163
1784
  }
1164
1785
  async updateTask(input) {
1165
1786
  const parsed = this.validate(() => updateTaskSchema.parse(input));
@@ -1179,6 +1800,7 @@ export class SynomemCore {
1179
1800
  throw new SynomemError('REVISION_CONFLICT', 'The task changed before this update was stored.');
1180
1801
  const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
1181
1802
  const event = {
1803
+ ...this.intervention(record.event.assignee),
1182
1804
  ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
1183
1805
  type: 'task.updated',
1184
1806
  taskId: record.event.id,
@@ -1199,9 +1821,41 @@ export class SynomemCore {
1199
1821
  };
1200
1822
  await this.repository.insertEvent(event);
1201
1823
  });
1202
- await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
1824
+ await this.syncActor(record.event.assignee);
1203
1825
  return await this.getTaskRecord(parsed.taskId);
1204
1826
  }
1827
+ async overrideTaskDecision(input) {
1828
+ this.checkAbort();
1829
+ const parsed = this.validate(() => overrideTaskDecisionSchema.parse(input));
1830
+ await this.repository.transaction(async () => {
1831
+ const record = await this.getTaskRecord(parsed.taskId);
1832
+ if (this.actor.kind !== 'human' ||
1833
+ !(this.administrative ||
1834
+ (record.event.assignee.kind === 'agent' &&
1835
+ overseesAgent(this.actor, this.authority, record.event.assignee.id)) ||
1836
+ (record.event.actor.kind === 'agent' &&
1837
+ overseesAgent(this.actor, this.authority, record.event.actor.id))))
1838
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only an authorized human overseer may override a decision.');
1839
+ if (record.current.version !== parsed.expectedVersion)
1840
+ throw new SynomemError('REVISION_CONFLICT', 'The task changed. Reload it before overriding.');
1841
+ if (record.status !== 'open' && record.status !== 'rejected')
1842
+ throw new SynomemError('INVALID_INPUT', 'Only accepted/open or rejected decisions can be overridden.');
1843
+ if (record.status === parsed.nextStatus)
1844
+ throw new SynomemError('INVALID_INPUT', 'The requested decision is already in effect.');
1845
+ const event = {
1846
+ ...this.eventBase(parsed.taskId, parsed.expectedVersion + 1),
1847
+ ...this.intervention(record.event.assignee, parsed.reason),
1848
+ type: 'task.decision_overridden',
1849
+ taskId: parsed.taskId,
1850
+ previousStatus: record.status,
1851
+ nextStatus: parsed.nextStatus,
1852
+ reason: parsed.reason,
1853
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1854
+ };
1855
+ await this.repository.insertEvent(event);
1856
+ });
1857
+ return this.getTaskRecord(parsed.taskId);
1858
+ }
1205
1859
  async taskTransition(input, type) {
1206
1860
  const record = await this.getTaskRecord(input.taskId);
1207
1861
  // A rejection must say why. Enforced here as well as in the schema so the
@@ -1240,6 +1894,7 @@ export class SynomemCore {
1240
1894
  if (prior)
1241
1895
  return;
1242
1896
  const base = {
1897
+ ...this.intervention(record.event.assignee),
1243
1898
  ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
1244
1899
  taskId: record.event.id,
1245
1900
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
@@ -1259,7 +1914,7 @@ export class SynomemCore {
1259
1914
  : { ...base, type };
1260
1915
  await this.repository.insertEvent(event);
1261
1916
  });
1262
- await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
1917
+ await this.syncActor(record.event.assignee);
1263
1918
  return await this.getTaskRecord(input.taskId);
1264
1919
  }
1265
1920
  completeTask(input) {
@@ -1289,6 +1944,9 @@ export class SynomemCore {
1289
1944
  await this.repository.assertEventCompatibility();
1290
1945
  const parsed = this.validate(() => createTodoSchema.parse(input));
1291
1946
  await this.assertTopicsExist(parsed.topicIds);
1947
+ const owner = await this.resolveActor(parsed.owner ?? this.boundRef(), true);
1948
+ if (!this.canManage(owner))
1949
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the owner or an authorized overseer may create private memory.');
1292
1950
  const outcome = await this.repository.transaction(async () => {
1293
1951
  const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
1294
1952
  if (prior?.type === 'todo.created')
@@ -1296,7 +1954,9 @@ export class SynomemCore {
1296
1954
  const id = this.nextId();
1297
1955
  const event = {
1298
1956
  ...this.eventBase(id, 1, id),
1957
+ ...this.intervention({ kind: owner.kind, id: owner.id }),
1299
1958
  type: 'todo.created',
1959
+ owner: { kind: owner.kind, id: owner.id },
1300
1960
  title: parsed.title,
1301
1961
  ...(parsed.details !== undefined ? { details: parsed.details } : {}),
1302
1962
  priority: parsed.priority ?? 3,
@@ -1321,7 +1981,7 @@ export class SynomemCore {
1321
1981
  if (!record)
1322
1982
  throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
1323
1983
  this.assertTodoOwner(record);
1324
- return record;
1984
+ return this.decorateRecord(record);
1325
1985
  }
1326
1986
  /**
1327
1987
  * Todos are owner-only for every ordinary actor — the same rule notes
@@ -1335,10 +1995,8 @@ export class SynomemCore {
1335
1995
  * on top of a permission that had already been granted.
1336
1996
  */
1337
1997
  assertTodoOwner(record) {
1338
- const owner = record.event.actor;
1339
- if (!this.administrative && (owner.kind !== this.actor.kind || owner.id !== this.actor.id)) {
1340
- throw new SynomemError('MUTATION_FORBIDDEN', 'A todo is private to the actor who created it.');
1341
- }
1998
+ if (!this.canManage(record.event.owner))
1999
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the owner or an authorized overseer may change private memory.');
1342
2000
  }
1343
2001
  async getTodo(id) {
1344
2002
  this.checkAbort();
@@ -1358,6 +2016,7 @@ export class SynomemCore {
1358
2016
  if (prior)
1359
2017
  return;
1360
2018
  const event = {
2019
+ ...this.intervention(record.event.owner),
1361
2020
  ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
1362
2021
  type: 'todo.updated',
1363
2022
  todoId: record.event.id,
@@ -1377,6 +2036,7 @@ export class SynomemCore {
1377
2036
  }
1378
2037
  async todoTransition(input, type) {
1379
2038
  const record = await this.getTodoRecord(input.todoId);
2039
+ this.assertTodoOwner(record);
1380
2040
  if (type === 'todo.completed' && record.status !== 'open') {
1381
2041
  if (record.status === 'completed')
1382
2042
  return record;
@@ -1431,10 +2091,10 @@ export class SynomemCore {
1431
2091
  const parsed = this.validate(() => itemListInputSchema.parse(input));
1432
2092
  const resolved = {
1433
2093
  ...parsed,
1434
- ...(parsed.participantAgentId
1435
- ? { participantAgentId: await this.canonicalAgentId(parsed.participantAgentId) }
2094
+ ...(parsed.participant ? { participant: await this.resolveActor(parsed.participant) } : {}),
2095
+ ...(parsed.actorId && parsed.actorKind === 'agent'
2096
+ ? { actorId: await this.canonicalAgentId(parsed.actorId) }
1436
2097
  : {}),
1437
- ...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
1438
2098
  };
1439
2099
  return await this.repository.listItemSummaries(resolved, this.actor);
1440
2100
  }
@@ -1473,11 +2133,11 @@ export class SynomemCore {
1473
2133
  void _cursor;
1474
2134
  void _limit;
1475
2135
  void _offset;
1476
- const recipient = filters.recipientAgentId
1477
- ? await this.repository.getAgent(filters.recipientAgentId)
2136
+ const recipient = filters.recipient?.id
2137
+ ? await this.resolveActor(filters.recipient)
1478
2138
  : undefined;
1479
- if (filters.recipientAgentId && !recipient) {
1480
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipientAgentId}`);
2139
+ if (filters.recipient?.id && !recipient) {
2140
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipient?.id}`);
1481
2141
  }
1482
2142
  const records = [];
1483
2143
  let cursor;
@@ -1486,7 +2146,7 @@ export class SynomemCore {
1486
2146
  while (hasMore) {
1487
2147
  const page = await this.repository.listKudosSummaries({
1488
2148
  ...filters,
1489
- ...(recipient ? { recipientAgentId: recipient.id } : {}),
2149
+ ...(recipient ? { recipient: { kind: recipient.kind, id: recipient.id } } : {}),
1490
2150
  limit: 50,
1491
2151
  offset: 0,
1492
2152
  ...(cursor ? { cursor } : {}),
@@ -1508,7 +2168,7 @@ export class SynomemCore {
1508
2168
  byTag: {},
1509
2169
  };
1510
2170
  for (const record of includedRecords) {
1511
- stats.byAgent[record.recipientAgentId] = (stats.byAgent[record.recipientAgentId] ?? 0) + 1;
2171
+ stats.byAgent[record.recipient?.id] = (stats.byAgent[record.recipient?.id] ?? 0) + 1;
1512
2172
  const actor = `${record.actor.kind}:${record.actor.id}`;
1513
2173
  stats.byActor[actor] = (stats.byActor[actor] ?? 0) + 1;
1514
2174
  for (const tag of record.tags)
@@ -1523,8 +2183,8 @@ export class SynomemCore {
1523
2183
  * check that silently lags the migration runner reports a healthy database as
1524
2184
  * broken.
1525
2185
  */
1526
- const CURRENT_SCHEMA_VERSION = 8;
1527
- const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8];
2186
+ const CURRENT_SCHEMA_VERSION = 9;
2187
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9];
1528
2188
  export class SynomemClient extends SynomemCore {
1529
2189
  home;
1530
2190
  storage;
@@ -1533,7 +2193,9 @@ export class SynomemClient extends SynomemCore {
1533
2193
  const home = resolveHome(options.home);
1534
2194
  const storage = new SynomemStorage({
1535
2195
  home,
2196
+ ...(options.actor ? { actor: options.actor } : {}),
1536
2197
  readOnly: options.readOnly ?? false,
2198
+ ...(options.authority ? { authority: options.authority } : {}),
1537
2199
  ...(options.config ? { config: options.config } : {}),
1538
2200
  });
1539
2201
  const projections = new ProjectionManager(storage);
@@ -1541,10 +2203,11 @@ export class SynomemClient extends SynomemCore {
1541
2203
  repository: storage,
1542
2204
  projectionWriter: projections,
1543
2205
  ...(options.actor ? { actor: options.actor } : {}),
1544
- ...(options.administrative !== undefined ? { administrative: options.administrative } : {}),
2206
+ ...(options.authority ? { authority: options.authority } : {}),
1545
2207
  ...(options.clock ? { clock: options.clock } : {}),
1546
2208
  ...(options.idGenerator ? { idGenerator: options.idGenerator } : {}),
1547
2209
  ...(options.signal ? { signal: options.signal } : {}),
2210
+ canWrite: !options.readOnly,
1548
2211
  });
1549
2212
  this.home = home;
1550
2213
  this.storage = storage;
@@ -1714,7 +2377,33 @@ export class SynomemClient extends SynomemCore {
1714
2377
  }
1715
2378
  async export(format) {
1716
2379
  this.checkAbort();
1717
- const rows = this.storage.rawEventRows();
2380
+ const rows = [];
2381
+ const access = new Map();
2382
+ for (const row of this.storage.rawEventRows()) {
2383
+ let event;
2384
+ try {
2385
+ event = eventSchema.parse(JSON.parse(row.payload));
2386
+ }
2387
+ catch {
2388
+ if (this.administrative)
2389
+ rows.push(row);
2390
+ continue;
2391
+ }
2392
+ const rootId = 'rootId' in event ? event.rootId : event.aggregateId;
2393
+ if (event.type.startsWith('agent.')) {
2394
+ if (this.administrative)
2395
+ rows.push(row);
2396
+ continue;
2397
+ }
2398
+ if (!access.has(rootId))
2399
+ access.set(rootId, this.storage.canActorReadItem(rootId, this.actor));
2400
+ if (!access.get(rootId))
2401
+ continue;
2402
+ if (event.type === 'reply.created' &&
2403
+ (await this.storage.participation.getReply(event.id))?.deleted)
2404
+ continue;
2405
+ rows.push(row);
2406
+ }
1718
2407
  if (format === 'jsonl')
1719
2408
  return `${rows.map((row) => row.payload).join('\n')}\n`;
1720
2409
  const exported = rows.map((row) => {
@@ -1728,7 +2417,8 @@ export class SynomemClient extends SynomemCore {
1728
2417
  if (format === 'json')
1729
2418
  return `${JSON.stringify(exported, null, 2)}\n`;
1730
2419
  const scan = this.storage.scanEvents();
1731
- const events = scan.events;
2420
+ const allowedIds = new Set(rows.map((row) => row.id));
2421
+ const events = scan.events.filter((event) => allowedIds.has(event.id));
1732
2422
  const records = recordsFromEvents(events);
1733
2423
  const memos = memoRecordsFromEvents(events);
1734
2424
  const notes = noteRecordsFromEvents(events);
@@ -1756,6 +2446,16 @@ export class SynomemClient extends SynomemCore {
1756
2446
  this.checkAbort();
1757
2447
  return {
1758
2448
  backend: 'local',
2449
+ participation: {
2450
+ version: 2,
2451
+ replies: true,
2452
+ reactions: true,
2453
+ personalInbox: true,
2454
+ search: false,
2455
+ canWrite: !this.storage.readOnly,
2456
+ administrator: this.administrative,
2457
+ managedAgentIds: [...this.authority.operatedAgentIds],
2458
+ },
1759
2459
  binding: { workspaceId: this.storage.config.workspaceId, actor: this.actor },
1760
2460
  administration: {
1761
2461
  agentCreationViaMcp: this.storage.config.allowAgentCreationViaMcp,
@@ -1771,7 +2471,23 @@ export class SynomemClient extends SynomemCore {
1771
2471
  }
1772
2472
  async getCanonicalEvent(id) {
1773
2473
  this.checkAbort();
1774
- return this.storage.getEvent(id);
2474
+ const event = this.storage.getEvent(id);
2475
+ if (!event)
2476
+ return undefined;
2477
+ if (event.type.startsWith('agent.'))
2478
+ return this.administrative ? event : undefined;
2479
+ const rootId = 'rootId' in event ? event.rootId : event.aggregateId;
2480
+ if (!this.storage.canActorReadItem(rootId, this.actor))
2481
+ throw new SynomemError('POLICY_FORBIDDEN', 'This event is not visible.');
2482
+ if (event.type === 'reply.created' &&
2483
+ (await this.storage.participation.getReply(event.id))?.deleted)
2484
+ return undefined;
2485
+ return event;
1775
2486
  }
1776
2487
  }
2488
+ export function createLocalOwnerClient(options = {}) {
2489
+ if (options.actor?.kind !== 'human')
2490
+ throw new SynomemError('POLICY_FORBIDDEN', 'Local owner administration requires an explicit human actor.');
2491
+ return new SynomemClient({ ...options, authority: localOwnerAuthority() });
2492
+ }
1777
2493
  //# sourceMappingURL=client.js.map