@wowyuarm/dsh-agent-team 0.1.10 → 0.1.11

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 (110) hide show
  1. package/README.md +13 -0
  2. package/README.zh.md +13 -0
  3. package/package.json +52 -47
  4. package/packages/agent-team/README.md +4 -2
  5. package/packages/agent-team/README.zh.md +4 -2
  6. package/packages/agent-team/lib/context-management.js +40 -50
  7. package/packages/agent-team/lib/context-projection.js +10 -0
  8. package/packages/agent-team/lib/index.js +235 -112
  9. package/packages/agent-team/lib/invariant.js +38 -4
  10. package/packages/agent-team/lib/ledger.js +554 -271
  11. package/packages/agent-team/lib/member-context.js +6 -2
  12. package/packages/agent-team/lib/member-time-context.js +20 -5
  13. package/packages/agent-team/lib/pressure-policy.js +33 -9
  14. package/packages/agent-team/lib/session-event-cursor.js +116 -0
  15. package/packages/agent-team/lib/session-remediation.js +38 -26
  16. package/packages/agent-team/lib/spec.js +46 -34
  17. package/packages/agent-team/lib/stored-session-reader.js +138 -0
  18. package/packages/agent-team/lib/typert.host.js +107 -40
  19. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  20. package/packages/agent-team/lib/typert.remote-client.js +84 -32
  21. package/packages/agent-team/lib/types/context-management.d.ts +7 -0
  22. package/packages/agent-team/lib/types/context-management.d.ts.map +1 -1
  23. package/packages/agent-team/lib/types/context-projection.d.ts +8 -0
  24. package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
  25. package/packages/agent-team/lib/types/index.d.ts +78 -2
  26. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  27. package/packages/agent-team/lib/types/invariant.d.ts.map +1 -1
  28. package/packages/agent-team/lib/types/ledger.d.ts +180 -7
  29. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  30. package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
  31. package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -1
  32. package/packages/agent-team/lib/types/pressure-policy.d.ts +18 -0
  33. package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -1
  34. package/packages/agent-team/lib/types/session-event-cursor.d.ts +119 -0
  35. package/packages/agent-team/lib/types/session-event-cursor.d.ts.map +1 -0
  36. package/packages/agent-team/lib/types/session-remediation.d.ts +23 -0
  37. package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -1
  38. package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
  39. package/packages/agent-team/lib/types/stored-session-reader.d.ts +92 -0
  40. package/packages/agent-team/lib/types/stored-session-reader.d.ts.map +1 -0
  41. package/packages/agent-team/lib/types/types/entities.d.ts +28 -2
  42. package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
  43. package/packages/agent-team/lib/types/types/operations.d.ts +45 -19
  44. package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
  45. package/packages/agent-team/lib/types/types/requests-results.d.ts +44 -3
  46. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  47. package/packages/agent-team/preset/team-member/agent.cordis.yml +3 -1
  48. package/packages/client-agent-team/README.md +1 -1
  49. package/packages/client-agent-team/README.zh.md +1 -1
  50. package/packages/client-agent-team/lib/client.js +1068 -375
  51. package/packages/client-agent-team/lib/client.js.map +1 -1
  52. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
  53. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +24 -4
  54. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
  55. package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +42 -22
  56. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
  57. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +13 -24
  58. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts +11 -5
  59. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
  60. package/packages/client-agent-team/lib/types/client/TeamComposer.js +15 -9
  61. package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts +1 -1
  62. package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts.map +1 -1
  63. package/packages/client-agent-team/lib/types/client/TeamConversation.js +8 -1
  64. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +21 -0
  65. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -0
  66. package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +71 -0
  67. package/packages/client-agent-team/lib/types/client/TeamMemberEditor.d.ts.map +1 -1
  68. package/packages/client-agent-team/lib/types/client/TeamMemberEditor.js +8 -24
  69. package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts +9 -2
  70. package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts.map +1 -1
  71. package/packages/client-agent-team/lib/types/client/TeamMessage.js +11 -4
  72. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +8 -0
  73. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -1
  74. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.js +21 -1
  75. package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
  76. package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +102 -28
  77. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
  78. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
  79. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +50 -7
  80. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  81. package/packages/client-agent-team/lib/types/client/index.js +17 -6
  82. package/packages/client-agent-team/lib/types/client/locales.d.ts +18 -0
  83. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  84. package/packages/client-agent-team/lib/types/client/locales.js +18 -0
  85. package/packages/client-agent-team/lib/types/client/navigation.d.ts +10 -0
  86. package/packages/client-agent-team/lib/types/client/navigation.d.ts.map +1 -1
  87. package/packages/client-agent-team/lib/types/client/navigation.js +18 -7
  88. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts +41 -0
  89. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts.map +1 -0
  90. package/packages/client-agent-team/lib/types/client/scope-coverage.js +64 -0
  91. package/packages/client-agent-team/lib/types/client/slots.d.ts +7 -1
  92. package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
  93. package/packages/client-agent-team/lib/types/client/team-changes.d.ts +26 -1
  94. package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
  95. package/packages/client-agent-team/lib/types/client/team-changes.js +66 -15
  96. package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts +20 -0
  97. package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts.map +1 -0
  98. package/packages/client-agent-team/lib/types/client/team-dialog-save.js +36 -0
  99. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +9 -0
  100. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
  101. package/packages/client-agent-team/lib/types/client/team-formatters.js +34 -6
  102. package/packages/tool-agent-team/README.md +1 -1
  103. package/packages/tool-agent-team/README.zh.md +1 -1
  104. package/packages/tool-agent-team/lib/context-tools.js +18 -14
  105. package/packages/tool-agent-team/lib/host-access.js +20 -0
  106. package/packages/tool-agent-team/lib/index.js +16 -17
  107. package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -1
  108. package/packages/tool-agent-team/lib/types/host-access.d.ts +14 -0
  109. package/packages/tool-agent-team/lib/types/host-access.d.ts.map +1 -0
  110. package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
@@ -16,9 +16,12 @@ export function agentTeamHumanActor() {
16
16
  }
17
17
  function emptyProjection() {
18
18
  return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
19
- claims: new Map(), messages: [], tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
19
+ claims: new Map(), tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
20
20
  orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
21
- attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map() };
21
+ messagesByRef: new Map(),
22
+ attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map(),
23
+ anchorByThread: new Map(), taskNumberByTask: new Map(), taskCountByChannel: new Map(), attentionThreadsByMember: new Map(),
24
+ directMarkersByMember: new Map(), activityMarkersByMember: new Map(), observationsByThread: new Map() };
22
25
  }
23
26
  const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
24
27
  progress: Object.freeze([]),
@@ -28,6 +31,40 @@ const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
28
31
  function assertUnhandledKind(operation) {
29
32
  throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
30
33
  }
34
+ /**
35
+ * Opening-line preview for a direct-only Human Inbox row: the Thread anchor's
36
+ * first line, trimmed, then capped at 120 characters with an explicit mark —
37
+ * the same bound the Thread page applies to its Task title, so the row and
38
+ * the page it opens never disagree about what a Thread is about.
39
+ */
40
+ function boundedInboxPreview(body) {
41
+ const firstLine = body.split('\n', 1)[0]?.trim() ?? '';
42
+ return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine;
43
+ }
44
+ /**
45
+ * Whether one operation's Inbox delta would change nothing. A read that finds
46
+ * no unread fact for its reader and no marker to consume produces exactly this
47
+ * delta, which is why such a read is a no-op rather than a durable operation.
48
+ */
49
+ function isEmptyInboxDelta(delta) {
50
+ return delta.attention.set.length === 0 && delta.attention.removed.length === 0
51
+ && delta.directMarkers.added.length === 0 && delta.directMarkers.removed.length === 0
52
+ && delta.activityMarkers.added.length === 0 && delta.activityMarkers.removed.length === 0;
53
+ }
54
+ /**
55
+ * Which of the two durable Thread-read forms one record holds. Both schemas are
56
+ * strict and structurally disjoint — only the pre-receipt snapshot carries the
57
+ * Thread picture — so `thread` decides it for every stored record.
58
+ */
59
+ export function isThreadReadSnapshot(data) {
60
+ return 'thread' in data;
61
+ }
62
+ /** The Thread and optional Task one read targeted, whichever form the record holds. */
63
+ export function threadReadTargetOf(data) {
64
+ return isThreadReadSnapshot(data)
65
+ ? { threadRef: data.thread.threadRef, ...(data.task === undefined ? {} : { taskRef: data.task.taskRef }) }
66
+ : { threadRef: data.threadRef, ...(data.taskRef === undefined ? {} : { taskRef: data.taskRef }) };
67
+ }
31
68
  /** Deep-freeze a Member capability overlay; absent stays absent. */
32
69
  function freezeCapabilities(capabilities) {
33
70
  if (capabilities === undefined)
@@ -50,12 +87,35 @@ export class AgentTeamLedger {
50
87
  createOccurredAt;
51
88
  createRef;
52
89
  operationTail = Promise.resolve();
90
+ /**
91
+ * Head of the durable records the constructor's record-level replay
92
+ * validated, adoptable once by the invariant mount. See `validateAtMount`.
93
+ */
94
+ bootValidation;
95
+ /**
96
+ * Sequence of the newest committed record that invalidated the shared
97
+ * projection — the durable position the Client's change cursors compare
98
+ * against, rebuilt by replay like every other index.
99
+ *
100
+ * Records whose commit is private read progress or audit-only
101
+ * (`changeScopesOf` derives no scope for them) never move it, so one
102
+ * Member's Thread read cannot make a parked Client believe that something it
103
+ * watches changed. Being a ledger position rather than a process counter, it
104
+ * also stays monotone across a restart.
105
+ */
106
+ projectionVersion = 0;
53
107
  constructor(table, options = {}) {
54
108
  this.table = table;
55
109
  this.createOperationId = options.operationId ?? (() => `operation:${randomUUID()}`);
56
110
  this.createOccurredAt = options.occurredAt ?? (() => new Date().toISOString());
57
111
  this.createRef = options.ref ?? (kind => `${kind}:${randomUUID()}`);
58
112
  this.replay();
113
+ const head = this.state.ordered.at(-1);
114
+ this.bootValidation = Object.freeze({
115
+ records: this.state.ordered.length,
116
+ lastSequence: head?.sequence ?? 0,
117
+ lastOperationId: head?.operationId ?? null,
118
+ });
59
119
  }
60
120
  initialize(request = {
61
121
  requestId: AGENT_TEAM_INITIALIZE_REQUEST_ID,
@@ -1025,8 +1085,19 @@ export class AgentTeamLedger {
1025
1085
  const limit = request.limit ?? 50;
1026
1086
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1027
1087
  throw new Error('inbox limit must be an integer between 1 and 100');
1088
+ // The Human direct-only slice serves the Client's mention queue: rows show
1089
+ // only Threads with an unread mention, `totalUnreadCount` collapses to the
1090
+ // direct total so a badge on this call cannot bypass follow unread, and
1091
+ // each row carries its Channel display name plus the Thread's opening line
1092
+ // so the Client renders without a Channel view per row. The ordinary
1093
+ // (agent-facing) projection stays body-free.
1094
+ const directOnly = request.directOnly === true;
1095
+ const taskNumbers = directOnly ? this.taskNumbers(request.workspaceId) : undefined;
1028
1096
  const items = [];
1029
- for (const thread of this.state.threads.values()) {
1097
+ for (const threadRef of this.inboxCandidateThreads(authorized.memberId, directOnly)) {
1098
+ const thread = this.state.threads.get(threadRef);
1099
+ if (thread === undefined)
1100
+ continue;
1030
1101
  const channelRef = this.channelRefForThread(thread.threadRef);
1031
1102
  if (channelRef === undefined)
1032
1103
  continue;
@@ -1039,20 +1110,47 @@ export class AgentTeamLedger {
1039
1110
  continue;
1040
1111
  const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1041
1112
  const directCount = unread.filter(item => item.direct).length;
1113
+ if (directOnly && directCount === 0)
1114
+ continue;
1042
1115
  const attention = this.attentionFor(authorized.memberId, thread.threadRef);
1043
1116
  // Same snapshot, same source as newestSequence: the instant hangs off
1044
1117
  // the newest unread fact itself, never a second lookup that could
1045
1118
  // observe a different commit between the two reads.
1046
1119
  const newest = unread.at(-1).fact;
1047
- items.push(Object.freeze({ channelRef, ...(task === undefined ? {} : { task }), thread, unreadCount: unread.length, directCount,
1120
+ const taskNumber = directOnly && task !== undefined ? taskNumbers?.get(task.taskRef) : undefined;
1121
+ items.push(Object.freeze({ channelRef,
1122
+ ...(directOnly ? { channelName: this.state.channels.get(channelRef)?.name ?? '' } : {}),
1123
+ ...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
1124
+ unreadCount: unread.length, directCount,
1125
+ ...(directOnly ? { previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body) } : {}),
1048
1126
  newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt, ...(attention === undefined ? {} : { attention }) }));
1049
1127
  }
1050
1128
  items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
1051
1129
  const selected = items.slice(0, limit);
1052
1130
  return Object.freeze({ items: Object.freeze(selected),
1053
- totalUnreadCount: items.reduce((sum, item) => sum + item.unreadCount, 0),
1131
+ totalUnreadCount: directOnly
1132
+ ? items.reduce((sum, item) => sum + item.directCount, 0)
1133
+ : items.reduce((sum, item) => sum + item.unreadCount, 0),
1054
1134
  totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
1055
1135
  }
1136
+ /**
1137
+ * Threads that can hold unread facts for one reader, from the per-reader
1138
+ * derived indexes: Attention follows plus direct and activity markers.
1139
+ * Every unread source is covered — ordinary unread requires Attention, and
1140
+ * marker unread requires a marker — so no full Thread scan is needed. The
1141
+ * direct-only slice needs only the direct-marker threads, since a row
1142
+ * requires at least one direct unread.
1143
+ */
1144
+ inboxCandidateThreads(memberId, directOnly) {
1145
+ if (directOnly)
1146
+ return this.state.directMarkersByMember.get(memberId)?.keys() ?? [];
1147
+ const refs = new Set();
1148
+ for (const source of [this.state.attentionThreadsByMember.get(memberId), this.state.directMarkersByMember.get(memberId), this.state.activityMarkersByMember.get(memberId)]) {
1149
+ for (const threadRef of source?.keys() ?? [])
1150
+ refs.add(threadRef);
1151
+ }
1152
+ return refs;
1153
+ }
1056
1154
  /** Model-visible notification material derived from the recipient's current durable unread state. */
1057
1155
  notificationFacts(memberId, request) {
1058
1156
  const member = this.requireMember(memberId);
@@ -1067,22 +1165,31 @@ export class AgentTeamLedger {
1067
1165
  const existing = this.state.byRequest.get(request.requestId);
1068
1166
  if (existing !== undefined) {
1069
1167
  this.assertSameThreadRead(existing, request);
1070
- return this.resolved(this.threadReadResult(existing));
1168
+ // A retry reports the original receipt with the picture the current
1169
+ // projection derives, and never advances a watermark a second time.
1170
+ const current = this.prepareRead(existing.data.memberId, existing.data.workspaceId, request);
1171
+ return Object.freeze({ value: Object.freeze({ receipt: this.receipt(existing), ...this.readPicture(current) }), committed: false });
1071
1172
  }
1072
1173
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
1073
1174
  const prepared = this.prepareRead(actor.memberId, request.workspaceId, request);
1175
+ // A read whose Inbox delta is empty writes nothing: the watermark does
1176
+ // not advance and no marker is consumed. It answers with the same picture
1177
+ // and no receipt, so the caller learns "no operation" from the result's
1178
+ // committed flag, never from a sequence this read did not write.
1179
+ if (isEmptyInboxDelta(prepared.inbox))
1180
+ return Object.freeze({ value: this.readPicture(prepared), committed: false });
1181
+ // The record carries progress, not the picture: the Thread, its facts,
1182
+ // the anchor and the reader's Attention are projection state every replay
1183
+ // already holds.
1184
+ const data = Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
1185
+ threadRef: prepared.thread.threadRef, ...(prepared.task === undefined ? {} : { taskRef: prepared.task.taskRef }),
1186
+ readThroughSequence: prepared.readThroughSequence, inbox: prepared.inbox });
1074
1187
  const operation = Object.freeze({
1075
- ...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read',
1076
- data: Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
1077
- ...(prepared.task === undefined ? {} : { task: prepared.task }),
1078
- thread: prepared.thread, claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions,
1079
- facts: prepared.facts,
1080
- readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
1081
- ...(prepared.attention === undefined ? {} : { attention: prepared.attention }), inbox: prepared.inbox }),
1188
+ ...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read', data,
1082
1189
  });
1083
1190
  await this.table.put(operation.operationId, operation);
1084
1191
  this.apply(operation);
1085
- return this.committed(this.threadReadResult(operation));
1192
+ return Object.freeze({ value: Object.freeze({ receipt: this.receipt(operation), ...this.readPicture(prepared) }), committed: true });
1086
1193
  });
1087
1194
  }
1088
1195
  threadObservations(actor, request) {
@@ -1092,33 +1199,13 @@ export class AgentTeamLedger {
1092
1199
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1093
1200
  throw new Error('observation limit must be an integer between 1 and 100');
1094
1201
  const threadRef = thread.threadRef;
1095
- const current = new Map();
1096
- const observations = [];
1097
- for (const operation of this.state.ordered) {
1098
- const delta = this.attentionDelta(operation);
1099
- if (delta === undefined)
1100
- continue;
1101
- for (const removed of delta.attention.removed) {
1102
- if (removed.threadRef !== threadRef)
1103
- continue;
1104
- if (!current.delete(removed.memberId))
1105
- continue;
1106
- observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
1107
- memberId: removed.memberId, action: 'unfollow' }));
1108
- }
1109
- for (const next of delta.attention.set) {
1110
- if (next.threadRef !== threadRef)
1111
- continue;
1112
- const prior = current.get(next.memberId);
1113
- current.set(next.memberId, next);
1114
- // Task creation establishes initial Attention; reads only advance its watermark.
1115
- if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
1116
- continue;
1117
- observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
1118
- memberId: next.memberId, action: 'follow' }));
1119
- }
1120
- }
1121
- return Object.freeze({ items: Object.freeze(observations.slice(-limit)), followers: Object.freeze([...current.keys()]) });
1202
+ // Both slices are replay-derived: the observation log is appended by
1203
+ // committed Inbox deltas in ledger order, and the live follower set is
1204
+ // the same Attention index the follow history converges to.
1205
+ const observations = (this.state.observationsByThread.get(threadRef) ?? []).slice(-limit)
1206
+ .map(event => Object.freeze({ sequence: event.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
1207
+ memberId: event.memberId, action: event.action }));
1208
+ return Object.freeze({ items: Object.freeze(observations), followers: Object.freeze([...this.state.attentionByThread.get(threadRef) ?? []]) });
1122
1209
  }
1123
1210
  /** Every operation carrying an inbox delta drives the Inbox projection; the payload shape decides, not a per-kind list. */
1124
1211
  attentionDelta(operation) {
@@ -1216,7 +1303,8 @@ export class AgentTeamLedger {
1216
1303
  && channel.state !== 'archived'
1217
1304
  && (memberId === undefined || this.isChannelMember(channel.channelRef, memberId)));
1218
1305
  const channelRefs = new Set(channels.map(channel => channel.channelRef));
1219
- const allFacts = this.state.orderedFacts.filter(fact => {
1306
+ const before = request.cursor ?? this.state.ordered.length + 1;
1307
+ const matches = (fact) => {
1220
1308
  const threadRef = fact.kind === 'message' ? fact.message.threadRef : fact.activity.threadRef;
1221
1309
  const channelRef = this.channelRefForThread(threadRef);
1222
1310
  if (channelRef === undefined || !channelRefs.has(channelRef))
@@ -1229,9 +1317,40 @@ export class AgentTeamLedger {
1229
1317
  return false;
1230
1318
  if (request.includeActivities === false && fact.kind === 'activity')
1231
1319
  return false;
1232
- return direction === 'before' ? fact.sequence < (request.cursor ?? this.state.ordered.length + 1) : fact.sequence > cursor;
1233
- });
1234
- const selected = direction === 'before' ? allFacts.slice(-limit) : allFacts.slice(0, limit);
1320
+ return direction === 'before' ? fact.sequence < before : fact.sequence > cursor;
1321
+ };
1322
+ // Walk from the end the direction starts at and stop once `limit` matches
1323
+ // are collected plus one more proves `hasMore` — `limit` bounds only the
1324
+ // returned items, never the scan below a single extra match. A sidebar
1325
+ // asking for one item no longer sweeps the whole fact ledger.
1326
+ const selected = [];
1327
+ let hasMore = false;
1328
+ if (direction === 'before') {
1329
+ for (let index = this.state.orderedFacts.length - 1; index >= 0; index -= 1) {
1330
+ const fact = this.state.orderedFacts[index];
1331
+ if (!matches(fact))
1332
+ continue;
1333
+ if (selected.length < limit)
1334
+ selected.push(fact);
1335
+ else {
1336
+ hasMore = true;
1337
+ break;
1338
+ }
1339
+ }
1340
+ selected.reverse();
1341
+ }
1342
+ else {
1343
+ for (const fact of this.state.orderedFacts) {
1344
+ if (!matches(fact))
1345
+ continue;
1346
+ if (selected.length < limit)
1347
+ selected.push(fact);
1348
+ else {
1349
+ hasMore = true;
1350
+ break;
1351
+ }
1352
+ }
1353
+ }
1235
1354
  const visibleTasks = [...this.state.tasks.values()].filter(task => channelRefs.has(task.channelRef)
1236
1355
  && (request.channelRef === undefined || task.channelRef === request.channelRef)
1237
1356
  && (request.threadRef === undefined || task.threadRef === request.threadRef));
@@ -1266,7 +1385,7 @@ export class AgentTeamLedger {
1266
1385
  claims: this.claimsForVisibleTasks(visibleTasks),
1267
1386
  activities: Object.freeze(selected.filter((fact) => fact.kind === 'activity').map(fact => fact.activity)),
1268
1387
  cursor: nextCursor,
1269
- hasMore: allFacts.length > selected.length,
1388
+ hasMore,
1270
1389
  });
1271
1390
  }
1272
1391
  status() {
@@ -1277,6 +1396,31 @@ export class AgentTeamLedger {
1277
1396
  validate() {
1278
1397
  this.validateRecords(this.sortedRecords());
1279
1398
  }
1399
+ /**
1400
+ * Validate the durable ledger for the invariant's mount check, reusing the
1401
+ * constructor's record-level replay once instead of paying a second identical
1402
+ * one at startup.
1403
+ *
1404
+ * The adoption is gated on the operations table's single-writer identity —
1405
+ * this ledger's commit path is the only caller of `table.put` — so an
1406
+ * unchanged record count, head sequence and head operation id mean a
1407
+ * mount-time replay would re-derive precisely the records the constructor
1408
+ * already re-derived against its own scratch projection. Any commit between
1409
+ * construction and mount, and every later call, falls back to the full
1410
+ * replay. The check is never narrowed: the reused conclusion is still that
1411
+ * same independent replay of every durable record.
1412
+ */
1413
+ validateAtMount() {
1414
+ const boot = this.bootValidation;
1415
+ this.bootValidation = undefined;
1416
+ const head = this.state.ordered.at(-1);
1417
+ if (boot !== undefined
1418
+ && boot.records === this.state.ordered.length
1419
+ && boot.lastSequence === (head?.sequence ?? 0)
1420
+ && boot.lastOperationId === (head?.operationId ?? null))
1421
+ return;
1422
+ this.validate();
1423
+ }
1280
1424
  hasCommitted(requestId) {
1281
1425
  return this.state.byRequest.has(requestId);
1282
1426
  }
@@ -1284,6 +1428,33 @@ export class AgentTeamLedger {
1284
1428
  getOperation(operationId) {
1285
1429
  return this.state.byOperation.get(operationId);
1286
1430
  }
1431
+ /**
1432
+ * Claim releases are thread-visible facts: an open Channel page and every
1433
+ * affected Thread page must refetch alongside the workspace-wide change. Each
1434
+ * released activity contributes its own Thread scope plus the Channel that
1435
+ * owns its Task, deduplicated against the caller's initial scopes — which is
1436
+ * why the caller passes them in rather than the helper inventing them.
1437
+ */
1438
+ withReleasedActivityScopes(scopes, tasks, activities) {
1439
+ const channelByTask = new Map(tasks.map(task => [task.taskRef, task.channelRef]));
1440
+ for (const activity of activities) {
1441
+ scopes.push({ kind: 'thread', threadRef: activity.threadRef });
1442
+ const channelRef = channelByTask.get(activity.taskRef);
1443
+ if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
1444
+ scopes.push({ kind: 'channel', channelRef });
1445
+ }
1446
+ }
1447
+ return scopes;
1448
+ }
1449
+ /**
1450
+ * Durable position of the newest shared-projection commit: the version every
1451
+ * change waiter outside the presence scope observes. It only moves when a
1452
+ * commit has scopes somebody could refetch (`changeScopesOf`), never for
1453
+ * private read progress or an audit-only record.
1454
+ */
1455
+ projectionSequence() {
1456
+ return this.projectionVersion;
1457
+ }
1287
1458
  /** Scopes whose projections one committed operation invalidates; undefined wakes every waiter. */
1288
1459
  changeScopesOf(operation) {
1289
1460
  switch (operation.kind) {
@@ -1304,37 +1475,15 @@ export class AgentTeamLedger {
1304
1475
  case 'team/member-updated':
1305
1476
  case 'team/member-removed':
1306
1477
  return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
1307
- case 'team/member-archived': {
1308
- // Claim releases are thread-visible facts: open Channel and Thread
1309
- // pages refetch alongside the workspace-wide roster change.
1310
- const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
1311
- const scopes = [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
1312
- for (const activity of operation.data.activities) {
1313
- scopes.push({ kind: 'thread', threadRef: activity.threadRef });
1314
- const channelRef = channelByTask.get(activity.taskRef);
1315
- if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
1316
- scopes.push({ kind: 'channel', channelRef });
1317
- }
1318
- }
1319
- return scopes;
1320
- }
1478
+ case 'team/member-archived':
1479
+ return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }], operation.data.tasks, operation.data.activities);
1321
1480
  case 'team/channel-member-added':
1322
1481
  return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
1323
- case 'team/channel-member-removed': {
1324
- const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
1325
- const scopes = [
1482
+ case 'team/channel-member-removed':
1483
+ return this.withReleasedActivityScopes([
1326
1484
  { kind: 'workspace', workspaceId: operation.data.workspaceId },
1327
1485
  { kind: 'channel', channelRef: operation.data.channelRef },
1328
- ];
1329
- for (const activity of operation.data.activities) {
1330
- scopes.push({ kind: 'thread', threadRef: activity.threadRef });
1331
- const channelRef = channelByTask.get(activity.taskRef);
1332
- if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
1333
- scopes.push({ kind: 'channel', channelRef });
1334
- }
1335
- }
1336
- return scopes;
1337
- }
1486
+ ], operation.data.tasks, operation.data.activities);
1338
1487
  case 'team/channel-archived': {
1339
1488
  const scopes = [
1340
1489
  { kind: 'workspace', workspaceId: operation.data.workspaceId },
@@ -1632,7 +1781,7 @@ export class AgentTeamLedger {
1632
1781
  || operation.data.channel.createdAtSequence !== prior.createdAtSequence)
1633
1782
  throw new Error('invalid Channel archival');
1634
1783
  const threadRefs = new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === prior.channelRef));
1635
- this.validateChannelArchivalCleanup(operation.data, projection, threadRefs, operation.sequence, refs);
1784
+ this.validateReleaseCleanup(operation.data, projection, undefined, threadRefs, operation.sequence, refs);
1636
1785
  return;
1637
1786
  }
1638
1787
  if (operation.kind === 'team/member-removed') {
@@ -1683,20 +1832,38 @@ export class AgentTeamLedger {
1683
1832
  assertMember();
1684
1833
  else
1685
1834
  assertHuman();
1686
- if (operation.data.memberId !== operation.actor.memberId)
1835
+ const data = operation.data;
1836
+ if (data.memberId !== operation.actor.memberId)
1687
1837
  throw new Error('Thread read has wrong actor');
1688
- const expected = this.prepareReadFrom(projection, operation.data.memberId, operation.data.workspaceId, {
1689
- threadRef: operation.data.thread.threadRef,
1690
- ...(operation.data.task === undefined ? {} : { taskRef: operation.data.task.taskRef }),
1691
- });
1692
- const expectedData = Object.freeze({ workspaceId: operation.data.workspaceId, memberId: operation.data.memberId,
1693
- ...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
1694
- anchorMentions: expected.anchorMentions, facts: expected.facts,
1695
- readThroughSequence: expected.readThroughSequence, remainingUnreadCount: expected.remainingUnreadCount,
1696
- ...(expected.attention === undefined ? {} : { attention: expected.attention }), inbox: expected.inbox });
1697
- if (!isDeepStrictEqual(operation.data, expectedData))
1698
- throw new Error('invalid Thread read projection');
1699
- this.validateInboxDelta(operation.data.inbox, projection, refs);
1838
+ const target = threadReadTargetOf(data);
1839
+ if (isThreadReadSnapshot(data)) {
1840
+ // The pre-receipt form froze the whole picture, so its expected value
1841
+ // is re-derived the same way it was written: from this record's prior
1842
+ // projection only, never from the live one.
1843
+ const expected = this.prepareReadFrom(projection, data.memberId, data.workspaceId, target);
1844
+ const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId,
1845
+ ...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
1846
+ anchorMentions: expected.anchorMentions, facts: expected.facts,
1847
+ readThroughSequence: expected.readThroughSequence, remainingUnreadCount: expected.remainingUnreadCount,
1848
+ ...(expected.attention === undefined ? {} : { attention: expected.attention }), inbox: expected.inbox });
1849
+ if (!isDeepStrictEqual(data, expectedData))
1850
+ throw new Error('invalid Thread read projection');
1851
+ }
1852
+ else {
1853
+ // A receipt claims progress and its Inbox delta, nothing else, so the
1854
+ // independent derivation stops there too: the picture and the unread
1855
+ // count left behind are projection state a replay re-derives for the
1856
+ // surfaces that need them, not content of this record. The Task ref is
1857
+ // derived rather than copied back, so a Task Thread read cannot drop
1858
+ // the Task it belongs to and shrink its own claim unchecked.
1859
+ const expected = this.prepareReadReceiptFrom(projection, data.memberId, data.workspaceId, target);
1860
+ const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId, threadRef: data.threadRef,
1861
+ ...(expected.task === undefined ? {} : { taskRef: expected.task.taskRef }),
1862
+ readThroughSequence: expected.readThroughSequence, inbox: expected.inbox });
1863
+ if (!isDeepStrictEqual(data, expectedData))
1864
+ throw new Error('invalid Thread read receipt');
1865
+ }
1866
+ this.validateInboxDelta(data.inbox, projection, refs);
1700
1867
  return;
1701
1868
  }
1702
1869
  if (operation.kind === 'team/thread-promoted') {
@@ -1874,11 +2041,23 @@ export class AgentTeamLedger {
1874
2041
  }
1875
2042
  assertUnhandledKind(operation);
1876
2043
  }
2044
+ /**
2045
+ * Validation of one operation's Inbox delta against the projection it was
2046
+ * derived from.
2047
+ *
2048
+ * Direct marker references resolve through the replay-derived Message index:
2049
+ * this runs once per inbox-carrying record, so a rescan of the Message list
2050
+ * here would make every record cost the whole ledger. The index is written by
2051
+ * the same fact appends that build `factsByThread`, so a lookup sees exactly
2052
+ * the records replayed before this one and can never reach a Message that
2053
+ * arrives later — which is why the operation's own entity, not yet in the
2054
+ * projection, needs the `additional*` fallbacks. The commit path resolved the
2055
+ * projection first and then its own record, and that precedence is kept.
2056
+ */
1877
2057
  validateInboxDelta(delta, projection, _refs, additionalThreadRefs = [], additionalMessages = [], additionalActivities = []) {
1878
2058
  const attentionKeys = new Set();
1879
- const knownThreadRefs = new Set([...projection.threads.keys(), ...additionalThreadRefs]);
1880
2059
  for (const attention of delta.attention.set) {
1881
- if (!knownThreadRefs.has(attention.threadRef) || attention.startSequence < 1
2060
+ if ((!projection.threads.has(attention.threadRef) && !additionalThreadRefs.includes(attention.threadRef)) || attention.startSequence < 1
1882
2061
  || attention.readThroughSequence < attention.startSequence - 1) {
1883
2062
  throw new Error('invalid Attention delta');
1884
2063
  }
@@ -1892,13 +2071,12 @@ export class AgentTeamLedger {
1892
2071
  throw new Error('conflicting Attention delta');
1893
2072
  }
1894
2073
  const markerKeys = new Set();
1895
- const messages = [...projection.messages, ...additionalMessages];
1896
2074
  for (const marker of delta.directMarkers.added) {
1897
2075
  const key = this.directMarkerKey(marker);
1898
2076
  if (markerKeys.has(key) || projection.directMarkers.has(key))
1899
2077
  throw new Error('invalid direct marker addition');
1900
2078
  markerKeys.add(key);
1901
- const message = messages.find(candidate => candidate.messageRef === marker.messageRef);
2079
+ const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
1902
2080
  if (message === undefined || message.threadRef !== marker.threadRef || message.sequence !== marker.sequence
1903
2081
  || !this.validMentionTarget(projection, message.channelRef, marker.memberId))
1904
2082
  throw new Error('invalid direct marker addition');
@@ -1909,17 +2087,20 @@ export class AgentTeamLedger {
1909
2087
  throw new Error('invalid direct marker removal');
1910
2088
  markerKeys.add(key);
1911
2089
  const current = projection.directMarkers.get(key);
1912
- const message = messages.find(candidate => candidate.messageRef === marker.messageRef);
2090
+ const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
1913
2091
  if (!isDeepStrictEqual(current, marker) || message === undefined || message.threadRef !== marker.threadRef
1914
2092
  || message.sequence !== marker.sequence || !this.validMentionTarget(projection, message.channelRef, marker.memberId)) {
1915
2093
  throw new Error('invalid direct marker removal');
1916
2094
  }
1917
2095
  }
1918
2096
  const activityMarkerKeys = new Set();
1919
- const activities = [...projection.orderedFacts.filter(fact => fact.kind === 'activity').map(fact => fact.activity), ...additionalActivities];
1920
2097
  for (const marker of delta.activityMarkers.added) {
1921
2098
  const key = this.activityMarkerKey(marker);
1922
- const activity = activities.find(candidate => candidate.activityRef === marker.activityRef);
2099
+ // Every activity marker is minted for the activity the same record
2100
+ // carries (promotion, close, reopen, acceptance), and a removal never
2101
+ // looks an activity up, so this record's own list is the whole
2102
+ // resolution space: scanning recorded activities would add nothing.
2103
+ const activity = additionalActivities.find(candidate => candidate.activityRef === marker.activityRef);
1923
2104
  if (activityMarkerKeys.has(key) || projection.activityMarkers.has(key) || activity === undefined
1924
2105
  || activity.threadRef !== marker.threadRef || activity.sequence !== marker.sequence)
1925
2106
  throw new Error('invalid activity marker addition');
@@ -1933,73 +2114,31 @@ export class AgentTeamLedger {
1933
2114
  activityMarkerKeys.add(key);
1934
2115
  }
1935
2116
  }
2117
+ /**
2118
+ * Replay validation of one departure's release snapshot against the
2119
+ * projection it was derived from. `memberId` scopes everything to a single
2120
+ * departing Member; `undefined` validates every owner, which is what
2121
+ * archiving a whole Channel releases. The two scopes differ in exactly four
2122
+ * ways, all of them visible here rather than spread over two copies:
2123
+ *
2124
+ * 1. released Claims are owner-filtered for a Member, unfiltered for a Channel;
2125
+ * 2. Activities group per (owner, Thread) with owners sorted, which for a
2126
+ * single departing Member collapses to one Activity per Thread — the shape
2127
+ * the commit path writes;
2128
+ * 3. the inbox cleanup covers the departing Member's Attention and markers, or
2129
+ * every Member's on the archived Channel's Threads;
2130
+ * 4. the failure message names which of the two paths lost its cleanup.
2131
+ */
1936
2132
  validateReleaseCleanup(data, projection, memberId, threadRefs, sequence, refs) {
1937
2133
  const releasedClaims = [...projection.claims.values()]
1938
- .filter(claim => claim.owner === memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
1939
- .map(claim => Object.freeze({ ...claim, state: 'released' }));
1940
- if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
1941
- const expected = releasedClaims[index];
1942
- return expected === undefined || !this.sameClaim(expected, claim);
1943
- }))
1944
- throw new Error('invalid released Claim projection');
1945
- const byThread = new Map();
1946
- for (const claim of releasedClaims)
1947
- byThread.set(claim.threadRef, [...(byThread.get(claim.threadRef) ?? []), claim]);
1948
- const expectedActivities = [...byThread.entries()].map(([threadRef, claims]) => ({
1949
- kind: 'claims_released', taskRef: claims[0].taskRef, threadRef, actor: memberId, sequence,
1950
- claimRefs: claims.map(claim => claim.claimRef).sort(),
1951
- }));
1952
- if (data.activities.length !== expectedActivities.length || data.activities.some((activity, index) => {
1953
- const expected = expectedActivities[index];
1954
- return expected === undefined || activity.kind !== expected.kind || activity.taskRef !== expected.taskRef
1955
- || activity.threadRef !== expected.threadRef || activity.actor !== expected.actor || activity.sequence !== expected.sequence
1956
- || !this.sameList(activity.claimRefs, expected.claimRefs);
1957
- }))
1958
- throw new Error('invalid released Claim activities');
1959
- for (const claim of data.claims) {
1960
- const prior = projection.claims.get(claim.claimRef);
1961
- if (prior === undefined)
1962
- throw new Error('invalid released Claim reference');
1963
- }
1964
- for (const activity of data.activities)
1965
- this.addRef(refs, activity.activityRef);
1966
- const projectedClaims = new Map(projection.claims);
1967
- for (const claim of releasedClaims)
1968
- projectedClaims.set(claim.claimRef, claim);
1969
- const expectedTasks = [...new Set(releasedClaims.map(claim => claim.taskRef))].map(taskRef => {
1970
- const task = projection.tasks.get(taskRef);
1971
- return Object.freeze({ ...task, status: this.deriveResolvedTaskStatus(task, projectedClaims.values()) });
1972
- });
1973
- const expectedThreads = expectedActivities.map(activity => {
1974
- const thread = projection.threads.get(activity.threadRef);
1975
- return Object.freeze({ ...thread, revision: sequence });
1976
- });
1977
- if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
1978
- throw new Error('invalid released Claim Task or Thread projection');
1979
- }
1980
- const expectedAttention = [...projection.attention.values()]
1981
- .filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
1982
- .map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
1983
- const expectedMarkers = [...projection.directMarkers.values()]
1984
- .filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
1985
- const expectedActivityMarkers = [...projection.activityMarkers.values()]
1986
- .filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
1987
- const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
1988
- if (!isDeepStrictEqual(data.inbox, expectedInbox))
1989
- throw new Error('invalid Member inbox cleanup');
1990
- this.validateInboxDelta(data.inbox, projection, refs);
1991
- }
1992
- /** Replay validation of one Channel archival's release snapshot across every owner. */
1993
- validateChannelArchivalCleanup(data, projection, threadRefs, sequence, refs) {
1994
- const releasedClaims = [...projection.claims.values()]
1995
- .filter(claim => claim.state === 'active' && threadRefs.has(claim.threadRef))
2134
+ .filter(claim => (memberId === undefined || claim.owner === memberId) && claim.state === 'active' && threadRefs.has(claim.threadRef))
1996
2135
  .map(claim => Object.freeze({ ...claim, state: 'released' }));
1997
2136
  if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
1998
2137
  const expected = releasedClaims[index];
1999
2138
  return expected === undefined || !this.sameClaim(expected, claim);
2000
2139
  }))
2001
2140
  throw new Error('invalid released Claim projection');
2002
- // One activity per (owner, Thread), owners sorted like the commit path.
2141
+ // One Activity per (owner, Thread), owners sorted like the commit path.
2003
2142
  const byOwner = new Map();
2004
2143
  for (const claim of releasedClaims) {
2005
2144
  const byThread = byOwner.get(claim.owner) ?? new Map();
@@ -2038,17 +2177,17 @@ export class AgentTeamLedger {
2038
2177
  if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
2039
2178
  throw new Error('invalid released Claim Task or Thread projection');
2040
2179
  }
2041
- // Attention and marker cleanup covers EVERY Member on the archived Threads.
2042
2180
  const expectedAttention = [...projection.attention.values()]
2043
- .filter(attention => threadRefs.has(attention.threadRef))
2181
+ .filter(attention => (memberId === undefined || attention.memberId === memberId) && threadRefs.has(attention.threadRef))
2044
2182
  .map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
2045
2183
  const expectedMarkers = [...projection.directMarkers.values()]
2046
- .filter(marker => threadRefs.has(marker.threadRef));
2184
+ .filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
2047
2185
  const expectedActivityMarkers = [...projection.activityMarkers.values()]
2048
- .filter(marker => threadRefs.has(marker.threadRef));
2186
+ .filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
2049
2187
  const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
2050
- if (!isDeepStrictEqual(data.inbox, expectedInbox))
2051
- throw new Error('invalid Channel archival inbox cleanup');
2188
+ if (!isDeepStrictEqual(data.inbox, expectedInbox)) {
2189
+ throw new Error(memberId === undefined ? 'invalid Channel archival inbox cleanup' : 'invalid Member inbox cleanup');
2190
+ }
2052
2191
  this.validateInboxDelta(data.inbox, projection, refs);
2053
2192
  }
2054
2193
  validateMessageInbox(operation, projection) {
@@ -2088,6 +2227,12 @@ export class AgentTeamLedger {
2088
2227
  }
2089
2228
  apply(operation) {
2090
2229
  this.applyTo(this.state, operation);
2230
+ // Live apply only: the record-validation replay re-derives each record
2231
+ // against its own scratch projection, so a scope derivation there would be
2232
+ // discarded.
2233
+ const scopes = this.changeScopesOf(operation);
2234
+ if (scopes === undefined || scopes.length !== 0)
2235
+ this.projectionVersion = operation.sequence;
2091
2236
  }
2092
2237
  applyTo(target, operation) {
2093
2238
  target.ordered.push(operation);
@@ -2157,15 +2302,7 @@ export class AgentTeamLedger {
2157
2302
  }
2158
2303
  if (operation.kind === 'team/channel-member-removed') {
2159
2304
  target.memberships.get(operation.data.channelRef)?.delete(operation.data.memberId);
2160
- for (const claim of operation.data.claims)
2161
- target.claims.set(claim.claimRef, claim);
2162
- for (const activity of operation.data.activities)
2163
- this.appendActivityFact(target, activity, operation.occurredAt);
2164
- for (const task of operation.data.tasks)
2165
- target.tasks.set(task.taskRef, task);
2166
- for (const thread of operation.data.threads)
2167
- target.threads.set(thread.threadRef, thread);
2168
- this.applyInboxDelta(target, operation.data.inbox);
2305
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2169
2306
  return;
2170
2307
  }
2171
2308
  if (operation.kind === 'team/channel-archived') {
@@ -2173,30 +2310,14 @@ export class AgentTeamLedger {
2173
2310
  // restore returns every Member to the Channel. Visibility filters by
2174
2311
  // channel state instead.
2175
2312
  target.channels.set(operation.data.channel.channelRef, operation.data.channel);
2176
- for (const claim of operation.data.claims)
2177
- target.claims.set(claim.claimRef, claim);
2178
- for (const activity of operation.data.activities)
2179
- this.appendActivityFact(target, activity, operation.occurredAt);
2180
- for (const task of operation.data.tasks)
2181
- target.tasks.set(task.taskRef, task);
2182
- for (const thread of operation.data.threads)
2183
- target.threads.set(thread.threadRef, thread);
2184
- this.applyInboxDelta(target, operation.data.inbox);
2313
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2185
2314
  return;
2186
2315
  }
2187
2316
  if (operation.kind === 'team/member-removed') {
2188
2317
  target.members.set(operation.data.member.memberId, operation.data.member);
2189
2318
  for (const membership of target.memberships.values())
2190
2319
  membership.delete(operation.data.member.memberId);
2191
- for (const claim of operation.data.claims)
2192
- target.claims.set(claim.claimRef, claim);
2193
- for (const activity of operation.data.activities)
2194
- this.appendActivityFact(target, activity, operation.occurredAt);
2195
- for (const task of operation.data.tasks)
2196
- target.tasks.set(task.taskRef, task);
2197
- for (const thread of operation.data.threads)
2198
- target.threads.set(thread.threadRef, thread);
2199
- this.applyInboxDelta(target, operation.data.inbox);
2320
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2200
2321
  return;
2201
2322
  }
2202
2323
  if (operation.kind === 'team/member-archived') {
@@ -2204,34 +2325,37 @@ export class AgentTeamLedger {
2204
2325
  // restore returns the Member to its Channels. Visibility filters by
2205
2326
  // member state instead.
2206
2327
  target.members.set(operation.data.member.memberId, operation.data.member);
2207
- for (const claim of operation.data.claims)
2208
- target.claims.set(claim.claimRef, claim);
2209
- for (const activity of operation.data.activities)
2210
- this.appendActivityFact(target, activity, operation.occurredAt);
2211
- for (const task of operation.data.tasks)
2212
- target.tasks.set(task.taskRef, task);
2213
- for (const thread of operation.data.threads)
2214
- target.threads.set(thread.threadRef, thread);
2215
- this.applyInboxDelta(target, operation.data.inbox);
2328
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2216
2329
  return;
2217
2330
  }
2218
2331
  if (operation.kind === 'team/thread-promoted') {
2219
2332
  this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2220
2333
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2221
2334
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2335
+ this.recordTaskNumber(target, operation.data.task);
2336
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2222
2337
  this.applyInboxDelta(target, operation.data.inbox);
2223
2338
  return;
2224
2339
  }
2225
2340
  if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
2226
2341
  const { message, mentions } = operation.data;
2227
- target.messages.push(message);
2228
2342
  target.mentionsByMessage.set(message.messageRef, Object.freeze([...mentions]));
2229
2343
  this.appendMessageFact(target, message, mentions, message.occurredAt ?? operation.occurredAt);
2230
2344
  if (operation.data.task !== undefined)
2231
2345
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2232
2346
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2233
- if (message.topLevel)
2347
+ if (message.topLevel) {
2234
2348
  target.channelRefByThread.set(message.threadRef, message.channelRef);
2349
+ // The anchor is the Thread's first topLevel Message; a later topLevel
2350
+ // Message (none today) must not displace it, matching the linear scan.
2351
+ if (!target.anchorByThread.has(message.threadRef))
2352
+ target.anchorByThread.set(message.threadRef, message);
2353
+ }
2354
+ // Only message-sent mints a Task ordinal; thread-replied may echo the
2355
+ // Task without renumbering it, like the operation scan it replaces.
2356
+ if (operation.kind === 'team/message-sent' && operation.data.task !== undefined)
2357
+ this.recordTaskNumber(target, operation.data.task);
2358
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2235
2359
  this.applyInboxDelta(target, operation.data.inbox);
2236
2360
  return;
2237
2361
  }
@@ -2240,6 +2364,7 @@ export class AgentTeamLedger {
2240
2364
  this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2241
2365
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2242
2366
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2367
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2243
2368
  this.applyInboxDelta(target, operation.data.inbox);
2244
2369
  return;
2245
2370
  }
@@ -2249,10 +2374,12 @@ export class AgentTeamLedger {
2249
2374
  this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2250
2375
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2251
2376
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2377
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2252
2378
  this.applyInboxDelta(target, operation.data.inbox);
2253
2379
  return;
2254
2380
  }
2255
2381
  if (operation.kind === 'team/thread-attention-changed' || operation.kind === 'team/thread-read') {
2382
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2256
2383
  this.applyInboxDelta(target, operation.data.inbox);
2257
2384
  return;
2258
2385
  }
@@ -2263,6 +2390,25 @@ export class AgentTeamLedger {
2263
2390
  }
2264
2391
  assertUnhandledKind(operation);
2265
2392
  }
2393
+ /**
2394
+ * Replay the departure snapshot a releasing operation carries. The order is
2395
+ * replay semantics, not style: Claims land before the Activities that
2396
+ * reference them, and Tasks/Threads before the inbox delta that reads them.
2397
+ * Each caller still applies its own identifying writes (member, membership,
2398
+ * channel) around this call.
2399
+ */
2400
+ applyReleaseSnapshot(target, operation, data, occurredAt) {
2401
+ for (const claim of data.claims)
2402
+ target.claims.set(claim.claimRef, claim);
2403
+ for (const activity of data.activities)
2404
+ this.appendActivityFact(target, activity, occurredAt);
2405
+ for (const task of data.tasks)
2406
+ target.tasks.set(task.taskRef, task);
2407
+ for (const thread of data.threads)
2408
+ target.threads.set(thread.threadRef, thread);
2409
+ this.recordAttentionObservations(target, operation, data.inbox);
2410
+ this.applyInboxDelta(target, data.inbox);
2411
+ }
2266
2412
  /** Facts arrive in ledger sequence order, so global and per-thread lists stay sorted by append only. */
2267
2413
  appendMessageFact(target, message, mentions, occurredAt) {
2268
2414
  const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions, occurredAt });
@@ -2271,6 +2417,9 @@ export class AgentTeamLedger {
2271
2417
  facts.push(fact);
2272
2418
  target.factsByThread.set(message.threadRef, facts);
2273
2419
  target.messageCountByThread.set(message.threadRef, (target.messageCountByThread.get(message.threadRef) ?? 0) + 1);
2420
+ // The ref index is written in this same step, so it is exactly the set of
2421
+ // replayed Messages: a validator lookup can never reach a later record.
2422
+ target.messagesByRef.set(message.messageRef, message);
2274
2423
  }
2275
2424
  appendActivityFact(target, activity, occurredAt) {
2276
2425
  const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
@@ -2279,6 +2428,44 @@ export class AgentTeamLedger {
2279
2428
  facts.push(fact);
2280
2429
  target.factsByThread.set(activity.threadRef, facts);
2281
2430
  }
2431
+ /** Display ordinals are a per-Channel creation counter; the workspace filter happens at read time. */
2432
+ recordTaskNumber(target, task) {
2433
+ const ordinal = (target.taskCountByChannel.get(task.channelRef) ?? 0) + 1;
2434
+ target.taskCountByChannel.set(task.channelRef, ordinal);
2435
+ target.taskNumberByTask.set(task.taskRef, ordinal);
2436
+ }
2437
+ /**
2438
+ * Replay-order Attention observations, appended from committed Inbox deltas
2439
+ * only (the hypothetical read projection applies its delta directly). The
2440
+ * follow/unfollow decision mirrors the full replay scan it replaces: a
2441
+ * removal records only when Attention existed, and a set records unless it
2442
+ * is the Thread-creating Message (initial Attention) or merely advances the
2443
+ * same follow's watermark. Removals are evaluated before sets, exactly like
2444
+ * the scan's loop order.
2445
+ */
2446
+ recordAttentionObservations(target, operation, delta) {
2447
+ if (delta.attention.removed.length === 0 && delta.attention.set.length === 0)
2448
+ return;
2449
+ const retired = new Set();
2450
+ for (const key of delta.attention.removed) {
2451
+ retired.add(this.attentionKey(key.memberId, key.threadRef));
2452
+ if (!target.attention.has(this.attentionKey(key.memberId, key.threadRef)))
2453
+ continue;
2454
+ this.appendObservation(target, key.threadRef, { sequence: operation.sequence, memberId: key.memberId, action: 'unfollow' });
2455
+ }
2456
+ for (const next of delta.attention.set) {
2457
+ const key = this.attentionKey(next.memberId, next.threadRef);
2458
+ const prior = retired.has(key) ? undefined : target.attention.get(key);
2459
+ if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
2460
+ continue;
2461
+ this.appendObservation(target, next.threadRef, { sequence: operation.sequence, memberId: next.memberId, action: 'follow' });
2462
+ }
2463
+ }
2464
+ appendObservation(target, threadRef, observation) {
2465
+ const observations = target.observationsByThread.get(threadRef) ?? [];
2466
+ observations.push(Object.freeze(observation));
2467
+ target.observationsByThread.set(threadRef, observations);
2468
+ }
2282
2469
  applyInboxDelta(target, delta) {
2283
2470
  for (const key of delta.attention.removed) {
2284
2471
  target.attention.delete(this.attentionKey(key.memberId, key.threadRef));
@@ -2288,27 +2475,76 @@ export class AgentTeamLedger {
2288
2475
  if (followers.size === 0)
2289
2476
  target.attentionByThread.delete(key.threadRef);
2290
2477
  }
2478
+ const threads = target.attentionThreadsByMember.get(key.memberId);
2479
+ if (threads !== undefined) {
2480
+ threads.delete(key.threadRef);
2481
+ if (threads.size === 0)
2482
+ target.attentionThreadsByMember.delete(key.memberId);
2483
+ }
2291
2484
  }
2292
2485
  for (const attention of delta.attention.set) {
2293
2486
  target.attention.set(this.attentionKey(attention.memberId, attention.threadRef), attention);
2294
2487
  const followers = target.attentionByThread.get(attention.threadRef) ?? new Set();
2295
2488
  followers.add(attention.memberId);
2296
2489
  target.attentionByThread.set(attention.threadRef, followers);
2490
+ const threads = target.attentionThreadsByMember.get(attention.memberId) ?? new Set();
2491
+ threads.add(attention.threadRef);
2492
+ target.attentionThreadsByMember.set(attention.memberId, threads);
2297
2493
  }
2298
- for (const marker of delta.directMarkers.removed)
2494
+ for (const marker of delta.directMarkers.removed) {
2299
2495
  target.directMarkers.delete(this.directMarkerKey(marker));
2300
- for (const marker of delta.directMarkers.added)
2496
+ this.removeBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, this.directMarkerKey(marker), candidate => this.directMarkerKey(candidate));
2497
+ }
2498
+ for (const marker of delta.directMarkers.added) {
2301
2499
  target.directMarkers.set(this.directMarkerKey(marker), marker);
2302
- for (const marker of delta.activityMarkers.removed)
2500
+ this.insertBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, marker);
2501
+ }
2502
+ for (const marker of delta.activityMarkers.removed) {
2303
2503
  target.activityMarkers.delete(this.activityMarkerKey(marker));
2304
- for (const marker of delta.activityMarkers.added)
2504
+ this.removeBucketedMarker(target.activityMarkersByMember, marker.memberId, marker.threadRef, this.activityMarkerKey(marker), candidate => this.activityMarkerKey(candidate));
2505
+ }
2506
+ for (const marker of delta.activityMarkers.added) {
2305
2507
  target.activityMarkers.set(this.activityMarkerKey(marker), marker);
2508
+ this.insertBucketedMarker(target.activityMarkersByMember, marker.memberId, marker.threadRef, marker);
2509
+ }
2510
+ }
2511
+ /** Insert into the Member's Thread bucket keeping the sequence order the read path must see. */
2512
+ insertBucketedMarker(index, memberId, threadRef, marker) {
2513
+ const threads = index.get(memberId) ?? new Map();
2514
+ index.set(memberId, threads);
2515
+ const bucket = threads.get(threadRef) ?? [];
2516
+ threads.set(threadRef, bucket);
2517
+ let position = bucket.length;
2518
+ while (position > 0 && bucket[position - 1].sequence > marker.sequence)
2519
+ position -= 1;
2520
+ bucket.splice(position, 0, marker);
2521
+ }
2522
+ removeBucketedMarker(index, memberId, threadRef, key, identityOf) {
2523
+ const threads = index.get(memberId);
2524
+ const bucket = threads?.get(threadRef);
2525
+ if (bucket === undefined)
2526
+ return;
2527
+ const position = bucket.findIndex(item => identityOf(item) === key);
2528
+ if (position >= 0)
2529
+ bucket.splice(position, 1);
2530
+ if (bucket.length === 0) {
2531
+ threads.delete(threadRef);
2532
+ if (threads.size === 0)
2533
+ index.delete(memberId);
2534
+ }
2306
2535
  }
2307
2536
  prepareRead(memberId, workspaceId, request) {
2308
2537
  return this.prepareReadFrom(this.state, memberId, workspaceId, request);
2309
2538
  }
2310
- /** Derive the only legal durable result of one Thread read from a prior projection. */
2311
- prepareReadFrom(projection, memberId, workspaceId, request) {
2539
+ /**
2540
+ * Derive the durable receipt of one Thread read from a prior projection: the
2541
+ * Thread it targeted, the watermark it reaches, the Attention row it writes
2542
+ * and the Inbox delta it consumes. This is the whole durable content of a
2543
+ * receipt-shaped read and the only thing `validateRecords` re-derives for
2544
+ * one; the picture derivation below builds on the same result, so the two can
2545
+ * never drift.
2546
+ */
2547
+ prepareReadReceiptFrom(projection, memberId, workspaceId, request) {
2312
2548
  const { task, thread, channelRef } = this.threadContextFrom(projection, workspaceId, request);
2313
2549
  if (memberId !== AGENT_TEAM_HUMAN_MEMBER_ID) {
2314
2550
  const member = projection.members.get(memberId);
@@ -2316,21 +2552,9 @@ export class AgentTeamLedger {
2316
2552
  throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channelRef}'`);
2317
2553
  }
2318
2554
  }
2319
- const anchor = this.threadAnchorFrom(projection, thread.threadRef);
2320
2555
  const attention = this.attentionForFrom(projection, memberId, thread.threadRef);
2321
2556
  const unread = this.unreadForFrom(projection, memberId, thread.threadRef);
2322
2557
  const unreadFacts = unread.slice(0, 20);
2323
- const unreadFactKeys = new Set(unread.map(item => this.threadFactKey(item.fact)));
2324
- const firstRead = attention !== undefined && attention.readThroughSequence < attention.startSequence;
2325
- const background = firstRead
2326
- ? projection.messages.filter(message => message.threadRef === thread.threadRef && message.sequence < attention.startSequence)
2327
- .map(message => Object.freeze({ kind: 'message', sequence: message.sequence, message,
2328
- mentions: projection.mentionsByMessage.get(message.messageRef) ?? [],
2329
- occurredAt: this.occurredAtForFactFrom(projection, message.sequence, message.occurredAt) }))
2330
- .filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
2331
- : [];
2332
- const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...unreadFacts]
2333
- .sort((left, right) => left.fact.sequence - right.fact.sequence);
2334
2558
  // Direct markers are sparse acknowledgements, not part of the contiguous
2335
2559
  // follower watermark. Consuming an old marker after a later follow must
2336
2560
  // never move that watermark backwards.
@@ -2346,24 +2570,36 @@ export class AgentTeamLedger {
2346
2570
  const activityMarkers = this.activityMarkersForFrom(projection, memberId, thread.threadRef)
2347
2571
  .filter(marker => unreadFacts.some(item => item.fact.kind === 'activity' && item.fact.activity.activityRef === marker.activityRef));
2348
2572
  const inbox = this.inboxDelta(nextAttention, [], [], consumed, [], activityMarkers);
2349
- // The hypothetical projection copies every map that its inbox delta can
2350
- // mutate, including the follower sets inside attentionByThread; the fact
2351
- // indexes are read-only here and stay shared.
2352
- const nextProjection = {
2353
- ...projection,
2354
- attention: new Map(projection.attention),
2355
- directMarkers: new Map(projection.directMarkers),
2356
- activityMarkers: new Map(projection.activityMarkers),
2357
- attentionByThread: new Map([...projection.attentionByThread].map(([threadRef, followers]) => [threadRef, new Set(followers)])),
2358
- };
2359
- this.applyInboxDelta(nextProjection, inbox);
2360
- const remainingUnreadCount = this.unreadForFrom(nextProjection, memberId, thread.threadRef).length;
2361
- return Object.freeze({ ...(task === undefined ? {} : { task }), thread, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
2362
- anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
2363
- facts: Object.freeze(combined),
2364
- readThroughSequence, remainingUnreadCount, ...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
2573
+ return Object.freeze({ ...(task === undefined ? {} : { task }), thread, unread,
2574
+ ...(attention === undefined ? {} : { attentionBefore: attention }), readThroughSequence,
2575
+ ...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
2365
2576
  consumedDirectMarkers: Object.freeze(consumed), inbox });
2366
2577
  }
2578
+ /** Derive the only legal durable result of one Thread read from a prior projection. */
2579
+ prepareReadFrom(projection, memberId, workspaceId, request) {
2580
+ const receipt = this.prepareReadReceiptFrom(projection, memberId, workspaceId, request);
2581
+ const { task, thread } = receipt;
2582
+ const anchor = this.threadAnchorFrom(projection, thread.threadRef);
2583
+ // The first read of a Thread after following also shows the bounded
2584
+ // background that preceded the follow, so a reader sees what they joined.
2585
+ const firstRead = receipt.attentionBefore !== undefined && receipt.attentionBefore.readThroughSequence < receipt.attentionBefore.startSequence;
2586
+ const unreadFactKeys = new Set(receipt.unread.map(item => this.threadFactKey(item.fact)));
2587
+ const background = firstRead
2588
+ ? this.threadFactsFrom(projection, thread.threadRef)
2589
+ .filter((fact) => fact.kind === 'message'
2590
+ && fact.sequence < receipt.attentionBefore.startSequence)
2591
+ .map(fact => Object.freeze({ kind: 'message', sequence: fact.sequence, message: fact.message,
2592
+ mentions: projection.mentionsByMessage.get(fact.message.messageRef) ?? [],
2593
+ occurredAt: this.occurredAtForFactFrom(projection, fact.sequence, fact.message.occurredAt) }))
2594
+ .filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
2595
+ : [];
2596
+ const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...receipt.unread.slice(0, 20)]
2597
+ .sort((left, right) => left.fact.sequence - right.fact.sequence);
2598
+ const remainingUnreadCount = this.remainingUnreadAfter(projection, memberId, receipt);
2599
+ return Object.freeze({ ...receipt, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
2600
+ anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
2601
+ facts: Object.freeze(combined), remainingUnreadCount });
2602
+ }
2367
2603
  readFactFrom(projection, memberId, fact, unread) {
2368
2604
  return Object.freeze({ fact, unread, direct: fact.kind === 'message'
2369
2605
  && projection.directMarkers.has(this.directMarkerKey({ memberId, threadRef: fact.message.threadRef,
@@ -2374,23 +2610,60 @@ export class AgentTeamLedger {
2374
2610
  }
2375
2611
  unreadForFrom(projection, memberId, threadRef) {
2376
2612
  const attention = this.attentionForFrom(projection, memberId, threadRef);
2377
- const facts = this.threadFactsFrom(projection, threadRef);
2378
- const direct = this.directMarkersForFrom(projection, memberId, threadRef);
2379
- const directKeys = new Set(direct.map(marker => this.directMarkerKey(marker)));
2613
+ const directKeys = new Set(this.directMarkersForFrom(projection, memberId, threadRef).map(marker => this.directMarkerKey(marker)));
2380
2614
  const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
2381
2615
  const result = [];
2382
- for (const fact of facts) {
2383
- const marker = fact.kind === 'message' && directKeys.has(this.directMarkerKey({ memberId, threadRef,
2384
- messageRef: fact.message.messageRef, sequence: fact.sequence }));
2385
- const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
2386
- activityRef: fact.activity.activityRef, sequence: fact.sequence }));
2387
- const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
2388
- && fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
2389
- if (marker || activityMarker || ordinary)
2616
+ for (const fact of this.threadFactsFrom(projection, threadRef)) {
2617
+ if (this.isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys)) {
2390
2618
  result.push(this.readFactFrom(projection, memberId, fact, true));
2619
+ }
2391
2620
  }
2392
2621
  return Object.freeze(result);
2393
2622
  }
2623
+ /**
2624
+ * The single authority on "this fact is unread for this reader". The picture
2625
+ * derivation and the remaining-count derivation both go through it, so the
2626
+ * count a read reports can never disagree with the facts it lists. Marker
2627
+ * membership arrives as key sets: the caller may be looking at a state the
2628
+ * projection has not applied yet.
2629
+ */
2630
+ isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys) {
2631
+ const marker = fact.kind === 'message' && directKeys.has(this.directMarkerKey({ memberId, threadRef,
2632
+ messageRef: fact.message.messageRef, sequence: fact.sequence }));
2633
+ const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
2634
+ activityRef: fact.activity.activityRef, sequence: fact.sequence }));
2635
+ const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
2636
+ && fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
2637
+ return marker || activityMarker || ordinary;
2638
+ }
2639
+ /**
2640
+ * Unread count once this read's own Inbox delta has been applied, derived
2641
+ * without materializing a hypothetical projection. The delta can only touch
2642
+ * the reader's own Attention row and its own marker keys in one Thread, and
2643
+ * every lookup in `isUnreadFact` is scoped to that same (member, Thread)
2644
+ * pair, so the post-delta state is the Attention row the receipt already
2645
+ * derived plus those marker keys with the delta's own removals and additions
2646
+ * applied.
2647
+ */
2648
+ remainingUnreadAfter(projection, memberId, receipt) {
2649
+ const threadRef = receipt.thread.threadRef;
2650
+ const directKeys = new Set(this.directMarkersForFrom(projection, memberId, threadRef).map(marker => this.directMarkerKey(marker)));
2651
+ for (const marker of receipt.inbox.directMarkers.removed)
2652
+ directKeys.delete(this.directMarkerKey(marker));
2653
+ for (const marker of receipt.inbox.directMarkers.added)
2654
+ directKeys.add(this.directMarkerKey(marker));
2655
+ const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
2656
+ for (const marker of receipt.inbox.activityMarkers.removed)
2657
+ activityKeys.delete(this.activityMarkerKey(marker));
2658
+ for (const marker of receipt.inbox.activityMarkers.added)
2659
+ activityKeys.add(this.activityMarkerKey(marker));
2660
+ let count = 0;
2661
+ for (const fact of this.threadFactsFrom(projection, threadRef)) {
2662
+ if (this.isUnreadFact(fact, memberId, threadRef, receipt.attention, directKeys, activityKeys))
2663
+ count += 1;
2664
+ }
2665
+ return count;
2666
+ }
2394
2667
  visibleToFollower(fact, memberId) {
2395
2668
  const sender = fact.kind === 'message' ? fact.message.sender : fact.activity.actor;
2396
2669
  return sender !== memberId;
@@ -2535,12 +2808,14 @@ export class AgentTeamLedger {
2535
2808
  return this.directMarkersForFrom(this.state, memberId, threadRef);
2536
2809
  }
2537
2810
  directMarkersForFrom(projection, memberId, threadRef) {
2538
- return Object.freeze([...projection.directMarkers.values()].filter(marker => marker.memberId === memberId && marker.threadRef === threadRef)
2539
- .sort((left, right) => left.sequence - right.sequence));
2811
+ // A frozen copy, not the live bucket: callers may hand the array to an
2812
+ // Inbox delta that freezes it, which must not freeze the derived index.
2813
+ const markers = projection.directMarkersByMember.get(memberId)?.get(threadRef);
2814
+ return markers === undefined ? [] : Object.freeze([...markers]);
2540
2815
  }
2541
2816
  activityMarkersForFrom(projection, memberId, threadRef) {
2542
- return Object.freeze([...projection.activityMarkers.values()].filter(marker => marker.memberId === memberId && marker.threadRef === threadRef)
2543
- .sort((left, right) => left.sequence - right.sequence));
2817
+ const markers = projection.activityMarkersByMember.get(memberId)?.get(threadRef);
2818
+ return markers === undefined ? [] : Object.freeze([...markers]);
2544
2819
  }
2545
2820
  issueConfirmation(actor, workspaceId, channelRef, body, recipients, task, thread) {
2546
2821
  if (actor.kind !== 'human')
@@ -2651,7 +2926,7 @@ export class AgentTeamLedger {
2651
2926
  return this.threadAnchorFrom(this.state, threadRef);
2652
2927
  }
2653
2928
  threadAnchorFrom(projection, threadRef) {
2654
- const anchor = projection.messages.find(message => message.threadRef === threadRef && message.topLevel);
2929
+ const anchor = projection.anchorByThread.get(threadRef);
2655
2930
  if (anchor === undefined)
2656
2931
  throw new Error(`Thread '${threadRef}' has no anchor Message`);
2657
2932
  return anchor;
@@ -2663,20 +2938,17 @@ export class AgentTeamLedger {
2663
2938
  * Display numbers for Tasks: one counter per home Channel, in creation
2664
2939
  * order. This is the single numbering authority — Channel cards, Thread
2665
2940
  * headings, cross-channel ref resolution, and inbox renders all show the
2666
- * ordinal the Task holds inside its own Channel.
2941
+ * ordinal the Task holds inside its own Channel. The ordinals are derived
2942
+ * at replay time (`taskNumberByTask`); this read only filters them to one
2943
+ * Workspace.
2667
2944
  */
2668
2945
  taskNumbers(workspaceId) {
2669
2946
  const numbers = new Map();
2670
- const next = new Map();
2671
- for (const operation of this.state.ordered) {
2672
- const task = operation.kind === 'team/message-sent' || operation.kind === 'team/thread-promoted' ? operation.data.task : undefined;
2673
- if (task === undefined)
2674
- continue;
2675
- if (this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
2947
+ for (const [taskRef, taskNumber] of this.state.taskNumberByTask) {
2948
+ const task = this.state.tasks.get(taskRef);
2949
+ if (task === undefined || this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
2676
2950
  continue;
2677
- const ordinal = (next.get(task.channelRef) ?? 0) + 1;
2678
- next.set(task.channelRef, ordinal);
2679
- numbers.set(task.taskRef, ordinal);
2951
+ numbers.set(taskRef, taskNumber);
2680
2952
  }
2681
2953
  return numbers;
2682
2954
  }
@@ -3106,8 +3378,8 @@ export class AgentTeamLedger {
3106
3378
  assertSameThreadRead(operation, request) {
3107
3379
  if (operation.kind !== 'team/thread-read' || !this.sameActor(operation.actor, request.actor)
3108
3380
  || operation.data.workspaceId !== request.workspaceId
3109
- || (request.threadRef !== undefined && operation.data.thread.threadRef !== request.threadRef)
3110
- || (request.taskRef !== undefined && operation.data.task?.taskRef !== request.taskRef)
3381
+ || (request.threadRef !== undefined && threadReadTargetOf(operation.data).threadRef !== request.threadRef)
3382
+ || (request.taskRef !== undefined && threadReadTargetOf(operation.data).taskRef !== request.taskRef)
3111
3383
  || (request.threadRef === undefined && request.taskRef === undefined))
3112
3384
  this.throwRequestCollision(request.requestId);
3113
3385
  }
@@ -3212,14 +3484,18 @@ export class AgentTeamLedger {
3212
3484
  return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), activity: operation.data.activity,
3213
3485
  task: operation.data.task, thread: operation.data.thread });
3214
3486
  }
3215
- threadReadResult(operation) {
3216
- // Loaded records are normalized by sortedRecords(); fresh writes always carry instants.
3217
- const { anchor, facts } = operation.data;
3218
- return Object.freeze({ receipt: this.receipt(operation), ...(operation.data.task === undefined ? {} : { task: operation.data.task }), thread: operation.data.thread,
3219
- claims: operation.data.claims, anchor, anchorMentions: operation.data.anchorMentions, facts,
3220
- readThroughSequence: operation.data.readThroughSequence, remainingUnreadCount: operation.data.remainingUnreadCount,
3221
- ...(operation.data.attention === undefined ? {} : { attention: operation.data.attention }),
3222
- consumedDirectMarkers: operation.data.inbox.directMarkers.removed });
3487
+ /**
3488
+ * The Thread picture a read answers with, derived from the projection the
3489
+ * read resolved against. Committed reads, no-op reads and retries all share
3490
+ * this one derivation, so a read that writes nothing still returns the same
3491
+ * Attention, facts, watermark and unread count a committed one would.
3492
+ */
3493
+ readPicture(prepared) {
3494
+ return Object.freeze({ ...(prepared.task === undefined ? {} : { task: prepared.task }), thread: prepared.thread,
3495
+ claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions, facts: prepared.facts,
3496
+ readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
3497
+ ...(prepared.attention === undefined ? {} : { attention: prepared.attention }),
3498
+ consumedDirectMarkers: prepared.inbox.directMarkers.removed });
3223
3499
  }
3224
3500
  receipt(operation) {
3225
3501
  return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence, occurredAt: operation.occurredAt });
@@ -3300,10 +3576,17 @@ export class AgentTeamLedger {
3300
3576
  legacyChannelThreadRefs(projection, channelRef) {
3301
3577
  return new Set([...projection.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
3302
3578
  }
3303
- /** Ledgers written before message occurredAt existed store bare messages; Thread reads resolve instants from the originating operations. */
3579
+ /**
3580
+ * Ledgers written before message occurredAt existed store bare messages;
3581
+ * snapshot-shaped Thread reads resolve instants from the originating
3582
+ * operations. A receipt-shaped read carries no message at all, so it passes
3583
+ * through untouched — the load path must never touch a shape it does not
3584
+ * have, and it never writes either form back.
3585
+ */
3304
3586
  normalizeOperation(operation, occurrences, instants) {
3305
- if (operation.kind !== 'team/thread-read')
3587
+ if (operation.kind !== 'team/thread-read' || !isThreadReadSnapshot(operation.data))
3306
3588
  return operation;
3589
+ const data = operation.data;
3307
3590
  const stamp = (message) => (message.occurredAt === undefined
3308
3591
  ? { ...message, occurredAt: occurrences.get(message.messageRef) ?? operation.occurredAt }
3309
3592
  : { ...message, occurredAt: message.occurredAt });
@@ -3312,8 +3595,8 @@ export class AgentTeamLedger {
3312
3595
  occurredAt: envelope.occurredAt ?? envelope.message.occurredAt ?? occurrences.get(envelope.message.messageRef) ?? operation.occurredAt }
3313
3596
  : { kind: 'activity', sequence: envelope.sequence, activity: envelope.activity,
3314
3597
  occurredAt: envelope.occurredAt ?? instants.get(envelope.sequence) ?? operation.occurredAt };
3315
- const facts = operation.data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
3316
- return { ...operation, data: { ...operation.data, anchor: stamp(operation.data.anchor), facts } };
3598
+ const facts = data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
3599
+ return { ...operation, data: { ...data, anchor: stamp(data.anchor), facts } };
3317
3600
  }
3318
3601
  enqueue(operation) {
3319
3602
  const result = this.operationTail.then(operation);