@opengeni/db 0.9.3 → 0.10.7

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 (33) hide show
  1. package/dist/{chunk-4LG5NBTC.js → chunk-P6PKXY5W.js} +93 -1
  2. package/dist/chunk-P6PKXY5W.js.map +1 -0
  3. package/dist/index.d.ts +3 -2
  4. package/dist/index.js +1332 -178
  5. package/dist/index.js.map +1 -1
  6. package/dist/provision-roles.d.ts +406 -32
  7. package/dist/{schema-CdPGTHlD.d.ts → schema-CqkzrBRS.d.ts} +513 -2
  8. package/dist/schema.d.ts +1 -1
  9. package/dist/schema.js +3 -1
  10. package/drizzle/0053_codex_credential_leases.sql +2 -2
  11. package/drizzle/0057_durable_queue_control.sql +1 -1
  12. package/drizzle/0061_session_workflow_wake_outbox.sql +1 -1
  13. package/drizzle/0062_session_list_snapshot_reaper.sql +1 -1
  14. package/drizzle/0063_session_control_mega_foundation.sql +1 -1
  15. package/drizzle/0064_rotation_strategy_sharded_backfill.sql +1 -1
  16. package/drizzle/0065_codex_subscription_overview.sql +168 -0
  17. package/drizzle/0065_session_tool_policy.sql +38 -0
  18. package/drizzle/0067_session_event_payload_bounds.sql +2 -2
  19. package/drizzle/0068_workspace_control_event_bounds.sql +2 -2
  20. package/drizzle/0069_session_event_history_backfill.sql +2 -2
  21. package/drizzle/0074_session_activity_revisions.sql +2 -2
  22. package/drizzle/0106_session_attempt_mcp_approval_policies.sql +29 -0
  23. package/drizzle/0107_host_export_lineage_contract.sql +381 -0
  24. package/drizzle/0108_fence_invalidated_warming_epochs.sql +76 -0
  25. package/package.json +5 -4
  26. package/src/codex-token-resolver.ts +175 -14
  27. package/src/connection-token-resolver.ts +143 -120
  28. package/src/event-payload-sanitizer.ts +32 -2
  29. package/src/index.ts +1888 -205
  30. package/src/schema.ts +107 -1
  31. package/src/session-control.ts +2 -0
  32. package/src/session-queue-commands.ts +94 -21
  33. package/dist/chunk-4LG5NBTC.js.map +0 -1
package/src/index.ts CHANGED
@@ -57,8 +57,10 @@ import type {
57
57
  SessionGoalStatus,
58
58
  SessionHumanInputRequest,
59
59
  LineageNode,
60
+ SessionMcpApprovalPolicy,
60
61
  SessionMcpServerMetadata,
61
62
  SessionStatus,
63
+ SessionToolPolicy,
62
64
  SessionTurn,
63
65
  SessionQueueSnapshot,
64
66
  SessionSystemUpdate,
@@ -101,6 +103,9 @@ import {
101
103
  SESSION_EVENT_ENVELOPE_MAX_BYTES,
102
104
  SESSION_EVENT_TYPE_MAX_BYTES,
103
105
  resolveSessionEventTypeFilters,
106
+ capabilityCatalogItemIsTrustedForExposure,
107
+ metadataWithTurnExecutionPolicyV1,
108
+ readTurnExecutionPolicyV1,
104
109
  reasoningEffortForMetadata,
105
110
  resolveWorkspaceMemoryEnabled,
106
111
  RigChange as RigChangeContract,
@@ -113,6 +118,7 @@ import {
113
118
  OPENGENI_HOST_EXPORT_SCHEMA_REVISION,
114
119
  HumanInputQuestion as HumanInputQuestionContract,
115
120
  SubmitHumanInputResponseRequest,
121
+ TurnExecutionPolicyV1,
116
122
  } from "@opengeni/contracts";
117
123
  import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
118
124
  import { boundModelToolOutputItem, isCodexBilledModel } from "@opengeni/codex";
@@ -2589,6 +2595,11 @@ export async function countScheduledTasksForWorkspace(
2589
2595
  export type AppendEventInput = {
2590
2596
  type: SessionEventType;
2591
2597
  payload?: unknown;
2598
+ /**
2599
+ * Server-created retained-output evidence for canonical audit truncation.
2600
+ * Producer payload fields are never trusted as evidence.
2601
+ */
2602
+ retainedOutputEvidence?: unknown;
2592
2603
  clientEventId?: string;
2593
2604
  turnId?: string | null;
2594
2605
  turnGeneration?: number | null;
@@ -2909,7 +2920,7 @@ export type CreateSessionMcpServerInput = {
2909
2920
  allowedTools?: string[] | null;
2910
2921
  timeoutMs?: number | null;
2911
2922
  cacheToolsList?: boolean | null;
2912
- requireApproval?: boolean | string[] | null;
2923
+ requireApproval?: SessionMcpApprovalPolicy | null;
2913
2924
  connectionRef?: McpServerConnectionRef | null;
2914
2925
  headersEncrypted?: Record<string, string>;
2915
2926
  };
@@ -2924,11 +2935,16 @@ export type UpdateSessionMcpServerCredentialsResult = {
2924
2935
  missingIds: string[];
2925
2936
  };
2926
2937
 
2938
+ export type UpdateSessionMcpApprovalPolicyResult = {
2939
+ server: SessionMcpServerMetadata | null;
2940
+ changed: boolean;
2941
+ };
2942
+
2927
2943
  export type SessionMcpServerForRun = SessionMcpServerMetadata & {
2928
2944
  allowedTools?: string[];
2929
2945
  timeoutMs?: number;
2930
2946
  cacheToolsList?: boolean;
2931
- requireApproval?: boolean | string[];
2947
+ requireApproval: SessionMcpApprovalPolicy;
2932
2948
  headers: Record<string, string>;
2933
2949
  };
2934
2950
 
@@ -2943,6 +2959,7 @@ export type EnqueueSessionTurnInput = {
2943
2959
  turnInstructions?: string | null;
2944
2960
  resources: ResourceRef[];
2945
2961
  tools: ToolRef[];
2962
+ toolsProvided?: boolean;
2946
2963
  model: string;
2947
2964
  reasoningEffort: ReasoningEffort;
2948
2965
  sandboxBackend: SandboxBackend;
@@ -3055,6 +3072,49 @@ export async function requireFile(
3055
3072
  return file;
3056
3073
  }
3057
3074
 
3075
+ export type RetainedFileArtifact = {
3076
+ file: FileAsset;
3077
+ uploadStatus: FileUploadStatus | null;
3078
+ uploadExpiresAt: Date | null;
3079
+ };
3080
+
3081
+ /**
3082
+ * RLS-scoped file lookup with just enough upload lifecycle truth to distinguish
3083
+ * pending, failed, and expired artifact references. The bytes and provider
3084
+ * location remain in the existing FileAsset/object-storage seam.
3085
+ */
3086
+ export async function getRetainedFileArtifact(
3087
+ db: Database,
3088
+ workspaceId: string,
3089
+ fileId: string,
3090
+ ): Promise<RetainedFileArtifact | null> {
3091
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
3092
+ const [row] = await scopedDb
3093
+ .select({
3094
+ file: schema.files,
3095
+ uploadStatus: schema.fileUploads.status,
3096
+ uploadExpiresAt: schema.fileUploads.expiresAt,
3097
+ })
3098
+ .from(schema.files)
3099
+ .leftJoin(
3100
+ schema.fileUploads,
3101
+ and(
3102
+ eq(schema.fileUploads.workspaceId, schema.files.workspaceId),
3103
+ eq(schema.fileUploads.fileId, schema.files.id),
3104
+ ),
3105
+ )
3106
+ .where(and(eq(schema.files.workspaceId, workspaceId), eq(schema.files.id, fileId)))
3107
+ .orderBy(desc(schema.fileUploads.createdAt))
3108
+ .limit(1);
3109
+ if (!row) return null;
3110
+ return {
3111
+ file: mapFile(row.file),
3112
+ uploadStatus: row.uploadStatus as FileUploadStatus | null,
3113
+ uploadExpiresAt: row.uploadExpiresAt,
3114
+ };
3115
+ });
3116
+ }
3117
+
3058
3118
  export async function getFileUpload(
3059
3119
  db: Database,
3060
3120
  workspaceId: string,
@@ -3895,7 +3955,17 @@ export async function listCapabilityCatalogItems(
3895
3955
  ),
3896
3956
  )
3897
3957
  .orderBy(asc(schema.capabilityCatalogItems.kind), asc(schema.capabilityCatalogItems.name));
3898
- return rows.map(mapCapabilityCatalogItem);
3958
+ const installations = await scopedDb
3959
+ .select()
3960
+ .from(schema.capabilityInstallations)
3961
+ .where(eq(schema.capabilityInstallations.workspaceId, workspaceId));
3962
+ const installationByCapabilityId = new Map(
3963
+ installations.map((installation) => [installation.capabilityId, installation]),
3964
+ );
3965
+ return rows.flatMap((row) => {
3966
+ const exposure = catalogExposureState(row, installationByCapabilityId.get(row.id) ?? null);
3967
+ return exposure === "blocked" ? [] : [mapCapabilityCatalogItem(row, exposure)];
3968
+ });
3899
3969
  });
3900
3970
  }
3901
3971
 
@@ -3919,7 +3989,21 @@ export async function getCapabilityCatalogItem(
3919
3989
  )
3920
3990
  .orderBy(asc(sql`(${schema.capabilityCatalogItems.workspaceId} is null)`))
3921
3991
  .limit(1);
3922
- return row ? mapCapabilityCatalogItem(row) : null;
3992
+ if (!row) {
3993
+ return null;
3994
+ }
3995
+ const [installation] = await scopedDb
3996
+ .select()
3997
+ .from(schema.capabilityInstallations)
3998
+ .where(
3999
+ and(
4000
+ eq(schema.capabilityInstallations.workspaceId, workspaceId),
4001
+ eq(schema.capabilityInstallations.capabilityId, capabilityId),
4002
+ ),
4003
+ )
4004
+ .limit(1);
4005
+ const exposure = catalogExposureState(row, installation ?? null);
4006
+ return exposure === "blocked" ? null : mapCapabilityCatalogItem(row, exposure);
3923
4007
  });
3924
4008
  }
3925
4009
 
@@ -4098,7 +4182,11 @@ export async function listEnabledMcpCapabilityServers(
4098
4182
  }
4099
4183
 
4100
4184
  return [...preferredByInstallation.values()].flatMap(({ item, installation }) => {
4101
- if (!item.endpointUrl || !mcpConnectivityOk(installation.metadata)) {
4185
+ if (
4186
+ catalogExposureState(item, installation) === "blocked" ||
4187
+ !item.endpointUrl ||
4188
+ !mcpConnectivityOk(installation.metadata)
4189
+ ) {
4102
4190
  return [];
4103
4191
  }
4104
4192
  const headersEncrypted = encryptedHeadersConfig(installation.config.headersEncrypted);
@@ -8083,6 +8171,10 @@ export type CodexCredentialForRun = {
8083
8171
  * accessor — auto-activates a brand-new first account and ensures the
8084
8172
  * rotation-settings row exists.
8085
8173
  */
8174
+ export type UpsertCodexSubscriptionCredentialResult =
8175
+ | { kind: "upserted"; id: string; isNew: boolean }
8176
+ | { kind: "unresolved_redemption"; id: string; isNew: false };
8177
+
8086
8178
  export async function upsertCodexSubscriptionCredential(
8087
8179
  db: Database,
8088
8180
  input: {
@@ -8097,12 +8189,54 @@ export async function upsertCodexSubscriptionCredential(
8097
8189
  lastRefreshAt: Date | null;
8098
8190
  accountEmail?: string | null;
8099
8191
  label?: string | null;
8192
+ /** Direct managed-cookie human who most recently connected this row. */
8193
+ connectedBySubjectId?: string | null;
8100
8194
  },
8101
- ): Promise<{ id: string; isNew: boolean }> {
8195
+ ): Promise<UpsertCodexSubscriptionCredentialResult> {
8102
8196
  return await withRlsContext(
8103
8197
  db,
8104
8198
  { accountId: input.accountId, workspaceId: input.workspaceId },
8105
8199
  async (scopedDb) => {
8200
+ // Serialize both the initial partial-index insert and ownership-changing
8201
+ // reconnects for this exact provider account. The row lock is shared with
8202
+ // the final redemption-send fence: either reconnect wins before any send,
8203
+ // or it observes durable provider_started truth and cannot replace its
8204
+ // owning human while the upstream outcome is unresolved.
8205
+ await scopedDb.execute(
8206
+ sql`select pg_advisory_xact_lock(hashtextextended(${`codex-credential-upsert:${input.workspaceId}:${input.chatgptAccountId ?? "null"}`}, 0))`,
8207
+ );
8208
+ const [existing] = input.chatgptAccountId
8209
+ ? await scopedDb
8210
+ .select({
8211
+ id: schema.codexSubscriptionCredentials.id,
8212
+ connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
8213
+ })
8214
+ .from(schema.codexSubscriptionCredentials)
8215
+ .where(
8216
+ and(
8217
+ eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
8218
+ eq(schema.codexSubscriptionCredentials.chatgptAccountId, input.chatgptAccountId),
8219
+ ),
8220
+ )
8221
+ .for("update")
8222
+ .limit(1)
8223
+ : [];
8224
+ if (existing && existing.connectedBySubjectId !== (input.connectedBySubjectId ?? null)) {
8225
+ const [unresolved] = await scopedDb
8226
+ .select({ id: schema.codexResetRedemptionAttempts.id })
8227
+ .from(schema.codexResetRedemptionAttempts)
8228
+ .where(
8229
+ and(
8230
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
8231
+ eq(schema.codexResetRedemptionAttempts.credentialId, existing.id),
8232
+ eq(schema.codexResetRedemptionAttempts.status, "provider_started"),
8233
+ ),
8234
+ )
8235
+ .limit(1);
8236
+ if (unresolved) {
8237
+ return { kind: "unresolved_redemption", id: existing.id, isNew: false };
8238
+ }
8239
+ }
8106
8240
  const now = new Date();
8107
8241
  const [row] = await scopedDb
8108
8242
  .insert(schema.codexSubscriptionCredentials)
@@ -8118,6 +8252,7 @@ export async function upsertCodexSubscriptionCredential(
8118
8252
  lastRefreshAt: input.lastRefreshAt,
8119
8253
  accountEmail: input.accountEmail ?? null,
8120
8254
  label: input.label ?? null,
8255
+ connectedBySubjectId: input.connectedBySubjectId ?? null,
8121
8256
  status: "active",
8122
8257
  lastError: null,
8123
8258
  })
@@ -8146,6 +8281,11 @@ export async function upsertCodexSubscriptionCredential(
8146
8281
  // it when still null) so a re-connect never clobbers a rename.
8147
8282
  accountEmail: input.accountEmail ?? null,
8148
8283
  label: sql`coalesce(${schema.codexSubscriptionCredentials.label}, ${input.label ?? null})`,
8284
+ // Ownership follows the most recent connection exactly. A
8285
+ // configured/delegated/API-key reconnect is intentionally
8286
+ // nonhuman and clears the prior human owner, making the row
8287
+ // view-only until a direct managed-cookie human reconnects it.
8288
+ connectedBySubjectId: input.connectedBySubjectId ?? null,
8149
8289
  status: "active",
8150
8290
  lastError: null,
8151
8291
  version: sql`${schema.codexSubscriptionCredentials.version} + 1`,
@@ -8166,7 +8306,7 @@ export async function upsertCodexSubscriptionCredential(
8166
8306
  // keeps the original (older) value, so the two diverge. This distinguishes
8167
8307
  // insert from update without a second read.
8168
8308
  const isNew = row.createdAt.getTime() === row.updatedAt.getTime();
8169
- return { id: row.id, isNew };
8309
+ return { kind: "upserted", id: row.id, isNew };
8170
8310
  },
8171
8311
  );
8172
8312
  }
@@ -8637,6 +8777,12 @@ export type CodexAccountStatus = {
8637
8777
  status: string; // active | needs_relogin | error
8638
8778
  /** New automatic allocations only; health/refresh and existing turns remain independent. */
8639
8779
  allocatorEnabled: boolean;
8780
+ allocatorVersion: number;
8781
+ allocatorUpdatedBySubjectId: string | null;
8782
+ allocatorUpdatedAt: Date | null;
8783
+ resetCreditAvailableCount: number | null;
8784
+ resetCreditsCheckedAt: Date | null;
8785
+ connectedBySubjectId: string | null;
8640
8786
  isActive: boolean;
8641
8787
  expiresAt: Date | null;
8642
8788
  lastRefreshAt: Date | null;
@@ -8664,7 +8810,15 @@ export type CodexAccountStatus = {
8664
8810
  * the selection cursor gives deterministic fairness after those holders drain.
8665
8811
  * None of these fields contains credential material.
8666
8812
  */
8667
- export type CodexLeaseAccountStatus = CodexAccountStatus & {
8813
+ export type CodexLeaseAccountStatus = Omit<
8814
+ CodexAccountStatus,
8815
+ | "allocatorVersion"
8816
+ | "allocatorUpdatedBySubjectId"
8817
+ | "allocatorUpdatedAt"
8818
+ | "resetCreditAvailableCount"
8819
+ | "resetCreditsCheckedAt"
8820
+ | "connectedBySubjectId"
8821
+ > & {
8668
8822
  activeLeaseCount: number;
8669
8823
  selectionCount: number;
8670
8824
  lastSelectedAt: Date | null;
@@ -10467,6 +10621,13 @@ export async function listCodexAccountStatuses(
10467
10621
  planType: schema.codexSubscriptionCredentials.planType,
10468
10622
  status: schema.codexSubscriptionCredentials.status,
10469
10623
  allocatorEnabled: schema.codexSubscriptionCredentials.allocatorEnabled,
10624
+ allocatorVersion: schema.codexSubscriptionCredentials.allocatorVersion,
10625
+ allocatorUpdatedBySubjectId:
10626
+ schema.codexSubscriptionCredentials.allocatorUpdatedBySubjectId,
10627
+ allocatorUpdatedAt: schema.codexSubscriptionCredentials.allocatorUpdatedAt,
10628
+ resetCreditAvailableCount: schema.codexSubscriptionCredentials.resetCreditAvailableCount,
10629
+ resetCreditsCheckedAt: schema.codexSubscriptionCredentials.resetCreditsCheckedAt,
10630
+ connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
10470
10631
  expiresAt: schema.codexSubscriptionCredentials.expiresAt,
10471
10632
  lastRefreshAt: schema.codexSubscriptionCredentials.lastRefreshAt,
10472
10633
  lastError: schema.codexSubscriptionCredentials.lastError,
@@ -10491,6 +10652,8 @@ export async function listCodexAccountStatuses(
10491
10652
  ...row,
10492
10653
  expiresAt: codexMetadataDate(row.expiresAt),
10493
10654
  lastRefreshAt: codexMetadataDate(row.lastRefreshAt),
10655
+ allocatorUpdatedAt: codexMetadataDate(row.allocatorUpdatedAt),
10656
+ resetCreditsCheckedAt: codexMetadataDate(row.resetCreditsCheckedAt),
10494
10657
  primaryResetAt: codexMetadataDate(row.primaryResetAt),
10495
10658
  secondaryResetAt: codexMetadataDate(row.secondaryResetAt),
10496
10659
  usageCheckedAt: codexMetadataDate(row.usageCheckedAt),
@@ -10501,165 +10664,1051 @@ export async function listCodexAccountStatuses(
10501
10664
  });
10502
10665
  }
10503
10666
 
10504
- /** The P2 usage-cache snapshot written by the refreshing usage wrapper. */
10505
- export type CodexAccountUsageSnapshot = {
10506
- primaryUsedPercent: number | null;
10507
- primaryResetAt: Date | null;
10508
- secondaryUsedPercent: number | null;
10509
- secondaryResetAt: Date | null;
10510
- checkedAt: Date;
10511
- };
10667
+ export type CodexAllocatorUpdateResult =
10668
+ | {
10669
+ kind: "updated" | "unchanged";
10670
+ allocatorEnabled: boolean;
10671
+ allocatorVersion: number;
10672
+ allocatorUpdatedBySubjectId: string | null;
10673
+ allocatorUpdatedAt: Date | null;
10674
+ }
10675
+ | {
10676
+ kind: "conflict";
10677
+ allocatorEnabled: boolean;
10678
+ allocatorVersion: number;
10679
+ allocatorUpdatedBySubjectId: string | null;
10680
+ allocatorUpdatedAt: Date | null;
10681
+ }
10682
+ | { kind: "not_found" };
10512
10683
 
10513
10684
  /**
10514
- * Cache-write for P2 quota bars: persist the five plaintext usage columns on a
10515
- * SPECIFIC credential row. NEVER touches credential_encrypted. RLS-scoped, guarded
10516
- * by (id, workspace_id) so it can only write a row the workspace owns. Returns true
10517
- * iff a row was updated (false ⇒ the credential was disconnected under us — the
10518
- * snapshot is moot, drop it). This is the only writer of the usage_checked_at TTL
10519
- * clock that `listCodexAccountStatuses` reads back.
10685
+ * Toggle only NEW allocator eligibility under credential-row OCC.
10686
+ *
10687
+ * Same-state writes are idempotent even when `expectedVersion` is stale. A real
10688
+ * state transition requires the exact current version, increments only the
10689
+ * allocator version, and writes one audit row in the same transaction. It never
10690
+ * touches credential `version`, health, encrypted material, cooldown, or quota.
10520
10691
  */
10521
- export async function recordCodexAccountUsage(
10522
- db: Database,
10523
- workspaceId: string,
10524
- credentialId: string,
10525
- snapshot: CodexAccountUsageSnapshot,
10526
- ): Promise<boolean> {
10527
- return (await recordCodexAccountUsageWithWakeTargets(db, workspaceId, credentialId, snapshot))
10528
- .result;
10529
- }
10530
-
10531
- /** Usage-cache mutation plus its committed durable capacity-wake outbox. */
10532
- export async function recordCodexAccountUsageWithWakeTargets(
10692
+ export async function updateCodexAllocatorEligibility(
10533
10693
  db: Database,
10534
- workspaceId: string,
10535
- credentialId: string,
10536
- snapshot: CodexAccountUsageSnapshot,
10537
- ): Promise<CodexCapacityMutationResult<boolean>> {
10538
- return await withCodexCapacityMutation(
10694
+ input: {
10695
+ accountId: string;
10696
+ workspaceId: string;
10697
+ credentialId: string;
10698
+ subjectId: string;
10699
+ enabled: boolean;
10700
+ expectedVersion: number;
10701
+ },
10702
+ ): Promise<CodexCapacityMutationResult<CodexAllocatorUpdateResult>> {
10703
+ return await withCodexCapacityMutation<CodexAllocatorUpdateResult>(
10539
10704
  db,
10540
- { workspaceId, reason: "codex_usage_refreshed" },
10705
+ { workspaceId: input.workspaceId, reason: "codex_allocator_eligibility_changed" },
10541
10706
  async (tx) => {
10542
- const updated = await tx
10707
+ const [row] = await tx
10708
+ .select({
10709
+ allocatorEnabled: schema.codexSubscriptionCredentials.allocatorEnabled,
10710
+ allocatorVersion: schema.codexSubscriptionCredentials.allocatorVersion,
10711
+ allocatorUpdatedBySubjectId:
10712
+ schema.codexSubscriptionCredentials.allocatorUpdatedBySubjectId,
10713
+ allocatorUpdatedAt: schema.codexSubscriptionCredentials.allocatorUpdatedAt,
10714
+ })
10715
+ .from(schema.codexSubscriptionCredentials)
10716
+ .where(
10717
+ and(
10718
+ eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
10719
+ eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
10720
+ eq(schema.codexSubscriptionCredentials.id, input.credentialId),
10721
+ ),
10722
+ )
10723
+ .for("update")
10724
+ .limit(1);
10725
+ if (!row) return { result: { kind: "not_found" } as const, changed: false };
10726
+ const current = {
10727
+ allocatorEnabled: row.allocatorEnabled,
10728
+ allocatorVersion: row.allocatorVersion,
10729
+ allocatorUpdatedBySubjectId: row.allocatorUpdatedBySubjectId,
10730
+ allocatorUpdatedAt: codexMetadataDate(row.allocatorUpdatedAt),
10731
+ };
10732
+ if (row.allocatorEnabled === input.enabled) {
10733
+ return { result: { kind: "unchanged", ...current } as const, changed: false };
10734
+ }
10735
+ if (row.allocatorVersion !== input.expectedVersion) {
10736
+ return { result: { kind: "conflict", ...current } as const, changed: false };
10737
+ }
10738
+
10739
+ const changedAt = new Date();
10740
+ const [updated] = await tx
10543
10741
  .update(schema.codexSubscriptionCredentials)
10544
10742
  .set({
10545
- primaryUsedPercent: snapshot.primaryUsedPercent,
10546
- primaryResetAt: snapshot.primaryResetAt,
10547
- secondaryUsedPercent: snapshot.secondaryUsedPercent,
10548
- secondaryResetAt: snapshot.secondaryResetAt,
10549
- usageCheckedAt: snapshot.checkedAt,
10550
- // NB: no `version` bump and no `updatedAt` touch — usage is non-credential
10551
- // metadata and must NOT race the (id, version) refresh CAS in
10552
- // recordCodexTokenRefresh / setCodexCredentialStatus.
10743
+ allocatorEnabled: input.enabled,
10744
+ allocatorVersion: sql`${schema.codexSubscriptionCredentials.allocatorVersion} + 1`,
10745
+ allocatorUpdatedBySubjectId: input.subjectId,
10746
+ allocatorUpdatedAt: changedAt,
10747
+ // Deliberately no credential version/updatedAt write.
10553
10748
  })
10554
10749
  .where(
10555
10750
  and(
10556
- eq(schema.codexSubscriptionCredentials.id, credentialId),
10557
- eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId),
10751
+ eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
10752
+ eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
10753
+ eq(schema.codexSubscriptionCredentials.id, input.credentialId),
10754
+ eq(schema.codexSubscriptionCredentials.allocatorVersion, input.expectedVersion),
10558
10755
  ),
10559
10756
  )
10560
- .returning({ id: schema.codexSubscriptionCredentials.id });
10561
- const changed = updated.length > 0;
10562
- return { result: changed, changed };
10757
+ .returning({
10758
+ allocatorEnabled: schema.codexSubscriptionCredentials.allocatorEnabled,
10759
+ allocatorVersion: schema.codexSubscriptionCredentials.allocatorVersion,
10760
+ allocatorUpdatedBySubjectId:
10761
+ schema.codexSubscriptionCredentials.allocatorUpdatedBySubjectId,
10762
+ allocatorUpdatedAt: schema.codexSubscriptionCredentials.allocatorUpdatedAt,
10763
+ });
10764
+ if (!updated) {
10765
+ throw new Error("Codex allocator row changed while locked");
10766
+ }
10767
+ await tx.insert(schema.auditEvents).values({
10768
+ accountId: input.accountId,
10769
+ workspaceId: input.workspaceId,
10770
+ subjectId: input.subjectId,
10771
+ action: "codex.allocator.updated",
10772
+ targetType: "codex_subscription_credential",
10773
+ targetId: input.credentialId,
10774
+ metadata: {
10775
+ allocatorEnabled: updated.allocatorEnabled,
10776
+ allocatorVersion: updated.allocatorVersion,
10777
+ },
10778
+ });
10779
+ return {
10780
+ result: {
10781
+ kind: "updated",
10782
+ allocatorEnabled: updated.allocatorEnabled,
10783
+ allocatorVersion: updated.allocatorVersion,
10784
+ allocatorUpdatedBySubjectId: updated.allocatorUpdatedBySubjectId,
10785
+ allocatorUpdatedAt: codexMetadataDate(updated.allocatorUpdatedAt),
10786
+ } as const,
10787
+ changed: true,
10788
+ };
10563
10789
  },
10564
10790
  );
10565
10791
  }
10566
10792
 
10567
- export type CodexRotationSettings = {
10568
- activeCredentialId: string | null;
10569
- /** Legacy selector bit; old binaries only understand this field. */
10570
- rotationEnabled: boolean;
10571
- /** New allocator cutover bit; ignored safely by old binaries. */
10572
- leaseRotationEnabled: boolean;
10573
- rotationStrategy: string; // P1: 'most_remaining' (unused)
10574
- };
10793
+ export const CODEX_RESET_REDEMPTION_OUTCOMES = [
10794
+ "reset",
10795
+ "nothingToReset",
10796
+ "noCredit",
10797
+ "alreadyRedeemed",
10798
+ ] as const;
10799
+ export type CodexResetRedemptionOutcome = (typeof CODEX_RESET_REDEMPTION_OUTCOMES)[number];
10800
+ export type CodexResetRedemptionStatus = "processing" | "provider_started" | "completed";
10575
10801
 
10576
- /**
10577
- * Per-workspace model/provider availability policy. NULL fields = unrestricted
10578
- * (identical to no row — the default for every workspace). Non-null
10579
- * allowedProviders is a strict allowlist over resolved provider identities;
10580
- * non-null allowedModels an additional exact model-id allowlist. Consumers:
10581
- * the API model choke points (fail 422) and the worker's post-resolution gate
10582
- * (a blocked provider never reaches a model call and never silently remaps).
10583
- */
10584
- export type WorkspaceModelPolicy = {
10585
- allowedProviders: string[] | null;
10586
- allowedModels: string[] | null;
10802
+ export type CodexResetRedemptionAttempt = {
10803
+ id: string;
10804
+ accountId: string;
10805
+ workspaceId: string;
10806
+ credentialId: string;
10807
+ subjectId: string;
10808
+ browserSessionHash: string;
10809
+ creditId: string;
10810
+ upstreamIdempotencyKey: string;
10811
+ status: CodexResetRedemptionStatus;
10812
+ outcome: CodexResetRedemptionOutcome | null;
10813
+ claimHolderId: string | null;
10814
+ claimExpiresAt: Date | null;
10815
+ confirmationExpiresAt: Date;
10816
+ providerStartedAt: Date | null;
10817
+ completedAt: Date | null;
10818
+ lastFailureKind: string | null;
10819
+ retryCount: number;
10820
+ createdAt: Date;
10821
+ updatedAt: Date;
10587
10822
  };
10588
10823
 
10589
- /** The per-workspace model policy row (null when none exists = unrestricted). */
10590
- export async function getWorkspaceModelPolicy(
10824
+ function mapCodexResetRedemptionAttempt(
10825
+ row: typeof schema.codexResetRedemptionAttempts.$inferSelect,
10826
+ ): CodexResetRedemptionAttempt {
10827
+ return {
10828
+ id: row.id,
10829
+ accountId: row.accountId,
10830
+ workspaceId: row.workspaceId,
10831
+ credentialId: row.credentialId,
10832
+ subjectId: row.subjectId,
10833
+ browserSessionHash: row.browserSessionHash,
10834
+ creditId: row.creditId,
10835
+ upstreamIdempotencyKey: row.upstreamIdempotencyKey,
10836
+ status: row.status as CodexResetRedemptionStatus,
10837
+ outcome: row.outcome as CodexResetRedemptionOutcome | null,
10838
+ claimHolderId: row.claimHolderId,
10839
+ claimExpiresAt: codexMetadataDate(row.claimExpiresAt),
10840
+ confirmationExpiresAt: codexMetadataDate(row.confirmationExpiresAt)!,
10841
+ providerStartedAt: codexMetadataDate(row.providerStartedAt),
10842
+ completedAt: codexMetadataDate(row.completedAt),
10843
+ lastFailureKind: row.lastFailureKind,
10844
+ retryCount: row.retryCount,
10845
+ createdAt: codexMetadataDate(row.createdAt)!,
10846
+ updatedAt: codexMetadataDate(row.updatedAt)!,
10847
+ };
10848
+ }
10849
+
10850
+ /** Metadata-only attempt read used to resume a browser-session-bound ambiguity. */
10851
+ export async function getCodexResetRedemptionAttempt(
10591
10852
  db: Database,
10592
10853
  workspaceId: string,
10593
- ): Promise<WorkspaceModelPolicy | null> {
10854
+ attemptId: string,
10855
+ ): Promise<CodexResetRedemptionAttempt | null> {
10594
10856
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
10595
10857
  const [row] = await scopedDb
10596
- .select({
10597
- allowedProviders: schema.workspaceModelPolicies.allowedProviders,
10598
- allowedModels: schema.workspaceModelPolicies.allowedModels,
10599
- })
10600
- .from(schema.workspaceModelPolicies)
10601
- .where(eq(schema.workspaceModelPolicies.workspaceId, workspaceId))
10858
+ .select()
10859
+ .from(schema.codexResetRedemptionAttempts)
10860
+ .where(
10861
+ and(
10862
+ eq(schema.codexResetRedemptionAttempts.workspaceId, workspaceId),
10863
+ eq(schema.codexResetRedemptionAttempts.id, attemptId),
10864
+ ),
10865
+ )
10602
10866
  .limit(1);
10603
- return row ?? null;
10867
+ return row ? mapCodexResetRedemptionAttempt(row) : null;
10604
10868
  });
10605
10869
  }
10606
10870
 
10871
+ export type CodexResetRedemptionRecovery = {
10872
+ attemptId: string;
10873
+ credentialId: string;
10874
+ creditId: string;
10875
+ status: "provider_started" | "completed";
10876
+ outcome: CodexResetRedemptionOutcome | null;
10877
+ providerStartedAt: Date | null;
10878
+ completedAt: Date | null;
10879
+ createdAt: Date;
10880
+ updatedAt: Date;
10881
+ };
10882
+
10607
10883
  /**
10608
- * Create or replace the workspace's model policy. Passing null for a field
10609
- * clears that restriction; a policy of {null, null} is kept as an explicit
10610
- * "unrestricted" row (delete is not needed for correctness — it reads the same
10611
- * as no row).
10884
+ * Cookie-owner discovery source for durable ambiguous/completed attempts.
10885
+ * Browser storage is never required to recover these non-secret identifiers.
10612
10886
  */
10613
- export async function upsertWorkspaceModelPolicy(
10887
+ export async function listCodexResetRedemptionRecoveries(
10614
10888
  db: Database,
10615
- input: {
10616
- accountId: string;
10617
- workspaceId: string;
10618
- allowedProviders: string[] | null;
10619
- allowedModels: string[] | null;
10620
- },
10621
- ): Promise<WorkspaceModelPolicy> {
10889
+ input: { accountId: string; workspaceId: string; subjectId: string },
10890
+ ): Promise<CodexResetRedemptionRecovery[]> {
10622
10891
  return await withRlsContext(
10623
10892
  db,
10624
10893
  { accountId: input.accountId, workspaceId: input.workspaceId },
10625
10894
  async (scopedDb) => {
10626
- const [row] = await scopedDb
10627
- .insert(schema.workspaceModelPolicies)
10628
- .values({
10629
- accountId: input.accountId,
10630
- workspaceId: input.workspaceId,
10631
- allowedProviders: input.allowedProviders,
10632
- allowedModels: input.allowedModels,
10633
- })
10634
- .onConflictDoUpdate({
10635
- target: [schema.workspaceModelPolicies.workspaceId],
10636
- set: {
10637
- allowedProviders: input.allowedProviders,
10638
- allowedModels: input.allowedModels,
10639
- updatedAt: new Date(),
10640
- },
10895
+ const rows = await scopedDb
10896
+ .select({
10897
+ attemptId: schema.codexResetRedemptionAttempts.id,
10898
+ credentialId: schema.codexResetRedemptionAttempts.credentialId,
10899
+ creditId: schema.codexResetRedemptionAttempts.creditId,
10900
+ status: schema.codexResetRedemptionAttempts.status,
10901
+ outcome: schema.codexResetRedemptionAttempts.outcome,
10902
+ providerStartedAt: schema.codexResetRedemptionAttempts.providerStartedAt,
10903
+ completedAt: schema.codexResetRedemptionAttempts.completedAt,
10904
+ createdAt: schema.codexResetRedemptionAttempts.createdAt,
10905
+ updatedAt: schema.codexResetRedemptionAttempts.updatedAt,
10641
10906
  })
10642
- .returning({
10643
- allowedProviders: schema.workspaceModelPolicies.allowedProviders,
10644
- allowedModels: schema.workspaceModelPolicies.allowedModels,
10645
- });
10646
- return row!;
10907
+ .from(schema.codexResetRedemptionAttempts)
10908
+ .innerJoin(
10909
+ schema.codexSubscriptionCredentials,
10910
+ and(
10911
+ eq(
10912
+ schema.codexSubscriptionCredentials.id,
10913
+ schema.codexResetRedemptionAttempts.credentialId,
10914
+ ),
10915
+ eq(
10916
+ schema.codexSubscriptionCredentials.workspaceId,
10917
+ schema.codexResetRedemptionAttempts.workspaceId,
10918
+ ),
10919
+ ),
10920
+ )
10921
+ .where(
10922
+ and(
10923
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
10924
+ eq(schema.codexResetRedemptionAttempts.subjectId, input.subjectId),
10925
+ eq(schema.codexSubscriptionCredentials.connectedBySubjectId, input.subjectId),
10926
+ inArray(schema.codexResetRedemptionAttempts.status, ["provider_started", "completed"]),
10927
+ ),
10928
+ )
10929
+ .orderBy(desc(schema.codexResetRedemptionAttempts.createdAt));
10930
+ return rows.map((row) => ({
10931
+ attemptId: row.attemptId,
10932
+ credentialId: row.credentialId,
10933
+ creditId: row.creditId,
10934
+ status: row.status as "provider_started" | "completed",
10935
+ outcome: row.outcome as CodexResetRedemptionOutcome | null,
10936
+ providerStartedAt: codexMetadataDate(row.providerStartedAt),
10937
+ completedAt: codexMetadataDate(row.completedAt),
10938
+ createdAt: codexMetadataDate(row.createdAt)!,
10939
+ updatedAt: codexMetadataDate(row.updatedAt)!,
10940
+ }));
10647
10941
  },
10648
10942
  );
10649
10943
  }
10650
10944
 
10651
- /** The per-workspace rotation/active-pointer row (null when none exists yet). */
10652
- export async function getCodexRotationSettings(
10945
+ export type AdoptCodexResetRedemptionResult =
10946
+ | { kind: "current" | "adopted"; attempt: CodexResetRedemptionAttempt }
10947
+ | { kind: "in_progress" }
10948
+ | { kind: "not_found" }
10949
+ | { kind: "forbidden" }
10950
+ | { kind: "conflict" };
10951
+
10952
+ /** Adopt a released durable ambiguity/completion into the owner's current browser session. */
10953
+ export async function adoptCodexResetRedemptionAttempt(
10653
10954
  db: Database,
10654
- workspaceId: string,
10655
- ): Promise<CodexRotationSettings | null> {
10656
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
10657
- const [row] = await scopedDb
10658
- .select({
10659
- activeCredentialId: schema.codexRotationSettings.activeCredentialId,
10660
- rotationEnabled: schema.codexRotationSettings.rotationEnabled,
10661
- leaseRotationEnabled: schema.codexRotationSettings.leaseRotationEnabled,
10662
- rotationStrategy: schema.codexRotationSettings.rotationStrategy,
10955
+ input: {
10956
+ accountId: string;
10957
+ workspaceId: string;
10958
+ attemptId: string;
10959
+ credentialId: string;
10960
+ creditId: string;
10961
+ subjectId: string;
10962
+ browserSessionHash: string;
10963
+ },
10964
+ ): Promise<AdoptCodexResetRedemptionResult> {
10965
+ return await withRlsContext(
10966
+ db,
10967
+ { accountId: input.accountId, workspaceId: input.workspaceId },
10968
+ async (scopedDb) =>
10969
+ await scopedDb.transaction(async (tx) => {
10970
+ const [credential] = await tx
10971
+ .select({
10972
+ connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
10973
+ })
10974
+ .from(schema.codexSubscriptionCredentials)
10975
+ .where(
10976
+ and(
10977
+ eq(schema.codexSubscriptionCredentials.id, input.credentialId),
10978
+ eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
10979
+ ),
10980
+ )
10981
+ .for("share")
10982
+ .limit(1);
10983
+ if (!credential) return { kind: "not_found" } as const;
10984
+ if (credential.connectedBySubjectId !== input.subjectId) {
10985
+ return { kind: "forbidden" } as const;
10986
+ }
10987
+ const [attempt] = await tx
10988
+ .select()
10989
+ .from(schema.codexResetRedemptionAttempts)
10990
+ .where(
10991
+ and(
10992
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
10993
+ eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
10994
+ ),
10995
+ )
10996
+ .for("update")
10997
+ .limit(1);
10998
+ if (!attempt) return { kind: "not_found" } as const;
10999
+ if (
11000
+ attempt.accountId !== input.accountId ||
11001
+ attempt.credentialId !== input.credentialId ||
11002
+ attempt.creditId !== input.creditId ||
11003
+ attempt.subjectId !== input.subjectId
11004
+ ) {
11005
+ return { kind: "conflict" } as const;
11006
+ }
11007
+ if (attempt.browserSessionHash === input.browserSessionHash) {
11008
+ return { kind: "current", attempt: mapCodexResetRedemptionAttempt(attempt) } as const;
11009
+ }
11010
+ if (attempt.status !== "provider_started" && attempt.status !== "completed") {
11011
+ return { kind: "conflict" } as const;
11012
+ }
11013
+ const claim = await tx.execute<{ claim_live: boolean }>(sql`
11014
+ select claim_expires_at > now() as claim_live
11015
+ from codex_reset_redemption_attempts
11016
+ where workspace_id = ${input.workspaceId} and id = ${input.attemptId}
11017
+ `);
11018
+ if (claim[0]?.claim_live) return { kind: "in_progress" } as const;
11019
+ const [adopted] = await tx
11020
+ .update(schema.codexResetRedemptionAttempts)
11021
+ .set({
11022
+ browserSessionHash: input.browserSessionHash,
11023
+ claimHolderId: null,
11024
+ claimExpiresAt: null,
11025
+ updatedAt: sql`now()`,
11026
+ })
11027
+ .where(eq(schema.codexResetRedemptionAttempts.id, input.attemptId))
11028
+ .returning();
11029
+ if (!adopted) throw new Error("Codex redemption adoption returned no row");
11030
+ return { kind: "adopted", attempt: mapCodexResetRedemptionAttempt(adopted) } as const;
11031
+ }),
11032
+ );
11033
+ }
11034
+
11035
+ export type ClaimCodexResetRedemptionResult =
11036
+ | { kind: "claimed"; attempt: CodexResetRedemptionAttempt }
11037
+ | { kind: "in_progress"; attempt: CodexResetRedemptionAttempt }
11038
+ | { kind: "completed"; attempt: CodexResetRedemptionAttempt }
11039
+ | { kind: "not_found" }
11040
+ | { kind: "forbidden" }
11041
+ | { kind: "conflict" };
11042
+
11043
+ /**
11044
+ * Claim or reclaim one logical redemption across API replicas/crashes.
11045
+ *
11046
+ * A transaction advisory lock closes the initial INSERT race. A live claim
11047
+ * yields `in_progress`; an expired/released claim is reclaimed without changing
11048
+ * the status or upstream key. Existing attempt identity is immutable.
11049
+ */
11050
+ export async function claimCodexResetRedemption(
11051
+ db: Database,
11052
+ input: {
11053
+ id: string;
11054
+ accountId: string;
11055
+ workspaceId: string;
11056
+ credentialId: string;
11057
+ subjectId: string;
11058
+ browserSessionHash: string;
11059
+ creditId: string;
11060
+ confirmationExpiresAt: Date;
11061
+ claimHolderId: string;
11062
+ claimTtlMs?: number;
11063
+ },
11064
+ ): Promise<ClaimCodexResetRedemptionResult> {
11065
+ const claimTtlMs = input.claimTtlMs ?? 60_000;
11066
+ if (!Number.isFinite(claimTtlMs) || claimTtlMs <= 0) {
11067
+ throw new Error("Codex redemption claim TTL must be positive");
11068
+ }
11069
+ if (
11070
+ !Number.isFinite(input.confirmationExpiresAt.getTime()) ||
11071
+ input.confirmationExpiresAt.getTime() <= Date.now()
11072
+ ) {
11073
+ return { kind: "forbidden" };
11074
+ }
11075
+ return await withRlsContext(
11076
+ db,
11077
+ { accountId: input.accountId, workspaceId: input.workspaceId },
11078
+ async (scopedDb) =>
11079
+ await scopedDb.transaction(async (tx) => {
11080
+ await tx.execute(
11081
+ sql`select pg_advisory_xact_lock(hashtextextended(${`codex-reset-attempt:${input.id}`}, 0))`,
11082
+ );
11083
+ // A second browser tab has a different attempt UUID. Serialize on the
11084
+ // irreversible provider credit too, then let the unique index provide
11085
+ // the durable fail-closed backstop.
11086
+ await tx.execute(
11087
+ sql`select pg_advisory_xact_lock(hashtextextended(${`codex-reset-credit:${input.workspaceId}:${input.credentialId}:${input.creditId}`}, 0))`,
11088
+ );
11089
+ const [credential] = await tx
11090
+ .select({
11091
+ connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
11092
+ status: schema.codexSubscriptionCredentials.status,
11093
+ })
11094
+ .from(schema.codexSubscriptionCredentials)
11095
+ .where(
11096
+ and(
11097
+ eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
11098
+ eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
11099
+ eq(schema.codexSubscriptionCredentials.id, input.credentialId),
11100
+ ),
11101
+ )
11102
+ .for("share")
11103
+ .limit(1);
11104
+ if (!credential) return { kind: "not_found" } as const;
11105
+ const [existing] = await tx
11106
+ .select()
11107
+ .from(schema.codexResetRedemptionAttempts)
11108
+ .where(
11109
+ and(
11110
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
11111
+ eq(schema.codexResetRedemptionAttempts.id, input.id),
11112
+ ),
11113
+ )
11114
+ .for("update")
11115
+ .limit(1);
11116
+ const now = new Date();
11117
+ if (existing) {
11118
+ if (
11119
+ existing.accountId !== input.accountId ||
11120
+ existing.credentialId !== input.credentialId ||
11121
+ existing.subjectId !== input.subjectId ||
11122
+ existing.browserSessionHash !== input.browserSessionHash ||
11123
+ existing.creditId !== input.creditId
11124
+ ) {
11125
+ return { kind: "conflict" } as const;
11126
+ }
11127
+ if (credential.connectedBySubjectId !== input.subjectId) {
11128
+ return { kind: "forbidden" } as const;
11129
+ }
11130
+ const mapped = mapCodexResetRedemptionAttempt(existing);
11131
+ // Completion is durable truth. A token-health transition after the
11132
+ // provider outcome committed must not turn an HTTP-response-loss
11133
+ // replay into a false 403 or trigger a second consume. Current human
11134
+ // ownership remains mandatory, but active health is needed only when
11135
+ // work may still have to reach the provider.
11136
+ if (mapped.status === "completed") return { kind: "completed", attempt: mapped };
11137
+ if (credential.status !== "active") {
11138
+ return { kind: "forbidden" } as const;
11139
+ }
11140
+ const claimState = await tx.execute(sql<{ claim_live: boolean }>`
11141
+ select claim_expires_at > now() as claim_live
11142
+ from codex_reset_redemption_attempts
11143
+ where workspace_id = ${input.workspaceId} and id = ${input.id}
11144
+ `);
11145
+ if (claimState[0]?.claim_live) {
11146
+ return { kind: "in_progress", attempt: mapped };
11147
+ }
11148
+ const [reclaimed] = await tx
11149
+ .update(schema.codexResetRedemptionAttempts)
11150
+ .set({
11151
+ claimHolderId: input.claimHolderId,
11152
+ claimExpiresAt: sql`now() + (${claimTtlMs} * interval '1 millisecond')`,
11153
+ confirmationExpiresAt: input.confirmationExpiresAt,
11154
+ lastFailureKind: null,
11155
+ retryCount: sql`${schema.codexResetRedemptionAttempts.retryCount} + 1`,
11156
+ updatedAt: now,
11157
+ })
11158
+ .where(eq(schema.codexResetRedemptionAttempts.id, input.id))
11159
+ .returning();
11160
+ if (!reclaimed) throw new Error("Codex redemption reclaim returned no row");
11161
+ return {
11162
+ kind: "claimed",
11163
+ attempt: mapCodexResetRedemptionAttempt(reclaimed),
11164
+ };
11165
+ }
11166
+
11167
+ // Authorize before looking up credit-attempt state so a non-owner cannot
11168
+ // distinguish an unused provider credit from one with an existing attempt.
11169
+ if (credential.status !== "active" || credential.connectedBySubjectId !== input.subjectId) {
11170
+ return { kind: "forbidden" } as const;
11171
+ }
11172
+
11173
+ const [creditAttempt] = await tx.execute<{
11174
+ id: string;
11175
+ status: CodexResetRedemptionStatus;
11176
+ claim_live: boolean | null;
11177
+ }>(sql`
11178
+ select id, status, claim_expires_at > now() as claim_live
11179
+ from codex_reset_redemption_attempts
11180
+ where workspace_id = ${input.workspaceId}
11181
+ and credential_id = ${input.credentialId}
11182
+ and credit_id = ${input.creditId}
11183
+ and (status <> 'completed' or outcome in ('reset', 'alreadyRedeemed'))
11184
+ limit 1
11185
+ for update
11186
+ `);
11187
+ if (creditAttempt) {
11188
+ // A different browser UUID may replace only definite pre-provider
11189
+ // work whose claim is released/expired. The per-credit advisory lock
11190
+ // serializes replacement with a late original claimant, and the
11191
+ // DB-time predicate prevents an application-clock race. Once
11192
+ // provider_started (or successfully completed), preserve the one
11193
+ // upstream idempotency key and fail closed.
11194
+ if (creditAttempt.status !== "processing" || creditAttempt.claim_live) {
11195
+ return { kind: "conflict" } as const;
11196
+ }
11197
+ const removed = await tx
11198
+ .delete(schema.codexResetRedemptionAttempts)
11199
+ .where(
11200
+ and(
11201
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
11202
+ eq(schema.codexResetRedemptionAttempts.id, creditAttempt.id),
11203
+ eq(schema.codexResetRedemptionAttempts.status, "processing"),
11204
+ sql`(${schema.codexResetRedemptionAttempts.claimExpiresAt} is null or ${schema.codexResetRedemptionAttempts.claimExpiresAt} <= now())`,
11205
+ ),
11206
+ )
11207
+ .returning({ id: schema.codexResetRedemptionAttempts.id });
11208
+ if (removed.length !== 1) return { kind: "conflict" } as const;
11209
+ }
11210
+
11211
+ const [created] = await tx
11212
+ .insert(schema.codexResetRedemptionAttempts)
11213
+ .values({
11214
+ id: input.id,
11215
+ accountId: input.accountId,
11216
+ workspaceId: input.workspaceId,
11217
+ credentialId: input.credentialId,
11218
+ subjectId: input.subjectId,
11219
+ browserSessionHash: input.browserSessionHash,
11220
+ creditId: input.creditId,
11221
+ status: "processing",
11222
+ claimHolderId: input.claimHolderId,
11223
+ claimExpiresAt: sql`now() + (${claimTtlMs} * interval '1 millisecond')`,
11224
+ confirmationExpiresAt: input.confirmationExpiresAt,
11225
+ })
11226
+ .returning();
11227
+ if (!created) throw new Error("Codex redemption claim returned no row");
11228
+ return {
11229
+ kind: "claimed",
11230
+ attempt: mapCodexResetRedemptionAttempt(created),
11231
+ };
11232
+ }),
11233
+ );
11234
+ }
11235
+
11236
+ export type CodexResetRedemptionSendNotReadyReason =
11237
+ | "not_found"
11238
+ | "identity_mismatch"
11239
+ | "claim_expired"
11240
+ | "confirmation_expired"
11241
+ | "credential_unavailable"
11242
+ | "already_completed";
11243
+
11244
+ export type FenceCodexResetRedemptionSendResult =
11245
+ | { kind: "ready"; attempt: CodexResetRedemptionAttempt }
11246
+ | {
11247
+ kind: "not_ready";
11248
+ reason: CodexResetRedemptionSendNotReadyReason;
11249
+ };
11250
+
11251
+ /**
11252
+ * Final DB-time irreversible-send fence, called only after preflight and token
11253
+ * resolution and immediately before every provider POST (including ambiguity
11254
+ * retries). The credential SHARE lock serializes against disconnect and
11255
+ * ownership-changing reconnect. The claim is extended beyond the bounded
11256
+ * consume operation in the same transaction that persists provider_started.
11257
+ */
11258
+ export async function fenceCodexResetRedemptionSend(
11259
+ db: Database,
11260
+ input: {
11261
+ accountId: string;
11262
+ workspaceId: string;
11263
+ attemptId: string;
11264
+ claimHolderId: string;
11265
+ credentialId: string;
11266
+ subjectId: string;
11267
+ browserSessionHash: string;
11268
+ sendLeaseMs?: number;
11269
+ },
11270
+ ): Promise<FenceCodexResetRedemptionSendResult> {
11271
+ const sendLeaseMs = input.sendLeaseMs ?? 30_000;
11272
+ if (!Number.isFinite(sendLeaseMs) || sendLeaseMs <= 10_000) {
11273
+ throw new Error("Codex redemption send lease must exceed the bounded provider call");
11274
+ }
11275
+ return await withRlsContext(
11276
+ db,
11277
+ { accountId: input.accountId, workspaceId: input.workspaceId },
11278
+ async (scopedDb) =>
11279
+ await scopedDb.transaction(async (tx) => {
11280
+ const [credential] = await tx
11281
+ .select({
11282
+ connectedBySubjectId: schema.codexSubscriptionCredentials.connectedBySubjectId,
11283
+ status: schema.codexSubscriptionCredentials.status,
11284
+ })
11285
+ .from(schema.codexSubscriptionCredentials)
11286
+ .where(
11287
+ and(
11288
+ eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
11289
+ eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
11290
+ eq(schema.codexSubscriptionCredentials.id, input.credentialId),
11291
+ ),
11292
+ )
11293
+ .for("share")
11294
+ .limit(1);
11295
+ const [attempt] = await tx
11296
+ .select()
11297
+ .from(schema.codexResetRedemptionAttempts)
11298
+ .where(
11299
+ and(
11300
+ eq(schema.codexResetRedemptionAttempts.accountId, input.accountId),
11301
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
11302
+ eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
11303
+ ),
11304
+ )
11305
+ .for("update")
11306
+ .limit(1);
11307
+ if (!attempt) return { kind: "not_ready", reason: "not_found" } as const;
11308
+ if (
11309
+ attempt.credentialId !== input.credentialId ||
11310
+ attempt.subjectId !== input.subjectId ||
11311
+ attempt.browserSessionHash !== input.browserSessionHash ||
11312
+ attempt.claimHolderId !== input.claimHolderId
11313
+ ) {
11314
+ return { kind: "not_ready", reason: "identity_mismatch" } as const;
11315
+ }
11316
+ if (attempt.status === "completed") {
11317
+ return { kind: "not_ready", reason: "already_completed" } as const;
11318
+ }
11319
+ const [liveness] = await tx.execute<{
11320
+ claim_live: boolean;
11321
+ confirmation_live: boolean;
11322
+ }>(sql`
11323
+ select claim_expires_at > now() as claim_live,
11324
+ confirmation_expires_at > now() as confirmation_live
11325
+ from codex_reset_redemption_attempts
11326
+ where workspace_id = ${input.workspaceId} and id = ${input.attemptId}
11327
+ `);
11328
+ let reason: CodexResetRedemptionSendNotReadyReason;
11329
+ if (!liveness?.claim_live) reason = "claim_expired";
11330
+ else if (!liveness.confirmation_live) reason = "confirmation_expired";
11331
+ else if (
11332
+ !credential ||
11333
+ credential.status !== "active" ||
11334
+ credential.connectedBySubjectId !== input.subjectId
11335
+ ) {
11336
+ reason = "credential_unavailable";
11337
+ } else {
11338
+ const [ready] = await tx
11339
+ .update(schema.codexResetRedemptionAttempts)
11340
+ .set({
11341
+ status: "provider_started",
11342
+ providerStartedAt: sql`coalesce(${schema.codexResetRedemptionAttempts.providerStartedAt}, now())`,
11343
+ claimExpiresAt: sql`now() + (${sendLeaseMs} * interval '1 millisecond')`,
11344
+ lastFailureKind: null,
11345
+ updatedAt: sql`now()`,
11346
+ })
11347
+ .where(eq(schema.codexResetRedemptionAttempts.id, input.attemptId))
11348
+ .returning();
11349
+ if (!ready) throw new Error("Codex redemption send fence returned no row");
11350
+ return { kind: "ready", attempt: mapCodexResetRedemptionAttempt(ready) } as const;
11351
+ }
11352
+
11353
+ // Before provider_started it is safe to remove the false logical
11354
+ // attempt. Once provider work may have begun, preserve the upstream key
11355
+ // and merely release this request's stale claim for owner recovery.
11356
+ if (attempt.status === "processing") {
11357
+ await tx
11358
+ .delete(schema.codexResetRedemptionAttempts)
11359
+ .where(eq(schema.codexResetRedemptionAttempts.id, input.attemptId));
11360
+ } else {
11361
+ await tx
11362
+ .update(schema.codexResetRedemptionAttempts)
11363
+ .set({
11364
+ claimHolderId: null,
11365
+ claimExpiresAt: null,
11366
+ lastFailureKind: `send_fence_${reason}`,
11367
+ updatedAt: sql`now()`,
11368
+ })
11369
+ .where(eq(schema.codexResetRedemptionAttempts.id, input.attemptId));
11370
+ }
11371
+ return { kind: "not_ready", reason } as const;
11372
+ }),
11373
+ );
11374
+ }
11375
+
11376
+ /** Delete only definite pre-provider work still owned by this request. */
11377
+ export async function abandonCodexResetRedemptionBeforeProvider(
11378
+ db: Database,
11379
+ input: {
11380
+ accountId: string;
11381
+ workspaceId: string;
11382
+ attemptId: string;
11383
+ claimHolderId: string;
11384
+ },
11385
+ ): Promise<boolean> {
11386
+ return await withRlsContext(
11387
+ db,
11388
+ { accountId: input.accountId, workspaceId: input.workspaceId },
11389
+ async (scopedDb) => {
11390
+ const deleted = await scopedDb
11391
+ .delete(schema.codexResetRedemptionAttempts)
11392
+ .where(
11393
+ and(
11394
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
11395
+ eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
11396
+ eq(schema.codexResetRedemptionAttempts.status, "processing"),
11397
+ eq(schema.codexResetRedemptionAttempts.claimHolderId, input.claimHolderId),
11398
+ ),
11399
+ )
11400
+ .returning({ id: schema.codexResetRedemptionAttempts.id });
11401
+ return deleted.length === 1;
11402
+ },
11403
+ );
11404
+ }
11405
+
11406
+ /** Release a failed claim while preserving processing/provider_started truth. */
11407
+ export async function releaseCodexResetRedemptionClaim(
11408
+ db: Database,
11409
+ input: {
11410
+ accountId: string;
11411
+ workspaceId: string;
11412
+ attemptId: string;
11413
+ claimHolderId: string;
11414
+ failureKind: string;
11415
+ },
11416
+ ): Promise<boolean> {
11417
+ return await withRlsContext(
11418
+ db,
11419
+ { accountId: input.accountId, workspaceId: input.workspaceId },
11420
+ async (scopedDb) => {
11421
+ const rows = await scopedDb
11422
+ .update(schema.codexResetRedemptionAttempts)
11423
+ .set({
11424
+ claimHolderId: null,
11425
+ claimExpiresAt: null,
11426
+ lastFailureKind: input.failureKind.slice(0, 100),
11427
+ updatedAt: new Date(),
11428
+ })
11429
+ .where(
11430
+ and(
11431
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
11432
+ eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
11433
+ eq(schema.codexResetRedemptionAttempts.claimHolderId, input.claimHolderId),
11434
+ sql`${schema.codexResetRedemptionAttempts.status} <> 'completed'`,
11435
+ ),
11436
+ )
11437
+ .returning({ id: schema.codexResetRedemptionAttempts.id });
11438
+ return rows.length === 1;
11439
+ },
11440
+ );
11441
+ }
11442
+
11443
+ /** Persist the exact provider outcome, cooldown repair, and audit atomically. */
11444
+ export async function completeCodexResetRedemption(
11445
+ db: Database,
11446
+ input: {
11447
+ accountId: string;
11448
+ workspaceId: string;
11449
+ attemptId: string;
11450
+ claimHolderId: string;
11451
+ outcome: CodexResetRedemptionOutcome;
11452
+ },
11453
+ ): Promise<CodexCapacityMutationResult<CodexResetRedemptionAttempt | null>> {
11454
+ if (!CODEX_RESET_REDEMPTION_OUTCOMES.includes(input.outcome)) {
11455
+ throw new Error("Unknown Codex redemption outcome");
11456
+ }
11457
+ return await withCodexCapacityMutation(
11458
+ db,
11459
+ { workspaceId: input.workspaceId, reason: "codex_reset_credit_redeemed" },
11460
+ async (tx) => {
11461
+ // Claims take this lock before touching the credential and attempt rows.
11462
+ // Completion takes the same fence after the canonical capacity lock, so
11463
+ // a claim cannot hold a credential SHARE lock while waiting for an
11464
+ // attempt row that this transaction already owns.
11465
+ await tx.execute(
11466
+ sql`select pg_advisory_xact_lock(hashtextextended(${`codex-reset-attempt:${input.attemptId}`}, 0))`,
11467
+ );
11468
+ const [current] = await tx
11469
+ .select()
11470
+ .from(schema.codexResetRedemptionAttempts)
11471
+ .where(
11472
+ and(
11473
+ eq(schema.codexResetRedemptionAttempts.accountId, input.accountId),
11474
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
11475
+ eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
11476
+ ),
11477
+ )
11478
+ .for("update")
11479
+ .limit(1);
11480
+ if (!current) return { result: null, changed: false };
11481
+ if (current.status === "completed") {
11482
+ return { result: mapCodexResetRedemptionAttempt(current), changed: false };
11483
+ }
11484
+ if (current.status !== "provider_started" || current.claimHolderId !== input.claimHolderId) {
11485
+ return { result: null, changed: false };
11486
+ }
11487
+ const completedAt = new Date();
11488
+ const [completed] = await tx
11489
+ .update(schema.codexResetRedemptionAttempts)
11490
+ .set({
11491
+ status: "completed",
11492
+ outcome: input.outcome,
11493
+ completedAt,
11494
+ claimHolderId: null,
11495
+ claimExpiresAt: null,
11496
+ lastFailureKind: null,
11497
+ updatedAt: completedAt,
11498
+ })
11499
+ .where(
11500
+ and(
11501
+ eq(schema.codexResetRedemptionAttempts.accountId, input.accountId),
11502
+ eq(schema.codexResetRedemptionAttempts.workspaceId, input.workspaceId),
11503
+ eq(schema.codexResetRedemptionAttempts.id, input.attemptId),
11504
+ ),
11505
+ )
11506
+ .returning();
11507
+ if (!completed) throw new Error("Codex redemption completion returned no row");
11508
+ const restoresCapacity = input.outcome === "reset" || input.outcome === "alreadyRedeemed";
11509
+ if (restoresCapacity) {
11510
+ await tx
11511
+ .update(schema.codexSubscriptionCredentials)
11512
+ .set({ exhaustedUntil: null })
11513
+ .where(
11514
+ and(
11515
+ eq(schema.codexSubscriptionCredentials.accountId, input.accountId),
11516
+ eq(schema.codexSubscriptionCredentials.workspaceId, input.workspaceId),
11517
+ eq(schema.codexSubscriptionCredentials.id, current.credentialId),
11518
+ ),
11519
+ );
11520
+ }
11521
+ await tx.insert(schema.auditEvents).values({
11522
+ accountId: input.accountId,
11523
+ workspaceId: input.workspaceId,
11524
+ subjectId: current.subjectId,
11525
+ action: "codex.reset_credit.redemption.completed",
11526
+ targetType: "codex_reset_redemption_attempt",
11527
+ targetId: input.attemptId,
11528
+ metadata: { outcome: input.outcome },
11529
+ });
11530
+ return {
11531
+ result: mapCodexResetRedemptionAttempt(completed),
11532
+ // A successful/already-applied upstream reset can make durable waiters
11533
+ // eligible immediately even when the local cooldown was already null.
11534
+ changed: restoresCapacity,
11535
+ };
11536
+ },
11537
+ );
11538
+ }
11539
+
11540
+ /** The P2 usage-cache snapshot written by the refreshing usage wrapper. */
11541
+ export type CodexAccountUsageSnapshot = {
11542
+ primaryUsedPercent?: number | null;
11543
+ primaryResetAt?: Date | null;
11544
+ secondaryUsedPercent?: number | null;
11545
+ secondaryResetAt?: Date | null;
11546
+ /** Present only when the quota body parsed successfully. */
11547
+ checkedAt?: Date;
11548
+ resetCreditAvailableCount?: number | null;
11549
+ resetCreditsCheckedAt?: Date | null;
11550
+ };
11551
+
11552
+ /**
11553
+ * Cache-write for P2 quota bars: persist the five plaintext usage columns on a
11554
+ * SPECIFIC credential row. NEVER touches credential_encrypted. RLS-scoped, guarded
11555
+ * by (id, workspace_id) so it can only write a row the workspace owns. Returns true
11556
+ * iff a row was updated (false ⇒ the credential was disconnected under us — the
11557
+ * snapshot is moot, drop it). This is the only writer of the usage_checked_at TTL
11558
+ * clock that `listCodexAccountStatuses` reads back.
11559
+ */
11560
+ export async function recordCodexAccountUsage(
11561
+ db: Database,
11562
+ workspaceId: string,
11563
+ credentialId: string,
11564
+ snapshot: CodexAccountUsageSnapshot,
11565
+ ): Promise<boolean> {
11566
+ return (await recordCodexAccountUsageWithWakeTargets(db, workspaceId, credentialId, snapshot))
11567
+ .result;
11568
+ }
11569
+
11570
+ /** Usage-cache mutation plus its committed durable capacity-wake outbox. */
11571
+ export async function recordCodexAccountUsageWithWakeTargets(
11572
+ db: Database,
11573
+ workspaceId: string,
11574
+ credentialId: string,
11575
+ snapshot: CodexAccountUsageSnapshot,
11576
+ ): Promise<CodexCapacityMutationResult<boolean>> {
11577
+ return await withCodexCapacityMutation(
11578
+ db,
11579
+ { workspaceId, reason: "codex_usage_refreshed" },
11580
+ async (tx) => {
11581
+ const updated = await tx
11582
+ .update(schema.codexSubscriptionCredentials)
11583
+ .set({
11584
+ ...(snapshot.checkedAt !== undefined
11585
+ ? {
11586
+ primaryUsedPercent: snapshot.primaryUsedPercent ?? null,
11587
+ primaryResetAt: snapshot.primaryResetAt ?? null,
11588
+ secondaryUsedPercent: snapshot.secondaryUsedPercent ?? null,
11589
+ secondaryResetAt: snapshot.secondaryResetAt ?? null,
11590
+ usageCheckedAt: snapshot.checkedAt,
11591
+ }
11592
+ : {}),
11593
+ ...(snapshot.resetCreditAvailableCount !== undefined
11594
+ ? {
11595
+ resetCreditAvailableCount: snapshot.resetCreditAvailableCount,
11596
+ resetCreditsCheckedAt: snapshot.resetCreditsCheckedAt ?? null,
11597
+ }
11598
+ : {}),
11599
+ // NB: no `version` bump and no `updatedAt` touch — usage is non-credential
11600
+ // metadata and must NOT race the (id, version) refresh CAS in
11601
+ // recordCodexTokenRefresh / setCodexCredentialStatus.
11602
+ })
11603
+ .where(
11604
+ and(
11605
+ eq(schema.codexSubscriptionCredentials.id, credentialId),
11606
+ eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId),
11607
+ ),
11608
+ )
11609
+ .returning({ id: schema.codexSubscriptionCredentials.id });
11610
+ const changed = updated.length > 0;
11611
+ return { result: changed, changed };
11612
+ },
11613
+ );
11614
+ }
11615
+
11616
+ export type CodexRotationSettings = {
11617
+ activeCredentialId: string | null;
11618
+ /** Legacy selector bit; old binaries only understand this field. */
11619
+ rotationEnabled: boolean;
11620
+ /** New allocator cutover bit; ignored safely by old binaries. */
11621
+ leaseRotationEnabled: boolean;
11622
+ rotationStrategy: string; // P1: 'most_remaining' (unused)
11623
+ };
11624
+
11625
+ /**
11626
+ * Per-workspace model/provider availability policy. NULL fields = unrestricted
11627
+ * (identical to no row — the default for every workspace). Non-null
11628
+ * allowedProviders is a strict allowlist over resolved provider identities;
11629
+ * non-null allowedModels an additional exact model-id allowlist. Consumers:
11630
+ * the API model choke points (fail 422) and the worker's post-resolution gate
11631
+ * (a blocked provider never reaches a model call and never silently remaps).
11632
+ */
11633
+ export type WorkspaceModelPolicy = {
11634
+ allowedProviders: string[] | null;
11635
+ allowedModels: string[] | null;
11636
+ };
11637
+
11638
+ /** The per-workspace model policy row (null when none exists = unrestricted). */
11639
+ export async function getWorkspaceModelPolicy(
11640
+ db: Database,
11641
+ workspaceId: string,
11642
+ ): Promise<WorkspaceModelPolicy | null> {
11643
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
11644
+ const [row] = await scopedDb
11645
+ .select({
11646
+ allowedProviders: schema.workspaceModelPolicies.allowedProviders,
11647
+ allowedModels: schema.workspaceModelPolicies.allowedModels,
11648
+ })
11649
+ .from(schema.workspaceModelPolicies)
11650
+ .where(eq(schema.workspaceModelPolicies.workspaceId, workspaceId))
11651
+ .limit(1);
11652
+ return row ?? null;
11653
+ });
11654
+ }
11655
+
11656
+ /**
11657
+ * Create or replace the workspace's model policy. Passing null for a field
11658
+ * clears that restriction; a policy of {null, null} is kept as an explicit
11659
+ * "unrestricted" row (delete is not needed for correctness — it reads the same
11660
+ * as no row).
11661
+ */
11662
+ export async function upsertWorkspaceModelPolicy(
11663
+ db: Database,
11664
+ input: {
11665
+ accountId: string;
11666
+ workspaceId: string;
11667
+ allowedProviders: string[] | null;
11668
+ allowedModels: string[] | null;
11669
+ },
11670
+ ): Promise<WorkspaceModelPolicy> {
11671
+ return await withRlsContext(
11672
+ db,
11673
+ { accountId: input.accountId, workspaceId: input.workspaceId },
11674
+ async (scopedDb) => {
11675
+ const [row] = await scopedDb
11676
+ .insert(schema.workspaceModelPolicies)
11677
+ .values({
11678
+ accountId: input.accountId,
11679
+ workspaceId: input.workspaceId,
11680
+ allowedProviders: input.allowedProviders,
11681
+ allowedModels: input.allowedModels,
11682
+ })
11683
+ .onConflictDoUpdate({
11684
+ target: [schema.workspaceModelPolicies.workspaceId],
11685
+ set: {
11686
+ allowedProviders: input.allowedProviders,
11687
+ allowedModels: input.allowedModels,
11688
+ updatedAt: new Date(),
11689
+ },
11690
+ })
11691
+ .returning({
11692
+ allowedProviders: schema.workspaceModelPolicies.allowedProviders,
11693
+ allowedModels: schema.workspaceModelPolicies.allowedModels,
11694
+ });
11695
+ return row!;
11696
+ },
11697
+ );
11698
+ }
11699
+
11700
+ /** The per-workspace rotation/active-pointer row (null when none exists yet). */
11701
+ export async function getCodexRotationSettings(
11702
+ db: Database,
11703
+ workspaceId: string,
11704
+ ): Promise<CodexRotationSettings | null> {
11705
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
11706
+ const [row] = await scopedDb
11707
+ .select({
11708
+ activeCredentialId: schema.codexRotationSettings.activeCredentialId,
11709
+ rotationEnabled: schema.codexRotationSettings.rotationEnabled,
11710
+ leaseRotationEnabled: schema.codexRotationSettings.leaseRotationEnabled,
11711
+ rotationStrategy: schema.codexRotationSettings.rotationStrategy,
10663
11712
  })
10664
11713
  .from(schema.codexRotationSettings)
10665
11714
  .where(eq(schema.codexRotationSettings.workspaceId, workspaceId))
@@ -11111,13 +12160,58 @@ export async function disconnectCodexAccount(
11111
12160
  db: Database,
11112
12161
  workspaceId: string,
11113
12162
  credentialId: string,
11114
- ): Promise<{ removed: boolean; newActiveCredentialId: string | null }> {
12163
+ ): Promise<{
12164
+ removed: boolean;
12165
+ newActiveCredentialId: string | null;
12166
+ blockedByUnresolvedRedemption: boolean;
12167
+ }> {
11115
12168
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
11116
12169
  await scopedDb.execute(sql`
11117
12170
  select id from codex_rotation_settings
11118
12171
  where workspace_id = ${workspaceId}
11119
12172
  for update
11120
12173
  `);
12174
+ const [credential] = await scopedDb
12175
+ .select({ id: schema.codexSubscriptionCredentials.id })
12176
+ .from(schema.codexSubscriptionCredentials)
12177
+ .where(
12178
+ and(
12179
+ eq(schema.codexSubscriptionCredentials.id, credentialId),
12180
+ eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId),
12181
+ ),
12182
+ )
12183
+ .for("update")
12184
+ .limit(1);
12185
+ const [settingsBefore] = await scopedDb
12186
+ .select({ activeCredentialId: schema.codexRotationSettings.activeCredentialId })
12187
+ .from(schema.codexRotationSettings)
12188
+ .where(eq(schema.codexRotationSettings.workspaceId, workspaceId))
12189
+ .limit(1);
12190
+ if (!credential) {
12191
+ return {
12192
+ removed: false,
12193
+ newActiveCredentialId: settingsBefore?.activeCredentialId ?? null,
12194
+ blockedByUnresolvedRedemption: false,
12195
+ };
12196
+ }
12197
+ const [unresolved] = await scopedDb
12198
+ .select({ id: schema.codexResetRedemptionAttempts.id })
12199
+ .from(schema.codexResetRedemptionAttempts)
12200
+ .where(
12201
+ and(
12202
+ eq(schema.codexResetRedemptionAttempts.workspaceId, workspaceId),
12203
+ eq(schema.codexResetRedemptionAttempts.credentialId, credentialId),
12204
+ eq(schema.codexResetRedemptionAttempts.status, "provider_started"),
12205
+ ),
12206
+ )
12207
+ .limit(1);
12208
+ if (unresolved) {
12209
+ return {
12210
+ removed: false,
12211
+ newActiveCredentialId: settingsBefore?.activeCredentialId ?? null,
12212
+ blockedByUnresolvedRedemption: true,
12213
+ };
12214
+ }
11121
12215
  const removedRows = await scopedDb
11122
12216
  .delete(schema.codexSubscriptionCredentials)
11123
12217
  .where(
@@ -11139,6 +12233,7 @@ export async function disconnectCodexAccount(
11139
12233
  return {
11140
12234
  removed: false,
11141
12235
  newActiveCredentialId: settingsRow?.activeCredentialId ?? null,
12236
+ blockedByUnresolvedRedemption: false,
11142
12237
  };
11143
12238
  }
11144
12239
  let newActive = settingsRow?.activeCredentialId ?? null;
@@ -11157,21 +12252,47 @@ export async function disconnectCodexAccount(
11157
12252
  .where(eq(schema.codexRotationSettings.workspaceId, workspaceId));
11158
12253
  }
11159
12254
  }
11160
- return { removed: true, newActiveCredentialId: newActive };
12255
+ return {
12256
+ removed: true,
12257
+ newActiveCredentialId: newActive,
12258
+ blockedByUnresolvedRedemption: false,
12259
+ };
11161
12260
  });
11162
12261
  }
11163
12262
 
11164
- /** Legacy "disconnect all" (old workspace-wide behavior). Returns rows removed. */
12263
+ /** Legacy "disconnect all"; atomically rejects when any provider work is unresolved. */
11165
12264
  export async function disconnectAllCodexAccounts(
11166
12265
  db: Database,
11167
12266
  workspaceId: string,
11168
- ): Promise<number> {
12267
+ ): Promise<{ removed: number; blockedCredentialIds: string[] }> {
11169
12268
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
12269
+ const credentials = await scopedDb
12270
+ .select({ id: schema.codexSubscriptionCredentials.id })
12271
+ .from(schema.codexSubscriptionCredentials)
12272
+ .where(eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId))
12273
+ .orderBy(asc(schema.codexSubscriptionCredentials.id))
12274
+ .for("update");
12275
+ if (credentials.length === 0) return { removed: 0, blockedCredentialIds: [] };
12276
+ const blocked = await scopedDb
12277
+ .selectDistinct({ credentialId: schema.codexResetRedemptionAttempts.credentialId })
12278
+ .from(schema.codexResetRedemptionAttempts)
12279
+ .where(
12280
+ and(
12281
+ eq(schema.codexResetRedemptionAttempts.workspaceId, workspaceId),
12282
+ eq(schema.codexResetRedemptionAttempts.status, "provider_started"),
12283
+ ),
12284
+ );
12285
+ if (blocked.length > 0) {
12286
+ return {
12287
+ removed: 0,
12288
+ blockedCredentialIds: blocked.map((row) => row.credentialId).sort(),
12289
+ };
12290
+ }
11170
12291
  const rows = await scopedDb
11171
12292
  .delete(schema.codexSubscriptionCredentials)
11172
12293
  .where(eq(schema.codexSubscriptionCredentials.workspaceId, workspaceId))
11173
12294
  .returning({ id: schema.codexSubscriptionCredentials.id });
11174
- return rows.length;
12295
+ return { removed: rows.length, blockedCredentialIds: [] };
11175
12296
  });
11176
12297
  }
11177
12298
 
@@ -11268,6 +12389,7 @@ function mapSessionMcpServerMetadata(
11268
12389
  url: row.url,
11269
12390
  headerNames: Object.keys(row.headersEncrypted ?? {}).sort(),
11270
12391
  credentialVersion: Number(row.credentialVersion),
12392
+ requireApproval: row.requireApproval ?? false,
11271
12393
  connectionRef: row.connectionRef ?? null,
11272
12394
  };
11273
12395
  }
@@ -11453,13 +12575,98 @@ async function updateSessionMcpServerCredentialsInTransaction(
11453
12575
  return { servers, missingIds };
11454
12576
  }
11455
12577
 
12578
+ export async function updateSessionMcpApprovalPolicy(
12579
+ db: Database,
12580
+ input: {
12581
+ workspaceId: string;
12582
+ sessionId: string;
12583
+ serverId: string;
12584
+ requireApproval: SessionMcpApprovalPolicy;
12585
+ },
12586
+ ): Promise<UpdateSessionMcpApprovalPolicyResult> {
12587
+ return await withWorkspaceRls(
12588
+ db,
12589
+ input.workspaceId,
12590
+ async (scopedDb) =>
12591
+ await scopedDb.transaction(
12592
+ async (tx) => await updateSessionMcpApprovalPolicyInTransaction(tx, input),
12593
+ ),
12594
+ );
12595
+ }
12596
+
12597
+ async function updateSessionMcpApprovalPolicyInTransaction(
12598
+ tx: Pick<Database, "select" | "update">,
12599
+ input: {
12600
+ workspaceId: string;
12601
+ sessionId: string;
12602
+ serverId: string;
12603
+ requireApproval: SessionMcpApprovalPolicy;
12604
+ },
12605
+ ): Promise<UpdateSessionMcpApprovalPolicyResult> {
12606
+ const [existing] = await tx
12607
+ .select()
12608
+ .from(schema.sessionMcpServers)
12609
+ .where(
12610
+ and(
12611
+ eq(schema.sessionMcpServers.workspaceId, input.workspaceId),
12612
+ eq(schema.sessionMcpServers.sessionId, input.sessionId),
12613
+ eq(schema.sessionMcpServers.serverId, input.serverId),
12614
+ ),
12615
+ )
12616
+ .for("update")
12617
+ .limit(1);
12618
+ if (!existing) {
12619
+ return { server: null, changed: false };
12620
+ }
12621
+ const current = existing.requireApproval ?? false;
12622
+ if (JSON.stringify(current) === JSON.stringify(input.requireApproval)) {
12623
+ return { server: mapSessionMcpServerMetadata(existing), changed: false };
12624
+ }
12625
+ const [updated] = await tx
12626
+ .update(schema.sessionMcpServers)
12627
+ .set({
12628
+ requireApproval: input.requireApproval,
12629
+ updatedAt: new Date(),
12630
+ })
12631
+ .where(
12632
+ and(
12633
+ eq(schema.sessionMcpServers.workspaceId, input.workspaceId),
12634
+ eq(schema.sessionMcpServers.sessionId, input.sessionId),
12635
+ eq(schema.sessionMcpServers.serverId, input.serverId),
12636
+ ),
12637
+ )
12638
+ .returning();
12639
+ if (!updated) {
12640
+ throw new Error(`Session MCP server disappeared during policy update: ${input.serverId}`);
12641
+ }
12642
+ return { server: mapSessionMcpServerMetadata(updated), changed: true };
12643
+ }
12644
+
11456
12645
  export async function listSessionMcpServersForRun(
11457
12646
  db: Database,
11458
12647
  workspaceId: string,
11459
12648
  sessionId: string,
12649
+ attemptId: string,
11460
12650
  encryptionKey: Uint8Array | null,
11461
12651
  ): Promise<SessionMcpServerForRun[]> {
11462
12652
  return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
12653
+ const [attempt] = await scopedDb
12654
+ .select({
12655
+ sessionId: schema.sessionTurnAttempts.sessionId,
12656
+ mcpApprovalPolicies: schema.sessionTurnAttempts.mcpApprovalPolicies,
12657
+ })
12658
+ .from(schema.sessionTurnAttempts)
12659
+ .where(
12660
+ and(
12661
+ eq(schema.sessionTurnAttempts.workspaceId, workspaceId),
12662
+ eq(schema.sessionTurnAttempts.id, attemptId),
12663
+ inArray(schema.sessionTurnAttempts.state, ["claimed", "running"]),
12664
+ ),
12665
+ )
12666
+ .limit(1);
12667
+ if (!attempt || attempt.sessionId !== sessionId) {
12668
+ throw new Error(`session MCP policy snapshot is unavailable for attempt ${attemptId}`);
12669
+ }
11463
12670
  const rows = await scopedDb
11464
12671
  .select()
11465
12672
  .from(schema.sessionMcpServers)
@@ -11471,6 +12678,11 @@ export async function listSessionMcpServersForRun(
11471
12678
  )
11472
12679
  .orderBy(asc(schema.sessionMcpServers.createdAt), asc(schema.sessionMcpServers.serverId));
11473
12680
  return rows.map((row) => {
12681
+ if (!Object.hasOwn(attempt.mcpApprovalPolicies, row.serverId)) {
12682
+ throw new Error(
12683
+ `session MCP policy snapshot is missing server ${row.serverId} for attempt ${attemptId}`,
12684
+ );
12685
+ }
11474
12686
  let headers: Record<string, string>;
11475
12687
  try {
11476
12688
  if (!encryptionKey && Object.keys(row.headersEncrypted ?? {}).length > 0) {
@@ -11490,7 +12702,7 @@ export async function listSessionMcpServersForRun(
11490
12702
  ...(row.allowedTools ? { allowedTools: row.allowedTools } : {}),
11491
12703
  ...(row.timeoutMs ? { timeoutMs: row.timeoutMs } : {}),
11492
12704
  ...(row.cacheToolsList ? { cacheToolsList: row.cacheToolsList } : {}),
11493
- ...(row.requireApproval != null ? { requireApproval: row.requireApproval } : {}),
12705
+ requireApproval: attempt.mcpApprovalPolicies[row.serverId]!,
11494
12706
  headers,
11495
12707
  };
11496
12708
  });
@@ -11558,6 +12770,7 @@ export async function createSession(
11558
12770
  initialTurnInstructions?: string | null;
11559
12771
  resources: ResourceRef[];
11560
12772
  tools?: ToolRef[];
12773
+ toolPolicy?: SessionToolPolicy | null;
11561
12774
  metadata: Record<string, unknown>;
11562
12775
  /**
11563
12776
  * Frozen creator authority. Legacy/test-only callers may omit this and get
@@ -11608,6 +12821,7 @@ export async function createSession(
11608
12821
  initialTurnInstructions: input.initialTurnInstructions ?? null,
11609
12822
  resources: input.resources,
11610
12823
  tools: input.tools ?? [],
12824
+ toolPolicy: input.toolPolicy ?? null,
11611
12825
  metadata: input.metadata,
11612
12826
  ...creatorColumns(frozenCreator),
11613
12827
  model: input.model,
@@ -11662,6 +12876,7 @@ export async function createSessionWithIdempotencyKey(
11662
12876
  initialTurnInstructions?: string | null;
11663
12877
  resources: ResourceRef[];
11664
12878
  tools?: ToolRef[];
12879
+ toolPolicy?: SessionToolPolicy | null;
11665
12880
  metadata: Record<string, unknown>;
11666
12881
  createdBy?: TurnInitiator;
11667
12882
  createdByContext?: TurnInitiatorContext;
@@ -11705,6 +12920,7 @@ export async function createSessionWithIdempotencyKey(
11705
12920
  initialTurnInstructions: input.initialTurnInstructions ?? null,
11706
12921
  resources: input.resources,
11707
12922
  tools: input.tools ?? [],
12923
+ toolPolicy: input.toolPolicy ?? null,
11708
12924
  metadata: input.metadata,
11709
12925
  ...creatorColumns(frozenCreator),
11710
12926
  model: input.model,
@@ -13595,6 +14811,14 @@ export type ListSessionEventsOptions = {
13595
14811
  excludeClasses?: readonly SessionEventSemanticClass[];
13596
14812
  defaultExcludeTypes?: readonly SessionEventType[];
13597
14813
  payloadMode?: SessionEventPayloadMode;
14814
+ /**
14815
+ * Internal exclusive-latest selector. Eligible legacy rows with a null
14816
+ * association remain visible, while late-rejected and duplicate callbacks do
14817
+ * not compete with current truth. Durable session sequence is authoritative
14818
+ * across distinct turns; turn generation is metadata only here. Callers must
14819
+ * pair this with a single semantic class and limit 1.
14820
+ */
14821
+ authoritativeLatest?: boolean;
13598
14822
  };
13599
14823
 
13600
14824
  export type ListSessionEventPageOptions = ListSessionEventsOptions & {
@@ -13691,6 +14915,20 @@ export async function listSessionEventPage(
13691
14915
  eq(schema.sessionEvents.sessionId, sessionId),
13692
14916
  gt(schema.sessionEvents.sequence, after),
13693
14917
  ];
14918
+ if (options.authoritativeLatest) {
14919
+ // Historical rows predate association stamping and intentionally carry
14920
+ // null. They remain eligible; explicitly stale/duplicate rows and rows
14921
+ // carrying a duplicate reference cannot compete with current truth.
14922
+ filters.push(
14923
+ and(
14924
+ or(
14925
+ isNull(schema.sessionEvents.turnAssociation),
14926
+ eq(schema.sessionEvents.turnAssociation, "current"),
14927
+ )!,
14928
+ isNull(schema.sessionEvents.duplicateOfEventId),
14929
+ )!,
14930
+ );
14931
+ }
13694
14932
  if (typeFilters.includeTypes.length > 0) {
13695
14933
  filters.push(inArray(schema.sessionEvents.type, typeFilters.includeTypes));
13696
14934
  }
@@ -13713,9 +14951,11 @@ export async function listSessionEventPage(
13713
14951
  .from(schema.sessionEvents)
13714
14952
  .where(and(...filters))
13715
14953
  .orderBy(
13716
- direction === "before"
14954
+ options.authoritativeLatest
13717
14955
  ? desc(schema.sessionEvents.sequence)
13718
- : asc(schema.sessionEvents.sequence),
14956
+ : direction === "before"
14957
+ ? desc(schema.sessionEvents.sequence)
14958
+ : asc(schema.sessionEvents.sequence),
13719
14959
  )
13720
14960
  .limit(queryLimit);
13721
14961
  if (rows.length === 0) break;
@@ -13956,7 +15196,42 @@ export async function listSessionEvents(
13956
15196
  });
13957
15197
  }
13958
15198
 
13959
- export type ToolspaceCallReservation = { reserved: true; count: number } | { reserved: false };
15199
+ export type ToolspaceCallReservation =
15200
+ | { reserved: true; count: number; turn: SessionTurnForExecution }
15201
+ | { reserved: false; reason: TurnAttemptFenceRejectReason | "budget_exhausted" };
15202
+
15203
+ export type ToolspaceTurnAttemptClaims = {
15204
+ sessionId: string;
15205
+ turnId: string;
15206
+ attemptId: string;
15207
+ executionGeneration: number;
15208
+ };
15209
+
15210
+ /**
15211
+ * Admit one exact Toolspace bearer before any session credential is decrypted or
15212
+ * any upstream schema is enumerated. This intentionally reuses the canonical
15213
+ * activity write fence so Pause/Steer, attempt replacement, generation changes,
15214
+ * and terminal settlement revoke a copied token at the same linearization point
15215
+ * as other attempt-owned writes.
15216
+ */
15217
+ export async function admitToolspaceTurnAttempt(
15218
+ db: Database,
15219
+ workspaceId: string,
15220
+ claims: ToolspaceTurnAttemptClaims,
15221
+ ): Promise<boolean> {
15222
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
15223
+ return await scopedDb.transaction(async (tx) => {
15224
+ const fence = await lockTurnAttemptWriteFenceTx(tx, {
15225
+ workspaceId,
15226
+ sessionId: claims.sessionId,
15227
+ turnId: claims.turnId,
15228
+ attemptId: claims.attemptId,
15229
+ executionGeneration: claims.executionGeneration,
15230
+ });
15231
+ return fence.allowed && fence.turn.status === "running";
15232
+ });
15233
+ });
15234
+ }
13960
15235
 
13961
15236
  /**
13962
15237
  * Atomically reserve one toolspace call against a turn's per-turn budget.
@@ -13965,33 +15240,73 @@ export type ToolspaceCallReservation = { reserved: true; count: number } | { res
13965
15240
  * below `limit` and returns the post-increment value. Concurrent reservations
13966
15241
  * for the same turn serialize on the row lock, so exactly `limit` of N
13967
15242
  * simultaneous callers observe `reserved: true` — closing the read-then-append
13968
- * TOCTOU the event-count approach had. `reserved: false` means the turn is at or
13969
- * over budget (or the turn row no longer exists).
15243
+ * TOCTOU the event-count approach had. The returned attempt id is captured by
15244
+ * that same UPDATE, so callers cannot accidentally execute under a successor
15245
+ * attempt. `reserved: false` means the turn is not executable, at/over budget,
15246
+ * or no longer exists.
13970
15247
  */
13971
- export async function reserveToolspaceCallForTurn(
15248
+ export async function reserveToolspaceCallForAttempt(
13972
15249
  db: Database,
13973
- workspaceId: string,
13974
- sessionId: string,
13975
- turnId: string,
13976
- limit: number,
15250
+ input: {
15251
+ accountId: string;
15252
+ workspaceId: string;
15253
+ sessionId: string;
15254
+ turnId: string;
15255
+ executionGeneration: number;
15256
+ attemptId: string;
15257
+ limit: number;
15258
+ },
13977
15259
  ): Promise<ToolspaceCallReservation> {
13978
- return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
13979
- const [row] = await scopedDb
13980
- .update(schema.sessionTurns)
13981
- .set({
13982
- toolspaceCallCount: sql`${schema.sessionTurns.toolspaceCallCount} + 1`,
13983
- })
13984
- .where(
13985
- and(
13986
- eq(schema.sessionTurns.workspaceId, workspaceId),
13987
- eq(schema.sessionTurns.sessionId, sessionId),
13988
- eq(schema.sessionTurns.id, turnId),
13989
- sql`${schema.sessionTurns.toolspaceCallCount} < ${limit}`,
13990
- ),
13991
- )
13992
- .returning({ count: schema.sessionTurns.toolspaceCallCount });
13993
- return row ? { reserved: true, count: Number(row.count) } : { reserved: false };
13994
- });
15260
+ return await withRlsContext(
15261
+ db,
15262
+ { accountId: input.accountId, workspaceId: input.workspaceId },
15263
+ async (scopedDb) =>
15264
+ await scopedDb.transaction(async (tx) => {
15265
+ const fence = await lockTurnAttemptWriteFenceTx(tx as unknown as Database, {
15266
+ workspaceId: input.workspaceId,
15267
+ sessionId: input.sessionId,
15268
+ turnId: input.turnId,
15269
+ executionGeneration: input.executionGeneration,
15270
+ attemptId: input.attemptId,
15271
+ });
15272
+ if (!fence.allowed) {
15273
+ return { reserved: false, reason: fence.reason };
15274
+ }
15275
+ if (fence.turn.status !== "running") {
15276
+ return { reserved: false, reason: "turn_terminal" };
15277
+ }
15278
+ if (Number(fence.turn.toolspaceCallCount) >= input.limit) {
15279
+ return { reserved: false, reason: "budget_exhausted" };
15280
+ }
15281
+ const [row] = await tx
15282
+ .update(schema.sessionTurns)
15283
+ .set({
15284
+ toolspaceCallCount: sql`${schema.sessionTurns.toolspaceCallCount} + 1`,
15285
+ })
15286
+ .where(
15287
+ and(
15288
+ eq(schema.sessionTurns.workspaceId, input.workspaceId),
15289
+ eq(schema.sessionTurns.sessionId, input.sessionId),
15290
+ eq(schema.sessionTurns.id, input.turnId),
15291
+ eq(schema.sessionTurns.executionGeneration, input.executionGeneration),
15292
+ eq(schema.sessionTurns.activeAttemptId, input.attemptId),
15293
+ sql`${schema.sessionTurns.toolspaceCallCount} < ${input.limit}`,
15294
+ ),
15295
+ )
15296
+ .returning({ count: schema.sessionTurns.toolspaceCallCount });
15297
+ if (!row) {
15298
+ throw new Error("Toolspace call reservation lost its locked turn");
15299
+ }
15300
+ return {
15301
+ reserved: true,
15302
+ count: Number(row.count),
15303
+ turn: mapSessionTurnForExecution({
15304
+ ...fence.turn,
15305
+ toolspaceCallCount: Number(row.count),
15306
+ }),
15307
+ };
15308
+ }),
15309
+ );
13995
15310
  }
13996
15311
 
13997
15312
  function normalizeEventSequence(value: number | undefined, fallback: number): number {
@@ -14809,6 +16124,93 @@ async function lockTurnAttemptWriteFenceTx(
14809
16124
  return { allowed: true, workspace, session, turn, attempt };
14810
16125
  }
14811
16126
 
16127
+ export type InstallOrReadTurnExecutionPolicyForAttemptResult =
16128
+ | {
16129
+ accepted: true;
16130
+ installed: boolean;
16131
+ policy: TurnExecutionPolicyV1;
16132
+ turn: SessionTurn;
16133
+ }
16134
+ | {
16135
+ accepted: false;
16136
+ reason: TurnAttemptFenceRejectReason;
16137
+ };
16138
+
16139
+ /**
16140
+ * Freeze a legacy turn's execution identity at its first admitted attempt, or
16141
+ * return the policy already accepted for this logical turn. The exact active
16142
+ * attempt owns this mutation; malformed present metadata fails closed and a
16143
+ * replay never replaces an existing valid policy.
16144
+ */
16145
+ export async function installOrReadTurnExecutionPolicyForAttempt(
16146
+ db: Database,
16147
+ input: {
16148
+ accountId: string;
16149
+ workspaceId: string;
16150
+ sessionId: string;
16151
+ turnId: string;
16152
+ executionGeneration: number;
16153
+ attemptId: string;
16154
+ policyForAbsent: TurnExecutionPolicyV1;
16155
+ },
16156
+ ): Promise<InstallOrReadTurnExecutionPolicyForAttemptResult> {
16157
+ return await withRlsContext(
16158
+ db,
16159
+ { accountId: input.accountId, workspaceId: input.workspaceId },
16160
+ async (scopedDb) =>
16161
+ await scopedDb.transaction(async (tx) => {
16162
+ const fence = await lockTurnAttemptWriteFenceTx(tx, {
16163
+ workspaceId: input.workspaceId,
16164
+ sessionId: input.sessionId,
16165
+ turnId: input.turnId,
16166
+ executionGeneration: input.executionGeneration,
16167
+ attemptId: input.attemptId,
16168
+ });
16169
+ if (!fence.allowed) {
16170
+ return { accepted: false as const, reason: fence.reason };
16171
+ }
16172
+
16173
+ const existing = readTurnExecutionPolicyV1(fence.turn.metadata);
16174
+ if (existing.kind === "valid") {
16175
+ return {
16176
+ accepted: true as const,
16177
+ installed: false,
16178
+ policy: existing.policy,
16179
+ turn: mapSessionTurn(fence.turn),
16180
+ };
16181
+ }
16182
+
16183
+ const policy = TurnExecutionPolicyV1.parse(input.policyForAbsent);
16184
+ const [updated] = await tx
16185
+ .update(schema.sessionTurns)
16186
+ .set({
16187
+ metadata: metadataWithTurnExecutionPolicyV1(fence.turn.metadata, policy),
16188
+ updatedAt: new Date(),
16189
+ })
16190
+ .where(
16191
+ and(
16192
+ eq(schema.sessionTurns.workspaceId, input.workspaceId),
16193
+ eq(schema.sessionTurns.sessionId, input.sessionId),
16194
+ eq(schema.sessionTurns.id, input.turnId),
16195
+ eq(schema.sessionTurns.status, "running"),
16196
+ eq(schema.sessionTurns.executionGeneration, input.executionGeneration),
16197
+ eq(schema.sessionTurns.activeAttemptId, input.attemptId),
16198
+ ),
16199
+ )
16200
+ .returning();
16201
+ if (!updated) {
16202
+ throw new Error("Turn execution policy owner changed while its row was locked");
16203
+ }
16204
+ return {
16205
+ accepted: true as const,
16206
+ installed: true,
16207
+ policy,
16208
+ turn: mapSessionTurn(updated),
16209
+ };
16210
+ }),
16211
+ );
16212
+ }
16213
+
14812
16214
  /**
14813
16215
  * Append conversation items (verbatim SDK AgentInputItems) to the session's
14814
16216
  * history. Idempotent on (workspace, session, position): concurrent or
@@ -14941,6 +16343,47 @@ export async function registerPendingSessionToolCall(
14941
16343
  );
14942
16344
  }
14943
16345
 
16346
+ /**
16347
+ * Clear one completed Toolspace receipt after its attempt-fenced output event is
16348
+ * durable. If control already replaced the attempt, its settlement transaction
16349
+ * owns the receipt and will retain an explicit outcome-unknown or the durable
16350
+ * output event instead.
16351
+ */
16352
+ export async function clearPendingSessionToolspaceCall(
16353
+ db: Database,
16354
+ input: Omit<PendingSessionToolCallInput, "callType" | "callItem">,
16355
+ ): Promise<{ accepted: boolean; cleared: boolean }> {
16356
+ return await withRlsContext(
16357
+ db,
16358
+ { accountId: input.accountId, workspaceId: input.workspaceId },
16359
+ async (scopedDb) =>
16360
+ await scopedDb.transaction(async (tx) => {
16361
+ const fence = await lockTurnAttemptWriteFenceTx(tx as unknown as Database, {
16362
+ workspaceId: input.workspaceId,
16363
+ sessionId: input.sessionId,
16364
+ turnId: input.turnId,
16365
+ executionGeneration: input.executionGeneration,
16366
+ attemptId: input.attemptId,
16367
+ });
16368
+ if (!fence.allowed) return { accepted: false, cleared: false };
16369
+ const deleted = await tx
16370
+ .delete(schema.sessionPendingToolCalls)
16371
+ .where(
16372
+ and(
16373
+ eq(schema.sessionPendingToolCalls.workspaceId, input.workspaceId),
16374
+ eq(schema.sessionPendingToolCalls.sessionId, input.sessionId),
16375
+ eq(schema.sessionPendingToolCalls.turnId, input.turnId),
16376
+ eq(schema.sessionPendingToolCalls.attemptId, input.attemptId),
16377
+ eq(schema.sessionPendingToolCalls.callId, input.callId),
16378
+ eq(schema.sessionPendingToolCalls.callType, "toolspace_call"),
16379
+ ),
16380
+ )
16381
+ .returning({ id: schema.sessionPendingToolCalls.id });
16382
+ return { accepted: true, cleared: deleted.length === 1 };
16383
+ }),
16384
+ );
16385
+ }
16386
+
14944
16387
  /** Record the raw SDK result without dropping the call receipt. */
14945
16388
  export async function recordPendingSessionToolCallResult(
14946
16389
  db: Database,
@@ -16618,8 +18061,9 @@ export async function acquireLease(
16618
18061
  // (2) Serialize ALL concurrent arrivals on this group's row. Plain FOR
16619
18062
  // UPDATE (block, do NOT skip) — unlike concurrent enrollment scans,
16620
18063
  // because we WANT the loser to block then attach, not skip and lose.
16621
- const rows = await tx.execute<LeaseRow>(sql`
16622
- select * from sandbox_leases
18064
+ const rows = await tx.execute<LeaseRow & { draining_expired: boolean }>(sql`
18065
+ select *, (liveness = 'draining' and expires_at <= now()) as draining_expired
18066
+ from sandbox_leases
16623
18067
  where workspace_id = ${workspaceId} and sandbox_group_id = ${sandboxGroupId}
16624
18068
  for update
16625
18069
  `);
@@ -16628,6 +18072,16 @@ export async function acquireLease(
16628
18072
 
16629
18073
  let liveness = row.liveness;
16630
18074
 
18075
+ // An expired DRAINING row is owned by the reaper's provider teardown,
18076
+ // not by a new arrival. In particular, do this before image/rig conflict
18077
+ // handling: a conflicting successor must not take the SOLO-recreate path
18078
+ // and clear the attributed instance while the reaper/old creator still
18079
+ // owns its termination. The row remains drainable until confirmDrainCold
18080
+ // settles it, so callers fail closed and retry after the box is cold.
18081
+ if (liveness === "draining" && row.draining_expired) {
18082
+ return { role: "fenced" as const, lease: mapLeaseRow(row) };
18083
+ }
18084
+
16631
18085
  // -- SHARED STATE CONFLICT (B3 image + M3 rig): a LIVE box (warm/draining/warming)
16632
18086
  // was created under a specific image AND rig version. If this run resolves a
16633
18087
  // DIFFERENT image OR a DIFFERENT rig version (each checked only when both sides are
@@ -16690,7 +18144,9 @@ export async function acquireLease(
16690
18144
  liveness = "cold";
16691
18145
  }
16692
18146
 
16693
- // -- draining: late arrival re-arms (D1). Box still alive (grace open).
18147
+ // -- draining: late arrival re-arms (D1) only while the grace is open.
18148
+ // An expired row was fenced above because the reaper owns its provider
18149
+ // teardown and must not lose the attributed instance race.
16694
18150
  if (liveness === "draining") {
16695
18151
  await upsertLeaseHolder(tx, row.id, accountId, workspaceId, kind, holderId, subjectId);
16696
18152
  const updated = await recomputeAndStampLease(tx, row.id, input.leaseTtlMs, "warm");
@@ -16747,8 +18203,11 @@ export async function acquireLease(
16747
18203
  );
16748
18204
  }
16749
18205
 
16750
- // §4.2 — the ONLY lease_epoch++ site. CAS on (warming AND lease_epoch=expected).
16751
- // Folds the group box-envelope (resume_backend_id/resume_state) onto the lease.
18206
+ // §4.2 — warm commit and every warming invalidation are epoch-fenced transitions.
18207
+ // CAS on (warming AND lease_epoch=expected) folds the group box-envelope
18208
+ // (resume_backend_id/resume_state) onto the lease. A rollback/reset also bumps
18209
+ // the epoch before exposing cold/draining, permanently fencing any provider
18210
+ // create callback that is still unresolved from the invalidated acquisition.
16752
18211
  export async function commitWarmingToWarm(
16753
18212
  db: Database,
16754
18213
  input: {
@@ -16935,7 +18394,9 @@ export async function markWarmLeaseInstanceLost(
16935
18394
 
16936
18395
  // §4.3 — caught spawn failure: warming -> cold (W3). Holders are intentionally
16937
18396
  // left intact — the arrival that triggered the spawn still wants a box, so the
16938
- // next acquireLease re-CAS cold->warming.
18397
+ // next acquireLease re-CAS cold->warming. The rollback increments the epoch in
18398
+ // the same CAS, so a non-abortable provider create from this invalidated
18399
+ // acquisition can never mutate a successor that reuses the lease row.
16939
18400
  //
16940
18401
  // ARCHIVE PRESERVATION (sandbox-file-persistence): when the cold lease that was
16941
18402
  // selected for re-warm carried a persisted /workspace archive on its resume_state
@@ -16965,6 +18426,7 @@ export async function failWarmingToCold(
16965
18426
  update sandbox_leases set
16966
18427
  liveness = 'cold', instance_id = null,
16967
18428
  data_plane_url = null, terminal_data_plane_url = null, updated_at = now(),
18429
+ lease_epoch = lease_epoch + 1,
16968
18430
  resume_state = case
16969
18431
  when (resume_state #>> '{sessionState,workspaceArchive}') is not null
16970
18432
  then jsonb_build_object(
@@ -17234,21 +18696,24 @@ export async function reapStaleLeaseHolders(
17234
18696
  `);
17235
18697
 
17236
18698
  // (c1) WARMING-death before provider create returned: no instance_id was
17237
- // ever persisted, so there is no provider box to stop. Reset to cold so a
17238
- // queued turn can re-acquire and re-spawn.
18699
+ // ever persisted, so there is no provider box to stop. Reset to cold and
18700
+ // bump the epoch so any late provider callback is fenced before a queued
18701
+ // turn can re-acquire and re-spawn.
17239
18702
  const warmingReset = await tx.execute<{ id: string }>(sql`
17240
18703
  update sandbox_leases set
17241
18704
  liveness = 'cold', instance_id = null,
17242
18705
  resume_backend_id = null, resume_state = null,
17243
- data_plane_url = null, terminal_data_plane_url = null, updated_at = now()
18706
+ data_plane_url = null, terminal_data_plane_url = null,
18707
+ lease_epoch = lease_epoch + 1, updated_at = now()
17244
18708
  where workspace_id = ${input.workspaceId}
17245
18709
  and liveness = 'warming' and expires_at < now() and instance_id is null
17246
18710
  returning id
17247
18711
  `);
17248
18712
 
17249
18713
  // (c2) WARMING-death after provider create returned: instance_id is known,
17250
- // so do NOT drop it. Convert to immediately-drainable so the caller's
17251
- // provider terminate path stops the box before the lease goes cold.
18714
+ // so do NOT drop it. Bump the epoch and convert to immediately-drainable
18715
+ // so the caller's provider terminate path stops the box before the lease
18716
+ // goes cold, while late creator callbacks are fenced.
17252
18717
  const warmingDrain = await tx.execute<{ id: string }>(sql`
17253
18718
  update sandbox_leases set
17254
18719
  liveness = 'draining',
@@ -17257,6 +18722,7 @@ export async function reapStaleLeaseHolders(
17257
18722
  viewer_holders = 0,
17258
18723
  data_plane_url = null,
17259
18724
  terminal_data_plane_url = null,
18725
+ lease_epoch = lease_epoch + 1,
17260
18726
  expires_at = now() - interval '1 millisecond',
17261
18727
  updated_at = now()
17262
18728
  where workspace_id = ${input.workspaceId}
@@ -17510,6 +18976,7 @@ export async function reArmDrainingLease(
17510
18976
  updated_at = now()
17511
18977
  where workspace_id = ${input.workspaceId} and sandbox_group_id = ${input.sandboxGroupId}
17512
18978
  and liveness = 'draining'
18979
+ and expires_at > now()
17513
18980
  returning id
17514
18981
  `);
17515
18982
  return { rearmed: rows.length > 0 };
@@ -21300,6 +22767,8 @@ export type InitializeSessionStartInput = {
21300
22767
  sessionId: string;
21301
22768
  clientEventId?: string;
21302
22769
  reasoningEffortFallback: ReasoningEffort;
22770
+ /** Trusted create-session policy. Omitted only by legacy low-level callers. */
22771
+ turnExecutionPolicy?: TurnExecutionPolicyV1;
21303
22772
  createdEventPayload: Record<string, unknown>;
21304
22773
  goal?: {
21305
22774
  text: string;
@@ -21506,6 +22975,7 @@ export async function initializeSessionStartAtomically(
21506
22975
  turnInstructions: session.initialTurnInstructions ?? null,
21507
22976
  resources: session.resources,
21508
22977
  tools: session.tools,
22978
+ toolsProvided: session.toolPolicy?.mode === "explicit",
21509
22979
  model: session.model,
21510
22980
  reasoningEffort: reasoningEffortForMetadata(
21511
22981
  session.metadata,
@@ -21513,7 +22983,9 @@ export async function initializeSessionStartAtomically(
21513
22983
  ),
21514
22984
  sandboxBackend: session.sandboxBackend,
21515
22985
  sandboxOs: session.sandboxOs,
21516
- metadata: {},
22986
+ metadata: input.turnExecutionPolicy
22987
+ ? metadataWithTurnExecutionPolicyV1({}, input.turnExecutionPolicy)
22988
+ : {},
21517
22989
  lineage: {},
21518
22990
  ...initiatorColumns(creator),
21519
22991
  })
@@ -21653,6 +23125,7 @@ export async function enqueueSessionTurn(
21653
23125
  turnInstructions: input.turnInstructions ?? null,
21654
23126
  resources: input.resources,
21655
23127
  tools: input.tools,
23128
+ toolsProvided: input.toolsProvided ?? false,
21656
23129
  model: input.model,
21657
23130
  reasoningEffort: input.reasoningEffort,
21658
23131
  sandboxBackend: input.sandboxBackend,
@@ -21963,8 +23436,24 @@ export async function claimSessionWorkForAttempt(
21963
23436
  if (unquiescedInterruption) {
21964
23437
  return { action: "unclaimed", reason: "control-pending" };
21965
23438
  }
21966
- const registerAttempt = async (turn: typeof schema.sessionTurns.$inferSelect) =>
21967
- await registerSessionTurnAttemptClaim(tx as unknown as Database, {
23439
+ const registerAttempt = async (turn: typeof schema.sessionTurns.$inferSelect) => {
23440
+ const policyRows = await tx
23441
+ .select({
23442
+ serverId: schema.sessionMcpServers.serverId,
23443
+ requireApproval: schema.sessionMcpServers.requireApproval,
23444
+ })
23445
+ .from(schema.sessionMcpServers)
23446
+ .where(
23447
+ and(
23448
+ eq(schema.sessionMcpServers.workspaceId, workspaceId),
23449
+ eq(schema.sessionMcpServers.sessionId, sessionId),
23450
+ ),
23451
+ )
23452
+ .orderBy(asc(schema.sessionMcpServers.serverId));
23453
+ const mcpApprovalPolicies: Record<string, SessionMcpApprovalPolicy> = Object.fromEntries(
23454
+ policyRows.map((row) => [row.serverId, row.requireApproval ?? false]),
23455
+ );
23456
+ return await registerSessionTurnAttemptClaim(tx as unknown as Database, {
21968
23457
  id: input.attemptId,
21969
23458
  accountId: session.accountId,
21970
23459
  workspaceId,
@@ -21975,7 +23464,9 @@ export async function claimSessionWorkForAttempt(
21975
23464
  temporalWorkflowRunId: input.workflowRunId,
21976
23465
  temporalActivityId: input.dispatchId,
21977
23466
  verifiedControlRevision: Number(workspaceControl.revision),
23467
+ mcpApprovalPolicies,
21978
23468
  });
23469
+ };
21979
23470
  if (session.activeTurnId !== null) {
21980
23471
  const [activeTurnPreview] = await tx
21981
23472
  .select()
@@ -24483,6 +25974,7 @@ export async function applySessionTurnSettlement(
24483
25974
  effectiveSessionStatus !== input.sessionStatus
24484
25975
  ? { ...payload, status: effectiveSessionStatus }
24485
25976
  : payload,
25977
+ { fullEvidence: event.retainedOutputEvidence },
24486
25978
  ),
24487
25979
  clientEventId: event.clientEventId ?? null,
24488
25980
  turnId: input.turnId,
@@ -25570,6 +27062,57 @@ export async function getSessionTurn(
25570
27062
  });
25571
27063
  }
25572
27064
 
27065
+ /**
27066
+ * Resolve the exact currently executable turn/attempt from authoritative
27067
+ * pointers in one query. This avoids treating a stale session preview as proof
27068
+ * that no attempt exists during an atomic claim transition.
27069
+ */
27070
+ export async function getActiveSessionTurnForExecution(
27071
+ db: Database,
27072
+ workspaceId: string,
27073
+ sessionId: string,
27074
+ ): Promise<SessionTurnForExecution | null> {
27075
+ return await withWorkspaceRls(db, workspaceId, async (scopedDb) => {
27076
+ const [row] = await scopedDb
27077
+ .select({ turn: schema.sessionTurns })
27078
+ .from(schema.sessions)
27079
+ .innerJoin(
27080
+ schema.sessionTurns,
27081
+ and(
27082
+ eq(schema.sessionTurns.workspaceId, schema.sessions.workspaceId),
27083
+ eq(schema.sessionTurns.id, schema.sessions.activeTurnId),
27084
+ ),
27085
+ )
27086
+ .innerJoin(
27087
+ schema.sessionTurnAttempts,
27088
+ and(
27089
+ eq(schema.sessionTurnAttempts.workspaceId, schema.sessionTurns.workspaceId),
27090
+ eq(schema.sessionTurnAttempts.id, schema.sessionTurns.activeAttemptId),
27091
+ ),
27092
+ )
27093
+ .where(
27094
+ and(
27095
+ eq(schema.sessions.workspaceId, workspaceId),
27096
+ eq(schema.sessions.id, sessionId),
27097
+ eq(schema.sessionTurns.sessionId, sessionId),
27098
+ eq(schema.sessionTurnAttempts.sessionId, sessionId),
27099
+ eq(schema.sessionTurnAttempts.turnId, schema.sessionTurns.id),
27100
+ inArray(schema.sessionTurnAttempts.state, ["claimed", "running"]),
27101
+ inArray(schema.sessionTurns.status, ["running", "recovering", "waiting_capacity"]),
27102
+ sql`not exists (
27103
+ select 1
27104
+ from ${schema.sessionAttemptInterruptions} interruption
27105
+ where interruption.workspace_id = ${workspaceId}
27106
+ and interruption.attempt_id = ${schema.sessionTurnAttempts.id}
27107
+ and interruption.state in ('pending', 'delivered', 'acknowledged')
27108
+ )`,
27109
+ ),
27110
+ )
27111
+ .limit(1);
27112
+ return row ? mapSessionTurnForExecution(row.turn) : null;
27113
+ });
27114
+ }
27115
+
25573
27116
  export async function getSessionTurnForAttempt(
25574
27117
  db: Database,
25575
27118
  workspaceId: string,
@@ -26070,40 +27613,85 @@ export async function claimPendingSessionWorkflowWakes(
26070
27613
  }
26071
27614
 
26072
27615
  /**
26073
- * Acknowledge an immediate post-commit signal. An older sender may advance only
26074
- * its own revision; it cannot clear a claim or failure state belonging to a
26075
- * newer revision.
27616
+ * Acknowledge an immediate post-commit signal only after it cannot strand an
27617
+ * accepted Agent Steer. Temporal accepting a signal is transport evidence, not
27618
+ * proof that a closing workflow observed Postgres or admitted its pending
27619
+ * direction. While active control still has an actionable Agent Steer, retain
27620
+ * the revision so the bounded outbox dispatcher retries signalWithStart. The
27621
+ * attempt-fenced claim consumes the update once; a real Pause is the typed
27622
+ * blocker and may acknowledge this revision because Resume commits a new one.
27623
+ *
27624
+ * An older sender may advance only its own revision; it cannot clear a claim or
27625
+ * failure state belonging to a newer revision. The control -> workspace ->
27626
+ * session lock prefix serializes this decision with Steer, Pause/Resume, and
27627
+ * claim without weakening physical-quiescence admission.
26076
27628
  */
27629
+ export type SessionWorkflowWakeDeliveryResult =
27630
+ | { action: "acknowledged" }
27631
+ | { action: "pending_admission"; blocker: "pending_agent_steer" };
27632
+
26077
27633
  export async function markSessionWorkflowWakeDelivered(
26078
27634
  db: Database,
26079
27635
  input: Omit<SessionWorkflowWake, "interruptionRequested">,
26080
- ): Promise<void> {
26081
- await withRlsContext(
27636
+ ): Promise<SessionWorkflowWakeDeliveryResult> {
27637
+ return await withRlsContext(
26082
27638
  db,
26083
27639
  { accountId: input.accountId, workspaceId: input.workspaceId },
26084
- async (scopedDb) => {
26085
- const [row] = await scopedDb
26086
- .update(schema.sessionWorkflowWakeOutbox)
26087
- .set({
26088
- deliveredRevision: sql`greatest(${schema.sessionWorkflowWakeOutbox.deliveredRevision}, ${input.wakeRevision})`,
26089
- attempts: sql`case when ${schema.sessionWorkflowWakeOutbox.wakeRevision} = ${input.wakeRevision} then 0 else ${schema.sessionWorkflowWakeOutbox.attempts} end`,
26090
- lastError: sql`case when ${schema.sessionWorkflowWakeOutbox.wakeRevision} = ${input.wakeRevision} then null else ${schema.sessionWorkflowWakeOutbox.lastError} end`,
26091
- updatedAt: new Date(),
26092
- })
26093
- .where(
26094
- and(
26095
- eq(schema.sessionWorkflowWakeOutbox.workspaceId, input.workspaceId),
26096
- eq(schema.sessionWorkflowWakeOutbox.sessionId, input.sessionId),
26097
- gte(schema.sessionWorkflowWakeOutbox.wakeRevision, input.wakeRevision),
26098
- ),
26099
- )
26100
- .returning({ sessionId: schema.sessionWorkflowWakeOutbox.sessionId });
26101
- if (!row) {
26102
- throw new Error(
26103
- `Workflow wake revision ${input.wakeRevision} is not current for session ${input.sessionId}`,
27640
+ async (scopedDb) =>
27641
+ await scopedDb.transaction(async (tx) => {
27642
+ const locks = await lockSessionEventWriteRows(tx as unknown as Database, {
27643
+ workspaceId: input.workspaceId,
27644
+ controlLock: "share",
27645
+ sessionIds: [input.sessionId],
27646
+ });
27647
+ const session = locks.sessions[0];
27648
+ if (!session) throw new Error(`Session not found: ${input.sessionId}`);
27649
+ const effectiveControl = await evaluateSessionControl(
27650
+ tx as unknown as Database,
27651
+ input.workspaceId,
27652
+ input.sessionId,
27653
+ { workspaceControl: locks.control ?? undefined },
26104
27654
  );
26105
- }
26106
- },
27655
+ if (effectiveControl.state === "active") {
27656
+ const [pendingAgentSteer] = await tx
27657
+ .select({ id: schema.sessionSystemUpdates.id })
27658
+ .from(schema.sessionSystemUpdates)
27659
+ .where(
27660
+ and(
27661
+ eq(schema.sessionSystemUpdates.workspaceId, input.workspaceId),
27662
+ eq(schema.sessionSystemUpdates.sessionId, input.sessionId),
27663
+ eq(schema.sessionSystemUpdates.kind, "agent_steer_instruction"),
27664
+ eq(schema.sessionSystemUpdates.state, "pending"),
27665
+ ),
27666
+ )
27667
+ .limit(1);
27668
+ if (pendingAgentSteer) {
27669
+ return { action: "pending_admission", blocker: "pending_agent_steer" } as const;
27670
+ }
27671
+ }
27672
+ const [row] = await tx
27673
+ .update(schema.sessionWorkflowWakeOutbox)
27674
+ .set({
27675
+ deliveredRevision: sql`greatest(${schema.sessionWorkflowWakeOutbox.deliveredRevision}, ${input.wakeRevision})`,
27676
+ attempts: sql`case when ${schema.sessionWorkflowWakeOutbox.wakeRevision} = ${input.wakeRevision} then 0 else ${schema.sessionWorkflowWakeOutbox.attempts} end`,
27677
+ lastError: sql`case when ${schema.sessionWorkflowWakeOutbox.wakeRevision} = ${input.wakeRevision} then null else ${schema.sessionWorkflowWakeOutbox.lastError} end`,
27678
+ updatedAt: new Date(),
27679
+ })
27680
+ .where(
27681
+ and(
27682
+ eq(schema.sessionWorkflowWakeOutbox.workspaceId, input.workspaceId),
27683
+ eq(schema.sessionWorkflowWakeOutbox.sessionId, input.sessionId),
27684
+ gte(schema.sessionWorkflowWakeOutbox.wakeRevision, input.wakeRevision),
27685
+ ),
27686
+ )
27687
+ .returning({ sessionId: schema.sessionWorkflowWakeOutbox.sessionId });
27688
+ if (!row) {
27689
+ throw new Error(
27690
+ `Workflow wake revision ${input.wakeRevision} is not current for session ${input.sessionId}`,
27691
+ );
27692
+ }
27693
+ return { action: "acknowledged" } as const;
27694
+ }),
26107
27695
  );
26108
27696
  }
26109
27697
 
@@ -26639,7 +28227,9 @@ export async function appendSessionEvents(
26639
28227
  sessionId,
26640
28228
  sequence: ++sequence,
26641
28229
  type: input.type,
26642
- payload: sanitizeEventPayload(input.payload ?? {}),
28230
+ payload: sanitizeEventPayload(input.payload ?? {}, {
28231
+ fullEvidence: input.retainedOutputEvidence,
28232
+ }),
26643
28233
  clientEventId: input.clientEventId ?? null,
26644
28234
  turnId: input.turnId ?? null,
26645
28235
  turnGeneration: input.turnGeneration ?? null,
@@ -26979,7 +28569,9 @@ export async function appendSessionEventsForTurnAttempt(
26979
28569
  sessionId,
26980
28570
  sequence: ++sequence,
26981
28571
  type: input.type,
26982
- payload: sanitizeEventPayload(input.payload ?? {}),
28572
+ payload: sanitizeEventPayload(input.payload ?? {}, {
28573
+ fullEvidence: input.retainedOutputEvidence,
28574
+ }),
26983
28575
  clientEventId: input.clientEventId ?? null,
26984
28576
  turnId,
26985
28577
  turnGeneration: executionGeneration,
@@ -27053,7 +28645,9 @@ export async function appendSessionEventToSandboxGroup(
27053
28645
  sessionId: row.id,
27054
28646
  sequence: row.lastSequence + 1,
27055
28647
  type: input.type,
27056
- payload: sanitizeEventPayload(input.payload ?? {}),
28648
+ payload: sanitizeEventPayload(input.payload ?? {}, {
28649
+ fullEvidence: input.retainedOutputEvidence,
28650
+ }),
27057
28651
  clientEventId: input.clientEventId ?? null,
27058
28652
  turnId: input.turnId ?? null,
27059
28653
  turnGeneration: input.turnGeneration ?? null,
@@ -27131,7 +28725,9 @@ export async function appendSessionEventsAndUpdateSession(
27131
28725
  sessionId,
27132
28726
  sequence: ++sequence,
27133
28727
  type: input.type,
27134
- payload: sanitizeEventPayload(input.payload ?? {}),
28728
+ payload: sanitizeEventPayload(input.payload ?? {}, {
28729
+ fullEvidence: input.retainedOutputEvidence,
28730
+ }),
27135
28731
  clientEventId: input.clientEventId ?? null,
27136
28732
  turnId: input.turnId ?? null,
27137
28733
  turnGeneration: input.turnGeneration ?? null,
@@ -27166,6 +28762,10 @@ type LockedSessionUpdateContext = {
27166
28762
  updateSessionMcpServerCredentials: (
27167
28763
  updates: UpdateSessionMcpServerCredentialsInput[],
27168
28764
  ) => Promise<UpdateSessionMcpServerCredentialsResult>;
28765
+ updateSessionMcpApprovalPolicy: (
28766
+ serverId: string,
28767
+ requireApproval: SessionMcpApprovalPolicy,
28768
+ ) => Promise<UpdateSessionMcpApprovalPolicyResult>;
27169
28769
  listPendingSessionTurns: () => Promise<SessionTurn[]>;
27170
28770
  };
27171
28771
 
@@ -27218,6 +28818,13 @@ export async function appendSessionEventsWithLockedSessionUpdate(
27218
28818
  sessionId,
27219
28819
  updates,
27220
28820
  }),
28821
+ updateSessionMcpApprovalPolicy: async (serverId, requireApproval) =>
28822
+ await updateSessionMcpApprovalPolicyInTransaction(tx, {
28823
+ workspaceId,
28824
+ sessionId,
28825
+ serverId,
28826
+ requireApproval,
28827
+ }),
27221
28828
  listPendingSessionTurns: async () => {
27222
28829
  const rows = await tx
27223
28830
  .select()
@@ -27255,7 +28862,9 @@ export async function appendSessionEventsWithLockedSessionUpdate(
27255
28862
  sessionId,
27256
28863
  sequence: ++sequence,
27257
28864
  type: input.type,
27258
- payload: sanitizeEventPayload(input.payload ?? {}),
28865
+ payload: sanitizeEventPayload(input.payload ?? {}, {
28866
+ fullEvidence: input.retainedOutputEvidence,
28867
+ }),
27259
28868
  clientEventId: input.clientEventId ?? null,
27260
28869
  turnId: input.turnId ?? null,
27261
28870
  turnGeneration: input.turnGeneration ?? null,
@@ -27338,6 +28947,10 @@ function mapSession(
27338
28947
  instructions: row.instructions ?? null,
27339
28948
  resources: row.resources as ResourceRef[],
27340
28949
  tools: row.tools as ToolRef[],
28950
+ toolPolicy: (row.toolPolicy as SessionToolPolicy | null) ?? {
28951
+ mode: "legacy",
28952
+ inheritedFromSessionId: null,
28953
+ },
27341
28954
  metadata: row.metadata,
27342
28955
  createdBy: initiatorFromStorage(
27343
28956
  row.createdByKind,
@@ -27438,6 +29051,7 @@ function mapSessionTurn(row: typeof schema.sessionTurns.$inferSelect): SessionTu
27438
29051
  prompt: row.prompt,
27439
29052
  resources: row.resources as ResourceRef[],
27440
29053
  tools: row.tools as ToolRef[],
29054
+ toolsProvided: row.toolsProvided,
27441
29055
  model: row.model,
27442
29056
  reasoningEffort: row.reasoningEffort as ReasoningEffort,
27443
29057
  sandboxBackend: row.sandboxBackend as SandboxBackend,
@@ -27613,9 +29227,76 @@ function mapImportBatch(row: typeof schema.importBatches.$inferSelect): ImportBa
27613
29227
  };
27614
29228
  }
27615
29229
 
29230
+ type CatalogExposureState = "trusted" | "legacy_active" | "blocked";
29231
+
29232
+ function catalogExposureState(
29233
+ item: typeof schema.capabilityCatalogItems.$inferSelect,
29234
+ installation: typeof schema.capabilityInstallations.$inferSelect | null,
29235
+ ): CatalogExposureState {
29236
+ if (
29237
+ capabilityCatalogItemIsTrustedForExposure({
29238
+ source: item.source as CapabilitySource,
29239
+ stale: item.stale,
29240
+ authKind: item.authKind as CapabilityCatalogItem["authKind"],
29241
+ metadata: item.metadata,
29242
+ })
29243
+ ) {
29244
+ return "trusted";
29245
+ }
29246
+ // Rolling compatibility for installations enabled before registry probe
29247
+ // provenance existed. This is deliberately narrower than the normal trust
29248
+ // gate: only an already-active, non-stale, known-auth row with enable-time
29249
+ // connectivity evidence can continue. A present-but-non-real probe is an
29250
+ // explicit negative verdict and can never be grandfathered.
29251
+ if (
29252
+ item.source !== registryCapabilitySource ||
29253
+ item.stale ||
29254
+ Object.prototype.hasOwnProperty.call(item.metadata, "mcpProbe") ||
29255
+ !item.authKind ||
29256
+ item.authKind === "unknown" ||
29257
+ installation?.status !== "active" ||
29258
+ !mcpConnectivityOk(installation.metadata)
29259
+ ) {
29260
+ return "blocked";
29261
+ }
29262
+ const hasCredentialBinding =
29263
+ !!encryptedHeadersConfig(installation.config.headersEncrypted) ||
29264
+ !!connectionRefConfig(installation.config.connectionRef);
29265
+ if (item.authModel && !hasCredentialBinding) {
29266
+ return "blocked";
29267
+ }
29268
+ return "legacy_active";
29269
+ }
29270
+
27616
29271
  function mapCapabilityCatalogItem(
27617
29272
  row: typeof schema.capabilityCatalogItems.$inferSelect,
29273
+ exposure: "trusted" | "legacy_active" | "unverified" = capabilityCatalogItemIsTrustedForExposure({
29274
+ source: row.source as CapabilitySource,
29275
+ stale: row.stale,
29276
+ authKind: row.authKind as CapabilityCatalogItem["authKind"],
29277
+ metadata: row.metadata,
29278
+ })
29279
+ ? "trusted"
29280
+ : "unverified",
27618
29281
  ): CapabilityCatalogItem {
29282
+ const catalogTrust =
29283
+ exposure === "legacy_active"
29284
+ ? {
29285
+ state: "legacy_active" as const,
29286
+ reason: "active_installation_compatibility" as const,
29287
+ }
29288
+ : exposure === "trusted"
29289
+ ? {
29290
+ state: "trusted" as const,
29291
+ reason:
29292
+ row.source === registryCapabilitySource
29293
+ ? ("verified_probe" as const)
29294
+ : ("trusted_source" as const),
29295
+ }
29296
+ : {
29297
+ state: "unverified" as const,
29298
+ reason: "missing_verification" as const,
29299
+ };
27619
29300
  const runtime =
27620
29301
  row.kind === "mcp" && row.endpointUrl
27621
29302
  ? {
@@ -27625,6 +29306,7 @@ function mapCapabilityCatalogItem(
27625
29306
  notes: row.authModel
27626
29307
  ? "Requires credential headers supplied in the enable request."
27627
29308
  : null,
29309
+ catalogTrust,
27628
29310
  }
27629
29311
  : {
27630
29312
  available: false,
@@ -27632,6 +29314,7 @@ function mapCapabilityCatalogItem(
27632
29314
  row.kind === "mcp"
27633
29315
  ? "Remote streamable HTTP endpoint is required for runtime use."
27634
29316
  : null,
29317
+ catalogTrust,
27635
29318
  };
27636
29319
  return {
27637
29320
  id: row.id,