@axiom-lattice/protocols 4.1.1 → 4.1.2

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.
@@ -0,0 +1,349 @@
1
+ import type {
2
+ ProjectBotMembershipStatus,
3
+ ProjectBotRole,
4
+ ProjectHumanRole,
5
+ ProjectMembershipStatus,
6
+ ProjectRoomMention,
7
+ ProjectRoomMessageSource,
8
+ } from "./ProjectRoomProtocol";
9
+ import type { TaskItem } from "./TaskStoreProtocol";
10
+ import { snapshotExactArray, snapshotExactRecord } from "./ExactDataSnapshot";
11
+
12
+ /** A message shape safe to expose to Project Room clients. */
13
+ export interface ProjectRoomPublicMessage {
14
+ id: string;
15
+ roomId: string;
16
+ author:
17
+ | { type: "human"; userId: string }
18
+ | { type: "bot"; membershipId: string }
19
+ | { type: "system" };
20
+ content: { type: "text"; text: string };
21
+ mentions: ProjectRoomMention[];
22
+ replyToMessageId?: string;
23
+ source: ProjectRoomMessageSource;
24
+ createdAt: string;
25
+ }
26
+
27
+ /** A human membership shape safe to expose to Project Room clients. */
28
+ export interface ProjectRoomPublicMembership {
29
+ id: string;
30
+ userId: string;
31
+ role: ProjectHumanRole;
32
+ status: ProjectMembershipStatus;
33
+ joinedAt: string;
34
+ updatedAt: string;
35
+ }
36
+
37
+ /** A bot membership shape safe to expose to Project Room clients. */
38
+ export interface ProjectRoomPublicBotMembership {
39
+ id: string;
40
+ role: ProjectBotRole;
41
+ title: string;
42
+ responsibility?: string;
43
+ mentionName: string;
44
+ status: ProjectBotMembershipStatus;
45
+ joinedAt: string;
46
+ updatedAt: string;
47
+ }
48
+
49
+ /** A fully identified business event carried by the realtime stream. */
50
+ export interface ProjectRoomEventOf<TType extends string, TData> {
51
+ id: string;
52
+ type: TType;
53
+ occurredAt: string;
54
+ data: TData;
55
+ }
56
+
57
+ /** A newly committed message event. */
58
+ export type ProjectRoomMessageCreatedEvent = ProjectRoomEventOf<
59
+ "message.created",
60
+ { message: ProjectRoomPublicMessage }
61
+ >;
62
+
63
+ /** A changed bot roster event. */
64
+ export type ProjectRoomRosterChangedEvent = ProjectRoomEventOf<
65
+ "roster.changed",
66
+ { change: "added" | "updated" | "paused" | "resumed" | "removed"; membership: ProjectRoomPublicBotMembership }
67
+ >;
68
+
69
+ /** A changed human membership event. */
70
+ export type ProjectRoomMembershipChangedEvent = ProjectRoomEventOf<
71
+ "membership.changed",
72
+ { change: "added" | "role_changed" | "removed"; membership: ProjectRoomPublicMembership }
73
+ >;
74
+
75
+ /** A changed Project Task fact event. */
76
+ export type ProjectRoomTaskChangedEvent = ProjectRoomEventOf<
77
+ "task.changed",
78
+ {
79
+ taskId: string;
80
+ status: TaskItem["status"];
81
+ ownerMembershipId: string;
82
+ updatedAt: string;
83
+ }
84
+ >;
85
+
86
+ /** All identified business events retained by the realtime broker. */
87
+ export type ProjectRoomBusinessEvent =
88
+ | ProjectRoomMessageCreatedEvent
89
+ | ProjectRoomRosterChangedEvent
90
+ | ProjectRoomMembershipChangedEvent
91
+ | ProjectRoomTaskChangedEvent;
92
+
93
+ /** A business event before the broker assigns its process-local ID. */
94
+ export type ProjectRoomBusinessEventDraft =
95
+ | Omit<ProjectRoomMessageCreatedEvent, "id">
96
+ | Omit<ProjectRoomRosterChangedEvent, "id">
97
+ | Omit<ProjectRoomMembershipChangedEvent, "id">
98
+ | Omit<ProjectRoomTaskChangedEvent, "id">;
99
+
100
+ /** A connection control event; control events are never replayed. */
101
+ export type ProjectRoomControlEvent =
102
+ | { type: "ready"; data: { epoch: string; headEventId: string | null } }
103
+ | { type: "resync"; data: { reason: "SERVER_RESTART" | "CURSOR_EXPIRED" | "SLOW_CONSUMER" } }
104
+ | { type: "access.revoked"; data: { reason: "PROJECT_ACCESS_REVOKED" | "TOKEN_EXPIRED" } };
105
+
106
+ /** The authenticated identity used by Project Room realtime access checks. */
107
+ export interface ProjectRoomRealtimeActor {
108
+ tenantId: string;
109
+ userId: string;
110
+ projectId: string;
111
+ tokenExpiresAt: number;
112
+ }
113
+
114
+ /** Writable HTTP socket surface required by the bounded SSE transport. */
115
+ export interface ProjectRoomSseWritable {
116
+ write(chunk: string): boolean;
117
+ end(): void;
118
+ destroy(): void;
119
+ on(event: "close" | "error" | "drain", listener: () => void): this;
120
+ off(event: "close" | "error" | "drain", listener: () => void): this;
121
+ }
122
+
123
+ /** The scope used to isolate events between tenant rooms. */
124
+ export interface ProjectRoomEventScope {
125
+ tenantId: string;
126
+ roomId: string;
127
+ projectId: string;
128
+ }
129
+
130
+ /** An internal broker event carrying scope that is removed before public serialization. */
131
+ export type ProjectRoomScopedBusinessEvent = ProjectRoomBusinessEvent & {
132
+ scope: ProjectRoomEventScope;
133
+ };
134
+
135
+ /** A broker subscription containing replay and its room head. */
136
+ export interface ProjectRoomEventSubscription {
137
+ replay: ProjectRoomBusinessEvent[];
138
+ headEventId: string | null;
139
+ unsubscribe(): void;
140
+ }
141
+
142
+ /** The narrow broker contract consumed by realtime publishers and services. */
143
+ export interface ProjectRoomEventBrokerProtocol {
144
+ readonly epoch: string;
145
+ publish(scope: ProjectRoomEventScope, draft: ProjectRoomBusinessEventDraft): ProjectRoomScopedBusinessEvent;
146
+ subscribe(
147
+ scope: ProjectRoomEventScope,
148
+ afterEventId: string | undefined,
149
+ listener: (event: ProjectRoomBusinessEvent) => void,
150
+ ): ProjectRoomEventSubscription;
151
+ close(): void;
152
+ }
153
+
154
+ /** A typed cursor failure requiring client REST resynchronization. */
155
+ export class ProjectRoomCursorError extends Error {
156
+ readonly name = "ProjectRoomCursorError";
157
+
158
+ constructor(readonly code: "SERVER_RESTART" | "CURSOR_EXPIRED") {
159
+ super(`Project Room realtime cursor requires resynchronization: ${code}`);
160
+ }
161
+ }
162
+
163
+ /** A typed failure raised when the process-local event sequence is exhausted. */
164
+ export class ProjectRoomBrokerCapacityError extends Error {
165
+ readonly name = "ProjectRoomBrokerCapacityError";
166
+ readonly code = "PROJECT_ROOM_EVENT_SEQUENCE_EXHAUSTED" as const;
167
+
168
+ constructor() {
169
+ super("Project Room realtime event sequence is exhausted");
170
+ }
171
+ }
172
+
173
+ const PROJECT_ROOM_EVENT_ID_PATTERN = /^([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}):([1-9][0-9]*)$/i;
174
+
175
+ /** The parsed components of a canonical Project Room event ID. */
176
+ export interface ProjectRoomEventId {
177
+ epoch: string;
178
+ sequence: number;
179
+ }
180
+
181
+ /** Parses a canonical event ID, returning undefined for malformed or unsafe IDs. */
182
+ export function parseProjectRoomEventId(value: unknown): ProjectRoomEventId | undefined {
183
+ if (typeof value !== "string") return undefined;
184
+ const match = PROJECT_ROOM_EVENT_ID_PATTERN.exec(value);
185
+ if (!match) return undefined;
186
+ const sequence = Number(match[2]);
187
+ if (!Number.isSafeInteger(sequence)) return undefined;
188
+ if (match[1] !== match[1].toLowerCase()) return undefined;
189
+ return { epoch: match[1], sequence };
190
+ }
191
+
192
+ /** Checks an event ID without relying on realm-specific object identity. */
193
+ export function isProjectRoomEventId(value: unknown): value is string {
194
+ return parseProjectRoomEventId(value) !== undefined;
195
+ }
196
+
197
+ function isoDate(value: unknown): string | undefined {
198
+ if (typeof value !== "object" || value === null) return undefined;
199
+ try {
200
+ const time = Date.prototype.getTime.call(value);
201
+ return Number.isFinite(time) ? new Date(time).toISOString() : undefined;
202
+ } catch {
203
+ return undefined;
204
+ }
205
+ }
206
+
207
+ function stringField(record: Record<string, unknown>, key: string): string | undefined {
208
+ return typeof record[key] === "string" ? record[key] : undefined;
209
+ }
210
+
211
+ function isOneOf<T extends string>(value: unknown, values: readonly T[]): value is T {
212
+ return typeof value === "string" && values.includes(value as T);
213
+ }
214
+
215
+ const messageSources = ["user", "agent", "task", "routine", "system"] as const;
216
+ const humanRoles = ["owner", "admin", "member", "viewer"] as const;
217
+ const membershipStatuses = ["active", "removed"] as const;
218
+ const botRoles = ["coordinator", "specialist"] as const;
219
+ const botStatuses = ["active", "paused", "removed"] as const;
220
+
221
+ function mapPublicMessageRecord(record: Record<string, unknown>): ProjectRoomPublicMessage | undefined {
222
+ const id = stringField(record, "id");
223
+ const roomId = stringField(record, "roomId");
224
+ const source = stringField(record, "source");
225
+ const createdAt = isoDate(record.createdAt);
226
+ const content = snapshotExactRecord(record.content, ["type", "text"]);
227
+ const mentions = snapshotPublicMentions(record.mentions);
228
+ const author = snapshotExactRecord(record.author, ["type"], ["userId", "membershipId", "assistantId"]);
229
+ if (!id || !roomId || !isOneOf(source, messageSources) || !createdAt || !content || content.type !== "text"
230
+ || typeof content.text !== "string" || !mentions || !author || typeof author.type !== "string") return undefined;
231
+ const publicAuthor = author.type === "human" && typeof author.userId === "string"
232
+ ? { type: "human" as const, userId: author.userId }
233
+ : author.type === "bot" && typeof author.membershipId === "string"
234
+ ? { type: "bot" as const, membershipId: author.membershipId }
235
+ : author.type === "system" ? { type: "system" as const } : undefined;
236
+ if (!publicAuthor) return undefined;
237
+ const result: ProjectRoomPublicMessage = { id, roomId, author: publicAuthor, content: { type: "text", text: content.text }, mentions: mentions as ProjectRoomMention[], source: source as ProjectRoomMessageSource, createdAt };
238
+ if (record.replyToMessageId !== undefined) {
239
+ if (typeof record.replyToMessageId !== "string") return undefined;
240
+ result.replyToMessageId = record.replyToMessageId;
241
+ }
242
+ return result;
243
+ }
244
+
245
+ function snapshotPublicMentions(value: unknown): ProjectRoomMention[] | undefined {
246
+ const rows = snapshotExactArray(value);
247
+ if (!rows) return undefined;
248
+ const result: ProjectRoomMention[] = [];
249
+ for (const row of rows) {
250
+ const team = snapshotExactRecord(row, ["type"]);
251
+ if (team?.type === "team") { result.push({ type: "team" }); continue; }
252
+ const bot = snapshotExactRecord(row, ["type", "membershipId"]);
253
+ if (bot?.type === "bot" && typeof bot.membershipId === "string") {
254
+ result.push({ type: "bot", membershipId: bot.membershipId });
255
+ continue;
256
+ }
257
+ return undefined;
258
+ }
259
+ return result;
260
+ }
261
+
262
+ function mapPublicMembershipRecord(record: Record<string, unknown>): ProjectRoomPublicMembership | undefined {
263
+ const joinedAt = isoDate(record.joinedAt); const updatedAt = isoDate(record.updatedAt);
264
+ if (typeof record.id !== "string" || typeof record.userId !== "string" || !isOneOf(record.role, humanRoles) || !isOneOf(record.status, membershipStatuses) || !joinedAt || !updatedAt) return undefined;
265
+ return { id: record.id, userId: record.userId, role: record.role, status: record.status, joinedAt, updatedAt };
266
+ }
267
+
268
+ function mapPublicBotMembershipRecord(record: Record<string, unknown>): ProjectRoomPublicBotMembership | undefined {
269
+ const joinedAt = isoDate(record.joinedAt); const updatedAt = isoDate(record.updatedAt);
270
+ if (typeof record.id !== "string" || !isOneOf(record.role, botRoles) || typeof record.title !== "string" || typeof record.mentionName !== "string" || !isOneOf(record.status, botStatuses) || !joinedAt || !updatedAt || record.responsibility !== undefined && typeof record.responsibility !== "string") return undefined;
271
+ return { id: record.id, role: record.role, title: record.title, ...(record.responsibility === undefined ? {} : { responsibility: record.responsibility }), mentionName: record.mentionName, status: record.status, joinedAt, updatedAt };
272
+ }
273
+
274
+ /** Maps a canonical internal message to the strict public message DTO. */
275
+ export function toProjectRoomPublicMessage(value: unknown): ProjectRoomPublicMessage | undefined {
276
+ const record = snapshotExactRecord(value, [
277
+ "id", "tenantId", "workspaceId", "projectId", "roomId", "author", "content", "mentions", "source", "createdAt",
278
+ ], ["replyToMessageId", "sourceId", "idempotencyKey"]);
279
+ if (!record) return undefined;
280
+ return mapPublicMessageRecord(record);
281
+ }
282
+
283
+ /** A descriptor-safe message projection together with its canonical realtime scope. */
284
+ export function snapshotProjectRoomMessageRealtime(value: unknown): {
285
+ scope: { tenantId: string; roomId: string; projectId: string };
286
+ publicMessage: ProjectRoomPublicMessage;
287
+ } | undefined {
288
+ const record = snapshotExactRecord(value, ["id", "tenantId", "workspaceId", "projectId", "roomId", "author", "content", "mentions", "source", "createdAt"], ["replyToMessageId", "sourceId", "idempotencyKey"]);
289
+ if (!record) return undefined;
290
+ const publicMessage = mapPublicMessageRecord(record);
291
+ if (!publicMessage || typeof record.tenantId !== "string" || typeof record.projectId !== "string") return undefined;
292
+ return { scope: { tenantId: record.tenantId, roomId: publicMessage.roomId, projectId: record.projectId }, publicMessage };
293
+ }
294
+
295
+ /** Maps a canonical internal human membership to the strict public DTO. */
296
+ export function toProjectRoomPublicMembership(value: unknown): ProjectRoomPublicMembership | undefined {
297
+ const record = snapshotExactRecord(value,
298
+ ["id", "tenantId", "projectId", "userId", "role", "status", "joinedAt", "updatedAt"]);
299
+ if (!record || typeof record.id !== "string" || typeof record.userId !== "string"
300
+ || !isOneOf(record.role, humanRoles) || !isOneOf(record.status, membershipStatuses)) return undefined;
301
+ const joinedAt = isoDate(record.joinedAt);
302
+ const updatedAt = isoDate(record.updatedAt);
303
+ if (!joinedAt || !updatedAt) return undefined;
304
+ return { id: record.id, userId: record.userId, role: record.role,
305
+ status: record.status, joinedAt, updatedAt };
306
+ }
307
+
308
+ /** A descriptor-safe human membership projection with canonical tenant/project scope. */
309
+ export function snapshotProjectRoomMembershipRealtime(value: unknown): {
310
+ scope: { tenantId: string; projectId: string };
311
+ publicMembership: ProjectRoomPublicMembership;
312
+ } | undefined {
313
+ const record = snapshotExactRecord(value, ["id", "tenantId", "projectId", "userId", "role", "status", "joinedAt", "updatedAt"]);
314
+ if (!record) return undefined;
315
+ const publicMembership = mapPublicMembershipRecord(record);
316
+ if (!publicMembership || typeof record.tenantId !== "string" || typeof record.projectId !== "string") return undefined;
317
+ return { scope: { tenantId: record.tenantId, projectId: record.projectId }, publicMembership };
318
+ }
319
+
320
+ /** Maps a canonical internal bot membership to the strict public DTO. */
321
+ export function toProjectRoomPublicBotMembership(value: unknown): ProjectRoomPublicBotMembership | undefined {
322
+ const record = snapshotExactRecord(value,
323
+ ["id", "tenantId", "workspaceId", "projectId", "roomId", "assistantId", "role", "title", "mentionName", "status", "roomThreadId", "joinedAt", "updatedAt"],
324
+ ["responsibility"]);
325
+ if (!record || typeof record.id !== "string" || !isOneOf(record.role, botRoles) || typeof record.title !== "string"
326
+ || typeof record.mentionName !== "string" || !isOneOf(record.status, botStatuses)) return undefined;
327
+ if (record.responsibility !== undefined && typeof record.responsibility !== "string") return undefined;
328
+ const joinedAt = isoDate(record.joinedAt);
329
+ const updatedAt = isoDate(record.updatedAt);
330
+ if (!joinedAt || !updatedAt) return undefined;
331
+ const result: ProjectRoomPublicBotMembership = {
332
+ id: record.id, role: record.role, title: record.title,
333
+ mentionName: record.mentionName, status: record.status, joinedAt, updatedAt,
334
+ };
335
+ if (record.responsibility !== undefined) result.responsibility = record.responsibility;
336
+ return result;
337
+ }
338
+
339
+ /** A descriptor-safe bot membership projection with canonical realtime scope. */
340
+ export function snapshotProjectRoomBotMembershipRealtime(value: unknown): {
341
+ scope: { tenantId: string; roomId: string; projectId: string };
342
+ publicMembership: ProjectRoomPublicBotMembership;
343
+ } | undefined {
344
+ const record = snapshotExactRecord(value, ["id", "tenantId", "workspaceId", "projectId", "roomId", "assistantId", "role", "title", "mentionName", "status", "roomThreadId", "joinedAt", "updatedAt"], ["responsibility"]);
345
+ if (!record) return undefined;
346
+ const publicMembership = mapPublicBotMembershipRecord(record);
347
+ if (!publicMembership || typeof record.tenantId !== "string" || typeof record.roomId !== "string" || typeof record.projectId !== "string") return undefined;
348
+ return { scope: { tenantId: record.tenantId, roomId: record.roomId, projectId: record.projectId }, publicMembership };
349
+ }
@@ -0,0 +1,16 @@
1
+ import type { ProjectRoom } from "./ProjectRoomProtocol";
2
+
3
+ /** Persistence operations for a project's canonical main room. */
4
+ export interface ProjectRoomStore {
5
+ /** Creates the main room if needed and returns the canonical record. */
6
+ ensureMainRoom(input: {
7
+ id: string;
8
+ tenantId: string;
9
+ workspaceId: string;
10
+ projectId: string;
11
+ name: string;
12
+ }): Promise<ProjectRoom>;
13
+
14
+ /** Finds the main room for a project, if one exists. */
15
+ getMainRoom(tenantId: string, projectId: string): Promise<ProjectRoom | null>;
16
+ }
@@ -366,9 +366,10 @@ export interface TaskListFilter {
366
366
  workspaceId?: string;
367
367
 
368
368
  /**
369
- * Filter by project ID
369
+ * Filter by project ID. `null` matches only absent, empty, or `default` projects;
370
+ * `undefined` applies no project predicate.
370
371
  */
371
- projectId?: string;
372
+ projectId?: string | null;
372
373
 
373
374
  /**
374
375
  * Filter by parent task ID
@@ -396,6 +397,42 @@ export interface TaskListFilter {
396
397
  offset?: number;
397
398
  }
398
399
 
400
+ /**
401
+ * Exact scope and pagination for tasks that depend on another project task.
402
+ */
403
+ export interface TaskDependentListQuery {
404
+ /** Tenant identifier. */
405
+ tenantId: string;
406
+ /** Workspace identifier. */
407
+ workspaceId: string;
408
+ /** Project identifier. */
409
+ projectId: string;
410
+ /** Identifier that must occur as a string in the dependency array. */
411
+ dependencyTaskId: string;
412
+ /** Nonempty statuses eligible for recovery. */
413
+ statuses: TaskItem["status"][];
414
+ /** Maximum rows to return, from 1 through 100. */
415
+ limit: number;
416
+ /** Number of matching rows to skip. */
417
+ offset: number;
418
+ }
419
+
420
+ /** Exact mutable identity captured before a trusted task mutation. */
421
+ export interface TaskMutationSnapshot {
422
+ /** Status observed during authorization. */
423
+ status: TaskItem["status"];
424
+ /** Update timestamp observed during authorization. */
425
+ updatedAt: Date | string;
426
+ /** Owner kind observed during authorization. */
427
+ ownerType: TaskItem["ownerType"];
428
+ /** Owner identifier observed during authorization. */
429
+ ownerId: string;
430
+ /** Exact workspace scope observed during authorization. */
431
+ workspaceId: string | null;
432
+ /** Exact Project scope observed during authorization. */
433
+ projectId: string | null;
434
+ }
435
+
399
436
  /**
400
437
  * TaskStore interface
401
438
  * Provides CRUD operations for task data
@@ -423,6 +460,14 @@ export interface TaskStore {
423
460
  */
424
461
  list(filter: TaskListFilter): Promise<TaskItem[]>;
425
462
 
463
+ /**
464
+ * Lists exact project tasks that depend on another task.
465
+ *
466
+ * @param query Exact scope, statuses, and bounded offset page.
467
+ * @returns Matching tasks ordered by creation time and ID descending.
468
+ */
469
+ listDependents(query: TaskDependentListQuery): Promise<TaskItem[]>;
470
+
426
471
  /**
427
472
  * Update an existing task
428
473
  * @param tenantId Tenant identifier
@@ -473,6 +518,22 @@ export interface TaskStore {
473
518
  expectedUpdatedAt: Date | string,
474
519
  ): Promise<TaskItem | null>;
475
520
 
521
+ /**
522
+ * Atomically updates a task only while its full trusted mutation snapshot matches.
523
+ *
524
+ * @param tenantId Tenant identifier.
525
+ * @param id Task identifier.
526
+ * @param updates Partial task data to update.
527
+ * @param snapshot Exact status, timestamp, owner, and Project scope snapshot.
528
+ * @returns Updated task, or `null` when any snapshot field differs.
529
+ */
530
+ updateIfSnapshot(
531
+ tenantId: string,
532
+ id: string,
533
+ updates: UpdateTaskRequest,
534
+ snapshot: TaskMutationSnapshot,
535
+ ): Promise<TaskItem | null>;
536
+
476
537
  /**
477
538
  * Atomically updates a child only when both child and parent snapshots match.
478
539
  *
@@ -495,6 +556,9 @@ export interface TaskStore {
495
556
  expectedParentUpdatedAt: Date | string,
496
557
  ): Promise<TaskItem | null>;
497
558
 
559
+ /** Atomically deletes a task only while its full trusted mutation snapshot matches. */
560
+ deleteIfSnapshot(tenantId: string, id: string, snapshot: TaskMutationSnapshot): Promise<boolean>;
561
+
498
562
  /**
499
563
  * Atomically update a task unless its current status is blocked.
500
564
  *
@@ -1,3 +1,5 @@
1
+ import type { TaskMutationSnapshot } from "./TaskStoreProtocol";
2
+
1
3
  /**
2
4
  * TaskWorkItemProtocol
3
5
  *
@@ -86,8 +88,45 @@ export function isExecutionResultEventKey(value: unknown): value is string {
86
88
  return typeof value === "string" && EXECUTION_RESULT_EVENT_KEY_PATTERN.test(value);
87
89
  }
88
90
 
91
+ /** Canonical lifecycle actions projected into Project Rooms. */
92
+ export const PROJECT_TASK_LIFECYCLE_ACTIONS = [
93
+ "in_progress", "interrupted", "failed", "completed", "cancelled", "reassigned",
94
+ ] as const;
95
+
96
+ /** Lifecycle action eligible for Project Room projection. */
97
+ export type ProjectTaskLifecycleAction = typeof PROJECT_TASK_LIFECYCLE_ACTIONS[number];
98
+
99
+ /** Exclusive cursor for deterministic project lifecycle pagination. */
100
+ export interface ProjectLifecycleEventCursor {
101
+ /** Creation timestamp of the last returned event. */
102
+ createdAt: Date;
103
+ /** Identifier of the last returned event. */
104
+ id: string;
105
+ }
106
+
107
+ /** Exact project scope and bounded page for canonical lifecycle events. */
108
+ export interface ProjectLifecycleEventQuery {
109
+ /** Tenant identifier. */
110
+ tenantId: string;
111
+ /** Workspace identifier. */
112
+ workspaceId: string;
113
+ /** Project identifier. */
114
+ projectId: string;
115
+ /** Nonempty lifecycle actions to include. */
116
+ actions: ProjectTaskLifecycleAction[];
117
+ /** Optional exclusive descending cursor. */
118
+ before?: ProjectLifecycleEventCursor;
119
+ /** Maximum rows to return, from 1 through 100. */
120
+ limit: number;
121
+ }
122
+
89
123
  export interface TaskWorkItemStore {
90
124
  create(params: CreateWorkItemRequest): Promise<TaskWorkItem>;
125
+ /** Atomically creates a work item only while the owning task snapshot matches. */
126
+ createIfTaskSnapshot?(
127
+ params: CreateWorkItemRequest,
128
+ snapshot: TaskMutationSnapshot,
129
+ ): Promise<TaskWorkItem | null>;
91
130
  list(filter: TaskWorkItemListFilter): Promise<TaskWorkItem[]>;
92
131
 
93
132
  /**
@@ -112,6 +151,14 @@ export interface TaskWorkItemStore {
112
151
  limit: number;
113
152
  }): Promise<TaskWorkItem[]>;
114
153
 
154
+ /**
155
+ * Lists canonical lifecycle events in an exact project scope.
156
+ *
157
+ * @param query Exact scope, actions, exclusive cursor, and page bound.
158
+ * @returns Events ordered by creation time and ID descending.
159
+ */
160
+ listProjectLifecycleEvents?(query: ProjectLifecycleEventQuery): Promise<TaskWorkItem[]>;
161
+
115
162
  /**
116
163
  * Find an event by deterministic identity without list pagination.
117
164
  *
@@ -131,3 +178,42 @@ export interface TaskWorkItemStore {
131
178
  */
132
179
  createIfAbsentByEventKey(params: CreateWorkItemIfAbsentRequest): Promise<TaskWorkItem>;
133
180
  }
181
+
182
+ /** Work-item capabilities required by trusted Project Task consumers. */
183
+ export interface ProjectTaskWorkItemStore extends TaskWorkItemStore {
184
+ createIfTaskSnapshot(
185
+ params: CreateWorkItemRequest,
186
+ snapshot: TaskMutationSnapshot,
187
+ ): Promise<TaskWorkItem | null>;
188
+ listProjectLifecycleEvents(
189
+ query: ProjectLifecycleEventQuery,
190
+ ): Promise<TaskWorkItem[]>;
191
+ }
192
+
193
+ /** Stable capability failure raised before any trusted Project Task mutation. */
194
+ export class ProjectTaskStoreUnsupportedError extends Error {
195
+ readonly code = "PROJECT_TASK_STORE_UNSUPPORTED" as const;
196
+
197
+ constructor(readonly missingMethods: readonly string[]) {
198
+ super(`Project Task WorkItem store is missing: ${missingMethods.join(", ")}`);
199
+ this.name = "ProjectTaskStoreUnsupportedError";
200
+ }
201
+ }
202
+
203
+ /** Refines a Main-compatible WorkItem store for trusted Project Task consumers. */
204
+ export function requireProjectTaskWorkItemStore(
205
+ store: TaskWorkItemStore,
206
+ ): ProjectTaskWorkItemStore {
207
+ const missingMethods = ["createIfTaskSnapshot", "listProjectLifecycleEvents"]
208
+ .filter((name) => {
209
+ try {
210
+ return typeof Reflect.get(store as object, name) !== "function";
211
+ } catch {
212
+ return true;
213
+ }
214
+ });
215
+ if (missingMethods.length > 0) {
216
+ throw new ProjectTaskStoreUnsupportedError(missingMethods);
217
+ }
218
+ return store as ProjectTaskWorkItemStore;
219
+ }