@opengeni/db 0.23.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/{chunk-3UCHDMKG.js → chunk-7WTDI7Y3.js} +109 -2
  2. package/dist/chunk-7WTDI7Y3.js.map +1 -0
  3. package/dist/{chunk-L6ADMZHE.js → chunk-KW6U54V2.js} +3 -1
  4. package/dist/chunk-KW6U54V2.js.map +1 -0
  5. package/dist/connection-token-resolver.d.ts +24 -2
  6. package/dist/index.d.ts +68 -2
  7. package/dist/index.js +1168 -336
  8. package/dist/index.js.map +1 -1
  9. package/dist/preference-registry.d.ts +14 -0
  10. package/dist/provision-roles.js +1 -1
  11. package/dist/runtime-posture.d.ts +2 -2
  12. package/dist/schema.d.ts +262 -4
  13. package/dist/schema.js +3 -1
  14. package/dist/session-realtime-mirror.d.ts +22 -1
  15. package/dist/workspace-instruction-policies-schema.d.ts +68 -0
  16. package/dist/workspace-instruction-policies.d.ts +53 -7
  17. package/drizzle/0165_document_authority_foundation.sql +259 -0
  18. package/drizzle/0166_connection_disconnect_idempotency.sql +49 -0
  19. package/drizzle/0167_document_index_replay_authority.sql +61 -0
  20. package/drizzle/0168_workspace_instruction_policy_operation_receipts.sql +44 -0
  21. package/package.json +3 -3
  22. package/src/connection-token-resolver.ts +79 -16
  23. package/src/index.ts +419 -23
  24. package/src/preference-registry.ts +103 -0
  25. package/src/runtime-posture.ts +2 -0
  26. package/src/schema.ts +86 -0
  27. package/src/session-control.ts +48 -1
  28. package/src/session-queue-commands.ts +45 -11
  29. package/src/session-realtime-mirror.ts +117 -1
  30. package/src/session-realtime.ts +49 -1
  31. package/src/workspace-instruction-policies-schema.ts +30 -0
  32. package/src/workspace-instruction-policies.ts +438 -24
  33. package/dist/chunk-3UCHDMKG.js.map +0 -1
  34. package/dist/chunk-L6ADMZHE.js.map +0 -1
@@ -1,5 +1,6 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import {
3
+ PreferenceRegistrySnapshot,
3
4
  ResolvedWorkspaceInstructionPolicySnapshot,
4
5
  WORKSPACE_INSTRUCTION_POLICY_CONTENT_MAX_CHARS,
5
6
  WorkspaceInstructionPolicySnapshot,
@@ -20,13 +21,14 @@ import type {
20
21
  WorkspaceInstructionPolicySnapshotEntry,
21
22
  WorkspaceInstructionPolicyTarget,
22
23
  } from "@opengeni/contracts";
23
- import { and, asc, desc, eq, inArray, isNull, lt, sql, type SQL } from "drizzle-orm";
24
+ import { and, asc, desc, eq, inArray, isNull, lt, or, sql, type SQL } from "drizzle-orm";
24
25
  import type { Database } from "./index";
25
- import { withRlsContext, withWorkspaceRls } from "./index";
26
+ import { withRlsContext, withWorkspaceRls, withWorkspaceSubjectRls } from "./index";
26
27
  import { nestedPostgresSqlState } from "./persistence-errors";
27
28
  import * as schema from "./schema";
28
29
 
29
- type DraftInput = WorkspaceInstructionPolicyTarget & {
30
+ type DraftRequestInput = WorkspaceInstructionPolicyTarget & {
31
+ operationId: string;
30
32
  accountId: string;
31
33
  workspaceId: string;
32
34
  content: string;
@@ -36,10 +38,35 @@ type DraftInput = WorkspaceInstructionPolicyTarget & {
36
38
  createdBySubjectId: string;
37
39
  };
38
40
 
39
- type CallerDraftInput = Omit<DraftInput, "provenanceSource"> & {
41
+ type DraftInput = DraftRequestInput & {
42
+ requestFingerprint: string;
43
+ };
44
+
45
+ type CallerDraftInput = Omit<DraftRequestInput, "operationId" | "provenanceSource"> & {
46
+ operationId?: string;
40
47
  provenanceSource: WorkspaceInstructionPolicyDraftProvenanceSource;
41
48
  };
42
49
 
50
+ type ImportLegacyRequestInput = {
51
+ operationId: string;
52
+ accountId: string;
53
+ workspaceId: string;
54
+ createdBySubjectId: string;
55
+ supersedesRevisionId: string | null;
56
+ };
57
+
58
+ type ActiveRevisionInput = {
59
+ operationId: string;
60
+ accountId: string;
61
+ workspaceId: string;
62
+ targetRevisionId: string;
63
+ expectedCurrentRevisionId: string | null;
64
+ expectedActivationVersion?: number;
65
+ actorSubjectId: string;
66
+ reason: string;
67
+ type: WorkspaceInstructionPolicyActivationType;
68
+ };
69
+
43
70
  export class WorkspaceInstructionPolicyConflictError extends Error {
44
71
  readonly name = "WorkspaceInstructionPolicyConflictError";
45
72
  readonly code = "WORKSPACE_INSTRUCTION_POLICY_CONFLICT";
@@ -49,6 +76,15 @@ export class WorkspaceInstructionPolicyConflictError extends Error {
49
76
  }
50
77
  }
51
78
 
79
+ export class WorkspaceInstructionPolicyOperationReuseError extends Error {
80
+ readonly name = "WorkspaceInstructionPolicyOperationReuseError";
81
+ readonly code = "WORKSPACE_INSTRUCTION_POLICY_OPERATION_REUSED";
82
+
83
+ constructor() {
84
+ super("The workspace instruction-policy operation id was already used for another request");
85
+ }
86
+ }
87
+
52
88
  export class WorkspaceInstructionPolicyNotFoundError extends Error {
53
89
  readonly name = "WorkspaceInstructionPolicyNotFoundError";
54
90
 
@@ -82,14 +118,231 @@ export type WorkspaceInstructionPolicyAttemptClaims = {
82
118
  executionGeneration: number;
83
119
  };
84
120
 
121
+ export type WorkspaceStateAcceptedAttemptGovernance = {
122
+ attemptId: string;
123
+ executionGeneration: number;
124
+ acceptedAt: string;
125
+ policySnapshot: WorkspaceInstructionPolicySnapshot | null;
126
+ preferenceSnapshot: {
127
+ id: string;
128
+ descriptorHash: string;
129
+ descriptors: Array<{
130
+ id: string;
131
+ revisionId: string;
132
+ contentHash: string;
133
+ activeVersion: number;
134
+ scope: "organization" | "workspace" | "user";
135
+ }>;
136
+ truncated: boolean;
137
+ createdAt: string;
138
+ } | null;
139
+ };
140
+
85
141
  function contentHash(content: string): string {
86
142
  return createHash("sha256").update(content, "utf8").digest("hex");
87
143
  }
88
144
 
145
+ type OperationFingerprintField = readonly [
146
+ name: string,
147
+ present: boolean,
148
+ value: string | number | null,
149
+ ];
150
+
151
+ function hasOwnField(value: object, field: PropertyKey): boolean {
152
+ return Object.prototype.hasOwnProperty.call(value, field);
153
+ }
154
+
155
+ function operationRequestFingerprint(
156
+ operation: string,
157
+ fields: readonly OperationFingerprintField[],
158
+ ): string {
159
+ const canonicalRequest = JSON.stringify([
160
+ "workspace_instruction_policy_operation",
161
+ 1,
162
+ operation,
163
+ fields,
164
+ ]);
165
+ return createHash("sha256").update(canonicalRequest, "utf8").digest("hex");
166
+ }
167
+
168
+ function draftRequestFingerprint(input: DraftRequestInput): string {
169
+ return operationRequestFingerprint("create_draft", [
170
+ ["accountId", true, input.accountId],
171
+ ["workspaceId", true, input.workspaceId],
172
+ ["kind", true, input.kind],
173
+ ["scope", true, input.scope],
174
+ ["roleKey", true, input.roleKey],
175
+ ["content", true, input.content],
176
+ ["provenanceSource", true, input.provenanceSource],
177
+ ["provenanceSourceId", true, input.provenanceSourceId],
178
+ ["supersedesRevisionId", true, input.supersedesRevisionId],
179
+ ["createdBySubjectId", true, input.createdBySubjectId],
180
+ ]);
181
+ }
182
+
183
+ function importLegacyRequestFingerprint(input: ImportLegacyRequestInput): string {
184
+ return operationRequestFingerprint("import_legacy", [
185
+ ["accountId", true, input.accountId],
186
+ ["workspaceId", true, input.workspaceId],
187
+ ["createdBySubjectId", true, input.createdBySubjectId],
188
+ ["supersedesRevisionId", true, input.supersedesRevisionId],
189
+ ]);
190
+ }
191
+
192
+ function activeRevisionRequestFingerprint(input: ActiveRevisionInput): string {
193
+ const expectedCurrentRevisionIdPresent = hasOwnField(input, "expectedCurrentRevisionId");
194
+ const expectedActivationVersionPresent = hasOwnField(input, "expectedActivationVersion");
195
+ return operationRequestFingerprint(`change_active_revision:${input.type}`, [
196
+ ["accountId", true, input.accountId],
197
+ ["workspaceId", true, input.workspaceId],
198
+ ["targetRevisionId", true, input.targetRevisionId],
199
+ [
200
+ "expectedCurrentRevisionId",
201
+ expectedCurrentRevisionIdPresent,
202
+ input.expectedCurrentRevisionId ?? null,
203
+ ],
204
+ [
205
+ "expectedActivationVersion",
206
+ expectedActivationVersionPresent,
207
+ input.expectedActivationVersion ?? null,
208
+ ],
209
+ ["actorSubjectId", true, input.actorSubjectId],
210
+ ["reason", true, input.reason],
211
+ ]);
212
+ }
213
+
89
214
  function iso(value: Date | string): string {
90
215
  return (value instanceof Date ? value : new Date(value)).toISOString();
91
216
  }
92
217
 
218
+ /**
219
+ * Inspect one immutable accepted attempt only when the authenticated caller is
220
+ * exactly its frozen initiating human. The null result deliberately conflates
221
+ * absence, cross-account/workspace lookup, and another subject's attempt.
222
+ */
223
+ export async function getWorkspaceStateAcceptedAttemptGovernance(
224
+ db: Database,
225
+ input: { accountId: string; workspaceId: string; subjectId: string; attemptId: string },
226
+ ): Promise<WorkspaceStateAcceptedAttemptGovernance | null> {
227
+ return await withWorkspaceSubjectRls(db, input.workspaceId, input.subjectId, async (scopedDb) => {
228
+ const initiatingSubject = sql<string>`coalesce(
229
+ ${schema.sessionTurns.initiatingHumanSubjectId},
230
+ case when ${schema.sessionTurns.initiatorKind} = 'subject'
231
+ then ${schema.sessionTurns.initiatorSubjectId}
232
+ end
233
+ )`;
234
+ const [attempt] = await scopedDb
235
+ .select({
236
+ attemptId: schema.sessionTurnAttempts.id,
237
+ executionGeneration: schema.sessionTurnAttempts.executionGeneration,
238
+ acceptedAt: schema.sessionTurns.createdAt,
239
+ })
240
+ .from(schema.sessionTurnAttempts)
241
+ .innerJoin(
242
+ schema.sessionTurns,
243
+ and(
244
+ eq(schema.sessionTurns.accountId, schema.sessionTurnAttempts.accountId),
245
+ eq(schema.sessionTurns.workspaceId, schema.sessionTurnAttempts.workspaceId),
246
+ eq(schema.sessionTurns.sessionId, schema.sessionTurnAttempts.sessionId),
247
+ eq(schema.sessionTurns.id, schema.sessionTurnAttempts.turnId),
248
+ ),
249
+ )
250
+ .where(
251
+ and(
252
+ eq(schema.sessionTurnAttempts.accountId, input.accountId),
253
+ eq(schema.sessionTurnAttempts.workspaceId, input.workspaceId),
254
+ eq(schema.sessionTurnAttempts.id, input.attemptId),
255
+ eq(initiatingSubject, input.subjectId),
256
+ ),
257
+ )
258
+ .limit(1);
259
+ if (!attempt) return null;
260
+
261
+ const [policyRows, preferenceRows] = await Promise.all([
262
+ scopedDb
263
+ .select()
264
+ .from(schema.workspaceInstructionPolicySnapshots)
265
+ .where(
266
+ and(
267
+ eq(schema.workspaceInstructionPolicySnapshots.accountId, input.accountId),
268
+ eq(schema.workspaceInstructionPolicySnapshots.workspaceId, input.workspaceId),
269
+ eq(schema.workspaceInstructionPolicySnapshots.attemptId, input.attemptId),
270
+ eq(
271
+ schema.workspaceInstructionPolicySnapshots.executionGeneration,
272
+ attempt.executionGeneration,
273
+ ),
274
+ ),
275
+ )
276
+ .limit(1),
277
+ scopedDb
278
+ .select()
279
+ .from(schema.preferenceRegistrySnapshots)
280
+ .where(
281
+ and(
282
+ eq(schema.preferenceRegistrySnapshots.accountId, input.accountId),
283
+ eq(schema.preferenceRegistrySnapshots.workspaceId, input.workspaceId),
284
+ eq(schema.preferenceRegistrySnapshots.attemptId, input.attemptId),
285
+ eq(schema.preferenceRegistrySnapshots.executionGeneration, attempt.executionGeneration),
286
+ eq(schema.preferenceRegistrySnapshots.initiatingHumanSubjectId, input.subjectId),
287
+ ),
288
+ )
289
+ .limit(1),
290
+ ]);
291
+ const policyRow = policyRows[0] ?? null;
292
+ const preferenceRow = preferenceRows[0] ?? null;
293
+ return {
294
+ attemptId: attempt.attemptId,
295
+ executionGeneration: attempt.executionGeneration,
296
+ acceptedAt: iso(attempt.acceptedAt),
297
+ policySnapshot: policyRow
298
+ ? WorkspaceInstructionPolicySnapshot.parse({
299
+ id: policyRow.id,
300
+ workspaceId: policyRow.workspaceId,
301
+ sessionId: policyRow.sessionId,
302
+ turnId: policyRow.turnId,
303
+ attemptId: policyRow.attemptId,
304
+ executionGeneration: policyRow.executionGeneration,
305
+ policyRole: policyRow.policyRole,
306
+ roleSource: policyRow.roleSource,
307
+ entries: policyRow.entries,
308
+ entryHash: policyRow.entryHash,
309
+ createdAt: iso(policyRow.createdAt),
310
+ })
311
+ : null,
312
+ preferenceSnapshot: preferenceRow
313
+ ? (() => {
314
+ const snapshot = PreferenceRegistrySnapshot.parse({
315
+ id: preferenceRow.id,
316
+ workspaceId: preferenceRow.workspaceId,
317
+ sessionId: preferenceRow.sessionId,
318
+ turnId: preferenceRow.turnId,
319
+ attemptId: preferenceRow.attemptId,
320
+ executionGeneration: preferenceRow.executionGeneration,
321
+ initiatingHumanSubjectId: preferenceRow.initiatingHumanSubjectId,
322
+ descriptorHash: preferenceRow.descriptorHash,
323
+ descriptors: preferenceRow.descriptors,
324
+ truncated: preferenceRow.truncated,
325
+ createdAt: iso(preferenceRow.createdAt),
326
+ });
327
+ return {
328
+ id: snapshot.id,
329
+ descriptorHash: snapshot.descriptorHash,
330
+ descriptors: snapshot.descriptors.map((descriptor) => ({
331
+ id: descriptor.id,
332
+ revisionId: descriptor.revisionId,
333
+ contentHash: descriptor.contentHash,
334
+ activeVersion: descriptor.activeVersion,
335
+ scope: descriptor.scope,
336
+ })),
337
+ truncated: snapshot.truncated,
338
+ createdAt: snapshot.createdAt,
339
+ };
340
+ })()
341
+ : null,
342
+ };
343
+ });
344
+ }
345
+
93
346
  type RevisionRow = typeof schema.workspaceInstructionPolicyRevisions.$inferSelect;
94
347
  type HeadRow = typeof schema.workspaceInstructionPolicyHeads.$inferSelect;
95
348
  type EventRow = typeof schema.workspaceInstructionPolicyActivationEvents.$inferSelect;
@@ -97,6 +350,7 @@ type EventRow = typeof schema.workspaceInstructionPolicyActivationEvents.$inferS
97
350
  function revisionFromRow(row: RevisionRow): WorkspaceInstructionPolicyRevision {
98
351
  return {
99
352
  id: row.id,
353
+ operationId: row.operationId ?? row.id,
100
354
  accountId: row.accountId,
101
355
  workspaceId: row.workspaceId,
102
356
  revision: row.revision,
@@ -132,6 +386,7 @@ function headFromRow(row: HeadRow): WorkspaceInstructionPolicyHead {
132
386
  function eventFromRow(row: EventRow): WorkspaceInstructionPolicyActivationEvent {
133
387
  return {
134
388
  id: row.id,
389
+ operationId: row.operationId ?? row.id,
135
390
  accountId: row.accountId,
136
391
  workspaceId: row.workspaceId,
137
392
  kind: row.kind as WorkspaceInstructionPolicyKind,
@@ -158,6 +413,20 @@ function eventFromRow(row: EventRow): WorkspaceInstructionPolicyActivationEvent
158
413
  };
159
414
  }
160
415
 
416
+ function headFromEventRow(row: EventRow): WorkspaceInstructionPolicyHead {
417
+ return {
418
+ workspaceId: row.workspaceId,
419
+ kind: row.kind as WorkspaceInstructionPolicyKind,
420
+ scope: row.scope as WorkspaceInstructionPolicyScope,
421
+ roleKey: row.roleKey,
422
+ revisionId: row.newRevisionId,
423
+ revision: row.newRevision,
424
+ contentHash: row.newContentHash,
425
+ activationVersion: row.activationVersion,
426
+ activatedAt: iso(row.createdAt),
427
+ };
428
+ }
429
+
161
430
  function headTargetConditions(
162
431
  workspaceId: string,
163
432
  target: WorkspaceInstructionPolicyTarget,
@@ -225,6 +494,54 @@ async function getRevisionInTransaction(
225
494
  return row ?? null;
226
495
  }
227
496
 
497
+ async function getRevisionByOperationInTransaction(
498
+ db: Database,
499
+ workspaceId: string,
500
+ operationId: string,
501
+ ): Promise<RevisionRow | null> {
502
+ const [row] = await db
503
+ .select()
504
+ .from(schema.workspaceInstructionPolicyRevisions)
505
+ .where(
506
+ and(
507
+ eq(schema.workspaceInstructionPolicyRevisions.workspaceId, workspaceId),
508
+ or(
509
+ eq(schema.workspaceInstructionPolicyRevisions.operationId, operationId),
510
+ and(
511
+ isNull(schema.workspaceInstructionPolicyRevisions.operationId),
512
+ eq(schema.workspaceInstructionPolicyRevisions.id, operationId),
513
+ ),
514
+ ),
515
+ ),
516
+ )
517
+ .limit(1);
518
+ return row ?? null;
519
+ }
520
+
521
+ async function getEventByOperationInTransaction(
522
+ db: Database,
523
+ workspaceId: string,
524
+ operationId: string,
525
+ ): Promise<EventRow | null> {
526
+ const [row] = await db
527
+ .select()
528
+ .from(schema.workspaceInstructionPolicyActivationEvents)
529
+ .where(
530
+ and(
531
+ eq(schema.workspaceInstructionPolicyActivationEvents.workspaceId, workspaceId),
532
+ or(
533
+ eq(schema.workspaceInstructionPolicyActivationEvents.operationId, operationId),
534
+ and(
535
+ isNull(schema.workspaceInstructionPolicyActivationEvents.operationId),
536
+ eq(schema.workspaceInstructionPolicyActivationEvents.id, operationId),
537
+ ),
538
+ ),
539
+ ),
540
+ )
541
+ .limit(1);
542
+ return row ?? null;
543
+ }
544
+
228
545
  async function getHeadInTransaction(
229
546
  db: Database,
230
547
  workspaceId: string,
@@ -246,10 +563,41 @@ function sameTarget(
246
563
  return left.kind === right.kind && left.scope === right.scope && left.roleKey === right.roleKey;
247
564
  }
248
565
 
566
+ function draftReceiptMatches(row: RevisionRow, input: DraftInput): boolean {
567
+ return (
568
+ row.createdBySubjectId === input.createdBySubjectId &&
569
+ row.kind === input.kind &&
570
+ row.scope === input.scope &&
571
+ row.roleKey === input.roleKey &&
572
+ row.content === input.content &&
573
+ row.provenanceSource === input.provenanceSource &&
574
+ row.provenanceSourceId === input.provenanceSourceId &&
575
+ row.supersedesRevisionId === input.supersedesRevisionId
576
+ );
577
+ }
578
+
249
579
  async function createDraftInTransaction(
250
580
  db: Database,
251
581
  input: DraftInput,
252
582
  ): Promise<WorkspaceInstructionPolicyRevision> {
583
+ if (await getEventByOperationInTransaction(db, input.workspaceId, input.operationId)) {
584
+ throw new WorkspaceInstructionPolicyOperationReuseError();
585
+ }
586
+ const existing = await getRevisionByOperationInTransaction(
587
+ db,
588
+ input.workspaceId,
589
+ input.operationId,
590
+ );
591
+ if (existing) {
592
+ if (
593
+ existing.requestFingerprint === null
594
+ ? !draftReceiptMatches(existing, input)
595
+ : existing.requestFingerprint !== input.requestFingerprint
596
+ ) {
597
+ throw new WorkspaceInstructionPolicyOperationReuseError();
598
+ }
599
+ return revisionFromRow(existing);
600
+ }
253
601
  if (input.supersedesRevisionId !== null) {
254
602
  const superseded = await getRevisionInTransaction(
255
603
  db,
@@ -265,6 +613,8 @@ async function createDraftInTransaction(
265
613
  const [created] = await db
266
614
  .insert(schema.workspaceInstructionPolicyRevisions)
267
615
  .values({
616
+ operationId: input.operationId,
617
+ requestFingerprint: input.requestFingerprint,
268
618
  accountId: input.accountId,
269
619
  workspaceId: input.workspaceId,
270
620
  kind: input.kind,
@@ -286,12 +636,14 @@ export async function createWorkspaceInstructionPolicyDraft(
286
636
  db: Database,
287
637
  input: CallerDraftInput,
288
638
  ): Promise<WorkspaceInstructionPolicyRevision> {
639
+ const request = { ...input, operationId: input.operationId ?? randomUUID() };
640
+ const normalized = { ...request, requestFingerprint: draftRequestFingerprint(request) };
289
641
  return await withRlsContext(
290
642
  db,
291
643
  { accountId: input.accountId, workspaceId: input.workspaceId },
292
644
  async (scopedDb) => {
293
645
  await lockWorkspace(scopedDb, input);
294
- return await createDraftInTransaction(scopedDb, input);
646
+ return await createDraftInTransaction(scopedDb, normalized);
295
647
  },
296
648
  );
297
649
  }
@@ -299,18 +651,44 @@ export async function createWorkspaceInstructionPolicyDraft(
299
651
  /** Import only the stored legacy override; never materialize a deployment default or activate it. */
300
652
  export async function importLegacyWorkspaceInstructionPolicyDraft(
301
653
  db: Database,
302
- input: {
303
- accountId: string;
304
- workspaceId: string;
305
- createdBySubjectId: string;
306
- supersedesRevisionId: string | null;
307
- },
654
+ input: Omit<ImportLegacyRequestInput, "operationId"> & { operationId?: string },
308
655
  ): Promise<WorkspaceInstructionPolicyRevision> {
656
+ const request = { ...input, operationId: input.operationId ?? randomUUID() };
657
+ const normalized = {
658
+ ...request,
659
+ requestFingerprint: importLegacyRequestFingerprint(request),
660
+ };
309
661
  return await withRlsContext(
310
662
  db,
311
663
  { accountId: input.accountId, workspaceId: input.workspaceId },
312
664
  async (scopedDb) => {
313
665
  const workspace = await lockWorkspace(scopedDb, input);
666
+ if (
667
+ await getEventByOperationInTransaction(scopedDb, input.workspaceId, normalized.operationId)
668
+ ) {
669
+ throw new WorkspaceInstructionPolicyOperationReuseError();
670
+ }
671
+ const existing = await getRevisionByOperationInTransaction(
672
+ scopedDb,
673
+ input.workspaceId,
674
+ normalized.operationId,
675
+ );
676
+ if (existing) {
677
+ if (
678
+ existing.requestFingerprint === null
679
+ ? existing.createdBySubjectId !== input.createdBySubjectId ||
680
+ existing.kind !== "charter" ||
681
+ existing.scope !== "global" ||
682
+ existing.roleKey !== null ||
683
+ existing.provenanceSource !== "legacy_import" ||
684
+ existing.provenanceSourceId !== "workspaces.agent_instructions" ||
685
+ existing.supersedesRevisionId !== input.supersedesRevisionId
686
+ : existing.requestFingerprint !== normalized.requestFingerprint
687
+ ) {
688
+ throw new WorkspaceInstructionPolicyOperationReuseError();
689
+ }
690
+ return revisionFromRow(existing);
691
+ }
314
692
  if (workspace.agentInstructions === null) {
315
693
  throw new WorkspaceInstructionPolicyLegacyUnavailableError();
316
694
  }
@@ -323,7 +701,7 @@ export async function importLegacyWorkspaceInstructionPolicyDraft(
323
701
  );
324
702
  }
325
703
  return await createDraftInTransaction(scopedDb, {
326
- ...input,
704
+ ...normalized,
327
705
  kind: "charter",
328
706
  scope: "global",
329
707
  roleKey: null,
@@ -493,16 +871,9 @@ export async function diffWorkspaceInstructionPolicyRevisions(
493
871
 
494
872
  async function changeActiveRevision(
495
873
  db: Database,
496
- input: {
497
- accountId: string;
498
- workspaceId: string;
499
- targetRevisionId: string;
500
- expectedCurrentRevisionId: string | null;
501
- actorSubjectId: string;
502
- reason: string;
503
- type: WorkspaceInstructionPolicyActivationType;
504
- },
874
+ input: ActiveRevisionInput,
505
875
  ): Promise<WorkspaceInstructionPolicyActivationResponse> {
876
+ const requestFingerprint = activeRevisionRequestFingerprint(input);
506
877
  return await withRlsContext(
507
878
  db,
508
879
  { accountId: input.accountId, workspaceId: input.workspaceId },
@@ -510,6 +881,34 @@ async function changeActiveRevision(
510
881
  // This row lock serializes both an absent-head first activation and later
511
882
  // updates, so every loser can return the authoritative typed conflict.
512
883
  await lockWorkspace(scopedDb, input);
884
+ if (
885
+ await getRevisionByOperationInTransaction(scopedDb, input.workspaceId, input.operationId)
886
+ ) {
887
+ throw new WorkspaceInstructionPolicyOperationReuseError();
888
+ }
889
+ const existingEvent = await getEventByOperationInTransaction(
890
+ scopedDb,
891
+ input.workspaceId,
892
+ input.operationId,
893
+ );
894
+ if (existingEvent) {
895
+ const legacyRequestMatches =
896
+ hasOwnField(input, "expectedCurrentRevisionId") &&
897
+ !hasOwnField(input, "expectedActivationVersion") &&
898
+ existingEvent.type === input.type &&
899
+ existingEvent.newRevisionId === input.targetRevisionId &&
900
+ existingEvent.oldRevisionId === input.expectedCurrentRevisionId &&
901
+ existingEvent.actorSubjectId === input.actorSubjectId &&
902
+ existingEvent.reason === input.reason;
903
+ if (
904
+ existingEvent.requestFingerprint === null
905
+ ? !legacyRequestMatches
906
+ : existingEvent.requestFingerprint !== requestFingerprint
907
+ ) {
908
+ throw new WorkspaceInstructionPolicyOperationReuseError();
909
+ }
910
+ return { head: headFromEventRow(existingEvent), event: eventFromRow(existingEvent) };
911
+ }
513
912
  const targetRow = await getRevisionInTransaction(
514
913
  scopedDb,
515
914
  input.workspaceId,
@@ -524,7 +923,11 @@ async function changeActiveRevision(
524
923
  };
525
924
  const currentRow = await getHeadInTransaction(scopedDb, input.workspaceId, targetIdentity);
526
925
  const currentHead = currentRow ? headFromRow(currentRow) : null;
527
- if ((currentHead?.revisionId ?? null) !== input.expectedCurrentRevisionId) {
926
+ if (
927
+ (currentHead?.revisionId ?? null) !== input.expectedCurrentRevisionId ||
928
+ (input.expectedActivationVersion !== undefined &&
929
+ (currentHead?.activationVersion ?? 0) !== input.expectedActivationVersion)
930
+ ) {
528
931
  throw new WorkspaceInstructionPolicyConflictError(currentHead);
529
932
  }
530
933
  if (currentHead?.revisionId === target.id) {
@@ -559,6 +962,8 @@ async function changeActiveRevision(
559
962
  const [eventRow] = await scopedDb
560
963
  .insert(schema.workspaceInstructionPolicyActivationEvents)
561
964
  .values({
965
+ operationId: input.operationId,
966
+ requestFingerprint,
562
967
  accountId: input.accountId,
563
968
  workspaceId: input.workspaceId,
564
969
  ...targetIdentity,
@@ -613,16 +1018,19 @@ async function changeActiveRevision(
613
1018
  export async function activateWorkspaceInstructionPolicyRevision(
614
1019
  db: Database,
615
1020
  input: {
1021
+ operationId?: string;
616
1022
  accountId: string;
617
1023
  workspaceId: string;
618
1024
  revisionId: string;
619
1025
  expectedCurrentRevisionId: string | null;
1026
+ expectedActivationVersion?: number;
620
1027
  actorSubjectId: string;
621
1028
  reason: string;
622
1029
  },
623
1030
  ): Promise<WorkspaceInstructionPolicyActivationResponse> {
624
1031
  return await changeActiveRevision(db, {
625
1032
  ...input,
1033
+ operationId: input.operationId ?? randomUUID(),
626
1034
  targetRevisionId: input.revisionId,
627
1035
  type: "activate",
628
1036
  });
@@ -631,15 +1039,21 @@ export async function activateWorkspaceInstructionPolicyRevision(
631
1039
  export async function rollbackWorkspaceInstructionPolicyRevision(
632
1040
  db: Database,
633
1041
  input: {
1042
+ operationId?: string;
634
1043
  accountId: string;
635
1044
  workspaceId: string;
636
1045
  targetRevisionId: string;
637
1046
  expectedCurrentRevisionId: string;
1047
+ expectedActivationVersion?: number;
638
1048
  actorSubjectId: string;
639
1049
  reason: string;
640
1050
  },
641
1051
  ): Promise<WorkspaceInstructionPolicyActivationResponse> {
642
- return await changeActiveRevision(db, { ...input, type: "rollback" });
1052
+ return await changeActiveRevision(db, {
1053
+ ...input,
1054
+ operationId: input.operationId ?? randomUUID(),
1055
+ type: "rollback",
1056
+ });
643
1057
  }
644
1058
 
645
1059
  /**