dsh-keys-manager 0.2.2 → 0.2.4

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.
package/lib/index.js CHANGED
@@ -15,7 +15,8 @@
15
15
  */
16
16
  import { AsyncLocalStorage } from 'node:async_hooks';
17
17
  import { createHash, randomBytes } from 'node:crypto';
18
- import { KEY_LIMIT, accountRef, applyFailureCooldown, bindingKey, classifyOAuthFailure, isSelectable, markKeySuccess, markKeyUsed, normalizeSettings, patchSettings, pickHealthyExcluding, pickRoundRobin, shouldFailover, statusOfKey, statusOfProvider, validateKeyInput, validateKeyPatch, } from './core.js';
18
+ import { CURSOR_ROUTE, cursorCatalogModels } from 'dsh-llm-omp-transports';
19
+ import { KEY_LIMIT, TEST_SUCCESS_FINISH_KINDS, accountRef, applyFailureCooldown, bindingKey, classifyOAuthFailure, isSelectable, markKeySuccess, markKeyUsed, normalizeSettings, patchSettings, pickHealthyExcluding, pickRoundRobin, shouldFailover, statusOfKey, statusOfProvider, validateKeyInput, validateKeyPatch, } from './core.js';
19
20
  import { EXTRA_PROVIDER_MAP, EXTRA_PROVIDERS } from './providers.js';
20
21
  import { KeysManagerStore, ensureStateFile, statePath } from './store.js';
21
22
  export const name = 'keys-manager';
@@ -255,6 +256,11 @@ const INVOCATIONS = [
255
256
  endpoint('beginTransportLogin', ['provider']),
256
257
  endpoint('activateTransportAccount', ['provider']),
257
258
  endpoint('removeTransportAccount', ['provider']),
259
+ // The transport card's model picker: the catalog lives in the transports
260
+ // package (listModels answers only the allow-list), and the selection is
261
+ // written to the route's own settings path.
262
+ endpoint('transportModels', ['provider']),
263
+ endpoint('saveTransportModels', ['provider', 'models']),
258
264
  ];
259
265
  /** Read a nested value by path. */
260
266
  function getPath(value, path) {
@@ -517,6 +523,15 @@ function transportAccountOf(flow, record, now) {
517
523
  function transportMethodOf(flow) {
518
524
  return flow.methods[0]?.id;
519
525
  }
526
+ /**
527
+ * The catalog a transport route advertises to its model picker.
528
+ *
529
+ * Only the Cursor route ships a catalog projection today; a route without one
530
+ * reports no models rather than borrowing another route's catalog.
531
+ */
532
+ function transportCatalogOf(route) {
533
+ return route === CURSOR_ROUTE ? cursorCatalogModels() : [];
534
+ }
520
535
  /** Env names that are a single API-key credential, not an OAuth/federation token. */
521
536
  function isKeyishEnv(name) {
522
537
  if (!/(?:API_KEY|_TOKEN)$/.test(name))
@@ -844,12 +859,15 @@ export class KeysManagerService {
844
859
  * order is preserved so the older observation can never commit after the
845
860
  * newer one and leave the row on a stale generation.
846
861
  *
847
- * Round-8 finding I-000106: activation joins the same chain. The only
848
- * writer of `row.oauth.grantRef` is {@link reconcileGrantRecord}, which runs
849
- * outside the mutation queue, so activation's record read and its projection
850
- * write must be mutually exclusive with that writer — otherwise a rotation
851
- * landing between them leaves the projection on a generation the row no
852
- * longer names.
862
+ * Round-8 finding I-000106 assumed activation would join this chain. It does
863
+ * not: activation runs its compare-and-write inside the mutation queue turn,
864
+ * and the only writer of `row.oauth.grantRef` is {@link reconcileGrantRecord},
865
+ * which runs outside that queue. The fence is therefore a generation re-check
866
+ * on both sides of the write — before it (the row must still name the
867
+ * generation that was read) and after it (a rotation commit can land inside
868
+ * the seam's compare-and-write await, which only reads the projection key).
869
+ * Chain members are the echo reconciler, the key-test borrow and adoption
870
+ * classification.
853
871
  */
854
872
  grantReconciliations = new Map();
855
873
  /**
@@ -944,6 +962,20 @@ export class KeysManagerService {
944
962
  credentialsMounted = false;
945
963
  /** Providers whose bounded-wait warning already fired (warn once). */
946
964
  hydrationWaitWarned = new Set();
965
+ /**
966
+ * Stream attempts each provider served without an attributed account
967
+ * (`I-000120`, D8): the ownership read had not landed, or it had failed.
968
+ * Counted at the one managed serving site (`wrapStream`), never in the
969
+ * hydration pass — a failed read serves no request, it only makes later
970
+ * attempts unattributed. Two earlier branches are outside this definition and
971
+ * stay uncounted: a provider this manager does not manage (no ownership is
972
+ * ever asked for) and the `mismatch` profile shape, which hands the request
973
+ * to pi-ai untouched precisely because this manager's own binding cannot
974
+ * describe what serves it. Cleared only when a later read decides the route's
975
+ * attribution; process-local, never persisted and never on the warnings
976
+ * channel.
977
+ */
978
+ unattributedRequests = new Map();
947
979
  /** Channels whose lost-subscription warning already fired (warn once). */
948
980
  subscriptionLostWarnings = new Set();
949
981
  /** Disposer for the `authorization/settled` subscription. */
@@ -1011,7 +1043,13 @@ export class KeysManagerService {
1011
1043
  }
1012
1044
  return (async () => {
1013
1045
  try {
1014
- await Promise.all(Object.values(this.store.providers).map(async (providerRecord) => {
1046
+ // allSettled, never all: under `all`, a rejection from any single
1047
+ // provider task settles this awaited pass at once, so the finally below
1048
+ // flips `hydrationSettled` while the other providers' reads are still
1049
+ // in flight and `awaitProjectionOwnership` calls them ready. No task
1050
+ // body can reject today (the read error is caught inside it), so this
1051
+ // is a structural guard — do not flip it back for style.
1052
+ await Promise.allSettled(Object.values(this.store.providers).map(async (providerRecord) => {
1015
1053
  const provider = providerRecord.provider;
1016
1054
  const accounts = providerRecord.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null);
1017
1055
  try {
@@ -1045,9 +1083,10 @@ export class KeysManagerService {
1045
1083
  // serves the route ahead of any broker reference exactly like an
1046
1084
  // unowned grant, so it is classified as foreign below (PR review
1047
1085
  // rounds 4 and 13).
1048
- const owner = record.kind === 'grant' && isOAuthGrantPayload(record.payload)
1049
- ? matchOwningAccount(provider, accounts, record.payload)
1086
+ const projection = record.kind === 'grant' && isOAuthGrantPayload(record.payload)
1087
+ ? record.payload
1050
1088
  : undefined;
1089
+ const owner = projection === undefined ? undefined : matchOwningAccount(provider, accounts, projection);
1051
1090
  // A newer activation/login claimed the owner while this read was in
1052
1091
  // flight: the stale read is discarded, never written over it. A
1053
1092
  // remount replaced the whole store: its classification is gone too.
@@ -1071,8 +1110,13 @@ export class KeysManagerService {
1071
1110
  // attributable now and must not wait behind another provider's
1072
1111
  // stalled read (round-10 should-fix SF1). A superseded pass does
1073
1112
  // not report readiness for a store it no longer describes.
1074
- if (this.hydrationEpoch === epoch)
1113
+ if (this.hydrationEpoch === epoch) {
1114
+ // A landed read decided this route's attribution; a failed one
1115
+ // decided nothing and never clears the count (I-000120).
1116
+ if (!this.ownershipReadFailures.has(provider))
1117
+ this.unattributedRequests.delete(provider);
1075
1118
  this.noteProviderHydrated(provider);
1119
+ }
1076
1120
  }
1077
1121
  }));
1078
1122
  }
@@ -1129,10 +1173,24 @@ export class KeysManagerService {
1129
1173
  * repair was skipped while that shape was unknown.
1130
1174
  */
1131
1175
  noteProjectionReadSucceeded(provider) {
1132
- if (!this.ownershipReadFailures.delete(provider))
1133
- return;
1176
+ // This read decided the route's attribution, so a count of requests served
1177
+ // without one no longer describes it (I-000120).
1178
+ this.unattributedRequests.delete(provider);
1179
+ const recovering = this.ownershipReadFailures.delete(provider);
1180
+ // Readiness is decided by the read, not by whether it was recovering: a
1181
+ // `record-updated` reconcile can answer the projection while startup
1182
+ // hydration is still pending for another provider, and the old early return
1183
+ // left this one out of `hydratedProviders` — waits kept timing out, routing
1184
+ // stayed skipped, and the count this read had just cleared started growing
1185
+ // again although attribution was already decided. `noteProviderHydrated` is
1186
+ // idempotent (a set add plus waking its waiters).
1134
1187
  this.noteProviderHydrated(provider);
1135
- void this.scheduleGrantReconciliation(provider, () => this.syncBindingAfterProjectionChange(provider));
1188
+ // Only recovery re-derives the binding: the normal call arrives from a
1189
+ // reconciliation that already owns that re-derive behind it, and the
1190
+ // startup repair is what a failed read skipped.
1191
+ if (recovering) {
1192
+ void this.scheduleGrantReconciliation(provider, () => this.syncBindingAfterProjectionChange(provider));
1193
+ }
1136
1194
  }
1137
1195
  /** Release every provider's readiness wait when hydration cannot proceed. */
1138
1196
  settleHydrationWaits() {
@@ -1184,6 +1242,9 @@ export class KeysManagerService {
1184
1242
  timer = setTimeout(() => {
1185
1243
  wake(false);
1186
1244
  }, ms);
1245
+ // I-000116: a request waiting on hydration must not hold the process
1246
+ // open for the rest of the wait; every exit path still clears it.
1247
+ timer.unref?.();
1187
1248
  live.add(onLanded);
1188
1249
  });
1189
1250
  }
@@ -1270,6 +1331,16 @@ export class KeysManagerService {
1270
1331
  this.ownershipGenerations.set(provider, generation);
1271
1332
  return generation;
1272
1333
  }
1334
+ /**
1335
+ * Whether a claim landed after `generation` was captured. A reconciliation
1336
+ * reads its snapshot before its awaited record read and decides after it, so
1337
+ * an activation (or login) that claimed the route in between owns the answer
1338
+ * now: the stale body must not clear the claim, mark the route foreign, or
1339
+ * re-point the binding. The hydration write uses the same guard.
1340
+ */
1341
+ ownershipSuperseded(provider, generation) {
1342
+ return (this.ownershipGenerations.get(provider) ?? 0) !== generation;
1343
+ }
1273
1344
  /**
1274
1345
  * Claim the projection owner for one account row, bumping the provider's
1275
1346
  * ownership generation so an in-flight hydration read of an older state
@@ -1279,6 +1350,8 @@ export class KeysManagerService {
1279
1350
  this.activeAccounts.set(provider, accountId);
1280
1351
  this.foreignProjections.delete(provider);
1281
1352
  this.noteProjectionCommitted(provider);
1353
+ // The route has an owner now, so requests are attributed from here on.
1354
+ this.unattributedRequests.delete(provider);
1282
1355
  }
1283
1356
  /**
1284
1357
  * Log without ever echoing secret material. */
@@ -1307,17 +1380,32 @@ export class KeysManagerService {
1307
1380
  }
1308
1381
  }
1309
1382
  /** Serialize mutations, rejecting submissions that arrive during shutdown. */
1310
- run(operation) {
1383
+ run(operation, onFailure) {
1311
1384
  if (this.closing)
1312
1385
  return Promise.reject(new Error('Keys Manager is shutting down'));
1313
1386
  const guarded = async () => {
1314
1387
  if (this.store.readOnly) {
1315
1388
  throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
1316
1389
  }
1317
- const result = await operation();
1318
- // A mutation is not successful until its state document is durable.
1319
- await this.store.flush();
1320
- return result;
1390
+ try {
1391
+ const result = await operation();
1392
+ // A mutation is not successful until its state document is durable.
1393
+ await this.store.flush();
1394
+ return result;
1395
+ }
1396
+ catch (error) {
1397
+ // Failure cleanup belongs to THIS turn's exclusive window, not a later
1398
+ // one: a queued mutation (a removal, say) would otherwise run first and
1399
+ // destroy the state the undo needs to restore. `guarded` resolves only
1400
+ // after the hook, so everything already submitted behind this turn sees
1401
+ // the compensated state. Both awaits are best-effort — the caller must
1402
+ // receive the original error, not the cleanup's.
1403
+ if (onFailure !== undefined) {
1404
+ await onFailure(error).catch(() => undefined);
1405
+ await this.store.flush().catch(() => undefined);
1406
+ }
1407
+ throw error;
1408
+ }
1321
1409
  };
1322
1410
  const next = this.queue.then(guarded, guarded);
1323
1411
  this.queue = next.catch(() => { });
@@ -2033,6 +2121,85 @@ export class KeysManagerService {
2033
2121
  transportGenerationOf(route) {
2034
2122
  return this.transportRemovals.get(route) ?? 0;
2035
2123
  }
2124
+ /**
2125
+ * Remote: read one transport route's catalog and configured allow-list.
2126
+ *
2127
+ * @remarks
2128
+ * The catalog comes from the transports package, because the route's
2129
+ * `listModels` answers only the allow-list. An empty allow-list means "all
2130
+ * models", so an unreadable settings store must not answer `[]` — a save
2131
+ * would then clear a narrowed selection — and it rejects instead.
2132
+ */
2133
+ async transportModels(route) {
2134
+ this.assertTransportRoute(route);
2135
+ const configured = this.transportModelIds(route);
2136
+ const catalog = transportCatalogOf(route);
2137
+ // A settings profile can outlive a catalog id; the picker renders the
2138
+ // catalog, so an unknown id would count toward {n} of {total} while
2139
+ // matching no checkbox and failing every save.
2140
+ const known = new Set(catalog.map(row => row.id));
2141
+ return { catalog, models: configured.filter(id => known.has(id)) };
2142
+ }
2143
+ /**
2144
+ * Remote: replace one transport route's model allow-list.
2145
+ *
2146
+ * Writes only the route's own `providers.<route>.models` path through the
2147
+ * live settings revision; an empty list restores the full catalog.
2148
+ */
2149
+ async saveTransportModels(route, models) {
2150
+ this.assertTransportRoute(route);
2151
+ if (!Array.isArray(models) || models.some(id => typeof id !== 'string')) {
2152
+ throw new TypeError('models must be a list of model ids');
2153
+ }
2154
+ const catalog = transportCatalogOf(route);
2155
+ if (catalog.length === 0)
2156
+ throw new Error(`Provider "${route}" offers no model list`);
2157
+ const known = new Map(catalog.map(row => [row.id, row.name]));
2158
+ const selected = [...new Set(models)];
2159
+ const unknown = selected.find(id => !known.has(id));
2160
+ if (unknown !== undefined)
2161
+ throw new Error(`Provider "${route}" has no model "${unknown}"`);
2162
+ await this.mutateSettings(TRANSPORT_CREDENTIAL_SCOPE, [
2163
+ {
2164
+ op: 'set',
2165
+ path: ['providers', route, 'models'],
2166
+ value: selected.map(id => ({ id, name: known.get(id) })),
2167
+ },
2168
+ ]);
2169
+ return this.transportModels(route);
2170
+ }
2171
+ /** Refuse a route the transports package does not register. */
2172
+ assertTransportRoute(route) {
2173
+ if (typeof route !== 'string' || route.length === 0)
2174
+ throw new TypeError('provider is required');
2175
+ if (this.transportFlowFor(route) === undefined)
2176
+ throw new Error(`Provider "${route}" offers no transport account`);
2177
+ }
2178
+ /**
2179
+ * One transport route's configured model ids, read from the live settings.
2180
+ *
2181
+ * @remarks
2182
+ * Rejects when the descriptor is unreadable or this plugin's namespace is
2183
+ * not mounted: a registered route always owns that namespace, so answering
2184
+ * "no models configured" would report the all-models state for a store the
2185
+ * host simply could not read.
2186
+ */
2187
+ transportModelIds(route) {
2188
+ const descriptor = this.describeSettings();
2189
+ if (!descriptor.readable)
2190
+ throw new Error('Cannot read settings: the settings store is unavailable');
2191
+ const namespace = this.namespace(descriptor, TRANSPORT_CREDENTIAL_SCOPE);
2192
+ if (namespace === undefined) {
2193
+ throw new Error(`Settings namespace "${TRANSPORT_CREDENTIAL_SCOPE}" is not mounted`);
2194
+ }
2195
+ const value = getPath(namespace.value, ['providers', route, 'models']);
2196
+ if (!Array.isArray(value))
2197
+ return [];
2198
+ return value.flatMap(row => {
2199
+ const id = row !== null && typeof row === 'object' ? row.id : undefined;
2200
+ return typeof id === 'string' ? [id] : [];
2201
+ });
2202
+ }
2036
2203
  /**
2037
2204
  * Drop a grant a withdrawn sign-in wrote after its account was removed.
2038
2205
  *
@@ -2564,11 +2731,20 @@ export class KeysManagerService {
2564
2731
  return;
2565
2732
  }
2566
2733
  if (harvest.wroteRef) {
2567
- try {
2568
- await records.deleteRecord(harvest.grantRef);
2734
+ // A live row still names this ref: a same-ref re-authentication writes
2735
+ // over the record its own pre-existing row depends on, and this
2736
+ // compensation must not leave that row dangling (same guard as the
2737
+ // rotation cleanup, R5-MF1).
2738
+ if (this.grantRefServed(attempt.provider, harvest.grantRef)) {
2739
+ this.warn(`harvest compensation kept ${harvest.grantRef} for ${attempt.provider}: an account row still references it`);
2569
2740
  }
2570
- catch (error) {
2571
- this.warn(`harvest compensation could not delete ${harvest.grantRef}: ${error instanceof Error ? error.message : String(error)}`);
2741
+ else {
2742
+ try {
2743
+ await records.deleteRecord(harvest.grantRef);
2744
+ }
2745
+ catch (error) {
2746
+ this.warn(`harvest compensation could not delete ${harvest.grantRef}: ${error instanceof Error ? error.message : String(error)}`);
2747
+ }
2572
2748
  }
2573
2749
  }
2574
2750
  await this.revertHarvestProjection(attempt, harvest);
@@ -2872,7 +3048,7 @@ export class KeysManagerService {
2872
3048
  // can already be superseded. The newer claim owns the route and settled
2873
3049
  // the binding itself: this stale removal decides nothing — no owner
2874
3050
  // delete, no rebind — it only consumes its own one-shot removal marker.
2875
- if ((this.ownershipGenerations.get(provider) ?? 0) !== generation) {
3051
+ if (this.ownershipSuperseded(provider, generation)) {
2876
3052
  this.intentionalProjectionRemovals.delete(provider);
2877
3053
  return;
2878
3054
  }
@@ -2928,6 +3104,11 @@ export class KeysManagerService {
2928
3104
  // reference over it. With no OAuth row the unowned-grant branch below
2929
3105
  // classifies a valid grant the same way.
2930
3106
  if (record.kind === 'api-key') {
3107
+ // A newer claim (an activation that wrote and claimed after this body
3108
+ // captured its snapshot) owns the route now; this stale classification
3109
+ // must not clear it.
3110
+ if (this.ownershipSuperseded(provider, generation))
3111
+ return;
2931
3112
  // Native api-key management took the route over: the manager no
2932
3113
  // longer owns any projection here.
2933
3114
  this.activeAccounts.delete(provider);
@@ -2939,11 +3120,15 @@ export class KeysManagerService {
2939
3120
  this.noteProjectionCommitted(provider);
2940
3121
  this.pushWarning(`The ${provider} route now holds a natively managed API-key credential; the Keys Manager account pool is not serving it.`);
2941
3122
  // pi-ai resolves the broker reference before this record, so a route
2942
- // left bound would keep serving the pool (PR review round 4).
3123
+ // left bound would keep serving the pool (PR review round 4). The sync
3124
+ // stamps the generation it entered with and re-checks before its own
3125
+ // write, so this body's own bump cannot strand it.
2943
3126
  await this.syncBindingAfterProjectionChange(provider);
2944
3127
  return;
2945
3128
  }
2946
3129
  if (!isOAuthGrantPayload(record.payload)) {
3130
+ if (this.ownershipSuperseded(provider, generation))
3131
+ return;
2947
3132
  // An unrecognized payload is not ours to interpret: drop the stale
2948
3133
  // owner entry so failure attribution and demotion never fire on it.
2949
3134
  this.activeAccounts.delete(provider);
@@ -2961,6 +3146,8 @@ export class KeysManagerService {
2961
3146
  // applies, so a restarted process and the live reconciler agree.
2962
3147
  const owner = matchOwningAccount(provider, accounts, payload);
2963
3148
  if (owner === undefined) {
3149
+ if (this.ownershipSuperseded(provider, generation))
3150
+ return;
2964
3151
  // A foreign account's grant serves the route: no pool row owns the
2965
3152
  // projection, so the previous owner entry must not survive it — and the
2966
3153
  // live record keeps the pool from claiming the route (round 3).
@@ -2981,6 +3168,11 @@ export class KeysManagerService {
2981
3168
  // is re-adopted below. Only a removal in progress is refused.
2982
3169
  if (!this.accountRowIsManaged(provider, owner))
2983
3170
  return;
3171
+ // A newer claim landed while this body was resolving its read: the
3172
+ // rotation write, the row commit and the binding sync below all describe a
3173
+ // generation that claim already superseded.
3174
+ if (this.ownershipSuperseded(provider, generation))
3175
+ return;
2984
3176
  // F1 (I-000081): the matched grant IS the live projection, so this row
2985
3177
  // owns it from now on — even when no in-process login or activation
2986
3178
  // ran (the post-restart echo-rotation case: a hydrated row, no owner
@@ -2992,30 +3184,29 @@ export class KeysManagerService {
2992
3184
  // the stale echo claim is discarded exactly like a stale hydration
2993
3185
  // read (the rotation sync below still runs, but a commit that a newer
2994
3186
  // claim has superseded is rejected — round-9 P1).
2995
- let claimedGeneration;
2996
- if ((this.ownershipGenerations.get(provider) ?? 0) === generation) {
2997
- this.claimProjectionOwner(provider, owner.id);
2998
- claimedGeneration = this.ownershipGenerations.get(provider);
2999
- // R7-SF3: the live projection proves this row's identity again, so a
3000
- // demoted row is re-adopted — restore its owner claim (above) and end
3001
- // the demoted state. `valueMissing` means "not serving but still
3002
- // managed", not "removal in progress": a native sign-in or pi-ai
3003
- // write that re-projects the grant recovers the account without a
3004
- // manual re-activation. The flag is persisted with the rest of the
3005
- // state document.
3006
- if (owner.valueMissing === true) {
3007
- owner.valueMissing = false;
3008
- // R14-SF3: a demotion is earned by a failure, so the row usually
3009
- // still carries the cooldown `markFailure` wrote. Re-adoption is the
3010
- // same manual-recovery equivalence `activateAccount` and re-auth
3011
- // implement: the live projection is serving again, so the old
3012
- // window ends with it — otherwise the resurrected row renders
3013
- // "cooling" and stays out of the healthy counts until the timer
3014
- // expires. Only this proven-adoption branch clears; a row whose
3015
- // projection did not reappear keeps its window.
3016
- owner.cooldownUntil = 0;
3017
- this.store.touch();
3018
- }
3187
+ // Unconditional: the guard above already returned when a newer claim
3188
+ // landed, and nothing between the two awaits.
3189
+ this.claimProjectionOwner(provider, owner.id);
3190
+ const claimedGeneration = this.ownershipGenerations.get(provider);
3191
+ // R7-SF3: the live projection proves this row's identity again, so a
3192
+ // demoted row is re-adopted — restore its owner claim (above) and end
3193
+ // the demoted state. `valueMissing` means "not serving but still
3194
+ // managed", not "removal in progress": a native sign-in or pi-ai
3195
+ // write that re-projects the grant recovers the account without a
3196
+ // manual re-activation. The flag is persisted with the rest of the
3197
+ // state document.
3198
+ if (owner.valueMissing === true) {
3199
+ owner.valueMissing = false;
3200
+ // R14-SF3: a demotion is earned by a failure, so the row usually
3201
+ // still carries the cooldown `markFailure` wrote. Re-adoption is the
3202
+ // same manual-recovery equivalence `activateAccount` and re-auth
3203
+ // implement: the live projection is serving again, so the old
3204
+ // window ends with it — otherwise the resurrected row renders
3205
+ // "cooling" and stays out of the healthy counts until the timer
3206
+ // expires. Only this proven-adoption branch clears; a row whose
3207
+ // projection did not reappear keeps its window.
3208
+ owner.cooldownUntil = 0;
3209
+ this.store.touch();
3019
3210
  }
3020
3211
  // Rotation sync: the rotated payload is authoritative (the plan's
3021
3212
  // single-writer contract — "sync them back to the owning plugin-scope
@@ -3106,7 +3297,12 @@ export class KeysManagerService {
3106
3297
  // shutdown that began under any of the awaits above refuses it.
3107
3298
  if (this.persistenceRefused())
3108
3299
  return;
3109
- await this.commitGrantGeneration(provider, owner, payload, rotatedRef);
3300
+ // The commit is the row repoint plus the superseded record's delete, both
3301
+ // after the awaits above (most recently `rotationStillCurrent`'s own record
3302
+ // read). A claim that landed in that read owns the route now, so the commit
3303
+ // refuses like every other mutation in this body.
3304
+ const commitGeneration = claimedGeneration ?? generation;
3305
+ await this.commitGrantGeneration(provider, owner, payload, rotatedRef, () => !this.ownershipSuperseded(provider, commitGeneration));
3110
3306
  // PR review round 44: the projection now serves this managed account again,
3111
3307
  // so a profile that still names the broker would make pi-ai resolve the
3112
3308
  // pool first and fail with MISSING_CREDENTIAL. Converge the binding with the
@@ -3227,66 +3423,655 @@ export class KeysManagerService {
3227
3423
  * Make one account's grant payload the route's stored credential
3228
3424
  * (D1 activation projection).
3229
3425
  *
3230
- * Round-8 finding I-000106 (must-fix): the decide snapshot used to be read
3231
- * before the seam's lock, and `reconcileGrantRecord` runs OUTSIDE the
3232
- * mutation queue — so a rotation echo could repoint `row.oauth.grantRef` and
3233
- * delete the record being projected between the read and the write, leaving
3234
- * the route on an older generation than the row names. The whole
3235
- * read → project → claim section therefore runs on the provider's grant
3236
- * chain ({@link scheduleGrantReconciliation}) — the same chain the
3237
- * reconciler bodies use. A rotation committed before this body is observed
3238
- * in `row.oauth.grantRef`; one that arrives while the body runs is queued
3239
- * behind it and reconciles the generation this body projected. Either way
3240
- * the projection holds exactly the generation the row names.
3426
+ * Plan iter-pool-activate-queue, "Spike outcome" — the contract this method
3427
+ * implements. The slow half (read the row's grant, validate it, capture what
3428
+ * the route serves now) runs outside the mutation queue, so a slow credential
3429
+ * store does not delay unrelated mutations. The mutation queue turn owns every
3430
+ * write:
3241
3431
  *
3242
- * The seam's per-key lock still serializes the read-decide-replace steps of
3243
- * `modifyRecord` (credentials/src/index.ts:236-250) against other writers.
3244
- * The account whose payload lands becomes the pool's active account and is
3245
- * cascade-selectable again.
3432
+ * 1. admission checks in queue order, which produce this method's error
3433
+ * surface (`Provider "…" is not managed`, `Account "…" not found on …`,
3434
+ * `Key "…" is not an OAuth account`, `The credential store does not support
3435
+ * records`) — plus a re-check that the row still names the generation the
3436
+ * read used;
3437
+ * 2. the projection write as a compare-and-write under the seam's per-key
3438
+ * lock (credentials/src/index.ts:236-250): it lands only while the live
3439
+ * record still equals the captured baseline, "both absent" included. A
3440
+ * rotation, a removal or any foreign writer that replaced the projection
3441
+ * meanwhile makes the compare fail, so this call refuses with the retryable
3442
+ * `could not be projected` message and leaves the newer projection
3443
+ * untouched — there is nothing to delete, because nothing was written;
3444
+ * 3. the owner claim, the row writes and the returned `buildState`.
3445
+ *
3446
+ * There is deliberately no shutdown check after the write: `close()` awaits
3447
+ * this queue before it closes the store, so a turn that started in time always
3448
+ * finishes (measured in the spike that produced this contract). A check there
3449
+ * is what used to leave an orphan projection behind.
3450
+ *
3451
+ * The one write the turn cannot make durable by itself is a rejected state
3452
+ * flush; see {@link compensateActivationFlush} for that compensation.
3246
3453
  */
3247
- activateAccount(provider, accountId) {
3248
- return this.run(async () => {
3249
- const providerRecord = this.store.provider(provider);
3250
- if (providerRecord === undefined)
3251
- throw new Error(`Provider "${provider}" is not managed`);
3252
- const row = providerRecord.keys.find(candidate => candidate.id === accountId);
3253
- if (row === undefined)
3254
- throw new Error(`Account "${accountId}" not found on ${provider}`);
3255
- if (row.kind !== 'oauth' || row.oauth === null)
3256
- throw new Error(`Key "${accountId}" is not an OAuth account`);
3257
- const records = this.credentialRecords();
3258
- if (records === undefined)
3259
- throw new Error('The credential store does not support records');
3260
- await this.scheduleGrantReconciliation(provider, async () => {
3261
- // Re-read the generation under the chain: a rotation that committed
3262
- // before this body claimed its slot is already reflected here.
3263
- const grantRef = row.oauth.grantRef;
3264
- const stored = await records.readRecord(grantRef);
3265
- if (stored === undefined || stored.kind !== 'grant' || !isOAuthGrantPayload(stored.payload)) {
3266
- throw new Error(`The grant payload at ${grantRef} is missing or unusable; sign in to this account again`);
3454
+ async activateAccount(provider, accountId) {
3455
+ const records = this.credentialRecords();
3456
+ // Locator only: it keeps the row's grantRef reachable for the read below.
3457
+ // The turn re-reads the store and decides; this lookup decides nothing.
3458
+ const located = this.store.provider(provider)?.keys.find(candidate => candidate.id === accountId);
3459
+ // The refusals `run` applies (1683, 1685-1687) are hoisted ahead of the
3460
+ // credential read: a refused call should not pay for it at all. They are
3461
+ // not the guarantee — the turn's own admission checks are.
3462
+ if (this.closing)
3463
+ throw new Error('Keys Manager is shutting down');
3464
+ if (this.store.readOnly) {
3465
+ throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
3466
+ }
3467
+ // The slow half, outside the queue: read the grant this activation would
3468
+ // project and capture what the route serves now as the compare baseline.
3469
+ // Nothing here decides — admission order belongs to the turn.
3470
+ const prepared = located !== undefined
3471
+ && located.kind === 'oauth'
3472
+ && located.oauth !== null
3473
+ && records !== undefined
3474
+ ? await this.prepareActivation(provider, located, records)
3475
+ : undefined;
3476
+ if (prepared !== undefined && records !== undefined) {
3477
+ // Same-`grantRef` rotation: pi-ai can refresh the ACCESS token while
3478
+ // keeping the refresh token, so the reference never moves, both `grantRef`
3479
+ // fences pass, and the projection can already carry the fresh payload
3480
+ // while the plugin-scope record still holds the stale one. Projecting
3481
+ // `prepared.payload` then writes an older credential over a fresher
3482
+ // projection. Re-read the source as late as possible and refuse unless it
3483
+ // still says what this activation prepared.
3484
+ //
3485
+ // This read sits OUTSIDE the queue, exactly where `prepareActivation` does
3486
+ // its credential work: a read inside the turn would put credential I/O
3487
+ // back on the global mutation queue, which is the cross-provider stall —
3488
+ // and the shutdown wait — the activation split exists to remove.
3489
+ //
3490
+ // Residual, named: reading outside the turn only narrows the same-ref
3491
+ // rotation window to this activation's QUEUE WAIT instead of closing it.
3492
+ // Closing it fully needs a seam that lets one read and one write share a
3493
+ // single lock, which `CredentialRecordsLike` (host-types.ts:136-143) does
3494
+ // not promise. What still covers the rest: the in-lock baseline compare on
3495
+ // the projection side, and the post-write `grantRef` recheck for reference
3496
+ // changes. A same-ref rotation that lands while the activation waits in
3497
+ // the queue is the uncovered case.
3498
+ const source = await records.readRecord(prepared.grantRef);
3499
+ const sourceMatches = source !== undefined
3500
+ && source.kind === 'grant'
3501
+ && JSON.stringify(source.payload) === prepared.written;
3502
+ // The preparation can carry a payload its own repair write did not
3503
+ // persist: the ownership-verified projection is then the live credential,
3504
+ // and an absent or unusable ref is that state — not a concurrent writer.
3505
+ const sourceAbsentOrUnusable = source === undefined
3506
+ || source.kind !== 'grant'
3507
+ || !isOAuthGrantPayload(source.payload);
3508
+ if (!sourceMatches && !(prepared.recoveredFromProjection && sourceAbsentOrUnusable)) {
3509
+ throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
3510
+ }
3511
+ }
3512
+ let undo;
3513
+ try {
3514
+ await this.run(async () => {
3515
+ const providerRecord = this.store.provider(provider);
3516
+ if (providerRecord === undefined)
3517
+ throw new Error(`Provider "${provider}" is not managed`);
3518
+ const row = providerRecord.keys.find(candidate => candidate.id === accountId);
3519
+ if (row === undefined)
3520
+ throw new Error(`Account "${accountId}" not found on ${provider}`);
3521
+ if (row.kind !== 'oauth' || row.oauth === null)
3522
+ throw new Error(`Key "${accountId}" is not an OAuth account`);
3523
+ if (records === undefined) {
3524
+ throw new Error('The credential store does not support records');
3267
3525
  }
3268
- this.intentionalProjectionRemovals.delete(provider);
3269
- await records.modifyRecord(`${OAUTH_GRANT_RECORD_SCOPE}/${provider}`, () => Promise.resolve({ kind: 'grant', payload: structuredClone(stored.payload) }));
3526
+ if (prepared === undefined) {
3527
+ // The locator only hints; the turn decides. A row that was not an
3528
+ // OAuth account when it was located (created, re-added, or repointed
3529
+ // in between) has nothing prepared for it — that is a stale locator,
3530
+ // not a store without record support.
3531
+ throw new Error(`The account "${accountId}" on ${provider} changed before it could be activated; activate it again`);
3532
+ }
3533
+ // A remount replaced the credential backend while the read ran; the
3534
+ // payload came from records this manager no longer serves.
3535
+ //
3536
+ // The row must still name the generation this activation read. A
3537
+ // rotation echo or a removal + re-add that repointed it meanwhile
3538
+ // belongs to a different grant, and projecting the old payload would
3539
+ // either overwrite a newer generation or report a success the row does
3540
+ // not serve. Refusing keeps the record untouched; the caller retries.
3541
+ if (this.credentialRecords() !== records || row.oauth.grantRef !== prepared.grantRef) {
3542
+ throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
3543
+ }
3544
+ // The projection write is a compare-and-write under the seam's per-key
3545
+ // lock: it lands only while the route still serves the baseline read
3546
+ // above. A removal or a foreign writer that got there first makes the
3547
+ // compare fail, and this activation refuses without touching the
3548
+ // record — there is nothing to compensate afterwards.
3549
+ const written = await this.compareAndWriteProjection(records, provider, prepared);
3550
+ if (written === undefined) {
3551
+ throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
3552
+ }
3553
+ // No post-write shutdown check: `close()` awaits this queue before it
3554
+ // closes the store (`1758-1762`), so a run that starts in time always
3555
+ // finishes before the store goes away. Refusing here after the write
3556
+ // is what used to manufacture an orphan.
3557
+ undo = { valueMissing: row.valueMissing, cooldownUntil: row.cooldownUntil, generation: 0 };
3270
3558
  this.claimProjectionOwner(provider, row.id);
3271
- // The re-activated grant is the serving credential again, so a broker
3272
- // reference left by the key pool must go (see syncBrokerBinding): the
3273
- // committed projection is not rolled back by a settings failure.
3559
+ undo.generation = this.ownershipGenerations.get(provider) ?? 0;
3560
+ row.valueMissing = false;
3561
+ // Round-5 finding M1: manual recovery ends the cooling window too, the
3562
+ // same reset a successful re-authentication applies. A demoted account
3563
+ // is re-activated while its stale cooldown still sits on the row, which
3564
+ // would render the freshly projected grant as cooling and keep it out
3565
+ // of the healthy counts until the timer expires. Clearing the window
3566
+ // also resets escalation: `applyFailureCooldown` derives the next level
3567
+ // from `cooldownUntil` only (core.ts:168-174).
3568
+ row.cooldownUntil = 0;
3569
+ this.store.touch();
3570
+ // The pre-write check closed the read window, but a rotation commit can
3571
+ // land inside the seam's compare-and-write await: it repoints the row
3572
+ // (a different record key) while the projection write is in flight, and
3573
+ // the in-lock compare cannot see the row. Projecting then leaves the
3574
+ // route serving a generation the row has already superseded, so the
3575
+ // activation refuses and the flush compensation above undoes both — the
3576
+ // claim is set, so the undo path owns it.
3577
+ if (row.oauth.grantRef !== prepared.grantRef) {
3578
+ throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
3579
+ }
3580
+ }, async () => {
3581
+ // A rejected flush lands here AFTER the turn resolved its work: `run`
3582
+ // does not roll the mutation back, so the row is still cleared in memory
3583
+ // and the projection still serves the route. Undo both once, by
3584
+ // identity, inside THIS turn's window — a later turn would lose the race
3585
+ // against every mutation already queued behind the activation (a removal
3586
+ // of the account deletes the half-committed projection first, and the
3587
+ // captured baseline could then no longer be restored). Failures before
3588
+ // the write leave `undo` unset: nothing to undo.
3589
+ //
3590
+ // Residual (I-000090 territory): `undo` is assigned only once the write
3591
+ // resolved, so a `modifyRecord` that applies its mutation and THEN
3592
+ // rejects refuses with the raw seam error and keeps the projection. What
3593
+ // compensation could run is undecidable from this repo — the seam would
3594
+ // have to promise it never commits then rejects — so this shape is left
3595
+ // as the seam reports it rather than guessed at.
3596
+ if (undo === undefined)
3597
+ return;
3598
+ await this.compensateActivationFlush(provider, accountId, prepared, undo, records);
3599
+ });
3600
+ // The re-activated grant is the serving credential again, so a broker
3601
+ // reference left by the key pool must go (see syncBrokerBinding). A
3602
+ // settings refusal does not roll back the committed projection.
3603
+ //
3604
+ // Both the sync and the returned snapshot run in a second queue turn:
3605
+ // the binding attempt must have happened before the view is built (a
3606
+ // refusal pushes its warning, and building the view inside the write turn
3607
+ // drained it, so the RPC reported a clean activation while requests kept
3608
+ // using the pool), and the snapshot itself is state: taken outside the
3609
+ // queue, another mutation could enter, mutate and be mid-flush while
3610
+ // `buildState` pruned and drained, exposing state that is not durable.
3611
+ // The turn waits for such a mutation to finish or fail first.
3612
+ if (this.closing) {
3613
+ // Shutdown opened while this activation was writing, so no new turn may
3614
+ // be appended: `closeOnce` captured `this.queue` when `closing` went up
3615
+ // and closes the store once THAT reference settles, so a turn submitted
3616
+ // here would run after close(). Instead, join the reference shutdown is
3617
+ // itself draining — mutations already queued behind this turn are still
3618
+ // running, and `buildState` prunes and drains, so the snapshot must come
3619
+ // after they settle rather than beside them.
3620
+ await this.queue;
3621
+ // The binding's last-point guard refuses the write once `closing` is
3622
+ // set and reports that refusal itself, so a route that needs converging
3623
+ // now says so in the returned state instead of reporting a clean
3624
+ // success; a route that is already converged needs no write at all. The
3625
+ // next start's repair pass owns the convergence either way.
3274
3626
  await this.syncBrokerBinding(provider);
3627
+ return this.buildState();
3628
+ }
3629
+ // The projection turn above already committed AND flushed, so the
3630
+ // activation is durable and serving. This second turn only converges the
3631
+ // binding and takes the snapshot; if its flush fails, that activation must
3632
+ // not be reported as failed — a caller would retry an activation that
3633
+ // already landed. `built` is the discriminator: `guarded` runs the body
3634
+ // before the flush, so a BODY failure leaves it unset and still
3635
+ // propagates (shutdown, a refusal), while a post-commit flush failure is
3636
+ // reported in the state the caller receives.
3637
+ let built;
3638
+ try {
3639
+ return await this.run(async () => {
3640
+ await this.syncBrokerBinding(provider);
3641
+ built = this.buildState();
3642
+ return built;
3643
+ });
3644
+ }
3645
+ catch (error) {
3646
+ if (built === undefined)
3647
+ throw error;
3648
+ const reason = error instanceof Error ? error.message : String(error);
3649
+ const warning = `The ${provider} activation is saved, but its binding and state snapshot could not be persisted (${reason}). The account is serving; the next successful change saves the rest.`;
3650
+ // One channel only: the returned snapshot. `buildState` has already
3651
+ // drained the warning queue, so ALSO queueing this copy would surface
3652
+ // the same failure twice (once in this payload, once on the next
3653
+ // refresh). It still reaches the operator log, which is what a caller
3654
+ // that ignores the returned state leaves visible.
3655
+ this.warn(`activation post-commit persistence failed for ${provider}: ${reason}`);
3656
+ // The snapshot was taken before this warning existed, and re-taking one
3657
+ // outside the queue is the race this turn exists to avoid, so the view
3658
+ // is annotated rather than rebuilt.
3659
+ return { ...built, warnings: [...built.warnings, warning] };
3660
+ }
3661
+ }
3662
+ catch (error) {
3663
+ // Which path covers which case:
3664
+ // - `run` refuses the submission while `closing`, and `guarded` refuses a
3665
+ // read-only store before the operation runs: nothing was written, so
3666
+ // `undo` stayed unset and there is nothing to compensate.
3667
+ // - a failure INSIDE the turn (the flush, or the seam) runs the
3668
+ // compensation hook above, inside that turn's exclusive window, even
3669
+ // when closing began during it; that hook is the fallback the previous
3670
+ // independent turn used to be, and it needs no closing branch.
3671
+ // - the hook's own flush is best-effort: a still-unwritable store leaves
3672
+ // memory restored and the next successful flush persists it.
3673
+ throw error;
3674
+ }
3675
+ }
3676
+ /**
3677
+ * The row's own grant payload, recovered from the live projection when the
3678
+ * plugin-scope record is gone. The projection IS the route's credential, so a
3679
+ * matching payload is written back (compare-at-absent) and used immediately.
3680
+ *
3681
+ * `recoveredFromProjection` marks a payload the ownership-verified projection
3682
+ * supplied when the repair write did not persist it: the ref stays absent or
3683
+ * unusable, so a caller that re-reads it must treat the payload as the live
3684
+ * credential instead of a concurrent writer's record. That case also returns
3685
+ * the projection record it validated: a caller that compares before writing
3686
+ * must use this snapshot as its baseline, or the compare would accept a
3687
+ * rotation that landed after the payload was read. It never marks a
3688
+ * projection whose row has been detached or repointed since the snapshot —
3689
+ * that recovery is refused, because the projection then belongs to a
3690
+ * generation this row no longer names. For the same reason a repair the
3691
+ * updater declined (row gone, retiring, or repointed) resolves `undefined`:
3692
+ * the callers' own "missing or unusable" failure is the truthful answer, not
3693
+ * the payload of a generation this row has left behind.
3694
+ */
3695
+ async recoverGrantPayload(provider, row, records) {
3696
+ const grantRef = row.oauth.grantRef;
3697
+ const stored = await records.readRecord(grantRef);
3698
+ if (stored !== undefined && stored.kind === 'grant' && isOAuthGrantPayload(stored.payload)) {
3699
+ return { payload: stored.payload, recoveredFromProjection: false };
3700
+ }
3701
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3702
+ // The seam can hand back its own live record and this repair awaits before
3703
+ // it writes: snapshot it once so the ownership check, the write and the
3704
+ // returned baseline all see the payload that was verified, even if the
3705
+ // source is updated in place meanwhile.
3706
+ const projection = structuredClone(await records.readRecord(projectionKey));
3707
+ if (projection === undefined || projection.kind !== 'grant' || !isOAuthGrantPayload(projection.payload))
3708
+ return undefined;
3709
+ // Only a projection this row owns is adopted; recovering a foreign grant
3710
+ // would attach another account's credential to this row.
3711
+ const oauthRows = this.store.provider(provider)?.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null) ?? [];
3712
+ if (matchOwningAccount(provider, oauthRows, projection.payload)?.id !== row.id)
3713
+ return undefined;
3714
+ // `row` is a snapshot taken before the reads: a removal or a re-sign-in can
3715
+ // have detached or repointed it since, and the repair must not land on a
3716
+ // ref this row no longer names.
3717
+ if (oauthRows.find(candidate => candidate.id === row.id)?.oauth?.grantRef !== grantRef) {
3718
+ // The row no longer names this ref, so the projection that matched it
3719
+ // belongs to a previous generation: refusing leaves the caller's own
3720
+ // "missing or unusable" failure in place instead of reviving it.
3721
+ return undefined;
3722
+ }
3723
+ // A declined updater is NOT "the ref is occupied by an unusable record":
3724
+ // it means nothing was written for this row, so the projection read above
3725
+ // is a previous generation's payload and must not be served.
3726
+ let declined = false;
3727
+ try {
3728
+ // Compare-at-absent: an occupied ref returns `current`, which the seam
3729
+ // keeps, so a concurrent login's or rotation's record wins the ref.
3730
+ const liveNow = await records.modifyRecord(grantRef, current => {
3731
+ if (current !== undefined)
3732
+ return Promise.resolve(current);
3733
+ // Runs under the seam's per-key lock, so this is the state the write
3734
+ // commits against: a removal that deleted the ref and marked (or
3735
+ // spliced) the row since the checks above must not be undone by
3736
+ // re-creating the credential it deleted.
3737
+ const liveRow = this.store.provider(provider)?.keys.find(candidate => candidate.id === row.id);
3738
+ if (liveRow === undefined
3739
+ || this.retiringAccounts.has(liveRow)
3740
+ || liveRow.kind !== 'oauth'
3741
+ || liveRow.oauth === null
3742
+ || liveRow.oauth.grantRef !== grantRef) {
3743
+ declined = true;
3744
+ return Promise.resolve(undefined);
3745
+ }
3746
+ return Promise.resolve({ kind: 'grant', payload: structuredClone(projection.payload) });
3275
3747
  });
3276
- row.valueMissing = false;
3277
- // Round-5 finding M1: manual recovery ends the cooling window too, the
3278
- // same reset a successful re-authentication applies. A demoted account
3279
- // is re-activated while its stale cooldown still sits on the row, which
3280
- // would render the freshly projected grant as cooling and keep it out
3281
- // of the healthy counts until the timer expires. Clearing the window
3282
- // also resets escalation: `applyFailureCooldown` derives the next level
3283
- // from `cooldownUntil` only (core.ts:168-174).
3284
- row.cooldownUntil = 0;
3285
- this.store.touch();
3286
- // Remote methods hand back the refreshed state view (addKey/deleteKey
3287
- // precedent), so the client updates its snapshot in one round trip.
3288
- return this.buildState();
3748
+ // No post-write compensation (PR #37 I-000233): it would prove what THIS
3749
+ // call wrote, not that the record it deleted was still ours — a same-ref
3750
+ // sign-in can commit a fresh record before attaching its row, and the
3751
+ // seam has no compare-and-delete (`deleteRecord(key)` takes no expected
3752
+ // payload). It is redundant: the removal path owns the ref it retires.
3753
+ // `removeOAuthGrant` marks the row in `retiringAccounts` BEFORE its first
3754
+ // awaited delete, and this updater revalidates synchronously under the
3755
+ // seam's per-key lock, so a removal that started first makes the update
3756
+ // decline, and one that starts after this write deletes
3757
+ // `row.oauth.grantRef` — the very ref this updater just proved the row
3758
+ // names — itself.
3759
+ if (declined)
3760
+ return undefined;
3761
+ // The ref may have been taken while this read was parked: the record's
3762
+ // payload is the credential the caller must borrow now, not the
3763
+ // projection this repair declined to write over it.
3764
+ if (liveNow !== undefined && liveNow.kind === 'grant' && isOAuthGrantPayload(liveNow.payload)) {
3765
+ return { payload: liveNow.payload, recoveredFromProjection: false };
3766
+ }
3767
+ }
3768
+ catch (error) {
3769
+ // Best-effort repair: the projection still served this read.
3770
+ this.warn(`could not restore ${grantRef} for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
3771
+ }
3772
+ return { payload: projection.payload, recoveredFromProjection: true, projection };
3773
+ }
3774
+ /**
3775
+ * The slow half of an activation, outside the queue: read the row's grant
3776
+ * generation (recovered from the live projection when its record is gone),
3777
+ * validate it, and capture the projection the route serves now.
3778
+ */
3779
+ async prepareActivation(provider, row, records) {
3780
+ const grantRef = row.oauth.grantRef;
3781
+ const recovered = await this.recoverGrantPayload(provider, row, records);
3782
+ if (recovered === undefined) {
3783
+ throw new Error(`The grant payload at ${grantRef} is missing or unusable; sign in to this account again`);
3784
+ }
3785
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3786
+ // A projection-only recovery already read and ownership-verified the route.
3787
+ // Re-reading it here would open a gap: a same-ref rotation landing in it
3788
+ // leaves the payload from the first read and a baseline from the second, so
3789
+ // the compare would pass and write the older credential. The record the
3790
+ // payload came from is the only truthful baseline for it.
3791
+ const live = recovered.projection ?? await records.readRecord(projectionKey);
3792
+ const payload = structuredClone(recovered.payload);
3793
+ // F5: the restored record decides its owner, so the classification is
3794
+ // derived from the baseline payload through the shared matcher — the same
3795
+ // rule the reconciler applies — rather than from an owner id captured
3796
+ // earlier that the record may no longer belong to.
3797
+ const accounts = this.store.provider(provider)?.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null) ?? [];
3798
+ const baselineOwner = live !== undefined && live.kind === 'grant' && isOAuthGrantPayload(live.payload)
3799
+ ? matchOwningAccount(provider, accounts, live.payload)
3800
+ : undefined;
3801
+ return {
3802
+ grantRef,
3803
+ payload,
3804
+ written: JSON.stringify(payload),
3805
+ recoveredFromProjection: recovered.recoveredFromProjection,
3806
+ baseline: live === undefined ? { state: 'absent' } : { state: 'present', record: structuredClone(live) },
3807
+ // Captured with the baseline, before the turn claims the route: the
3808
+ // compensation has to put back the classification that matches the
3809
+ // record it restores, and only this snapshot still knows it. Any present
3810
+ // record no row's payload matches is foreign (a native api-key
3811
+ // credential, an unrecognized payload, an unowned grant), so `none` is
3812
+ // only the absent-baseline case.
3813
+ classification: live === undefined
3814
+ ? { state: 'none' }
3815
+ : baselineOwner !== undefined ? { state: 'owned', rowId: baselineOwner.id } : { state: 'foreign' },
3816
+ };
3817
+ }
3818
+ /** Whether the projection still holds the value this activation read. */
3819
+ matchesActivationBaseline(current, baseline) {
3820
+ if (baseline.state === 'absent')
3821
+ return current === undefined;
3822
+ if (current === undefined)
3823
+ return false;
3824
+ // Compare the FIELDS, never the object shape: `CredentialRecordLike` makes
3825
+ // `key`/`env`/`payload` optional, and a seam whose read path and
3826
+ // `modifyRecord` path materialise different key sets (one omits them, one
3827
+ // sets them to `undefined`) would defeat a structural comparison and refuse
3828
+ // every activation over an already-serving route.
3829
+ //
3830
+ // An api-key projection keeps its secret in `key`/`env` with `payload`
3831
+ // absent, so every field is compared: payload plus the native fields.
3832
+ const baselineRecord = baseline.record;
3833
+ const sameSecret = current.key === baselineRecord.key;
3834
+ const sameEnv = JSON.stringify(current.env ?? null) === JSON.stringify(baselineRecord.env ?? null);
3835
+ const samePayload = JSON.stringify(current.payload ?? null) === JSON.stringify(baselineRecord.payload ?? null);
3836
+ return current.kind === baselineRecord.kind && sameSecret && sameEnv && samePayload;
3837
+ }
3838
+ /**
3839
+ * Write the projection only while the route still serves the baseline. The
3840
+ * seam's `modifyRecord` mutate body runs under the per-key lock, so this is
3841
+ * the one atomic primitive the activation has (I-000090: `deleteRecord` has
3842
+ * no expected-payload form). Returns the payload written, or `undefined` when
3843
+ * the compare failed and nothing was touched.
3844
+ */
3845
+ async compareAndWriteProjection(records, provider, prepared) {
3846
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3847
+ let landed = false;
3848
+ await records.modifyRecord(projectionKey, async (current) => {
3849
+ if (!this.matchesActivationBaseline(current, prepared.baseline))
3850
+ return undefined;
3851
+ landed = true;
3852
+ // Only this write supersedes a pending own-removal marker: a projection
3853
+ // is about to serve again, so a removal echo still in flight cannot
3854
+ // describe the live record. A refused compare must leave the marker
3855
+ // armed for the removal it belongs to — clearing it there would let that
3856
+ // removal's echo read as an out-of-band deletion (a spurious "removed
3857
+ // outside Keys Manager" warning and a `valueMissing` row).
3858
+ this.intentionalProjectionRemovals.delete(provider);
3859
+ return { kind: 'grant', payload: prepared.payload };
3289
3860
  });
3861
+ return landed ? prepared.written : undefined;
3862
+ }
3863
+ /**
3864
+ * Undo one activation whose state flush was rejected: the row this turn
3865
+ * cleared in memory, the owner claim it took, and the projection it wrote
3866
+ * (back to the captured baseline).
3867
+ *
3868
+ * The baseline decides the mechanism. A present baseline is restored with the
3869
+ * same compare-and-write the projection used; only an absent baseline has to
3870
+ * remove a record, and only while that record is still this activation's own
3871
+ * payload. A foreign writer's record is never deleted.
3872
+ */
3873
+ async compensateActivationFlush(provider, accountId, prepared, undo, records) {
3874
+ const outcome = prepared.baseline.state === 'absent'
3875
+ ? await this.deleteActivationProjection(records, provider, prepared.written, undo.generation)
3876
+ : await this.restoreActivationProjection(records, provider, prepared, undo.generation);
3877
+ // Both helpers awaited, so the classification below decides after the same
3878
+ // read the guard above made before it: a claim that landed inside that I/O
3879
+ // already settled what serves the route, and overwriting it would revert a
3880
+ // newer owner or mark its route foreign. No await separates this check from
3881
+ // the writes in the switch.
3882
+ if (this.ownershipSuperseded(provider, undo.generation))
3883
+ return;
3884
+ // The compensation is about to replace what the route serves (or which row
3885
+ // owns it), so every reconciler that captured the earlier generation read a
3886
+ // world this undoes and must not commit: its projection read may predate the
3887
+ // restore, and its row/binding writes would re-claim an account the restored
3888
+ // baseline does not serve. `ours` keeps the activation's projection and claim
3889
+ // in place — it changes nothing a reconciler could misread — so it needs no
3890
+ // bump.
3891
+ if (outcome !== 'ours')
3892
+ this.noteProjectionCommitted(provider);
3893
+ // P2-B: the row rollback belongs to the outcomes that actually changed what
3894
+ // serves the route. `ours` keeps the activation's projection and claim — the
3895
+ // account IS serving — so restoring the pre-activation demotion would render
3896
+ // a live account as missing/cooling, and a restart without an echo would
3897
+ // persist that lie.
3898
+ if (outcome !== 'ours') {
3899
+ const row = this.store.provider(provider)?.keys.find(candidate => candidate.id === accountId);
3900
+ if (row !== undefined) {
3901
+ row.valueMissing = undo.valueMissing;
3902
+ row.cooldownUntil = undo.cooldownUntil;
3903
+ this.store.touch();
3904
+ }
3905
+ }
3906
+ switch (outcome) {
3907
+ case 'superseded':
3908
+ // The newer claim that moved the generation owns the route and has
3909
+ // already settled what serves it; this undo decides nothing.
3910
+ return;
3911
+ case 'ours':
3912
+ // The activation's projection is still the live record, so its claim
3913
+ // stays: deleting it would call the route pool-served while a record
3914
+ // serves it, and selection plus failure attribution would be wrong
3915
+ // until an echo reconciled the route (never, if the optional
3916
+ // `record-updated` subscription is unavailable).
3917
+ return;
3918
+ case 'baseline':
3919
+ this.applyActivationClassification(provider, prepared.classification);
3920
+ return;
3921
+ case 'foreign':
3922
+ this.activeAccounts.delete(provider);
3923
+ this.foreignProjections.add(provider);
3924
+ return;
3925
+ case 'gone':
3926
+ // Nothing serves the route any more, so the pool does.
3927
+ this.activeAccounts.delete(provider);
3928
+ this.foreignProjections.delete(provider);
3929
+ return;
3930
+ }
3931
+ }
3932
+ /** Put the route's owner classification back to a captured snapshot. */
3933
+ applyActivationClassification(provider, classification) {
3934
+ if (classification.state === 'owned') {
3935
+ this.activeAccounts.set(provider, classification.rowId);
3936
+ this.foreignProjections.delete(provider);
3937
+ return;
3938
+ }
3939
+ this.activeAccounts.delete(provider);
3940
+ if (classification.state === 'foreign')
3941
+ this.foreignProjections.add(provider);
3942
+ else
3943
+ this.foreignProjections.delete(provider);
3944
+ }
3945
+ /** Put the projection back to the present baseline, if it is still ours. */
3946
+ async restoreActivationProjection(records, provider, prepared, generation) {
3947
+ const baseline = prepared.baseline;
3948
+ if (baseline.state !== 'present')
3949
+ return 'baseline';
3950
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3951
+ let outcome = 'ours';
3952
+ try {
3953
+ await records.modifyRecord(projectionKey, async (current) => {
3954
+ // The generation is decided HERE, under the seam's per-key lock and in
3955
+ // the same synchronous step as the payload compare: a claim that lands
3956
+ // between this compensation's entry and this callback would otherwise
3957
+ // see its own projection matched and get the baseline written back over
3958
+ // a route it owns, while the post-I/O check then exits early and leaves
3959
+ // that newer account claimed beside the restored credential.
3960
+ if (this.ownershipSuperseded(provider, generation)) {
3961
+ outcome = 'superseded';
3962
+ return undefined;
3963
+ }
3964
+ if (current === undefined) {
3965
+ outcome = 'gone';
3966
+ return undefined;
3967
+ }
3968
+ if (current.kind !== 'grant' || JSON.stringify(current.payload) !== prepared.written) {
3969
+ // A record this activation did not write serves the route now.
3970
+ outcome = 'foreign';
3971
+ return undefined;
3972
+ }
3973
+ // Return the captured record itself: rebuilding it from `kind` and
3974
+ // `payload` would drop the `key`/`env` an api-key projection carries and
3975
+ // leave the route unusable after a failed flush.
3976
+ outcome = 'baseline';
3977
+ return structuredClone(baseline.record);
3978
+ });
3979
+ }
3980
+ catch (error) {
3981
+ // An unreadable or refused seam proves nothing: the activation's
3982
+ // projection may still be live, so `ours` keeps its claim.
3983
+ this.warn(`activation projection restore failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
3984
+ return 'ours';
3985
+ }
3986
+ return outcome;
3987
+ }
3988
+ /**
3989
+ * The delete authority's proof for an activation's own projection, as an
3990
+ * explicit three-valued answer (QC R2): only `matches` may authorize touching
3991
+ * the record. `unknown` is a store failure — a closed seam, an unreadable
3992
+ * record — and proves nothing, so it is never permission.
3993
+ */
3994
+ async activationProjectionProof(records, provider, written, generation) {
3995
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3996
+ try {
3997
+ // Absent and "another writer's record" are different answers: absent
3998
+ // means nothing serves the route any more (our own demotion can remove
3999
+ // the projection without superseding the claim), while `differs` means a
4000
+ // replacement does. Folding them together marked a serviceless route
4001
+ // foreign, which makes `keyPoolServes` false and leaves no usable
4002
+ // credential at all.
4003
+ let state = 'absent';
4004
+ await records.modifyRecord(projectionKey, async (current) => {
4005
+ // The claim is decided under the same lock as the identity compare, for
4006
+ // the same reason as the restore: a newer owner must not have its
4007
+ // projection deleted by an undo that only checks the generation later.
4008
+ if (this.ownershipSuperseded(provider, generation)) {
4009
+ state = 'superseded';
4010
+ return undefined;
4011
+ }
4012
+ state = current === undefined
4013
+ ? 'absent'
4014
+ : current.kind === 'grant' && JSON.stringify(current.payload) === written ? 'matches' : 'differs';
4015
+ return undefined;
4016
+ });
4017
+ return state;
4018
+ }
4019
+ catch (error) {
4020
+ this.warn(`activation projection proof failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
4021
+ return 'unknown';
4022
+ }
4023
+ }
4024
+ /**
4025
+ * Delete one projection an activation just proved it still owns, through the
4026
+ * same guarded shape the demotion path uses (MF2): the intentional-removal
4027
+ * marker is armed before the delete, so the deletion's own echo is read as
4028
+ * ours instead of as an out-of-band removal.
4029
+ */
4030
+ async deleteActivationProjection(records, provider, written, generation) {
4031
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
4032
+ // F2: the identity read and the delete cannot be atomic here — the seam has
4033
+ // no compare-and-delete, `deleteRecord(key)` takes no expected payload
4034
+ // (host-types.ts:142, tracked as I-000090). This read immediately before the
4035
+ // delete narrows the window to the seam call itself; a writer that replaces
4036
+ // the record inside it still loses its grant, and that residual needs the
4037
+ // seam CAS, not another read here. Anything but a proven match is left
4038
+ // alone.
4039
+ const proof = await this.activationProjectionProof(records, provider, written, generation);
4040
+ if (proof === 'superseded')
4041
+ return 'superseded';
4042
+ // `absent` needs no delete and leaves nothing serving (the pool does);
4043
+ // `differs` means another writer's record serves the route; `unknown`
4044
+ // proves nothing and must keep the activation's claim.
4045
+ if (proof === 'absent')
4046
+ return 'gone';
4047
+ if (proof !== 'matches')
4048
+ return proof === 'differs' ? 'foreign' : 'ours';
4049
+ const markedAt = Date.now();
4050
+ this.intentionalProjectionRemovals.set(provider, markedAt);
4051
+ try {
4052
+ await records.deleteRecord(projectionKey);
4053
+ }
4054
+ catch (error) {
4055
+ // A delete that threw removed nothing, so no own-removal echo is coming:
4056
+ // revoke this call's marker or it would mask a genuine one for the TTL.
4057
+ if (this.intentionalProjectionRemovals.get(provider) === markedAt) {
4058
+ this.intentionalProjectionRemovals.delete(provider);
4059
+ }
4060
+ this.warn(`cancelled activation projection cleanup failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
4061
+ // R14-SF2 applies here too: a throwing delete may have removed the record
4062
+ // before throwing, so the outcome is re-proved rather than assumed —
4063
+ // guessing is what this file forbids. A second failure
4064
+ // proves nothing and keeps the claim (`ours`).
4065
+ const after = await this.activationProjectionProof(records, provider, written, generation);
4066
+ if (after === 'superseded')
4067
+ return 'superseded';
4068
+ if (after === 'absent')
4069
+ return 'gone';
4070
+ if (after === 'differs')
4071
+ return 'foreign';
4072
+ return 'ours';
4073
+ }
4074
+ return 'gone';
3290
4075
  }
3291
4076
  /**
3292
4077
  * Remote: send one minimal request with the selected key or account row.
@@ -3883,11 +4668,68 @@ export class KeysManagerService {
3883
4668
  test: true,
3884
4669
  });
3885
4670
  }
4671
+ /**
4672
+ * Whether this test may still borrow the route, or the result it must report
4673
+ * instead: the window is live, shutdown has not begun, and the row still
4674
+ * exists, is not retiring, and still names the `grantRef` the test captured.
4675
+ *
4676
+ * Read before the borrow and again after every await that precedes it. The
4677
+ * grant recovery is one: it outlives the isolation deadline on a slow seam,
4678
+ * and a window the watchdog already released no longer compensates its late
4679
+ * borrow (that branch is guarded by `abandonedWindows.get(provider) === window`).
4680
+ */
4681
+ borrowBarrier(window, provider, row, grantRef) {
4682
+ if (window.cleaned) {
4683
+ // QC fix wave 2: the watchdog abandoned this window while an await was in
4684
+ // flight. Starting (or resuming) the borrow now would land it after the
4685
+ // cleanup that already ran, so the test stops here.
4686
+ return this.abandonedTestResult();
4687
+ }
4688
+ if (this.shutdownRequested) {
4689
+ // PR review round 30: shutdown abandons the chain parked here, so the
4690
+ // borrow write must not land after `close()`. Round 34: the marker
4691
+ // is up from shutdown's entry, so the pre-close restoration is covered
4692
+ // too — it does not borrow, and this test must not either.
4693
+ return this.shutdownEntryResult();
4694
+ }
4695
+ // PR review round 12: the reads and the recovery above are awaits, so a
4696
+ // removal can have deleted the provider or this row while they were in
4697
+ // flight (a test that registered and one that did not are covered: the
4698
+ // marker refuses the latter, this re-check the former). Never write the
4699
+ // borrow — and never re-project the removed account's grant — on that state.
4700
+ // PR review round 15: the existence check alone still holds while a removal
4701
+ // is deleting credentials (the row is spliced only after its grant delete),
4702
+ // so the row's retirement mark is part of the assertion. The provider-wide
4703
+ // marker is deliberately not read here: a removal that began while this
4704
+ // test is registered waits for it in {@link settleTestBeforeRemoval} and is
4705
+ // the designed handshake, while the row mark is only set by a removal that
4706
+ // already passed that check.
4707
+ const live = this.store.provider(provider);
4708
+ const removing = this.retiringAccounts.has(row);
4709
+ const liveRow = live?.keys.find(candidate => candidate.id === row.id);
4710
+ if (removing || liveRow === undefined) {
4711
+ return removing
4712
+ ? { ok: false, latencyMs: 0, error: `Provider "${provider}" is being removed; the test did not borrow its credential` }
4713
+ : { ok: false, latencyMs: 0, error: `Key "${row.id}" was removed from ${provider} before its test could borrow it` };
4714
+ }
4715
+ // PR #37 review (I-000234): a re-sign-in repoints the row instead of
4716
+ // removing it. The recovery read then belongs to the generation the row no
4717
+ // longer names, so borrowing it would test the previous credential and
4718
+ // report the outcome against this row.
4719
+ if (liveRow.oauth === null || liveRow.oauth.grantRef !== grantRef) {
4720
+ return { ok: false, latencyMs: 0, error: `Key "${row.id}" was repointed on ${provider} before its test could borrow it` };
4721
+ }
4722
+ return undefined;
4723
+ }
3886
4724
  /** One request with the row's grant temporarily projected as the route's credential. */
3887
4725
  async runOAuthRowTest(provider, row, model, window) {
3888
4726
  const grant = row.oauth;
3889
4727
  if (grant === null)
3890
4728
  return { ok: false, latencyMs: 0, error: `Key "${row.id}" is not an OAuth account` };
4729
+ // Captured here, not read from the row later: a re-sign-in repoints the
4730
+ // row's generation in place, so the live object would answer with the NEW
4731
+ // ref and the borrow would look current.
4732
+ const grantRef = grant.grantRef;
3891
4733
  const records = this.credentialRecords();
3892
4734
  if (records === undefined)
3893
4735
  return { ok: false, latencyMs: 0, error: 'The credential store does not support records' };
@@ -3897,23 +4739,6 @@ export class KeysManagerService {
3897
4739
  // grant chain, so a rotation echo cannot observe or overwrite the
3898
4740
  // temporary projection between them.
3899
4741
  await this.scheduleGrantReconciliation(provider, async () => {
3900
- const stored = await records.readRecord(grant.grantRef);
3901
- if (window.cleaned) {
3902
- // QC fix wave 2: the watchdog abandoned this window while the read was
3903
- // in flight. Starting (or resuming) the borrow now would land it after
3904
- // the cleanup that already ran, so the test stops here.
3905
- result = this.abandonedTestResult();
3906
- return;
3907
- }
3908
- if (stored === undefined || stored.kind !== 'grant' || !isOAuthGrantPayload(stored.payload)) {
3909
- result = {
3910
- ok: false,
3911
- latencyMs: 0,
3912
- error: `The grant payload at ${grant.grantRef} is missing or unusable; sign in to this account again`,
3913
- };
3914
- return;
3915
- }
3916
- const borrowed = structuredClone(stored.payload);
3917
4742
  const captured = await records.readRecord(projectionKey);
3918
4743
  // PR review round 14: the snapshot and its comparison key are frozen
3919
4744
  // here. The seam hands back a live record, so a writer mutating that
@@ -3921,36 +4746,30 @@ export class KeysManagerService {
3921
4746
  // the updater's `current` and make the borrow look unchanged.
3922
4747
  const previous = captured === undefined ? undefined : structuredClone(captured);
3923
4748
  const previousKey = previous === undefined ? undefined : JSON.stringify(previous);
3924
- if (window.cleaned) {
3925
- result = this.abandonedTestResult();
4749
+ const blocked = this.borrowBarrier(window, provider, row, grantRef);
4750
+ if (blocked !== undefined) {
4751
+ result = blocked;
3926
4752
  return;
3927
4753
  }
3928
- if (this.shutdownRequested) {
3929
- // PR review round 30: shutdown abandons the chain parked here, so the
3930
- // borrow write below must not land after `close()`. Round 34: the
3931
- // marker is up from shutdown's entry, so the pre-close restoration is
3932
- // covered too — it does not borrow, and this test must not either.
3933
- result = this.shutdownEntryResult();
4754
+ // The recovery writes a grant record, so it runs only once this test is
4755
+ // known to borrow: an abandoned or retiring test must not restore one.
4756
+ const recovered = await this.recoverGrantPayload(provider, row, records);
4757
+ if (recovered === undefined) {
4758
+ result = {
4759
+ ok: false,
4760
+ latencyMs: 0,
4761
+ error: `The grant payload at ${grantRef} is missing or unusable; sign in to this account again`,
4762
+ };
3934
4763
  return;
3935
4764
  }
3936
- // PR review round 12: both reads above are awaits, so a removal can have
3937
- // deleted the provider or this row while they were in flight (a test that
3938
- // registered and one that did not are covered: the marker refuses the
3939
- // latter, this re-check the former). Never write the borrow — and never
3940
- // re-project the removed account's grant — on that state.
3941
- // PR review round 15: the existence check alone still holds while a
3942
- // removal is deleting credentials (the row is spliced only after its
3943
- // grant delete), so the row's retirement mark is part of the assertion.
3944
- // The provider-wide marker is deliberately not read here: a removal that
3945
- // began while this test is registered waits for it in
3946
- // {@link settleTestBeforeRemoval} and is the designed handshake, while
3947
- // the row mark is only set by a removal that already passed that check.
3948
- const live = this.store.provider(provider);
3949
- const removing = this.retiringAccounts.has(row);
3950
- if (removing || live === undefined || !live.keys.some(candidate => candidate.id === row.id)) {
3951
- result = removing
3952
- ? { ok: false, latencyMs: 0, error: `Provider "${provider}" is being removed; the test did not borrow its credential` }
3953
- : { ok: false, latencyMs: 0, error: `Key "${row.id}" was removed from ${provider} before its test could borrow it` };
4765
+ const borrowed = structuredClone(recovered.payload);
4766
+ // PR #37 review (must-fix): the recovery is an untimed seam round trip
4767
+ // and can outlive the isolation deadline, after which the window's
4768
+ // cleanup has already run and released the window. Registering facts or
4769
+ // borrowing now would land them with no owner: re-run the barrier first.
4770
+ const lateBlocked = this.borrowBarrier(window, provider, row, grantRef);
4771
+ if (lateBlocked !== undefined) {
4772
+ result = lateBlocked;
3954
4773
  return;
3955
4774
  }
3956
4775
  // QC fix wave 2: record the borrow before the write, so an abandoned
@@ -4597,9 +5416,11 @@ export class KeysManagerService {
4597
5416
  ...(typeof payload.expires === 'number' && Number.isFinite(payload.expires) ? { expiresAt: payload.expires } : {}),
4598
5417
  };
4599
5418
  if (previousRef !== rotatedRef) {
4600
- // The row repoint above is synchronous, but the delete is a second write:
4601
- // re-read both gates immediately before it so a shutdown or a takeover
4602
- // that landed in between cannot remove a record this attempt never owned.
5419
+ // Defence in depth, not a reachable window: no await separates the guard
5420
+ // above from the delete below, so neither a shutdown nor a takeover can
5421
+ // land in between. The re-check is kept because it is the condition the
5422
+ // delete depends on and a future await inserted here would otherwise
5423
+ // silently remove a record this attempt never owned.
4603
5424
  if (this.persistenceRefused(capability) || (guard !== undefined && !guard())) {
4604
5425
  this.store.touch();
4605
5426
  return;
@@ -4608,6 +5429,11 @@ export class KeysManagerService {
4608
5429
  try {
4609
5430
  if (records === undefined)
4610
5431
  throw new Error('The credential store does not support records');
5432
+ // Declared residual: this delete is an await with no expected-payload
5433
+ // CAS (`deleteRecord(key)` takes only the key), so a claim landing
5434
+ // inside it can still remove the same-ref record an activation just
5435
+ // wrote. Post-delete compensation is out of scope here; closing it
5436
+ // needs a seam-level compare-and-delete.
4611
5437
  await records.deleteRecord(previousRef);
4612
5438
  }
4613
5439
  catch (error) {
@@ -4641,11 +5467,13 @@ export class KeysManagerService {
4641
5467
  return { ok: false, error: failure?.message ?? failure?.code ?? 'The provider reported a failure' };
4642
5468
  }
4643
5469
  // PR review SF3 + round 8: only a real completion is a pass. The ping
4644
- // asks for one token, so `length` is the expected completion; a
5470
+ // asks for one token, so `max-tokens` is the expected completion; a
4645
5471
  // finish chunk with NO reason, `aborted`, or any kind this broker
4646
5472
  // does not know means the provider did not answer, so reporting a
4647
- // pass would claim a credential it never served.
4648
- if (kind === 'stop' || kind === 'length' || kind === 'toolUse') {
5473
+ // pass would claim a credential it never served. The accepted kinds
5474
+ // are the installed `@deepseek-ai/dsh-llm` union (`FinishReasonMap`),
5475
+ // shared with the tests so the two cannot drift again.
5476
+ if (kind !== undefined && TEST_SUCCESS_FINISH_KINDS.includes(kind)) {
4649
5477
  // PR review round 42: a successful ping only proves the route
4650
5478
  // answered. An api-key row test must also prove the pinned literal
4651
5479
  // was the credential that served it — a takeover during the stream
@@ -5165,6 +5993,15 @@ export class KeysManagerService {
5165
5993
  this.store.pruneBindings(now);
5166
5994
  this.pruneAttempts(now);
5167
5995
  }
5996
+ /**
5997
+ * Project one provider's unattributed-request count into its row. Additive
5998
+ * and optional (`I-000120`): a row with no count spreads nothing, so an
5999
+ * older snapshot and a healthy store render exactly as before.
6000
+ */
6001
+ unattributedView(provider) {
6002
+ const count = this.unattributedRequests.get(provider) ?? 0;
6003
+ return count > 0 ? { unattributedRequests: count } : {};
6004
+ }
5168
6005
  /** Build the complete UI state. */
5169
6006
  buildState() {
5170
6007
  const now = Date.now();
@@ -5181,6 +6018,11 @@ export class KeysManagerService {
5181
6018
  const profile = namespace === undefined ? undefined : asProfile(getPath(namespace.value, entry.settingsPath));
5182
6019
  const managed = this.store.provider(entry.provider);
5183
6020
  const extra = EXTRA_PROVIDER_MAP.get(entry.provider);
6021
+ // A transport route names its own credential record and registers no
6022
+ // settings namespace, so the profile/capability read below can only
6023
+ // describe the route wrongly: it is neither a DSH route nor key-pool
6024
+ // capable, and it is never adoptable.
6025
+ const transport = entry.settingsNs === TRANSPORT_CREDENTIAL_SCOPE;
5184
6026
  const oauthFlow = this.oauthFlowAvailable(entry.provider);
5185
6027
  const caps = capabilityOf(entry, profile, oauthFlow);
5186
6028
  const support = { supported: caps.keys, ...(caps.reason === undefined ? {} : { reason: caps.reason }) };
@@ -5198,14 +6040,17 @@ export class KeysManagerService {
5198
6040
  provider: entry.provider,
5199
6041
  displayName: extra?.name ?? entry.displayName ?? entry.provider,
5200
6042
  source: extra !== undefined ? 'extra' : 'dsh',
5201
- origin: extra !== undefined ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
6043
+ origin: extra !== undefined || transport ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
5202
6044
  managed: managed !== undefined,
5203
6045
  createdProfile: managed?.createdProfile ?? false,
5204
6046
  configured: active.has(entry.provider) || profile !== undefined,
5205
6047
  active: active.has(entry.provider),
5206
- supported: support.supported,
5207
- ...(support.reason === undefined ? {} : { reason: support.reason }),
5208
- accountSupported: accountSupport.supported,
6048
+ supported: transport ? false : support.supported,
6049
+ ...(transport
6050
+ ? { reason: 'This route is served by a transport plugin; manage its account from this provider card.' }
6051
+ : support.reason === undefined ? {} : { reason: support.reason }),
6052
+ accountSupported: transport ? false : accountSupport.supported,
6053
+ ...(transport ? { transport: true } : {}),
5209
6054
  settingsNs: entry.settingsNs,
5210
6055
  settingsPath: [...entry.settingsPath],
5211
6056
  apiKeyEnv,
@@ -5224,6 +6069,7 @@ export class KeysManagerService {
5224
6069
  status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
5225
6070
  keys,
5226
6071
  ...(warning === undefined ? {} : { warning }),
6072
+ ...this.unattributedView(entry.provider),
5227
6073
  });
5228
6074
  seen.add(entry.provider);
5229
6075
  }
@@ -5264,6 +6110,7 @@ export class KeysManagerService {
5264
6110
  status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
5265
6111
  keys,
5266
6112
  ...(managed === undefined ? {} : { warning: 'This extra provider is managed but its pi-ai route is not registered. Check the provider profile in Models.' }),
6113
+ ...this.unattributedView(extra.id),
5267
6114
  });
5268
6115
  seen.add(extra.id);
5269
6116
  }
@@ -5302,6 +6149,7 @@ export class KeysManagerService {
5302
6149
  status: statusOfProvider(managed.keys, now),
5303
6150
  keys,
5304
6151
  warning: 'This provider route is not currently registered. Check its profile in Models.',
6152
+ ...this.unattributedView(managed.provider),
5305
6153
  });
5306
6154
  seen.add(managed.provider);
5307
6155
  }
@@ -5325,6 +6173,8 @@ export class KeysManagerService {
5325
6173
  writable: descriptor.writable && !this.store.readOnly,
5326
6174
  settings: { ...normalizeSettings(this.store.settings) },
5327
6175
  providers: views,
6176
+ // Managed only: a transport route renders as a card but stays out of
6177
+ // this published count, the key counts and every key-pool write path.
5328
6178
  managedCount: views.filter(view => view.managed).length,
5329
6179
  keyCount: allKeys.length,
5330
6180
  healthyKeyCount: allKeys.filter(key => key.status === 'active').length,
@@ -5494,14 +6344,38 @@ export class KeysManagerService {
5494
6344
  // PR review round 35: the last point before the settings write. Only
5495
6345
  // `closing` is read: shutdown's own pre-close restoration writes here by
5496
6346
  // design and runs before that flag goes up.
5497
- if (this.closing)
6347
+ //
6348
+ // Reaching this point means the route genuinely needs a write: `current ===
6349
+ // desired` already returned true above, and the preserved / releasing /
6350
+ // foreign-value branches declined for their own reasons. So shutdown is
6351
+ // what stops it, and this is the one place that can say so — the callers
6352
+ // cannot infer it (`pushWarning` caps its list, and a silent `false` has
6353
+ // several non-refusal causes upstream).
6354
+ if (this.closing) {
6355
+ this.bindingWriteRefused(provider, 'Keys Manager is shutting down');
6356
+ return false;
6357
+ }
6358
+ // A caller that stamped the ownership generation it entered with forbids
6359
+ // this write once a newer claim landed: the binding it would commit was
6360
+ // derived from the classification that claim replaced.
6361
+ if (options.ownershipGeneration !== undefined
6362
+ && this.ownershipSuperseded(provider, options.ownershipGeneration))
5498
6363
  return false;
5499
6364
  try {
5500
- // The entry gate above read a snapshot; ownership can turn unknown while
5501
- // this write re-reads its descriptor (PR review round 25). Binding is a
5502
- // guess then, so the gate closes again at the last point before the
6365
+ // The entry gate above read a snapshot, and ownership can turn unknown
6366
+ // while this write re-reads its descriptor (PR review round 25). Binding
6367
+ // is a guess then, so the gate closes again at the last point before the
5503
6368
  // mutation. Unbinding stays allowed.
5504
- const written = await this.mutateSettings(record.settingsNs, [desired === undefined ? { op: 'unset', path } : { op: 'set', path, value: desired }], () => desired === undefined || this.projectionOwnershipKnown(provider));
6369
+ // That last point is `mutateSettings`' `stillWrites` predicate: it is
6370
+ // evaluated synchronously between the descriptor re-read and
6371
+ // `settings.mutate` (no await between them), and the mutate itself passes
6372
+ // the descriptor's revision as an expected-revision CAS, so a takeover
6373
+ // that changed the namespace in between makes the write fail instead of
6374
+ // landing stale state. A claim the predicate sees must not leave this
6375
+ // binding behind — it describes the classification that claim replaced.
6376
+ const stamp = options.ownershipGeneration;
6377
+ const written = await this.mutateSettings(record.settingsNs, [desired === undefined ? { op: 'unset', path } : { op: 'set', path, value: desired }], () => (desired === undefined || this.projectionOwnershipKnown(provider))
6378
+ && (stamp === undefined || !this.ownershipSuperseded(provider, stamp)));
5505
6379
  if (!written)
5506
6380
  return false;
5507
6381
  }
@@ -5575,8 +6449,15 @@ export class KeysManagerService {
5575
6449
  const takeover = record?.takeoverPending === true
5576
6450
  && !this.foreignProjections.has(provider)
5577
6451
  && this.oauthOwnerFor(provider) === null;
5578
- const bound = await this.syncBrokerBinding(provider, { write: false, takeover });
5579
- if (takeover && bound && record !== undefined) {
6452
+ // The awaited sync can be superseded (an activation claims the route while
6453
+ // it runs). It would then commit a binding derived from the pre-claim
6454
+ // classification, and settling the debt would record a takeover reconciled
6455
+ // for a generation that no longer owns the route. The generation is
6456
+ // captured here, passed to the sync's own last-mile gate, and re-read
6457
+ // immediately before the clear.
6458
+ const generation = this.ownershipGenerations.get(provider) ?? 0;
6459
+ const bound = await this.syncBrokerBinding(provider, { write: false, takeover, ownershipGeneration: generation });
6460
+ if (takeover && bound && record !== undefined && !this.ownershipSuperseded(provider, generation)) {
5580
6461
  record.takeoverPending = false;
5581
6462
  this.store.touch();
5582
6463
  }
@@ -5599,6 +6480,14 @@ export class KeysManagerService {
5599
6480
  // is persistence, so shutdown refuses it.
5600
6481
  if (this.closing)
5601
6482
  return;
6483
+ // The own-generation stamp: this sync must not commit a binding derived
6484
+ // from a classification a newer claim replaced. It travels into
6485
+ // `syncBrokerBinding`, whose `stillWrites` predicate is the last point
6486
+ // before the settings write and runs synchronously right before a
6487
+ // revision-CAS'd mutate; the only window left is that write call itself,
6488
+ // which fails on a revision mismatch rather than committing stale state.
6489
+ // Every caller is covered here rather than at the call sites.
6490
+ const generation = this.ownershipGenerations.get(provider) ?? 0;
5602
6491
  try {
5603
6492
  // PR review round 40: adoption keeps the pre-adoption reference while a
5604
6493
  // foreign projection serves the route and records that it still owes the
@@ -5607,8 +6496,8 @@ export class KeysManagerService {
5607
6496
  // once the bind lands; `originalApiKeyEnv` alone proves nothing.
5608
6497
  const record = this.store.provider(provider);
5609
6498
  const takeover = record?.takeoverPending === true;
5610
- const bound = await this.syncBrokerBinding(provider, { takeover });
5611
- if (takeover && bound && record !== undefined) {
6499
+ const bound = await this.syncBrokerBinding(provider, { takeover, ownershipGeneration: generation });
6500
+ if (takeover && bound && record !== undefined && !this.ownershipSuperseded(provider, generation)) {
5612
6501
  record.takeoverPending = false;
5613
6502
  this.store.touch();
5614
6503
  }
@@ -5690,6 +6579,12 @@ export class KeysManagerService {
5690
6579
  throw new Error('This extra provider needs a key; add it from the extra-provider section');
5691
6580
  throw new Error(`Provider "${provider}" is not registered by any adapter`);
5692
6581
  }
6582
+ // A transport route's credential is the record its own adapter reads, so
6583
+ // adopting it would bind a profile nothing serves (and its namespace is
6584
+ // not even mounted). Refuse before any settings read or write.
6585
+ if (entry.settingsNs === TRANSPORT_CREDENTIAL_SCOPE) {
6586
+ throw new Error(`Provider "${provider}" is served by a transport plugin; manage its account from its provider card`);
6587
+ }
5693
6588
  const descriptor = this.describeSettings();
5694
6589
  const namespace = this.namespace(descriptor, entry.settingsNs);
5695
6590
  if (namespace === undefined)
@@ -5848,6 +6743,7 @@ export class KeysManagerService {
5848
6743
  return 'superseded';
5849
6744
  }
5850
6745
  this.ownershipReadFailures.delete(provider);
6746
+ this.unattributedRequests.delete(provider);
5851
6747
  this.noteProviderHydrated(provider);
5852
6748
  if (record === undefined)
5853
6749
  this.foreignProjections.delete(provider);
@@ -6810,6 +7706,12 @@ export class KeysManagerService {
6810
7706
  // branches, so it is constructed after the isolation wait — a mismatch
6811
7707
  // profile still resolves pi-ai's stored grant, so the borrowed
6812
7708
  // projection reaches it too.
7709
+ //
7710
+ // No ownership read and no unattributed count here: the store already
7711
+ // said this route's profile disagrees with its binding, so this manager
7712
+ // cannot describe what serves it and hands the request over untouched.
7713
+ // The counter is defined over routes whose ownership it did try to read
7714
+ // (see `unattributedRequests`).
6813
7715
  return this.isolateFromTest(providerId, () => next());
6814
7716
  }
6815
7717
  // An ordinary request must never be served by the grant a row test has
@@ -7046,6 +7948,17 @@ export class KeysManagerService {
7046
7948
  });
7047
7949
  }
7048
7950
  iterator = await service.als.run(store, () => next()[Symbol.asyncIterator]());
7951
+ // I-000120 / D8 + I-000201: this request is served without an
7952
+ // attribution, and this is the only site that counts one — but
7953
+ // only once downstream actually has an iterator. Both causes (the
7954
+ // bounded wait expired, the read had failed) still arrive here as
7955
+ // the same `false`, and the hydration pass never counts. A routing
7956
+ // or adapter failure that throws before dispatch served nothing, so
7957
+ // it must not inflate the count; `started` keeps one attempt to one
7958
+ // increment, and a later attempt that does dispatch counts then.
7959
+ if (!hydrated) {
7960
+ service.unattributedRequests.set(providerId, (service.unattributedRequests.get(providerId) ?? 0) + 1);
7961
+ }
7049
7962
  }
7050
7963
  if (iterator === undefined)
7051
7964
  throw new Error('managed stream has no iterator');