@wowyuarm/dsh-agent-team 0.1.9 → 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 (119) hide show
  1. package/README.md +17 -11
  2. package/README.zh.md +17 -11
  3. package/package.json +61 -47
  4. package/packages/agent-team/README.md +6 -4
  5. package/packages/agent-team/README.zh.md +6 -3
  6. package/packages/agent-team/lib/context-management.js +48 -55
  7. package/packages/agent-team/lib/context-projection.js +22 -8
  8. package/packages/agent-team/lib/context-source.js +163 -23
  9. package/packages/agent-team/lib/index.js +349 -176
  10. package/packages/agent-team/lib/invariant.js +38 -4
  11. package/packages/agent-team/lib/ledger.js +658 -294
  12. package/packages/agent-team/lib/member-context.js +6 -2
  13. package/packages/agent-team/lib/member-time-context.js +141 -0
  14. package/packages/agent-team/lib/pressure-policy.js +34 -11
  15. package/packages/agent-team/lib/progress-nudge.js +12 -4
  16. package/packages/agent-team/lib/session-event-cursor.js +116 -0
  17. package/packages/agent-team/lib/session-remediation.js +493 -0
  18. package/packages/agent-team/lib/spec.js +49 -36
  19. package/packages/agent-team/lib/stored-session-reader.js +138 -0
  20. package/packages/agent-team/lib/time-format.js +56 -0
  21. package/packages/agent-team/lib/typert.host.js +163 -63
  22. package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
  23. package/packages/agent-team/lib/typert.remote-client.js +105 -32
  24. package/packages/agent-team/lib/types/context-management.d.ts +13 -1
  25. package/packages/agent-team/lib/types/context-management.d.ts.map +1 -1
  26. package/packages/agent-team/lib/types/context-projection.d.ts +8 -0
  27. package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
  28. package/packages/agent-team/lib/types/context-source.d.ts +70 -36
  29. package/packages/agent-team/lib/types/context-source.d.ts.map +1 -1
  30. package/packages/agent-team/lib/types/index.d.ts +110 -9
  31. package/packages/agent-team/lib/types/index.d.ts.map +1 -1
  32. package/packages/agent-team/lib/types/invariant.d.ts.map +1 -1
  33. package/packages/agent-team/lib/types/ledger.d.ts +205 -7
  34. package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
  35. package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
  36. package/packages/agent-team/lib/types/member-time-context.d.ts +83 -0
  37. package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -0
  38. package/packages/agent-team/lib/types/pressure-policy.d.ts +18 -0
  39. package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -1
  40. package/packages/agent-team/lib/types/progress-nudge.d.ts.map +1 -1
  41. package/packages/agent-team/lib/types/session-event-cursor.d.ts +119 -0
  42. package/packages/agent-team/lib/types/session-event-cursor.d.ts.map +1 -0
  43. package/packages/agent-team/lib/types/session-remediation.d.ts +172 -0
  44. package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -0
  45. package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
  46. package/packages/agent-team/lib/types/stored-session-reader.d.ts +92 -0
  47. package/packages/agent-team/lib/types/stored-session-reader.d.ts.map +1 -0
  48. package/packages/agent-team/lib/types/time-format.d.ts +28 -0
  49. package/packages/agent-team/lib/types/time-format.d.ts.map +1 -0
  50. package/packages/agent-team/lib/types/types/entities.d.ts +36 -2
  51. package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
  52. package/packages/agent-team/lib/types/types/operations.d.ts +45 -19
  53. package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
  54. package/packages/agent-team/lib/types/types/requests-results.d.ts +50 -3
  55. package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
  56. package/packages/agent-team/preset/team-member/agent.cordis.yml +21 -4
  57. package/packages/client-agent-team/README.md +1 -1
  58. package/packages/client-agent-team/README.zh.md +1 -1
  59. package/packages/client-agent-team/lib/client.js +1121 -402
  60. package/packages/client-agent-team/lib/client.js.map +1 -1
  61. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
  62. package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +24 -4
  63. package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
  64. package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +42 -22
  65. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
  66. package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +13 -24
  67. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts +11 -5
  68. package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
  69. package/packages/client-agent-team/lib/types/client/TeamComposer.js +15 -9
  70. package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts +1 -1
  71. package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts.map +1 -1
  72. package/packages/client-agent-team/lib/types/client/TeamConversation.js +8 -1
  73. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +21 -0
  74. package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -0
  75. package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +71 -0
  76. package/packages/client-agent-team/lib/types/client/TeamMemberEditor.d.ts.map +1 -1
  77. package/packages/client-agent-team/lib/types/client/TeamMemberEditor.js +8 -24
  78. package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts +9 -2
  79. package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts.map +1 -1
  80. package/packages/client-agent-team/lib/types/client/TeamMessage.js +13 -6
  81. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +8 -0
  82. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -1
  83. package/packages/client-agent-team/lib/types/client/TeamPresenceDot.js +21 -1
  84. package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
  85. package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +106 -32
  86. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
  87. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
  88. package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +50 -7
  89. package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
  90. package/packages/client-agent-team/lib/types/client/index.js +25 -9
  91. package/packages/client-agent-team/lib/types/client/locales.d.ts +18 -0
  92. package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
  93. package/packages/client-agent-team/lib/types/client/locales.js +18 -0
  94. package/packages/client-agent-team/lib/types/client/navigation.d.ts +10 -0
  95. package/packages/client-agent-team/lib/types/client/navigation.d.ts.map +1 -1
  96. package/packages/client-agent-team/lib/types/client/navigation.js +18 -7
  97. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts +41 -0
  98. package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts.map +1 -0
  99. package/packages/client-agent-team/lib/types/client/scope-coverage.js +64 -0
  100. package/packages/client-agent-team/lib/types/client/slots.d.ts +8 -2
  101. package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
  102. package/packages/client-agent-team/lib/types/client/team-changes.d.ts +26 -1
  103. package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
  104. package/packages/client-agent-team/lib/types/client/team-changes.js +66 -15
  105. package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts +20 -0
  106. package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts.map +1 -0
  107. package/packages/client-agent-team/lib/types/client/team-dialog-save.js +36 -0
  108. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +9 -0
  109. package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
  110. package/packages/client-agent-team/lib/types/client/team-formatters.js +34 -6
  111. package/packages/tool-agent-team/README.md +8 -6
  112. package/packages/tool-agent-team/README.zh.md +8 -6
  113. package/packages/tool-agent-team/lib/context-tools.js +18 -14
  114. package/packages/tool-agent-team/lib/host-access.js +20 -0
  115. package/packages/tool-agent-team/lib/index.js +314 -123
  116. package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -1
  117. package/packages/tool-agent-team/lib/types/host-access.d.ts +14 -0
  118. package/packages/tool-agent-team/lib/types/host-access.d.ts.map +1 -0
  119. package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { isDeepStrictEqual } from 'node:util';
3
+ import { formatTeamTimestamp } from "./time-format.js";
3
4
  /** Stable Human Member identity shared by every replay of one dshHome Team. */
4
5
  export const AGENT_TEAM_HUMAN_MEMBER_ID = 'member:human';
5
6
  /** Idempotency identity of the one Host bootstrap operation. */
@@ -15,9 +16,12 @@ export function agentTeamHumanActor() {
15
16
  }
16
17
  function emptyProjection() {
17
18
  return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
18
- 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(),
19
20
  orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
20
- 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() };
21
25
  }
22
26
  const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
23
27
  progress: Object.freeze([]),
@@ -27,6 +31,40 @@ const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
27
31
  function assertUnhandledKind(operation) {
28
32
  throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
29
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
+ }
30
68
  /** Deep-freeze a Member capability overlay; absent stays absent. */
31
69
  function freezeCapabilities(capabilities) {
32
70
  if (capabilities === undefined)
@@ -49,12 +87,35 @@ export class AgentTeamLedger {
49
87
  createOccurredAt;
50
88
  createRef;
51
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;
52
107
  constructor(table, options = {}) {
53
108
  this.table = table;
54
109
  this.createOperationId = options.operationId ?? (() => `operation:${randomUUID()}`);
55
110
  this.createOccurredAt = options.occurredAt ?? (() => new Date().toISOString());
56
111
  this.createRef = options.ref ?? (kind => `${kind}:${randomUUID()}`);
57
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
+ });
58
119
  }
59
120
  initialize(request = {
60
121
  requestId: AGENT_TEAM_INITIALIZE_REQUEST_ID,
@@ -1009,7 +1070,7 @@ export class AgentTeamLedger {
1009
1070
  // mirror: the reader sent it to the other Member → (you → them).
1010
1071
  const direction = pair ? 'them → you' : 'you → them';
1011
1072
  const truncated = operation.data.body.length > 160 ? `${operation.data.body.slice(0, 160)}…` : operation.data.body;
1012
- return `(${direction}) ${truncated}`;
1073
+ return `(${direction}, at ${formatTeamTimestamp(operation.occurredAt)}) ${truncated}`;
1013
1074
  }
1014
1075
  return undefined;
1015
1076
  }
@@ -1024,8 +1085,19 @@ export class AgentTeamLedger {
1024
1085
  const limit = request.limit ?? 50;
1025
1086
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1026
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;
1027
1096
  const items = [];
1028
- 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;
1029
1101
  const channelRef = this.channelRefForThread(thread.threadRef);
1030
1102
  if (channelRef === undefined)
1031
1103
  continue;
@@ -1038,16 +1110,47 @@ export class AgentTeamLedger {
1038
1110
  continue;
1039
1111
  const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1040
1112
  const directCount = unread.filter(item => item.direct).length;
1113
+ if (directOnly && directCount === 0)
1114
+ continue;
1041
1115
  const attention = this.attentionFor(authorized.memberId, thread.threadRef);
1042
- items.push(Object.freeze({ channelRef, ...(task === undefined ? {} : { task }), thread, unreadCount: unread.length, directCount,
1043
- newestSequence: unread.at(-1).fact.sequence, ...(attention === undefined ? {} : { attention }) }));
1116
+ // Same snapshot, same source as newestSequence: the instant hangs off
1117
+ // the newest unread fact itself, never a second lookup that could
1118
+ // observe a different commit between the two reads.
1119
+ const newest = unread.at(-1).fact;
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) } : {}),
1126
+ newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt, ...(attention === undefined ? {} : { attention }) }));
1044
1127
  }
1045
1128
  items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
1046
1129
  const selected = items.slice(0, limit);
1047
1130
  return Object.freeze({ items: Object.freeze(selected),
1048
- 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),
1049
1134
  totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
1050
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
+ }
1051
1154
  /** Model-visible notification material derived from the recipient's current durable unread state. */
1052
1155
  notificationFacts(memberId, request) {
1053
1156
  const member = this.requireMember(memberId);
@@ -1062,22 +1165,31 @@ export class AgentTeamLedger {
1062
1165
  const existing = this.state.byRequest.get(request.requestId);
1063
1166
  if (existing !== undefined) {
1064
1167
  this.assertSameThreadRead(existing, request);
1065
- 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 });
1066
1172
  }
1067
1173
  const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
1068
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 });
1069
1187
  const operation = Object.freeze({
1070
- ...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read',
1071
- data: Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
1072
- ...(prepared.task === undefined ? {} : { task: prepared.task }),
1073
- thread: prepared.thread, claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions,
1074
- facts: prepared.facts,
1075
- readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
1076
- ...(prepared.attention === undefined ? {} : { attention: prepared.attention }), inbox: prepared.inbox }),
1188
+ ...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read', data,
1077
1189
  });
1078
1190
  await this.table.put(operation.operationId, operation);
1079
1191
  this.apply(operation);
1080
- return this.committed(this.threadReadResult(operation));
1192
+ return Object.freeze({ value: Object.freeze({ receipt: this.receipt(operation), ...this.readPicture(prepared) }), committed: true });
1081
1193
  });
1082
1194
  }
1083
1195
  threadObservations(actor, request) {
@@ -1087,33 +1199,13 @@ export class AgentTeamLedger {
1087
1199
  if (!Number.isInteger(limit) || limit < 1 || limit > 100)
1088
1200
  throw new Error('observation limit must be an integer between 1 and 100');
1089
1201
  const threadRef = thread.threadRef;
1090
- const current = new Map();
1091
- const observations = [];
1092
- for (const operation of this.state.ordered) {
1093
- const delta = this.attentionDelta(operation);
1094
- if (delta === undefined)
1095
- continue;
1096
- for (const removed of delta.attention.removed) {
1097
- if (removed.threadRef !== threadRef)
1098
- continue;
1099
- if (!current.delete(removed.memberId))
1100
- continue;
1101
- observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
1102
- memberId: removed.memberId, action: 'unfollow' }));
1103
- }
1104
- for (const next of delta.attention.set) {
1105
- if (next.threadRef !== threadRef)
1106
- continue;
1107
- const prior = current.get(next.memberId);
1108
- current.set(next.memberId, next);
1109
- // Task creation establishes initial Attention; reads only advance its watermark.
1110
- if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
1111
- continue;
1112
- observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
1113
- memberId: next.memberId, action: 'follow' }));
1114
- }
1115
- }
1116
- 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) ?? []]) });
1117
1209
  }
1118
1210
  /** Every operation carrying an inbox delta drives the Inbox projection; the payload shape decides, not a per-kind list. */
1119
1211
  attentionDelta(operation) {
@@ -1211,7 +1303,8 @@ export class AgentTeamLedger {
1211
1303
  && channel.state !== 'archived'
1212
1304
  && (memberId === undefined || this.isChannelMember(channel.channelRef, memberId)));
1213
1305
  const channelRefs = new Set(channels.map(channel => channel.channelRef));
1214
- const allFacts = this.state.orderedFacts.filter(fact => {
1306
+ const before = request.cursor ?? this.state.ordered.length + 1;
1307
+ const matches = (fact) => {
1215
1308
  const threadRef = fact.kind === 'message' ? fact.message.threadRef : fact.activity.threadRef;
1216
1309
  const channelRef = this.channelRefForThread(threadRef);
1217
1310
  if (channelRef === undefined || !channelRefs.has(channelRef))
@@ -1224,9 +1317,40 @@ export class AgentTeamLedger {
1224
1317
  return false;
1225
1318
  if (request.includeActivities === false && fact.kind === 'activity')
1226
1319
  return false;
1227
- return direction === 'before' ? fact.sequence < (request.cursor ?? this.state.ordered.length + 1) : fact.sequence > cursor;
1228
- });
1229
- 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
+ }
1230
1354
  const visibleTasks = [...this.state.tasks.values()].filter(task => channelRefs.has(task.channelRef)
1231
1355
  && (request.channelRef === undefined || task.channelRef === request.channelRef)
1232
1356
  && (request.threadRef === undefined || task.threadRef === request.threadRef));
@@ -1235,8 +1359,10 @@ export class AgentTeamLedger {
1235
1359
  const message = fact.message;
1236
1360
  const thread = this.requireThread(message.threadRef);
1237
1361
  const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
1362
+ const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
1238
1363
  return Object.freeze({ message, mentions: fact.mentions, ...(task === undefined ? {} : { task, taskNumber: taskNumbers.get(task.taskRef) ?? 0 }), thread,
1239
- messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0 });
1364
+ messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0,
1365
+ lastActivityAt: facts.at(-1)?.occurredAt ?? '' });
1240
1366
  });
1241
1367
  const initialization = this.initialization();
1242
1368
  const nextCursor = selected.length === 0 ? cursor : direction === 'before' ? selected[0].sequence : selected.at(-1).sequence;
@@ -1259,7 +1385,7 @@ export class AgentTeamLedger {
1259
1385
  claims: this.claimsForVisibleTasks(visibleTasks),
1260
1386
  activities: Object.freeze(selected.filter((fact) => fact.kind === 'activity').map(fact => fact.activity)),
1261
1387
  cursor: nextCursor,
1262
- hasMore: allFacts.length > selected.length,
1388
+ hasMore,
1263
1389
  });
1264
1390
  }
1265
1391
  status() {
@@ -1270,6 +1396,31 @@ export class AgentTeamLedger {
1270
1396
  validate() {
1271
1397
  this.validateRecords(this.sortedRecords());
1272
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
+ }
1273
1424
  hasCommitted(requestId) {
1274
1425
  return this.state.byRequest.has(requestId);
1275
1426
  }
@@ -1277,6 +1428,33 @@ export class AgentTeamLedger {
1277
1428
  getOperation(operationId) {
1278
1429
  return this.state.byOperation.get(operationId);
1279
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
+ }
1280
1458
  /** Scopes whose projections one committed operation invalidates; undefined wakes every waiter. */
1281
1459
  changeScopesOf(operation) {
1282
1460
  switch (operation.kind) {
@@ -1297,37 +1475,15 @@ export class AgentTeamLedger {
1297
1475
  case 'team/member-updated':
1298
1476
  case 'team/member-removed':
1299
1477
  return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
1300
- case 'team/member-archived': {
1301
- // Claim releases are thread-visible facts: open Channel and Thread
1302
- // pages refetch alongside the workspace-wide roster change.
1303
- const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
1304
- const scopes = [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
1305
- for (const activity of operation.data.activities) {
1306
- scopes.push({ kind: 'thread', threadRef: activity.threadRef });
1307
- const channelRef = channelByTask.get(activity.taskRef);
1308
- if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
1309
- scopes.push({ kind: 'channel', channelRef });
1310
- }
1311
- }
1312
- return scopes;
1313
- }
1478
+ case 'team/member-archived':
1479
+ return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }], operation.data.tasks, operation.data.activities);
1314
1480
  case 'team/channel-member-added':
1315
1481
  return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
1316
- case 'team/channel-member-removed': {
1317
- const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
1318
- const scopes = [
1482
+ case 'team/channel-member-removed':
1483
+ return this.withReleasedActivityScopes([
1319
1484
  { kind: 'workspace', workspaceId: operation.data.workspaceId },
1320
1485
  { kind: 'channel', channelRef: operation.data.channelRef },
1321
- ];
1322
- for (const activity of operation.data.activities) {
1323
- scopes.push({ kind: 'thread', threadRef: activity.threadRef });
1324
- const channelRef = channelByTask.get(activity.taskRef);
1325
- if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
1326
- scopes.push({ kind: 'channel', channelRef });
1327
- }
1328
- }
1329
- return scopes;
1330
- }
1486
+ ], operation.data.tasks, operation.data.activities);
1331
1487
  case 'team/channel-archived': {
1332
1488
  const scopes = [
1333
1489
  { kind: 'workspace', workspaceId: operation.data.workspaceId },
@@ -1625,7 +1781,7 @@ export class AgentTeamLedger {
1625
1781
  || operation.data.channel.createdAtSequence !== prior.createdAtSequence)
1626
1782
  throw new Error('invalid Channel archival');
1627
1783
  const threadRefs = new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === prior.channelRef));
1628
- this.validateChannelArchivalCleanup(operation.data, projection, threadRefs, operation.sequence, refs);
1784
+ this.validateReleaseCleanup(operation.data, projection, undefined, threadRefs, operation.sequence, refs);
1629
1785
  return;
1630
1786
  }
1631
1787
  if (operation.kind === 'team/member-removed') {
@@ -1676,20 +1832,38 @@ export class AgentTeamLedger {
1676
1832
  assertMember();
1677
1833
  else
1678
1834
  assertHuman();
1679
- if (operation.data.memberId !== operation.actor.memberId)
1835
+ const data = operation.data;
1836
+ if (data.memberId !== operation.actor.memberId)
1680
1837
  throw new Error('Thread read has wrong actor');
1681
- const expected = this.prepareReadFrom(projection, operation.data.memberId, operation.data.workspaceId, {
1682
- threadRef: operation.data.thread.threadRef,
1683
- ...(operation.data.task === undefined ? {} : { taskRef: operation.data.task.taskRef }),
1684
- });
1685
- const expectedData = Object.freeze({ workspaceId: operation.data.workspaceId, memberId: operation.data.memberId,
1686
- ...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
1687
- anchorMentions: expected.anchorMentions, facts: expected.facts,
1688
- readThroughSequence: expected.readThroughSequence, remainingUnreadCount: expected.remainingUnreadCount,
1689
- ...(expected.attention === undefined ? {} : { attention: expected.attention }), inbox: expected.inbox });
1690
- if (!isDeepStrictEqual(operation.data, expectedData))
1691
- throw new Error('invalid Thread read projection');
1692
- 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);
1693
1867
  return;
1694
1868
  }
1695
1869
  if (operation.kind === 'team/thread-promoted') {
@@ -1867,11 +2041,23 @@ export class AgentTeamLedger {
1867
2041
  }
1868
2042
  assertUnhandledKind(operation);
1869
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
+ */
1870
2057
  validateInboxDelta(delta, projection, _refs, additionalThreadRefs = [], additionalMessages = [], additionalActivities = []) {
1871
2058
  const attentionKeys = new Set();
1872
- const knownThreadRefs = new Set([...projection.threads.keys(), ...additionalThreadRefs]);
1873
2059
  for (const attention of delta.attention.set) {
1874
- if (!knownThreadRefs.has(attention.threadRef) || attention.startSequence < 1
2060
+ if ((!projection.threads.has(attention.threadRef) && !additionalThreadRefs.includes(attention.threadRef)) || attention.startSequence < 1
1875
2061
  || attention.readThroughSequence < attention.startSequence - 1) {
1876
2062
  throw new Error('invalid Attention delta');
1877
2063
  }
@@ -1885,13 +2071,12 @@ export class AgentTeamLedger {
1885
2071
  throw new Error('conflicting Attention delta');
1886
2072
  }
1887
2073
  const markerKeys = new Set();
1888
- const messages = [...projection.messages, ...additionalMessages];
1889
2074
  for (const marker of delta.directMarkers.added) {
1890
2075
  const key = this.directMarkerKey(marker);
1891
2076
  if (markerKeys.has(key) || projection.directMarkers.has(key))
1892
2077
  throw new Error('invalid direct marker addition');
1893
2078
  markerKeys.add(key);
1894
- const message = messages.find(candidate => candidate.messageRef === marker.messageRef);
2079
+ const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
1895
2080
  if (message === undefined || message.threadRef !== marker.threadRef || message.sequence !== marker.sequence
1896
2081
  || !this.validMentionTarget(projection, message.channelRef, marker.memberId))
1897
2082
  throw new Error('invalid direct marker addition');
@@ -1902,17 +2087,20 @@ export class AgentTeamLedger {
1902
2087
  throw new Error('invalid direct marker removal');
1903
2088
  markerKeys.add(key);
1904
2089
  const current = projection.directMarkers.get(key);
1905
- const message = messages.find(candidate => candidate.messageRef === marker.messageRef);
2090
+ const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
1906
2091
  if (!isDeepStrictEqual(current, marker) || message === undefined || message.threadRef !== marker.threadRef
1907
2092
  || message.sequence !== marker.sequence || !this.validMentionTarget(projection, message.channelRef, marker.memberId)) {
1908
2093
  throw new Error('invalid direct marker removal');
1909
2094
  }
1910
2095
  }
1911
2096
  const activityMarkerKeys = new Set();
1912
- const activities = [...projection.orderedFacts.filter(fact => fact.kind === 'activity').map(fact => fact.activity), ...additionalActivities];
1913
2097
  for (const marker of delta.activityMarkers.added) {
1914
2098
  const key = this.activityMarkerKey(marker);
1915
- 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);
1916
2104
  if (activityMarkerKeys.has(key) || projection.activityMarkers.has(key) || activity === undefined
1917
2105
  || activity.threadRef !== marker.threadRef || activity.sequence !== marker.sequence)
1918
2106
  throw new Error('invalid activity marker addition');
@@ -1926,73 +2114,31 @@ export class AgentTeamLedger {
1926
2114
  activityMarkerKeys.add(key);
1927
2115
  }
1928
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
+ */
1929
2132
  validateReleaseCleanup(data, projection, memberId, threadRefs, sequence, refs) {
1930
2133
  const releasedClaims = [...projection.claims.values()]
1931
- .filter(claim => claim.owner === memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
2134
+ .filter(claim => (memberId === undefined || claim.owner === memberId) && claim.state === 'active' && threadRefs.has(claim.threadRef))
1932
2135
  .map(claim => Object.freeze({ ...claim, state: 'released' }));
1933
2136
  if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
1934
2137
  const expected = releasedClaims[index];
1935
2138
  return expected === undefined || !this.sameClaim(expected, claim);
1936
2139
  }))
1937
2140
  throw new Error('invalid released Claim projection');
1938
- const byThread = new Map();
1939
- for (const claim of releasedClaims)
1940
- byThread.set(claim.threadRef, [...(byThread.get(claim.threadRef) ?? []), claim]);
1941
- const expectedActivities = [...byThread.entries()].map(([threadRef, claims]) => ({
1942
- kind: 'claims_released', taskRef: claims[0].taskRef, threadRef, actor: memberId, sequence,
1943
- claimRefs: claims.map(claim => claim.claimRef).sort(),
1944
- }));
1945
- if (data.activities.length !== expectedActivities.length || data.activities.some((activity, index) => {
1946
- const expected = expectedActivities[index];
1947
- return expected === undefined || activity.kind !== expected.kind || activity.taskRef !== expected.taskRef
1948
- || activity.threadRef !== expected.threadRef || activity.actor !== expected.actor || activity.sequence !== expected.sequence
1949
- || !this.sameList(activity.claimRefs, expected.claimRefs);
1950
- }))
1951
- throw new Error('invalid released Claim activities');
1952
- for (const claim of data.claims) {
1953
- const prior = projection.claims.get(claim.claimRef);
1954
- if (prior === undefined)
1955
- throw new Error('invalid released Claim reference');
1956
- }
1957
- for (const activity of data.activities)
1958
- this.addRef(refs, activity.activityRef);
1959
- const projectedClaims = new Map(projection.claims);
1960
- for (const claim of releasedClaims)
1961
- projectedClaims.set(claim.claimRef, claim);
1962
- const expectedTasks = [...new Set(releasedClaims.map(claim => claim.taskRef))].map(taskRef => {
1963
- const task = projection.tasks.get(taskRef);
1964
- return Object.freeze({ ...task, status: this.deriveResolvedTaskStatus(task, projectedClaims.values()) });
1965
- });
1966
- const expectedThreads = expectedActivities.map(activity => {
1967
- const thread = projection.threads.get(activity.threadRef);
1968
- return Object.freeze({ ...thread, revision: sequence });
1969
- });
1970
- if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
1971
- throw new Error('invalid released Claim Task or Thread projection');
1972
- }
1973
- const expectedAttention = [...projection.attention.values()]
1974
- .filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
1975
- .map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
1976
- const expectedMarkers = [...projection.directMarkers.values()]
1977
- .filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
1978
- const expectedActivityMarkers = [...projection.activityMarkers.values()]
1979
- .filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
1980
- const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
1981
- if (!isDeepStrictEqual(data.inbox, expectedInbox))
1982
- throw new Error('invalid Member inbox cleanup');
1983
- this.validateInboxDelta(data.inbox, projection, refs);
1984
- }
1985
- /** Replay validation of one Channel archival's release snapshot across every owner. */
1986
- validateChannelArchivalCleanup(data, projection, threadRefs, sequence, refs) {
1987
- const releasedClaims = [...projection.claims.values()]
1988
- .filter(claim => claim.state === 'active' && threadRefs.has(claim.threadRef))
1989
- .map(claim => Object.freeze({ ...claim, state: 'released' }));
1990
- if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
1991
- const expected = releasedClaims[index];
1992
- return expected === undefined || !this.sameClaim(expected, claim);
1993
- }))
1994
- throw new Error('invalid released Claim projection');
1995
- // One activity per (owner, Thread), owners sorted like the commit path.
2141
+ // One Activity per (owner, Thread), owners sorted like the commit path.
1996
2142
  const byOwner = new Map();
1997
2143
  for (const claim of releasedClaims) {
1998
2144
  const byThread = byOwner.get(claim.owner) ?? new Map();
@@ -2031,17 +2177,17 @@ export class AgentTeamLedger {
2031
2177
  if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
2032
2178
  throw new Error('invalid released Claim Task or Thread projection');
2033
2179
  }
2034
- // Attention and marker cleanup covers EVERY Member on the archived Threads.
2035
2180
  const expectedAttention = [...projection.attention.values()]
2036
- .filter(attention => threadRefs.has(attention.threadRef))
2181
+ .filter(attention => (memberId === undefined || attention.memberId === memberId) && threadRefs.has(attention.threadRef))
2037
2182
  .map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
2038
2183
  const expectedMarkers = [...projection.directMarkers.values()]
2039
- .filter(marker => threadRefs.has(marker.threadRef));
2184
+ .filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
2040
2185
  const expectedActivityMarkers = [...projection.activityMarkers.values()]
2041
- .filter(marker => threadRefs.has(marker.threadRef));
2186
+ .filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
2042
2187
  const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
2043
- if (!isDeepStrictEqual(data.inbox, expectedInbox))
2044
- 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
+ }
2045
2191
  this.validateInboxDelta(data.inbox, projection, refs);
2046
2192
  }
2047
2193
  validateMessageInbox(operation, projection) {
@@ -2081,6 +2227,12 @@ export class AgentTeamLedger {
2081
2227
  }
2082
2228
  apply(operation) {
2083
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;
2084
2236
  }
2085
2237
  applyTo(target, operation) {
2086
2238
  target.ordered.push(operation);
@@ -2150,15 +2302,7 @@ export class AgentTeamLedger {
2150
2302
  }
2151
2303
  if (operation.kind === 'team/channel-member-removed') {
2152
2304
  target.memberships.get(operation.data.channelRef)?.delete(operation.data.memberId);
2153
- for (const claim of operation.data.claims)
2154
- target.claims.set(claim.claimRef, claim);
2155
- for (const activity of operation.data.activities)
2156
- this.appendActivityFact(target, activity);
2157
- for (const task of operation.data.tasks)
2158
- target.tasks.set(task.taskRef, task);
2159
- for (const thread of operation.data.threads)
2160
- target.threads.set(thread.threadRef, thread);
2161
- this.applyInboxDelta(target, operation.data.inbox);
2305
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2162
2306
  return;
2163
2307
  }
2164
2308
  if (operation.kind === 'team/channel-archived') {
@@ -2166,30 +2310,14 @@ export class AgentTeamLedger {
2166
2310
  // restore returns every Member to the Channel. Visibility filters by
2167
2311
  // channel state instead.
2168
2312
  target.channels.set(operation.data.channel.channelRef, operation.data.channel);
2169
- for (const claim of operation.data.claims)
2170
- target.claims.set(claim.claimRef, claim);
2171
- for (const activity of operation.data.activities)
2172
- this.appendActivityFact(target, activity);
2173
- for (const task of operation.data.tasks)
2174
- target.tasks.set(task.taskRef, task);
2175
- for (const thread of operation.data.threads)
2176
- target.threads.set(thread.threadRef, thread);
2177
- this.applyInboxDelta(target, operation.data.inbox);
2313
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2178
2314
  return;
2179
2315
  }
2180
2316
  if (operation.kind === 'team/member-removed') {
2181
2317
  target.members.set(operation.data.member.memberId, operation.data.member);
2182
2318
  for (const membership of target.memberships.values())
2183
2319
  membership.delete(operation.data.member.memberId);
2184
- for (const claim of operation.data.claims)
2185
- target.claims.set(claim.claimRef, claim);
2186
- for (const activity of operation.data.activities)
2187
- this.appendActivityFact(target, activity);
2188
- for (const task of operation.data.tasks)
2189
- target.tasks.set(task.taskRef, task);
2190
- for (const thread of operation.data.threads)
2191
- target.threads.set(thread.threadRef, thread);
2192
- this.applyInboxDelta(target, operation.data.inbox);
2320
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2193
2321
  return;
2194
2322
  }
2195
2323
  if (operation.kind === 'team/member-archived') {
@@ -2197,55 +2325,61 @@ export class AgentTeamLedger {
2197
2325
  // restore returns the Member to its Channels. Visibility filters by
2198
2326
  // member state instead.
2199
2327
  target.members.set(operation.data.member.memberId, operation.data.member);
2200
- for (const claim of operation.data.claims)
2201
- target.claims.set(claim.claimRef, claim);
2202
- for (const activity of operation.data.activities)
2203
- this.appendActivityFact(target, activity);
2204
- for (const task of operation.data.tasks)
2205
- target.tasks.set(task.taskRef, task);
2206
- for (const thread of operation.data.threads)
2207
- target.threads.set(thread.threadRef, thread);
2208
- this.applyInboxDelta(target, operation.data.inbox);
2328
+ this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
2209
2329
  return;
2210
2330
  }
2211
2331
  if (operation.kind === 'team/thread-promoted') {
2212
- this.appendActivityFact(target, operation.data.activity);
2332
+ this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2213
2333
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2214
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);
2215
2337
  this.applyInboxDelta(target, operation.data.inbox);
2216
2338
  return;
2217
2339
  }
2218
2340
  if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
2219
2341
  const { message, mentions } = operation.data;
2220
- target.messages.push(message);
2221
2342
  target.mentionsByMessage.set(message.messageRef, Object.freeze([...mentions]));
2222
- this.appendMessageFact(target, message, mentions);
2343
+ this.appendMessageFact(target, message, mentions, message.occurredAt ?? operation.occurredAt);
2223
2344
  if (operation.data.task !== undefined)
2224
2345
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2225
2346
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2226
- if (message.topLevel)
2347
+ if (message.topLevel) {
2227
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);
2228
2359
  this.applyInboxDelta(target, operation.data.inbox);
2229
2360
  return;
2230
2361
  }
2231
2362
  if (operation.kind === 'team/claim-created' || operation.kind === 'team/claim-done' || operation.kind === 'team/claim-released') {
2232
2363
  target.claims.set(operation.data.claim.claimRef, operation.data.claim);
2233
- this.appendActivityFact(target, operation.data.activity);
2364
+ this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2234
2365
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2235
2366
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2367
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2236
2368
  this.applyInboxDelta(target, operation.data.inbox);
2237
2369
  return;
2238
2370
  }
2239
2371
  if (operation.kind === 'team/task-changed') {
2240
2372
  for (const claim of operation.data.claims)
2241
2373
  target.claims.set(claim.claimRef, claim);
2242
- this.appendActivityFact(target, operation.data.activity);
2374
+ this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
2243
2375
  target.tasks.set(operation.data.task.taskRef, operation.data.task);
2244
2376
  target.threads.set(operation.data.thread.threadRef, operation.data.thread);
2377
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2245
2378
  this.applyInboxDelta(target, operation.data.inbox);
2246
2379
  return;
2247
2380
  }
2248
2381
  if (operation.kind === 'team/thread-attention-changed' || operation.kind === 'team/thread-read') {
2382
+ this.recordAttentionObservations(target, operation, operation.data.inbox);
2249
2383
  this.applyInboxDelta(target, operation.data.inbox);
2250
2384
  return;
2251
2385
  }
@@ -2256,22 +2390,82 @@ export class AgentTeamLedger {
2256
2390
  }
2257
2391
  assertUnhandledKind(operation);
2258
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
+ }
2259
2412
  /** Facts arrive in ledger sequence order, so global and per-thread lists stay sorted by append only. */
2260
- appendMessageFact(target, message, mentions) {
2261
- const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions });
2413
+ appendMessageFact(target, message, mentions, occurredAt) {
2414
+ const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions, occurredAt });
2262
2415
  target.orderedFacts.push(fact);
2263
2416
  const facts = target.factsByThread.get(message.threadRef) ?? [];
2264
2417
  facts.push(fact);
2265
2418
  target.factsByThread.set(message.threadRef, facts);
2266
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);
2267
2423
  }
2268
- appendActivityFact(target, activity) {
2269
- const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity });
2424
+ appendActivityFact(target, activity, occurredAt) {
2425
+ const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
2270
2426
  target.orderedFacts.push(fact);
2271
2427
  const facts = target.factsByThread.get(activity.threadRef) ?? [];
2272
2428
  facts.push(fact);
2273
2429
  target.factsByThread.set(activity.threadRef, facts);
2274
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
+ }
2275
2469
  applyInboxDelta(target, delta) {
2276
2470
  for (const key of delta.attention.removed) {
2277
2471
  target.attention.delete(this.attentionKey(key.memberId, key.threadRef));
@@ -2281,27 +2475,76 @@ export class AgentTeamLedger {
2281
2475
  if (followers.size === 0)
2282
2476
  target.attentionByThread.delete(key.threadRef);
2283
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
+ }
2284
2484
  }
2285
2485
  for (const attention of delta.attention.set) {
2286
2486
  target.attention.set(this.attentionKey(attention.memberId, attention.threadRef), attention);
2287
2487
  const followers = target.attentionByThread.get(attention.threadRef) ?? new Set();
2288
2488
  followers.add(attention.memberId);
2289
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);
2290
2493
  }
2291
- for (const marker of delta.directMarkers.removed)
2494
+ for (const marker of delta.directMarkers.removed) {
2292
2495
  target.directMarkers.delete(this.directMarkerKey(marker));
2293
- 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) {
2294
2499
  target.directMarkers.set(this.directMarkerKey(marker), marker);
2295
- 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) {
2296
2503
  target.activityMarkers.delete(this.activityMarkerKey(marker));
2297
- 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) {
2298
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
+ }
2299
2535
  }
2300
2536
  prepareRead(memberId, workspaceId, request) {
2301
2537
  return this.prepareReadFrom(this.state, memberId, workspaceId, request);
2302
2538
  }
2303
- /** Derive the only legal durable result of one Thread read from a prior projection. */
2304
- 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) {
2305
2548
  const { task, thread, channelRef } = this.threadContextFrom(projection, workspaceId, request);
2306
2549
  if (memberId !== AGENT_TEAM_HUMAN_MEMBER_ID) {
2307
2550
  const member = projection.members.get(memberId);
@@ -2309,20 +2552,9 @@ export class AgentTeamLedger {
2309
2552
  throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channelRef}'`);
2310
2553
  }
2311
2554
  }
2312
- const anchor = this.threadAnchorFrom(projection, thread.threadRef);
2313
2555
  const attention = this.attentionForFrom(projection, memberId, thread.threadRef);
2314
2556
  const unread = this.unreadForFrom(projection, memberId, thread.threadRef);
2315
2557
  const unreadFacts = unread.slice(0, 20);
2316
- const unreadFactKeys = new Set(unread.map(item => this.threadFactKey(item.fact)));
2317
- const firstRead = attention !== undefined && attention.readThroughSequence < attention.startSequence;
2318
- const background = firstRead
2319
- ? projection.messages.filter(message => message.threadRef === thread.threadRef && message.sequence < attention.startSequence)
2320
- .map(message => Object.freeze({ kind: 'message', sequence: message.sequence, message,
2321
- mentions: projection.mentionsByMessage.get(message.messageRef) ?? [] }))
2322
- .filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
2323
- : [];
2324
- const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...unreadFacts]
2325
- .sort((left, right) => left.fact.sequence - right.fact.sequence);
2326
2558
  // Direct markers are sparse acknowledgements, not part of the contiguous
2327
2559
  // follower watermark. Consuming an old marker after a later follow must
2328
2560
  // never move that watermark backwards.
@@ -2338,24 +2570,36 @@ export class AgentTeamLedger {
2338
2570
  const activityMarkers = this.activityMarkersForFrom(projection, memberId, thread.threadRef)
2339
2571
  .filter(marker => unreadFacts.some(item => item.fact.kind === 'activity' && item.fact.activity.activityRef === marker.activityRef));
2340
2572
  const inbox = this.inboxDelta(nextAttention, [], [], consumed, [], activityMarkers);
2341
- // The hypothetical projection copies every map that its inbox delta can
2342
- // mutate, including the follower sets inside attentionByThread; the fact
2343
- // indexes are read-only here and stay shared.
2344
- const nextProjection = {
2345
- ...projection,
2346
- attention: new Map(projection.attention),
2347
- directMarkers: new Map(projection.directMarkers),
2348
- activityMarkers: new Map(projection.activityMarkers),
2349
- attentionByThread: new Map([...projection.attentionByThread].map(([threadRef, followers]) => [threadRef, new Set(followers)])),
2350
- };
2351
- this.applyInboxDelta(nextProjection, inbox);
2352
- const remainingUnreadCount = this.unreadForFrom(nextProjection, memberId, thread.threadRef).length;
2353
- return Object.freeze({ ...(task === undefined ? {} : { task }), thread, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
2354
- anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
2355
- facts: Object.freeze(combined),
2356
- 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 }),
2357
2576
  consumedDirectMarkers: Object.freeze(consumed), inbox });
2358
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
+ }
2359
2603
  readFactFrom(projection, memberId, fact, unread) {
2360
2604
  return Object.freeze({ fact, unread, direct: fact.kind === 'message'
2361
2605
  && projection.directMarkers.has(this.directMarkerKey({ memberId, threadRef: fact.message.threadRef,
@@ -2366,23 +2610,60 @@ export class AgentTeamLedger {
2366
2610
  }
2367
2611
  unreadForFrom(projection, memberId, threadRef) {
2368
2612
  const attention = this.attentionForFrom(projection, memberId, threadRef);
2369
- const facts = this.threadFactsFrom(projection, threadRef);
2370
- const direct = this.directMarkersForFrom(projection, memberId, threadRef);
2371
- 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)));
2372
2614
  const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
2373
2615
  const result = [];
2374
- for (const fact of facts) {
2375
- const marker = fact.kind === 'message' && directKeys.has(this.directMarkerKey({ memberId, threadRef,
2376
- messageRef: fact.message.messageRef, sequence: fact.sequence }));
2377
- const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
2378
- activityRef: fact.activity.activityRef, sequence: fact.sequence }));
2379
- const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
2380
- && fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
2381
- if (marker || activityMarker || ordinary)
2616
+ for (const fact of this.threadFactsFrom(projection, threadRef)) {
2617
+ if (this.isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys)) {
2382
2618
  result.push(this.readFactFrom(projection, memberId, fact, true));
2619
+ }
2383
2620
  }
2384
2621
  return Object.freeze(result);
2385
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
+ }
2386
2667
  visibleToFollower(fact, memberId) {
2387
2668
  const sender = fact.kind === 'message' ? fact.message.sender : fact.activity.actor;
2388
2669
  return sender !== memberId;
@@ -2393,6 +2674,23 @@ export class AgentTeamLedger {
2393
2674
  threadFactsFrom(projection, threadRef) {
2394
2675
  return projection.factsByThread.get(threadRef) ?? [];
2395
2676
  }
2677
+ /**
2678
+ * The one per-fact instant projection: a fact's wall-clock time is the
2679
+ * occurredAt of the ledger operation that committed it, looked up by
2680
+ * sequence inside the same read snapshot. Every agent-facing surface
2681
+ * (Thread facts, notifications, DM history, mutation results) resolves
2682
+ * through this single path, never a second parallel lookup.
2683
+ */
2684
+ occurredAtForFactFrom(projection, sequence, fallback) {
2685
+ if (fallback !== undefined)
2686
+ return fallback;
2687
+ // Facts arrive in ledger sequence order and `ordered` is append-only, so
2688
+ // the entry at index sequence - 1 is the committing operation. The index
2689
+ // read is guarded for hypothetical projections whose prefix has not yet
2690
+ // caught up; those callers always pass an explicit fallback instead.
2691
+ const operation = projection.ordered[sequence - 1];
2692
+ return operation?.occurredAt ?? '';
2693
+ }
2396
2694
  messageInboxDelta(message, sender, mentions, attention) {
2397
2695
  const markers = mentions.map(memberId => Object.freeze({ memberId, threadRef: message.threadRef,
2398
2696
  messageRef: message.messageRef, sequence: message.sequence }));
@@ -2454,18 +2752,24 @@ export class AgentTeamLedger {
2454
2752
  return this.inboxDelta([], [], [], [], markers);
2455
2753
  }
2456
2754
  removeMemberThreadInbox(memberId, threadRefs) {
2457
- const removed = [...this.state.attention.values()].filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
2755
+ return this.removeMemberThreadInboxFrom(this.state, memberId, threadRefs);
2756
+ }
2757
+ removeMemberThreadInboxFrom(projection, memberId, threadRefs) {
2758
+ const removed = [...projection.attention.values()].filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
2458
2759
  .map(attention => Object.freeze({ memberId, threadRef: attention.threadRef }));
2459
- const markers = [...this.state.directMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
2460
- const activityMarkers = [...this.state.activityMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
2760
+ const markers = [...projection.directMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
2761
+ const activityMarkers = [...projection.activityMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
2461
2762
  return this.inboxDelta([], removed, [], markers, [], activityMarkers);
2462
2763
  }
2463
2764
  /** Attention and marker cleanup for EVERY Member on the given Threads. */
2464
2765
  channelArchivalInbox(threadRefs) {
2465
- const removed = [...this.state.attention.values()].filter(attention => threadRefs.has(attention.threadRef))
2766
+ return this.channelArchivalInboxFrom(this.state, threadRefs);
2767
+ }
2768
+ channelArchivalInboxFrom(projection, threadRefs) {
2769
+ const removed = [...projection.attention.values()].filter(attention => threadRefs.has(attention.threadRef))
2466
2770
  .map(attention => Object.freeze({ memberId: attention.memberId, threadRef: attention.threadRef }));
2467
- const markers = [...this.state.directMarkers.values()].filter(marker => threadRefs.has(marker.threadRef));
2468
- const activityMarkers = [...this.state.activityMarkers.values()].filter(marker => threadRefs.has(marker.threadRef));
2771
+ const markers = [...projection.directMarkers.values()].filter(marker => threadRefs.has(marker.threadRef));
2772
+ const activityMarkers = [...projection.activityMarkers.values()].filter(marker => threadRefs.has(marker.threadRef));
2469
2773
  return this.inboxDelta([], removed, [], markers, [], activityMarkers);
2470
2774
  }
2471
2775
  inboxDelta(set = [], removed = [], addedMarkers = [], removedMarkers = [], addedActivityMarkers = [], removedActivityMarkers = []) {
@@ -2504,12 +2808,14 @@ export class AgentTeamLedger {
2504
2808
  return this.directMarkersForFrom(this.state, memberId, threadRef);
2505
2809
  }
2506
2810
  directMarkersForFrom(projection, memberId, threadRef) {
2507
- return Object.freeze([...projection.directMarkers.values()].filter(marker => marker.memberId === memberId && marker.threadRef === threadRef)
2508
- .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]);
2509
2815
  }
2510
2816
  activityMarkersForFrom(projection, memberId, threadRef) {
2511
- return Object.freeze([...projection.activityMarkers.values()].filter(marker => marker.memberId === memberId && marker.threadRef === threadRef)
2512
- .sort((left, right) => left.sequence - right.sequence));
2817
+ const markers = projection.activityMarkersByMember.get(memberId)?.get(threadRef);
2818
+ return markers === undefined ? [] : Object.freeze([...markers]);
2513
2819
  }
2514
2820
  issueConfirmation(actor, workspaceId, channelRef, body, recipients, task, thread) {
2515
2821
  if (actor.kind !== 'human')
@@ -2620,7 +2926,7 @@ export class AgentTeamLedger {
2620
2926
  return this.threadAnchorFrom(this.state, threadRef);
2621
2927
  }
2622
2928
  threadAnchorFrom(projection, threadRef) {
2623
- const anchor = projection.messages.find(message => message.threadRef === threadRef && message.topLevel);
2929
+ const anchor = projection.anchorByThread.get(threadRef);
2624
2930
  if (anchor === undefined)
2625
2931
  throw new Error(`Thread '${threadRef}' has no anchor Message`);
2626
2932
  return anchor;
@@ -2632,20 +2938,17 @@ export class AgentTeamLedger {
2632
2938
  * Display numbers for Tasks: one counter per home Channel, in creation
2633
2939
  * order. This is the single numbering authority — Channel cards, Thread
2634
2940
  * headings, cross-channel ref resolution, and inbox renders all show the
2635
- * 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.
2636
2944
  */
2637
2945
  taskNumbers(workspaceId) {
2638
2946
  const numbers = new Map();
2639
- const next = new Map();
2640
- for (const operation of this.state.ordered) {
2641
- const task = operation.kind === 'team/message-sent' || operation.kind === 'team/thread-promoted' ? operation.data.task : undefined;
2642
- if (task === undefined)
2643
- continue;
2644
- 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)
2645
2950
  continue;
2646
- const ordinal = (next.get(task.channelRef) ?? 0) + 1;
2647
- next.set(task.channelRef, ordinal);
2648
- numbers.set(task.taskRef, ordinal);
2951
+ numbers.set(taskRef, taskNumber);
2649
2952
  }
2650
2953
  return numbers;
2651
2954
  }
@@ -2864,8 +3167,12 @@ export class AgentTeamLedger {
2864
3167
  isChannelMemberFrom(projection, channelRef, memberId) {
2865
3168
  return projection.memberships.get(channelRef)?.has(memberId) === true;
2866
3169
  }
3170
+ /** Every Thread of the Channel, taskful or taskless — the scope both cleanup validators replay against. */
2867
3171
  channelThreadRefs(channelRef) {
2868
- return new Set([...this.state.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
3172
+ return this.channelThreadRefsFrom(this.state, channelRef);
3173
+ }
3174
+ channelThreadRefsFrom(projection, channelRef) {
3175
+ return new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === channelRef));
2869
3176
  }
2870
3177
  assertJoinableMember(workspaceId, memberId) {
2871
3178
  const member = this.requireMember(memberId);
@@ -3071,8 +3378,8 @@ export class AgentTeamLedger {
3071
3378
  assertSameThreadRead(operation, request) {
3072
3379
  if (operation.kind !== 'team/thread-read' || !this.sameActor(operation.actor, request.actor)
3073
3380
  || operation.data.workspaceId !== request.workspaceId
3074
- || (request.threadRef !== undefined && operation.data.thread.threadRef !== request.threadRef)
3075
- || (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)
3076
3383
  || (request.threadRef === undefined && request.taskRef === undefined))
3077
3384
  this.throwRequestCollision(request.requestId);
3078
3385
  }
@@ -3177,17 +3484,21 @@ export class AgentTeamLedger {
3177
3484
  return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), activity: operation.data.activity,
3178
3485
  task: operation.data.task, thread: operation.data.thread });
3179
3486
  }
3180
- threadReadResult(operation) {
3181
- // Loaded records are normalized by sortedRecords(); fresh writes always carry instants.
3182
- const { anchor, facts } = operation.data;
3183
- return Object.freeze({ receipt: this.receipt(operation), ...(operation.data.task === undefined ? {} : { task: operation.data.task }), thread: operation.data.thread,
3184
- claims: operation.data.claims, anchor, anchorMentions: operation.data.anchorMentions, facts,
3185
- readThroughSequence: operation.data.readThroughSequence, remainingUnreadCount: operation.data.remainingUnreadCount,
3186
- ...(operation.data.attention === undefined ? {} : { attention: operation.data.attention }),
3187
- 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 });
3188
3499
  }
3189
3500
  receipt(operation) {
3190
- return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence });
3501
+ return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence, occurredAt: operation.occurredAt });
3191
3502
  }
3192
3503
  removalResult(operation) {
3193
3504
  return Object.freeze({ receipt: this.receipt(operation), member: operation.data.member,
@@ -3214,25 +3525,78 @@ export class AgentTeamLedger {
3214
3525
  sortedRecords() {
3215
3526
  const records = [...this.table.entries()].sort((left, right) => left[1].sequence - right[1].sequence);
3216
3527
  const occurrences = new Map();
3528
+ const instants = new Map();
3217
3529
  for (const [, operation] of records) {
3530
+ instants.set(operation.sequence, operation.occurredAt);
3218
3531
  if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
3219
3532
  occurrences.set(operation.data.message.messageRef, operation.data.message.occurredAt ?? operation.occurredAt);
3220
3533
  }
3221
3534
  }
3222
- return records.map(([id, operation]) => [id, this.normalizeOperation(operation, occurrences)]);
3535
+ // Replay against a scratch projection while normalizing, so a legacy
3536
+ // cleanup repair sees the same state its validator will.
3537
+ const projection = emptyProjection();
3538
+ return records.map(([id, operation]) => {
3539
+ const normalized = this.repairLegacyChannelCleanup(this.normalizeOperation(operation, occurrences, instants), projection);
3540
+ this.applyTo(projection, normalized);
3541
+ return [id, normalized];
3542
+ });
3543
+ }
3544
+ /**
3545
+ * Releases up to 0.1.9 scoped Channel archival and Channel member-removal
3546
+ * inbox cleanup to taskful Threads only (the collector read the Task
3547
+ * projection), so archiving or member removal in a Channel holding a
3548
+ * taskless Thread with Attention or markers wrote an incomplete inbox and
3549
+ * every later load rejected the record, leaving the profile unable to boot.
3550
+ * Records carrying exactly that legacy cleanup are repaired in memory the
3551
+ * same way pre-envelope Thread reads are; any other inbox still fails
3552
+ * validation, so a forgery is not silently accepted.
3553
+ */
3554
+ repairLegacyChannelCleanup(operation, projection) {
3555
+ if (operation.kind === 'team/channel-archived') {
3556
+ const channelRef = operation.data.channel.channelRef;
3557
+ const expected = this.channelArchivalInboxFrom(projection, this.channelThreadRefsFrom(projection, channelRef));
3558
+ if (!isDeepStrictEqual(operation.data.inbox, expected)
3559
+ && isDeepStrictEqual(operation.data.inbox, this.channelArchivalInboxFrom(projection, this.legacyChannelThreadRefs(projection, channelRef)))) {
3560
+ return { ...operation, data: { ...operation.data, inbox: expected } };
3561
+ }
3562
+ return operation;
3563
+ }
3564
+ if (operation.kind === 'team/channel-member-removed') {
3565
+ const { channelRef, memberId } = operation.data;
3566
+ const expected = this.removeMemberThreadInboxFrom(projection, memberId, this.channelThreadRefsFrom(projection, channelRef));
3567
+ if (!isDeepStrictEqual(operation.data.inbox, expected)
3568
+ && isDeepStrictEqual(operation.data.inbox, this.removeMemberThreadInboxFrom(projection, memberId, this.legacyChannelThreadRefs(projection, channelRef)))) {
3569
+ return { ...operation, data: { ...operation.data, inbox: expected } };
3570
+ }
3571
+ return operation;
3572
+ }
3573
+ return operation;
3223
3574
  }
3224
- /** Ledgers written before message occurredAt existed store bare messages; Thread reads resolve instants from the originating operations. */
3225
- normalizeOperation(operation, occurrences) {
3226
- if (operation.kind !== 'team/thread-read')
3575
+ /** The pre-fix Channel Thread scope: only Threads carrying a Task. */
3576
+ legacyChannelThreadRefs(projection, channelRef) {
3577
+ return new Set([...projection.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
3578
+ }
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
+ */
3586
+ normalizeOperation(operation, occurrences, instants) {
3587
+ if (operation.kind !== 'team/thread-read' || !isThreadReadSnapshot(operation.data))
3227
3588
  return operation;
3589
+ const data = operation.data;
3228
3590
  const stamp = (message) => (message.occurredAt === undefined
3229
3591
  ? { ...message, occurredAt: occurrences.get(message.messageRef) ?? operation.occurredAt }
3230
3592
  : { ...message, occurredAt: message.occurredAt });
3231
- const facts = operation.data.facts.map((fact) => (fact.fact.kind === 'message'
3232
- ? { ...fact, fact: { kind: 'message', sequence: fact.fact.sequence, message: stamp(fact.fact.message),
3233
- mentions: fact.fact.mentions } }
3234
- : { ...fact, fact: fact.fact }));
3235
- return { ...operation, data: { ...operation.data, anchor: stamp(operation.data.anchor), facts } };
3593
+ const stampEnvelope = (envelope) => envelope.kind === 'message'
3594
+ ? { kind: 'message', sequence: envelope.sequence, message: stamp(envelope.message), mentions: envelope.mentions,
3595
+ occurredAt: envelope.occurredAt ?? envelope.message.occurredAt ?? occurrences.get(envelope.message.messageRef) ?? operation.occurredAt }
3596
+ : { kind: 'activity', sequence: envelope.sequence, activity: envelope.activity,
3597
+ occurredAt: envelope.occurredAt ?? instants.get(envelope.sequence) ?? operation.occurredAt };
3598
+ const facts = data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
3599
+ return { ...operation, data: { ...data, anchor: stamp(data.anchor), facts } };
3236
3600
  }
3237
3601
  enqueue(operation) {
3238
3602
  const result = this.operationTail.then(operation);