dsh-keys-manager 0.2.2 → 0.2.3

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
@@ -844,12 +844,15 @@ export class KeysManagerService {
844
844
  * order is preserved so the older observation can never commit after the
845
845
  * newer one and leave the row on a stale generation.
846
846
  *
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.
847
+ * Round-8 finding I-000106 assumed activation would join this chain. It does
848
+ * not: activation runs its compare-and-write inside the mutation queue turn,
849
+ * and the only writer of `row.oauth.grantRef` is {@link reconcileGrantRecord},
850
+ * which runs outside that queue. The fence is therefore a generation re-check
851
+ * on both sides of the write — before it (the row must still name the
852
+ * generation that was read) and after it (a rotation commit can land inside
853
+ * the seam's compare-and-write await, which only reads the projection key).
854
+ * Chain members are the echo reconciler, the key-test borrow and adoption
855
+ * classification.
853
856
  */
854
857
  grantReconciliations = new Map();
855
858
  /**
@@ -944,6 +947,20 @@ export class KeysManagerService {
944
947
  credentialsMounted = false;
945
948
  /** Providers whose bounded-wait warning already fired (warn once). */
946
949
  hydrationWaitWarned = new Set();
950
+ /**
951
+ * Stream attempts each provider served without an attributed account
952
+ * (`I-000120`, D8): the ownership read had not landed, or it had failed.
953
+ * Counted at the one managed serving site (`wrapStream`), never in the
954
+ * hydration pass — a failed read serves no request, it only makes later
955
+ * attempts unattributed. Two earlier branches are outside this definition and
956
+ * stay uncounted: a provider this manager does not manage (no ownership is
957
+ * ever asked for) and the `mismatch` profile shape, which hands the request
958
+ * to pi-ai untouched precisely because this manager's own binding cannot
959
+ * describe what serves it. Cleared only when a later read decides the route's
960
+ * attribution; process-local, never persisted and never on the warnings
961
+ * channel.
962
+ */
963
+ unattributedRequests = new Map();
947
964
  /** Channels whose lost-subscription warning already fired (warn once). */
948
965
  subscriptionLostWarnings = new Set();
949
966
  /** Disposer for the `authorization/settled` subscription. */
@@ -1011,7 +1028,13 @@ export class KeysManagerService {
1011
1028
  }
1012
1029
  return (async () => {
1013
1030
  try {
1014
- await Promise.all(Object.values(this.store.providers).map(async (providerRecord) => {
1031
+ // allSettled, never all: under `all`, a rejection from any single
1032
+ // provider task settles this awaited pass at once, so the finally below
1033
+ // flips `hydrationSettled` while the other providers' reads are still
1034
+ // in flight and `awaitProjectionOwnership` calls them ready. No task
1035
+ // body can reject today (the read error is caught inside it), so this
1036
+ // is a structural guard — do not flip it back for style.
1037
+ await Promise.allSettled(Object.values(this.store.providers).map(async (providerRecord) => {
1015
1038
  const provider = providerRecord.provider;
1016
1039
  const accounts = providerRecord.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null);
1017
1040
  try {
@@ -1071,8 +1094,13 @@ export class KeysManagerService {
1071
1094
  // attributable now and must not wait behind another provider's
1072
1095
  // stalled read (round-10 should-fix SF1). A superseded pass does
1073
1096
  // not report readiness for a store it no longer describes.
1074
- if (this.hydrationEpoch === epoch)
1097
+ if (this.hydrationEpoch === epoch) {
1098
+ // A landed read decided this route's attribution; a failed one
1099
+ // decided nothing and never clears the count (I-000120).
1100
+ if (!this.ownershipReadFailures.has(provider))
1101
+ this.unattributedRequests.delete(provider);
1075
1102
  this.noteProviderHydrated(provider);
1103
+ }
1076
1104
  }
1077
1105
  }));
1078
1106
  }
@@ -1129,10 +1157,24 @@ export class KeysManagerService {
1129
1157
  * repair was skipped while that shape was unknown.
1130
1158
  */
1131
1159
  noteProjectionReadSucceeded(provider) {
1132
- if (!this.ownershipReadFailures.delete(provider))
1133
- return;
1160
+ // This read decided the route's attribution, so a count of requests served
1161
+ // without one no longer describes it (I-000120).
1162
+ this.unattributedRequests.delete(provider);
1163
+ const recovering = this.ownershipReadFailures.delete(provider);
1164
+ // Readiness is decided by the read, not by whether it was recovering: a
1165
+ // `record-updated` reconcile can answer the projection while startup
1166
+ // hydration is still pending for another provider, and the old early return
1167
+ // left this one out of `hydratedProviders` — waits kept timing out, routing
1168
+ // stayed skipped, and the count this read had just cleared started growing
1169
+ // again although attribution was already decided. `noteProviderHydrated` is
1170
+ // idempotent (a set add plus waking its waiters).
1134
1171
  this.noteProviderHydrated(provider);
1135
- void this.scheduleGrantReconciliation(provider, () => this.syncBindingAfterProjectionChange(provider));
1172
+ // Only recovery re-derives the binding: the normal call arrives from a
1173
+ // reconciliation that already owns that re-derive behind it, and the
1174
+ // startup repair is what a failed read skipped.
1175
+ if (recovering) {
1176
+ void this.scheduleGrantReconciliation(provider, () => this.syncBindingAfterProjectionChange(provider));
1177
+ }
1136
1178
  }
1137
1179
  /** Release every provider's readiness wait when hydration cannot proceed. */
1138
1180
  settleHydrationWaits() {
@@ -1184,6 +1226,9 @@ export class KeysManagerService {
1184
1226
  timer = setTimeout(() => {
1185
1227
  wake(false);
1186
1228
  }, ms);
1229
+ // I-000116: a request waiting on hydration must not hold the process
1230
+ // open for the rest of the wait; every exit path still clears it.
1231
+ timer.unref?.();
1187
1232
  live.add(onLanded);
1188
1233
  });
1189
1234
  }
@@ -1270,6 +1315,16 @@ export class KeysManagerService {
1270
1315
  this.ownershipGenerations.set(provider, generation);
1271
1316
  return generation;
1272
1317
  }
1318
+ /**
1319
+ * Whether a claim landed after `generation` was captured. A reconciliation
1320
+ * reads its snapshot before its awaited record read and decides after it, so
1321
+ * an activation (or login) that claimed the route in between owns the answer
1322
+ * now: the stale body must not clear the claim, mark the route foreign, or
1323
+ * re-point the binding. The hydration write uses the same guard.
1324
+ */
1325
+ ownershipSuperseded(provider, generation) {
1326
+ return (this.ownershipGenerations.get(provider) ?? 0) !== generation;
1327
+ }
1273
1328
  /**
1274
1329
  * Claim the projection owner for one account row, bumping the provider's
1275
1330
  * ownership generation so an in-flight hydration read of an older state
@@ -1279,6 +1334,8 @@ export class KeysManagerService {
1279
1334
  this.activeAccounts.set(provider, accountId);
1280
1335
  this.foreignProjections.delete(provider);
1281
1336
  this.noteProjectionCommitted(provider);
1337
+ // The route has an owner now, so requests are attributed from here on.
1338
+ this.unattributedRequests.delete(provider);
1282
1339
  }
1283
1340
  /**
1284
1341
  * Log without ever echoing secret material. */
@@ -1307,17 +1364,32 @@ export class KeysManagerService {
1307
1364
  }
1308
1365
  }
1309
1366
  /** Serialize mutations, rejecting submissions that arrive during shutdown. */
1310
- run(operation) {
1367
+ run(operation, onFailure) {
1311
1368
  if (this.closing)
1312
1369
  return Promise.reject(new Error('Keys Manager is shutting down'));
1313
1370
  const guarded = async () => {
1314
1371
  if (this.store.readOnly) {
1315
1372
  throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
1316
1373
  }
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;
1374
+ try {
1375
+ const result = await operation();
1376
+ // A mutation is not successful until its state document is durable.
1377
+ await this.store.flush();
1378
+ return result;
1379
+ }
1380
+ catch (error) {
1381
+ // Failure cleanup belongs to THIS turn's exclusive window, not a later
1382
+ // one: a queued mutation (a removal, say) would otherwise run first and
1383
+ // destroy the state the undo needs to restore. `guarded` resolves only
1384
+ // after the hook, so everything already submitted behind this turn sees
1385
+ // the compensated state. Both awaits are best-effort — the caller must
1386
+ // receive the original error, not the cleanup's.
1387
+ if (onFailure !== undefined) {
1388
+ await onFailure(error).catch(() => undefined);
1389
+ await this.store.flush().catch(() => undefined);
1390
+ }
1391
+ throw error;
1392
+ }
1321
1393
  };
1322
1394
  const next = this.queue.then(guarded, guarded);
1323
1395
  this.queue = next.catch(() => { });
@@ -2872,7 +2944,7 @@ export class KeysManagerService {
2872
2944
  // can already be superseded. The newer claim owns the route and settled
2873
2945
  // the binding itself: this stale removal decides nothing — no owner
2874
2946
  // delete, no rebind — it only consumes its own one-shot removal marker.
2875
- if ((this.ownershipGenerations.get(provider) ?? 0) !== generation) {
2947
+ if (this.ownershipSuperseded(provider, generation)) {
2876
2948
  this.intentionalProjectionRemovals.delete(provider);
2877
2949
  return;
2878
2950
  }
@@ -2928,6 +3000,11 @@ export class KeysManagerService {
2928
3000
  // reference over it. With no OAuth row the unowned-grant branch below
2929
3001
  // classifies a valid grant the same way.
2930
3002
  if (record.kind === 'api-key') {
3003
+ // A newer claim (an activation that wrote and claimed after this body
3004
+ // captured its snapshot) owns the route now; this stale classification
3005
+ // must not clear it.
3006
+ if (this.ownershipSuperseded(provider, generation))
3007
+ return;
2931
3008
  // Native api-key management took the route over: the manager no
2932
3009
  // longer owns any projection here.
2933
3010
  this.activeAccounts.delete(provider);
@@ -2939,11 +3016,15 @@ export class KeysManagerService {
2939
3016
  this.noteProjectionCommitted(provider);
2940
3017
  this.pushWarning(`The ${provider} route now holds a natively managed API-key credential; the Keys Manager account pool is not serving it.`);
2941
3018
  // pi-ai resolves the broker reference before this record, so a route
2942
- // left bound would keep serving the pool (PR review round 4).
3019
+ // left bound would keep serving the pool (PR review round 4). The sync
3020
+ // stamps the generation it entered with and re-checks before its own
3021
+ // write, so this body's own bump cannot strand it.
2943
3022
  await this.syncBindingAfterProjectionChange(provider);
2944
3023
  return;
2945
3024
  }
2946
3025
  if (!isOAuthGrantPayload(record.payload)) {
3026
+ if (this.ownershipSuperseded(provider, generation))
3027
+ return;
2947
3028
  // An unrecognized payload is not ours to interpret: drop the stale
2948
3029
  // owner entry so failure attribution and demotion never fire on it.
2949
3030
  this.activeAccounts.delete(provider);
@@ -2961,6 +3042,8 @@ export class KeysManagerService {
2961
3042
  // applies, so a restarted process and the live reconciler agree.
2962
3043
  const owner = matchOwningAccount(provider, accounts, payload);
2963
3044
  if (owner === undefined) {
3045
+ if (this.ownershipSuperseded(provider, generation))
3046
+ return;
2964
3047
  // A foreign account's grant serves the route: no pool row owns the
2965
3048
  // projection, so the previous owner entry must not survive it — and the
2966
3049
  // live record keeps the pool from claiming the route (round 3).
@@ -2981,6 +3064,11 @@ export class KeysManagerService {
2981
3064
  // is re-adopted below. Only a removal in progress is refused.
2982
3065
  if (!this.accountRowIsManaged(provider, owner))
2983
3066
  return;
3067
+ // A newer claim landed while this body was resolving its read: the
3068
+ // rotation write, the row commit and the binding sync below all describe a
3069
+ // generation that claim already superseded.
3070
+ if (this.ownershipSuperseded(provider, generation))
3071
+ return;
2984
3072
  // F1 (I-000081): the matched grant IS the live projection, so this row
2985
3073
  // owns it from now on — even when no in-process login or activation
2986
3074
  // ran (the post-restart echo-rotation case: a hydrated row, no owner
@@ -2992,30 +3080,29 @@ export class KeysManagerService {
2992
3080
  // the stale echo claim is discarded exactly like a stale hydration
2993
3081
  // read (the rotation sync below still runs, but a commit that a newer
2994
3082
  // 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
- }
3083
+ // Unconditional: the guard above already returned when a newer claim
3084
+ // landed, and nothing between the two awaits.
3085
+ this.claimProjectionOwner(provider, owner.id);
3086
+ const claimedGeneration = this.ownershipGenerations.get(provider);
3087
+ // R7-SF3: the live projection proves this row's identity again, so a
3088
+ // demoted row is re-adopted — restore its owner claim (above) and end
3089
+ // the demoted state. `valueMissing` means "not serving but still
3090
+ // managed", not "removal in progress": a native sign-in or pi-ai
3091
+ // write that re-projects the grant recovers the account without a
3092
+ // manual re-activation. The flag is persisted with the rest of the
3093
+ // state document.
3094
+ if (owner.valueMissing === true) {
3095
+ owner.valueMissing = false;
3096
+ // R14-SF3: a demotion is earned by a failure, so the row usually
3097
+ // still carries the cooldown `markFailure` wrote. Re-adoption is the
3098
+ // same manual-recovery equivalence `activateAccount` and re-auth
3099
+ // implement: the live projection is serving again, so the old
3100
+ // window ends with it — otherwise the resurrected row renders
3101
+ // "cooling" and stays out of the healthy counts until the timer
3102
+ // expires. Only this proven-adoption branch clears; a row whose
3103
+ // projection did not reappear keeps its window.
3104
+ owner.cooldownUntil = 0;
3105
+ this.store.touch();
3019
3106
  }
3020
3107
  // Rotation sync: the rotated payload is authoritative (the plan's
3021
3108
  // single-writer contract — "sync them back to the owning plugin-scope
@@ -3106,7 +3193,12 @@ export class KeysManagerService {
3106
3193
  // shutdown that began under any of the awaits above refuses it.
3107
3194
  if (this.persistenceRefused())
3108
3195
  return;
3109
- await this.commitGrantGeneration(provider, owner, payload, rotatedRef);
3196
+ // The commit is the row repoint plus the superseded record's delete, both
3197
+ // after the awaits above (most recently `rotationStillCurrent`'s own record
3198
+ // read). A claim that landed in that read owns the route now, so the commit
3199
+ // refuses like every other mutation in this body.
3200
+ const commitGeneration = claimedGeneration ?? generation;
3201
+ await this.commitGrantGeneration(provider, owner, payload, rotatedRef, () => !this.ownershipSuperseded(provider, commitGeneration));
3110
3202
  // PR review round 44: the projection now serves this managed account again,
3111
3203
  // so a profile that still names the broker would make pi-ai resolve the
3112
3204
  // pool first and fail with MISSING_CREDENTIAL. Converge the binding with the
@@ -3227,66 +3319,543 @@ export class KeysManagerService {
3227
3319
  * Make one account's grant payload the route's stored credential
3228
3320
  * (D1 activation projection).
3229
3321
  *
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.
3322
+ * Plan iter-pool-activate-queue, "Spike outcome" — the contract this method
3323
+ * implements. The slow half (read the row's grant, validate it, capture what
3324
+ * the route serves now) runs outside the mutation queue, so a slow credential
3325
+ * store does not delay unrelated mutations. The mutation queue turn owns every
3326
+ * write:
3327
+ *
3328
+ * 1. admission checks in queue order, which produce this method's error
3329
+ * surface (`Provider "…" is not managed`, `Account "…" not found on …`,
3330
+ * `Key "…" is not an OAuth account`, `The credential store does not support
3331
+ * records`) — plus a re-check that the row still names the generation the
3332
+ * read used;
3333
+ * 2. the projection write as a compare-and-write under the seam's per-key
3334
+ * lock (credentials/src/index.ts:236-250): it lands only while the live
3335
+ * record still equals the captured baseline, "both absent" included. A
3336
+ * rotation, a removal or any foreign writer that replaced the projection
3337
+ * meanwhile makes the compare fail, so this call refuses with the retryable
3338
+ * `could not be projected` message and leaves the newer projection
3339
+ * untouched — there is nothing to delete, because nothing was written;
3340
+ * 3. the owner claim, the row writes and the returned `buildState`.
3341
+ *
3342
+ * There is deliberately no shutdown check after the write: `close()` awaits
3343
+ * this queue before it closes the store, so a turn that started in time always
3344
+ * finishes (measured in the spike that produced this contract). A check there
3345
+ * is what used to leave an orphan projection behind.
3241
3346
  *
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.
3347
+ * The one write the turn cannot make durable by itself is a rejected state
3348
+ * flush; see {@link compensateActivationFlush} for that compensation.
3246
3349
  */
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`);
3350
+ async activateAccount(provider, accountId) {
3351
+ const records = this.credentialRecords();
3352
+ // Locator only: it keeps the row's grantRef reachable for the read below.
3353
+ // The turn re-reads the store and decides; this lookup decides nothing.
3354
+ const located = this.store.provider(provider)?.keys.find(candidate => candidate.id === accountId);
3355
+ // The refusals `run` applies (1683, 1685-1687) are hoisted ahead of the
3356
+ // credential read: a refused call should not pay for it at all. They are
3357
+ // not the guarantee — the turn's own admission checks are.
3358
+ if (this.closing)
3359
+ throw new Error('Keys Manager is shutting down');
3360
+ if (this.store.readOnly) {
3361
+ throw new Error('The state file could not be read; refusing to write over it. Restart after fixing file access.');
3362
+ }
3363
+ // The slow half, outside the queue: read the grant this activation would
3364
+ // project and capture what the route serves now as the compare baseline.
3365
+ // Nothing here decides — admission order belongs to the turn.
3366
+ const prepared = located !== undefined
3367
+ && located.kind === 'oauth'
3368
+ && located.oauth !== null
3369
+ && records !== undefined
3370
+ ? await this.prepareActivation(provider, located, records)
3371
+ : undefined;
3372
+ if (prepared !== undefined && records !== undefined) {
3373
+ // Same-`grantRef` rotation: pi-ai can refresh the ACCESS token while
3374
+ // keeping the refresh token, so the reference never moves, both `grantRef`
3375
+ // fences pass, and the projection can already carry the fresh payload
3376
+ // while the plugin-scope record still holds the stale one. Projecting
3377
+ // `prepared.payload` then writes an older credential over a fresher
3378
+ // projection. Re-read the source as late as possible and refuse unless it
3379
+ // still says what this activation prepared.
3380
+ //
3381
+ // This read sits OUTSIDE the queue, exactly where `prepareActivation` does
3382
+ // its credential work: a read inside the turn would put credential I/O
3383
+ // back on the global mutation queue, which is the cross-provider stall —
3384
+ // and the shutdown wait — the activation split exists to remove.
3385
+ //
3386
+ // Residual, named: reading outside the turn only narrows the same-ref
3387
+ // rotation window to this activation's QUEUE WAIT instead of closing it.
3388
+ // Closing it fully needs a seam that lets one read and one write share a
3389
+ // single lock, which `CredentialRecordsLike` (host-types.ts:136-143) does
3390
+ // not promise. What still covers the rest: the in-lock baseline compare on
3391
+ // the projection side, and the post-write `grantRef` recheck for reference
3392
+ // changes. A same-ref rotation that lands while the activation waits in
3393
+ // the queue is the uncovered case.
3394
+ const source = await records.readRecord(prepared.grantRef);
3395
+ if (source === undefined
3396
+ || source.kind !== 'grant'
3397
+ || JSON.stringify(source.payload) !== prepared.written) {
3398
+ throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
3399
+ }
3400
+ }
3401
+ let undo;
3402
+ try {
3403
+ await this.run(async () => {
3404
+ const providerRecord = this.store.provider(provider);
3405
+ if (providerRecord === undefined)
3406
+ throw new Error(`Provider "${provider}" is not managed`);
3407
+ const row = providerRecord.keys.find(candidate => candidate.id === accountId);
3408
+ if (row === undefined)
3409
+ throw new Error(`Account "${accountId}" not found on ${provider}`);
3410
+ if (row.kind !== 'oauth' || row.oauth === null)
3411
+ throw new Error(`Key "${accountId}" is not an OAuth account`);
3412
+ if (records === undefined) {
3413
+ throw new Error('The credential store does not support records');
3267
3414
  }
3268
- this.intentionalProjectionRemovals.delete(provider);
3269
- await records.modifyRecord(`${OAUTH_GRANT_RECORD_SCOPE}/${provider}`, () => Promise.resolve({ kind: 'grant', payload: structuredClone(stored.payload) }));
3415
+ if (prepared === undefined) {
3416
+ // The locator only hints; the turn decides. A row that was not an
3417
+ // OAuth account when it was located (created, re-added, or repointed
3418
+ // in between) has nothing prepared for it — that is a stale locator,
3419
+ // not a store without record support.
3420
+ throw new Error(`The account "${accountId}" on ${provider} changed before it could be activated; activate it again`);
3421
+ }
3422
+ // A remount replaced the credential backend while the read ran; the
3423
+ // payload came from records this manager no longer serves.
3424
+ //
3425
+ // The row must still name the generation this activation read. A
3426
+ // rotation echo or a removal + re-add that repointed it meanwhile
3427
+ // belongs to a different grant, and projecting the old payload would
3428
+ // either overwrite a newer generation or report a success the row does
3429
+ // not serve. Refusing keeps the record untouched; the caller retries.
3430
+ if (this.credentialRecords() !== records || row.oauth.grantRef !== prepared.grantRef) {
3431
+ throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
3432
+ }
3433
+ // The projection write is a compare-and-write under the seam's per-key
3434
+ // lock: it lands only while the route still serves the baseline read
3435
+ // above. A removal or a foreign writer that got there first makes the
3436
+ // compare fail, and this activation refuses without touching the
3437
+ // record — there is nothing to compensate afterwards.
3438
+ const written = await this.compareAndWriteProjection(records, provider, prepared);
3439
+ if (written === undefined) {
3440
+ throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
3441
+ }
3442
+ // No post-write shutdown check: `close()` awaits this queue before it
3443
+ // closes the store (`1758-1762`), so a run that starts in time always
3444
+ // finishes before the store goes away. Refusing here after the write
3445
+ // is what used to manufacture an orphan.
3446
+ undo = { valueMissing: row.valueMissing, cooldownUntil: row.cooldownUntil, generation: 0 };
3270
3447
  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.
3274
- await this.syncBrokerBinding(provider);
3448
+ undo.generation = this.ownershipGenerations.get(provider) ?? 0;
3449
+ row.valueMissing = false;
3450
+ // Round-5 finding M1: manual recovery ends the cooling window too, the
3451
+ // same reset a successful re-authentication applies. A demoted account
3452
+ // is re-activated while its stale cooldown still sits on the row, which
3453
+ // would render the freshly projected grant as cooling and keep it out
3454
+ // of the healthy counts until the timer expires. Clearing the window
3455
+ // also resets escalation: `applyFailureCooldown` derives the next level
3456
+ // from `cooldownUntil` only (core.ts:168-174).
3457
+ row.cooldownUntil = 0;
3458
+ this.store.touch();
3459
+ // The pre-write check closed the read window, but a rotation commit can
3460
+ // land inside the seam's compare-and-write await: it repoints the row
3461
+ // (a different record key) while the projection write is in flight, and
3462
+ // the in-lock compare cannot see the row. Projecting then leaves the
3463
+ // route serving a generation the row has already superseded, so the
3464
+ // activation refuses and the flush compensation above undoes both — the
3465
+ // claim is set, so the undo path owns it.
3466
+ if (row.oauth.grantRef !== prepared.grantRef) {
3467
+ throw new Error(`The grant payload for "${accountId}" on ${provider} could not be projected; activate the account again`);
3468
+ }
3469
+ }, async () => {
3470
+ // A rejected flush lands here AFTER the turn resolved its work: `run`
3471
+ // does not roll the mutation back, so the row is still cleared in memory
3472
+ // and the projection still serves the route. Undo both once, by
3473
+ // identity, inside THIS turn's window — a later turn would lose the race
3474
+ // against every mutation already queued behind the activation (a removal
3475
+ // of the account deletes the half-committed projection first, and the
3476
+ // captured baseline could then no longer be restored). Failures before
3477
+ // the write leave `undo` unset: nothing to undo.
3478
+ //
3479
+ // Residual (I-000090 territory): `undo` is assigned only once the write
3480
+ // resolved, so a `modifyRecord` that applies its mutation and THEN
3481
+ // rejects refuses with the raw seam error and keeps the projection. What
3482
+ // compensation could run is undecidable from this repo — the seam would
3483
+ // have to promise it never commits then rejects — so this shape is left
3484
+ // as the seam reports it rather than guessed at.
3485
+ if (undo === undefined)
3486
+ return;
3487
+ await this.compensateActivationFlush(provider, accountId, prepared, undo, records);
3275
3488
  });
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();
3489
+ // The re-activated grant is the serving credential again, so a broker
3490
+ // reference left by the key pool must go (see syncBrokerBinding). A
3491
+ // settings refusal does not roll back the committed projection.
3492
+ //
3493
+ // Both the sync and the returned snapshot run in a second queue turn:
3494
+ // the binding attempt must have happened before the view is built (a
3495
+ // refusal pushes its warning, and building the view inside the write turn
3496
+ // drained it, so the RPC reported a clean activation while requests kept
3497
+ // using the pool), and the snapshot itself is state: taken outside the
3498
+ // queue, another mutation could enter, mutate and be mid-flush while
3499
+ // `buildState` pruned and drained, exposing state that is not durable.
3500
+ // The turn waits for such a mutation to finish or fail first.
3501
+ if (this.closing) {
3502
+ // Shutdown opened while this activation was writing, so no new turn may
3503
+ // be appended: `closeOnce` captured `this.queue` when `closing` went up
3504
+ // and closes the store once THAT reference settles, so a turn submitted
3505
+ // here would run after close(). Instead, join the reference shutdown is
3506
+ // itself draining — mutations already queued behind this turn are still
3507
+ // running, and `buildState` prunes and drains, so the snapshot must come
3508
+ // after they settle rather than beside them.
3509
+ await this.queue;
3510
+ // The binding's last-point guard refuses the write once `closing` is
3511
+ // set and reports that refusal itself, so a route that needs converging
3512
+ // now says so in the returned state instead of reporting a clean
3513
+ // success; a route that is already converged needs no write at all. The
3514
+ // next start's repair pass owns the convergence either way.
3515
+ await this.syncBrokerBinding(provider);
3516
+ return this.buildState();
3517
+ }
3518
+ // The projection turn above already committed AND flushed, so the
3519
+ // activation is durable and serving. This second turn only converges the
3520
+ // binding and takes the snapshot; if its flush fails, that activation must
3521
+ // not be reported as failed — a caller would retry an activation that
3522
+ // already landed. `built` is the discriminator: `guarded` runs the body
3523
+ // before the flush, so a BODY failure leaves it unset and still
3524
+ // propagates (shutdown, a refusal), while a post-commit flush failure is
3525
+ // reported in the state the caller receives.
3526
+ let built;
3527
+ try {
3528
+ return await this.run(async () => {
3529
+ await this.syncBrokerBinding(provider);
3530
+ built = this.buildState();
3531
+ return built;
3532
+ });
3533
+ }
3534
+ catch (error) {
3535
+ if (built === undefined)
3536
+ throw error;
3537
+ const reason = error instanceof Error ? error.message : String(error);
3538
+ 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.`;
3539
+ // One channel only: the returned snapshot. `buildState` has already
3540
+ // drained the warning queue, so ALSO queueing this copy would surface
3541
+ // the same failure twice (once in this payload, once on the next
3542
+ // refresh). It still reaches the operator log, which is what a caller
3543
+ // that ignores the returned state leaves visible.
3544
+ this.warn(`activation post-commit persistence failed for ${provider}: ${reason}`);
3545
+ // The snapshot was taken before this warning existed, and re-taking one
3546
+ // outside the queue is the race this turn exists to avoid, so the view
3547
+ // is annotated rather than rebuilt.
3548
+ return { ...built, warnings: [...built.warnings, warning] };
3549
+ }
3550
+ }
3551
+ catch (error) {
3552
+ // Which path covers which case:
3553
+ // - `run` refuses the submission while `closing`, and `guarded` refuses a
3554
+ // read-only store before the operation runs: nothing was written, so
3555
+ // `undo` stayed unset and there is nothing to compensate.
3556
+ // - a failure INSIDE the turn (the flush, or the seam) runs the
3557
+ // compensation hook above, inside that turn's exclusive window, even
3558
+ // when closing began during it; that hook is the fallback the previous
3559
+ // independent turn used to be, and it needs no closing branch.
3560
+ // - the hook's own flush is best-effort: a still-unwritable store leaves
3561
+ // memory restored and the next successful flush persists it.
3562
+ throw error;
3563
+ }
3564
+ }
3565
+ /**
3566
+ * The slow half of an activation, outside the queue: read the row's grant
3567
+ * generation, validate it, and capture the projection the route serves now.
3568
+ */
3569
+ async prepareActivation(provider, row, records) {
3570
+ const grantRef = row.oauth.grantRef;
3571
+ const stored = await records.readRecord(grantRef);
3572
+ if (stored === undefined || stored.kind !== 'grant' || !isOAuthGrantPayload(stored.payload)) {
3573
+ throw new Error(`The grant payload at ${grantRef} is missing or unusable; sign in to this account again`);
3574
+ }
3575
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3576
+ const live = await records.readRecord(projectionKey);
3577
+ const payload = structuredClone(stored.payload);
3578
+ // F5: the restored record decides its owner, so the classification is
3579
+ // derived from the baseline payload through the shared matcher — the same
3580
+ // rule the reconciler applies — rather than from an owner id captured
3581
+ // earlier that the record may no longer belong to.
3582
+ const accounts = this.store.provider(provider)?.keys.filter(candidate => candidate.kind === 'oauth' && candidate.oauth !== null) ?? [];
3583
+ const baselineOwner = live !== undefined && live.kind === 'grant' && isOAuthGrantPayload(live.payload)
3584
+ ? matchOwningAccount(provider, accounts, live.payload)
3585
+ : undefined;
3586
+ return {
3587
+ grantRef,
3588
+ payload,
3589
+ written: JSON.stringify(payload),
3590
+ baseline: live === undefined ? { state: 'absent' } : { state: 'present', record: structuredClone(live) },
3591
+ // Captured with the baseline, before the turn claims the route: the
3592
+ // compensation has to put back the classification that matches the
3593
+ // record it restores, and only this snapshot still knows it. Any present
3594
+ // record no row's payload matches is foreign (a native api-key
3595
+ // credential, an unrecognized payload, an unowned grant), so `none` is
3596
+ // only the absent-baseline case.
3597
+ classification: live === undefined
3598
+ ? { state: 'none' }
3599
+ : baselineOwner !== undefined ? { state: 'owned', rowId: baselineOwner.id } : { state: 'foreign' },
3600
+ };
3601
+ }
3602
+ /** Whether the projection still holds the value this activation read. */
3603
+ matchesActivationBaseline(current, baseline) {
3604
+ if (baseline.state === 'absent')
3605
+ return current === undefined;
3606
+ if (current === undefined)
3607
+ return false;
3608
+ // Compare the FIELDS, never the object shape: `CredentialRecordLike` makes
3609
+ // `key`/`env`/`payload` optional, and a seam whose read path and
3610
+ // `modifyRecord` path materialise different key sets (one omits them, one
3611
+ // sets them to `undefined`) would defeat a structural comparison and refuse
3612
+ // every activation over an already-serving route.
3613
+ //
3614
+ // An api-key projection keeps its secret in `key`/`env` with `payload`
3615
+ // absent, so every field is compared: payload plus the native fields.
3616
+ const baselineRecord = baseline.record;
3617
+ const sameSecret = current.key === baselineRecord.key;
3618
+ const sameEnv = JSON.stringify(current.env ?? null) === JSON.stringify(baselineRecord.env ?? null);
3619
+ const samePayload = JSON.stringify(current.payload ?? null) === JSON.stringify(baselineRecord.payload ?? null);
3620
+ return current.kind === baselineRecord.kind && sameSecret && sameEnv && samePayload;
3621
+ }
3622
+ /**
3623
+ * Write the projection only while the route still serves the baseline. The
3624
+ * seam's `modifyRecord` mutate body runs under the per-key lock, so this is
3625
+ * the one atomic primitive the activation has (I-000090: `deleteRecord` has
3626
+ * no expected-payload form). Returns the payload written, or `undefined` when
3627
+ * the compare failed and nothing was touched.
3628
+ */
3629
+ async compareAndWriteProjection(records, provider, prepared) {
3630
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3631
+ let landed = false;
3632
+ await records.modifyRecord(projectionKey, async (current) => {
3633
+ if (!this.matchesActivationBaseline(current, prepared.baseline))
3634
+ return undefined;
3635
+ landed = true;
3636
+ // Only this write supersedes a pending own-removal marker: a projection
3637
+ // is about to serve again, so a removal echo still in flight cannot
3638
+ // describe the live record. A refused compare must leave the marker
3639
+ // armed for the removal it belongs to — clearing it there would let that
3640
+ // removal's echo read as an out-of-band deletion (a spurious "removed
3641
+ // outside Keys Manager" warning and a `valueMissing` row).
3642
+ this.intentionalProjectionRemovals.delete(provider);
3643
+ return { kind: 'grant', payload: prepared.payload };
3289
3644
  });
3645
+ return landed ? prepared.written : undefined;
3646
+ }
3647
+ /**
3648
+ * Undo one activation whose state flush was rejected: the row this turn
3649
+ * cleared in memory, the owner claim it took, and the projection it wrote
3650
+ * (back to the captured baseline).
3651
+ *
3652
+ * The baseline decides the mechanism. A present baseline is restored with the
3653
+ * same compare-and-write the projection used; only an absent baseline has to
3654
+ * remove a record, and only while that record is still this activation's own
3655
+ * payload. A foreign writer's record is never deleted.
3656
+ */
3657
+ async compensateActivationFlush(provider, accountId, prepared, undo, records) {
3658
+ const outcome = prepared.baseline.state === 'absent'
3659
+ ? await this.deleteActivationProjection(records, provider, prepared.written, undo.generation)
3660
+ : await this.restoreActivationProjection(records, provider, prepared, undo.generation);
3661
+ // Both helpers awaited, so the classification below decides after the same
3662
+ // read the guard above made before it: a claim that landed inside that I/O
3663
+ // already settled what serves the route, and overwriting it would revert a
3664
+ // newer owner or mark its route foreign. No await separates this check from
3665
+ // the writes in the switch.
3666
+ if (this.ownershipSuperseded(provider, undo.generation))
3667
+ return;
3668
+ // The compensation is about to replace what the route serves (or which row
3669
+ // owns it), so every reconciler that captured the earlier generation read a
3670
+ // world this undoes and must not commit: its projection read may predate the
3671
+ // restore, and its row/binding writes would re-claim an account the restored
3672
+ // baseline does not serve. `ours` keeps the activation's projection and claim
3673
+ // in place — it changes nothing a reconciler could misread — so it needs no
3674
+ // bump.
3675
+ if (outcome !== 'ours')
3676
+ this.noteProjectionCommitted(provider);
3677
+ // P2-B: the row rollback belongs to the outcomes that actually changed what
3678
+ // serves the route. `ours` keeps the activation's projection and claim — the
3679
+ // account IS serving — so restoring the pre-activation demotion would render
3680
+ // a live account as missing/cooling, and a restart without an echo would
3681
+ // persist that lie.
3682
+ if (outcome !== 'ours') {
3683
+ const row = this.store.provider(provider)?.keys.find(candidate => candidate.id === accountId);
3684
+ if (row !== undefined) {
3685
+ row.valueMissing = undo.valueMissing;
3686
+ row.cooldownUntil = undo.cooldownUntil;
3687
+ this.store.touch();
3688
+ }
3689
+ }
3690
+ switch (outcome) {
3691
+ case 'superseded':
3692
+ // The newer claim that moved the generation owns the route and has
3693
+ // already settled what serves it; this undo decides nothing.
3694
+ return;
3695
+ case 'ours':
3696
+ // The activation's projection is still the live record, so its claim
3697
+ // stays: deleting it would call the route pool-served while a record
3698
+ // serves it, and selection plus failure attribution would be wrong
3699
+ // until an echo reconciled the route (never, if the optional
3700
+ // `record-updated` subscription is unavailable).
3701
+ return;
3702
+ case 'baseline':
3703
+ this.applyActivationClassification(provider, prepared.classification);
3704
+ return;
3705
+ case 'foreign':
3706
+ this.activeAccounts.delete(provider);
3707
+ this.foreignProjections.add(provider);
3708
+ return;
3709
+ case 'gone':
3710
+ // Nothing serves the route any more, so the pool does.
3711
+ this.activeAccounts.delete(provider);
3712
+ this.foreignProjections.delete(provider);
3713
+ return;
3714
+ }
3715
+ }
3716
+ /** Put the route's owner classification back to a captured snapshot. */
3717
+ applyActivationClassification(provider, classification) {
3718
+ if (classification.state === 'owned') {
3719
+ this.activeAccounts.set(provider, classification.rowId);
3720
+ this.foreignProjections.delete(provider);
3721
+ return;
3722
+ }
3723
+ this.activeAccounts.delete(provider);
3724
+ if (classification.state === 'foreign')
3725
+ this.foreignProjections.add(provider);
3726
+ else
3727
+ this.foreignProjections.delete(provider);
3728
+ }
3729
+ /** Put the projection back to the present baseline, if it is still ours. */
3730
+ async restoreActivationProjection(records, provider, prepared, generation) {
3731
+ const baseline = prepared.baseline;
3732
+ if (baseline.state !== 'present')
3733
+ return 'baseline';
3734
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3735
+ let outcome = 'ours';
3736
+ try {
3737
+ await records.modifyRecord(projectionKey, async (current) => {
3738
+ // The generation is decided HERE, under the seam's per-key lock and in
3739
+ // the same synchronous step as the payload compare: a claim that lands
3740
+ // between this compensation's entry and this callback would otherwise
3741
+ // see its own projection matched and get the baseline written back over
3742
+ // a route it owns, while the post-I/O check then exits early and leaves
3743
+ // that newer account claimed beside the restored credential.
3744
+ if (this.ownershipSuperseded(provider, generation)) {
3745
+ outcome = 'superseded';
3746
+ return undefined;
3747
+ }
3748
+ if (current === undefined) {
3749
+ outcome = 'gone';
3750
+ return undefined;
3751
+ }
3752
+ if (current.kind !== 'grant' || JSON.stringify(current.payload) !== prepared.written) {
3753
+ // A record this activation did not write serves the route now.
3754
+ outcome = 'foreign';
3755
+ return undefined;
3756
+ }
3757
+ // Return the captured record itself: rebuilding it from `kind` and
3758
+ // `payload` would drop the `key`/`env` an api-key projection carries and
3759
+ // leave the route unusable after a failed flush.
3760
+ outcome = 'baseline';
3761
+ return structuredClone(baseline.record);
3762
+ });
3763
+ }
3764
+ catch (error) {
3765
+ // An unreadable or refused seam proves nothing: the activation's
3766
+ // projection may still be live, so `ours` keeps its claim.
3767
+ this.warn(`activation projection restore failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
3768
+ return 'ours';
3769
+ }
3770
+ return outcome;
3771
+ }
3772
+ /**
3773
+ * The delete authority's proof for an activation's own projection, as an
3774
+ * explicit three-valued answer (QC R2): only `matches` may authorize touching
3775
+ * the record. `unknown` is a store failure — a closed seam, an unreadable
3776
+ * record — and proves nothing, so it is never permission.
3777
+ */
3778
+ async activationProjectionProof(records, provider, written, generation) {
3779
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3780
+ try {
3781
+ // Absent and "another writer's record" are different answers: absent
3782
+ // means nothing serves the route any more (our own demotion can remove
3783
+ // the projection without superseding the claim), while `differs` means a
3784
+ // replacement does. Folding them together marked a serviceless route
3785
+ // foreign, which makes `keyPoolServes` false and leaves no usable
3786
+ // credential at all.
3787
+ let state = 'absent';
3788
+ await records.modifyRecord(projectionKey, async (current) => {
3789
+ // The claim is decided under the same lock as the identity compare, for
3790
+ // the same reason as the restore: a newer owner must not have its
3791
+ // projection deleted by an undo that only checks the generation later.
3792
+ if (this.ownershipSuperseded(provider, generation)) {
3793
+ state = 'superseded';
3794
+ return undefined;
3795
+ }
3796
+ state = current === undefined
3797
+ ? 'absent'
3798
+ : current.kind === 'grant' && JSON.stringify(current.payload) === written ? 'matches' : 'differs';
3799
+ return undefined;
3800
+ });
3801
+ return state;
3802
+ }
3803
+ catch (error) {
3804
+ this.warn(`activation projection proof failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
3805
+ return 'unknown';
3806
+ }
3807
+ }
3808
+ /**
3809
+ * Delete one projection an activation just proved it still owns, through the
3810
+ * same guarded shape the demotion path uses (MF2): the intentional-removal
3811
+ * marker is armed before the delete, so the deletion's own echo is read as
3812
+ * ours instead of as an out-of-band removal.
3813
+ */
3814
+ async deleteActivationProjection(records, provider, written, generation) {
3815
+ const projectionKey = `${OAUTH_GRANT_RECORD_SCOPE}/${provider}`;
3816
+ // F2: the identity read and the delete cannot be atomic here — the seam has
3817
+ // no compare-and-delete, `deleteRecord(key)` takes no expected payload
3818
+ // (host-types.ts:142, tracked as I-000090). This read immediately before the
3819
+ // delete narrows the window to the seam call itself; a writer that replaces
3820
+ // the record inside it still loses its grant, and that residual needs the
3821
+ // seam CAS, not another read here. Anything but a proven match is left
3822
+ // alone.
3823
+ const proof = await this.activationProjectionProof(records, provider, written, generation);
3824
+ if (proof === 'superseded')
3825
+ return 'superseded';
3826
+ // `absent` needs no delete and leaves nothing serving (the pool does);
3827
+ // `differs` means another writer's record serves the route; `unknown`
3828
+ // proves nothing and must keep the activation's claim.
3829
+ if (proof === 'absent')
3830
+ return 'gone';
3831
+ if (proof !== 'matches')
3832
+ return proof === 'differs' ? 'foreign' : 'ours';
3833
+ const markedAt = Date.now();
3834
+ this.intentionalProjectionRemovals.set(provider, markedAt);
3835
+ try {
3836
+ await records.deleteRecord(projectionKey);
3837
+ }
3838
+ catch (error) {
3839
+ // A delete that threw removed nothing, so no own-removal echo is coming:
3840
+ // revoke this call's marker or it would mask a genuine one for the TTL.
3841
+ if (this.intentionalProjectionRemovals.get(provider) === markedAt) {
3842
+ this.intentionalProjectionRemovals.delete(provider);
3843
+ }
3844
+ this.warn(`cancelled activation projection cleanup failed for ${provider}: ${error instanceof Error ? error.message : String(error)}`);
3845
+ // R14-SF2 applies here too: a throwing delete may have removed the record
3846
+ // before throwing, so the outcome is re-proved rather than assumed —
3847
+ // guessing is what this file forbids. A second failure
3848
+ // proves nothing and keeps the claim (`ours`).
3849
+ const after = await this.activationProjectionProof(records, provider, written, generation);
3850
+ if (after === 'superseded')
3851
+ return 'superseded';
3852
+ if (after === 'absent')
3853
+ return 'gone';
3854
+ if (after === 'differs')
3855
+ return 'foreign';
3856
+ return 'ours';
3857
+ }
3858
+ return 'gone';
3290
3859
  }
3291
3860
  /**
3292
3861
  * Remote: send one minimal request with the selected key or account row.
@@ -4597,9 +5166,11 @@ export class KeysManagerService {
4597
5166
  ...(typeof payload.expires === 'number' && Number.isFinite(payload.expires) ? { expiresAt: payload.expires } : {}),
4598
5167
  };
4599
5168
  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.
5169
+ // Defence in depth, not a reachable window: no await separates the guard
5170
+ // above from the delete below, so neither a shutdown nor a takeover can
5171
+ // land in between. The re-check is kept because it is the condition the
5172
+ // delete depends on and a future await inserted here would otherwise
5173
+ // silently remove a record this attempt never owned.
4603
5174
  if (this.persistenceRefused(capability) || (guard !== undefined && !guard())) {
4604
5175
  this.store.touch();
4605
5176
  return;
@@ -4608,6 +5179,11 @@ export class KeysManagerService {
4608
5179
  try {
4609
5180
  if (records === undefined)
4610
5181
  throw new Error('The credential store does not support records');
5182
+ // Declared residual: this delete is an await with no expected-payload
5183
+ // CAS (`deleteRecord(key)` takes only the key), so a claim landing
5184
+ // inside it can still remove the same-ref record an activation just
5185
+ // wrote. Post-delete compensation is out of scope here; closing it
5186
+ // needs a seam-level compare-and-delete.
4611
5187
  await records.deleteRecord(previousRef);
4612
5188
  }
4613
5189
  catch (error) {
@@ -5165,6 +5741,15 @@ export class KeysManagerService {
5165
5741
  this.store.pruneBindings(now);
5166
5742
  this.pruneAttempts(now);
5167
5743
  }
5744
+ /**
5745
+ * Project one provider's unattributed-request count into its row. Additive
5746
+ * and optional (`I-000120`): a row with no count spreads nothing, so an
5747
+ * older snapshot and a healthy store render exactly as before.
5748
+ */
5749
+ unattributedView(provider) {
5750
+ const count = this.unattributedRequests.get(provider) ?? 0;
5751
+ return count > 0 ? { unattributedRequests: count } : {};
5752
+ }
5168
5753
  /** Build the complete UI state. */
5169
5754
  buildState() {
5170
5755
  const now = Date.now();
@@ -5181,6 +5766,11 @@ export class KeysManagerService {
5181
5766
  const profile = namespace === undefined ? undefined : asProfile(getPath(namespace.value, entry.settingsPath));
5182
5767
  const managed = this.store.provider(entry.provider);
5183
5768
  const extra = EXTRA_PROVIDER_MAP.get(entry.provider);
5769
+ // A transport route names its own credential record and registers no
5770
+ // settings namespace, so the profile/capability read below can only
5771
+ // describe the route wrongly: it is neither a DSH route nor key-pool
5772
+ // capable, and it is never adoptable.
5773
+ const transport = entry.settingsNs === TRANSPORT_CREDENTIAL_SCOPE;
5184
5774
  const oauthFlow = this.oauthFlowAvailable(entry.provider);
5185
5775
  const caps = capabilityOf(entry, profile, oauthFlow);
5186
5776
  const support = { supported: caps.keys, ...(caps.reason === undefined ? {} : { reason: caps.reason }) };
@@ -5198,14 +5788,17 @@ export class KeysManagerService {
5198
5788
  provider: entry.provider,
5199
5789
  displayName: extra?.name ?? entry.displayName ?? entry.provider,
5200
5790
  source: extra !== undefined ? 'extra' : 'dsh',
5201
- origin: extra !== undefined ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
5791
+ origin: extra !== undefined || transport ? 'plugin' : entry.declared === true ? 'custom' : 'dsh',
5202
5792
  managed: managed !== undefined,
5203
5793
  createdProfile: managed?.createdProfile ?? false,
5204
5794
  configured: active.has(entry.provider) || profile !== undefined,
5205
5795
  active: active.has(entry.provider),
5206
- supported: support.supported,
5207
- ...(support.reason === undefined ? {} : { reason: support.reason }),
5208
- accountSupported: accountSupport.supported,
5796
+ supported: transport ? false : support.supported,
5797
+ ...(transport
5798
+ ? { reason: 'This route is served by a transport plugin; manage its account from this provider card.' }
5799
+ : support.reason === undefined ? {} : { reason: support.reason }),
5800
+ accountSupported: transport ? false : accountSupport.supported,
5801
+ ...(transport ? { transport: true } : {}),
5209
5802
  settingsNs: entry.settingsNs,
5210
5803
  settingsPath: [...entry.settingsPath],
5211
5804
  apiKeyEnv,
@@ -5224,6 +5817,7 @@ export class KeysManagerService {
5224
5817
  status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
5225
5818
  keys,
5226
5819
  ...(warning === undefined ? {} : { warning }),
5820
+ ...this.unattributedView(entry.provider),
5227
5821
  });
5228
5822
  seen.add(entry.provider);
5229
5823
  }
@@ -5264,6 +5858,7 @@ export class KeysManagerService {
5264
5858
  status: managed !== undefined ? statusOfProvider(managed.keys, now) : 'unmanaged',
5265
5859
  keys,
5266
5860
  ...(managed === undefined ? {} : { warning: 'This extra provider is managed but its pi-ai route is not registered. Check the provider profile in Models.' }),
5861
+ ...this.unattributedView(extra.id),
5267
5862
  });
5268
5863
  seen.add(extra.id);
5269
5864
  }
@@ -5302,6 +5897,7 @@ export class KeysManagerService {
5302
5897
  status: statusOfProvider(managed.keys, now),
5303
5898
  keys,
5304
5899
  warning: 'This provider route is not currently registered. Check its profile in Models.',
5900
+ ...this.unattributedView(managed.provider),
5305
5901
  });
5306
5902
  seen.add(managed.provider);
5307
5903
  }
@@ -5325,6 +5921,8 @@ export class KeysManagerService {
5325
5921
  writable: descriptor.writable && !this.store.readOnly,
5326
5922
  settings: { ...normalizeSettings(this.store.settings) },
5327
5923
  providers: views,
5924
+ // Managed only: a transport route renders as a card but stays out of
5925
+ // this published count, the key counts and every key-pool write path.
5328
5926
  managedCount: views.filter(view => view.managed).length,
5329
5927
  keyCount: allKeys.length,
5330
5928
  healthyKeyCount: allKeys.filter(key => key.status === 'active').length,
@@ -5494,14 +6092,38 @@ export class KeysManagerService {
5494
6092
  // PR review round 35: the last point before the settings write. Only
5495
6093
  // `closing` is read: shutdown's own pre-close restoration writes here by
5496
6094
  // design and runs before that flag goes up.
5497
- if (this.closing)
6095
+ //
6096
+ // Reaching this point means the route genuinely needs a write: `current ===
6097
+ // desired` already returned true above, and the preserved / releasing /
6098
+ // foreign-value branches declined for their own reasons. So shutdown is
6099
+ // what stops it, and this is the one place that can say so — the callers
6100
+ // cannot infer it (`pushWarning` caps its list, and a silent `false` has
6101
+ // several non-refusal causes upstream).
6102
+ if (this.closing) {
6103
+ this.bindingWriteRefused(provider, 'Keys Manager is shutting down');
6104
+ return false;
6105
+ }
6106
+ // A caller that stamped the ownership generation it entered with forbids
6107
+ // this write once a newer claim landed: the binding it would commit was
6108
+ // derived from the classification that claim replaced.
6109
+ if (options.ownershipGeneration !== undefined
6110
+ && this.ownershipSuperseded(provider, options.ownershipGeneration))
5498
6111
  return false;
5499
6112
  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
6113
+ // The entry gate above read a snapshot, and ownership can turn unknown
6114
+ // while this write re-reads its descriptor (PR review round 25). Binding
6115
+ // is a guess then, so the gate closes again at the last point before the
5503
6116
  // 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));
6117
+ // That last point is `mutateSettings`' `stillWrites` predicate: it is
6118
+ // evaluated synchronously between the descriptor re-read and
6119
+ // `settings.mutate` (no await between them), and the mutate itself passes
6120
+ // the descriptor's revision as an expected-revision CAS, so a takeover
6121
+ // that changed the namespace in between makes the write fail instead of
6122
+ // landing stale state. A claim the predicate sees must not leave this
6123
+ // binding behind — it describes the classification that claim replaced.
6124
+ const stamp = options.ownershipGeneration;
6125
+ const written = await this.mutateSettings(record.settingsNs, [desired === undefined ? { op: 'unset', path } : { op: 'set', path, value: desired }], () => (desired === undefined || this.projectionOwnershipKnown(provider))
6126
+ && (stamp === undefined || !this.ownershipSuperseded(provider, stamp)));
5505
6127
  if (!written)
5506
6128
  return false;
5507
6129
  }
@@ -5575,8 +6197,15 @@ export class KeysManagerService {
5575
6197
  const takeover = record?.takeoverPending === true
5576
6198
  && !this.foreignProjections.has(provider)
5577
6199
  && this.oauthOwnerFor(provider) === null;
5578
- const bound = await this.syncBrokerBinding(provider, { write: false, takeover });
5579
- if (takeover && bound && record !== undefined) {
6200
+ // The awaited sync can be superseded (an activation claims the route while
6201
+ // it runs). It would then commit a binding derived from the pre-claim
6202
+ // classification, and settling the debt would record a takeover reconciled
6203
+ // for a generation that no longer owns the route. The generation is
6204
+ // captured here, passed to the sync's own last-mile gate, and re-read
6205
+ // immediately before the clear.
6206
+ const generation = this.ownershipGenerations.get(provider) ?? 0;
6207
+ const bound = await this.syncBrokerBinding(provider, { write: false, takeover, ownershipGeneration: generation });
6208
+ if (takeover && bound && record !== undefined && !this.ownershipSuperseded(provider, generation)) {
5580
6209
  record.takeoverPending = false;
5581
6210
  this.store.touch();
5582
6211
  }
@@ -5599,6 +6228,14 @@ export class KeysManagerService {
5599
6228
  // is persistence, so shutdown refuses it.
5600
6229
  if (this.closing)
5601
6230
  return;
6231
+ // The own-generation stamp: this sync must not commit a binding derived
6232
+ // from a classification a newer claim replaced. It travels into
6233
+ // `syncBrokerBinding`, whose `stillWrites` predicate is the last point
6234
+ // before the settings write and runs synchronously right before a
6235
+ // revision-CAS'd mutate; the only window left is that write call itself,
6236
+ // which fails on a revision mismatch rather than committing stale state.
6237
+ // Every caller is covered here rather than at the call sites.
6238
+ const generation = this.ownershipGenerations.get(provider) ?? 0;
5602
6239
  try {
5603
6240
  // PR review round 40: adoption keeps the pre-adoption reference while a
5604
6241
  // foreign projection serves the route and records that it still owes the
@@ -5607,8 +6244,8 @@ export class KeysManagerService {
5607
6244
  // once the bind lands; `originalApiKeyEnv` alone proves nothing.
5608
6245
  const record = this.store.provider(provider);
5609
6246
  const takeover = record?.takeoverPending === true;
5610
- const bound = await this.syncBrokerBinding(provider, { takeover });
5611
- if (takeover && bound && record !== undefined) {
6247
+ const bound = await this.syncBrokerBinding(provider, { takeover, ownershipGeneration: generation });
6248
+ if (takeover && bound && record !== undefined && !this.ownershipSuperseded(provider, generation)) {
5612
6249
  record.takeoverPending = false;
5613
6250
  this.store.touch();
5614
6251
  }
@@ -5690,6 +6327,12 @@ export class KeysManagerService {
5690
6327
  throw new Error('This extra provider needs a key; add it from the extra-provider section');
5691
6328
  throw new Error(`Provider "${provider}" is not registered by any adapter`);
5692
6329
  }
6330
+ // A transport route's credential is the record its own adapter reads, so
6331
+ // adopting it would bind a profile nothing serves (and its namespace is
6332
+ // not even mounted). Refuse before any settings read or write.
6333
+ if (entry.settingsNs === TRANSPORT_CREDENTIAL_SCOPE) {
6334
+ throw new Error(`Provider "${provider}" is served by a transport plugin; manage its account from its provider card`);
6335
+ }
5693
6336
  const descriptor = this.describeSettings();
5694
6337
  const namespace = this.namespace(descriptor, entry.settingsNs);
5695
6338
  if (namespace === undefined)
@@ -5848,6 +6491,7 @@ export class KeysManagerService {
5848
6491
  return 'superseded';
5849
6492
  }
5850
6493
  this.ownershipReadFailures.delete(provider);
6494
+ this.unattributedRequests.delete(provider);
5851
6495
  this.noteProviderHydrated(provider);
5852
6496
  if (record === undefined)
5853
6497
  this.foreignProjections.delete(provider);
@@ -6810,6 +7454,12 @@ export class KeysManagerService {
6810
7454
  // branches, so it is constructed after the isolation wait — a mismatch
6811
7455
  // profile still resolves pi-ai's stored grant, so the borrowed
6812
7456
  // projection reaches it too.
7457
+ //
7458
+ // No ownership read and no unattributed count here: the store already
7459
+ // said this route's profile disagrees with its binding, so this manager
7460
+ // cannot describe what serves it and hands the request over untouched.
7461
+ // The counter is defined over routes whose ownership it did try to read
7462
+ // (see `unattributedRequests`).
6813
7463
  return this.isolateFromTest(providerId, () => next());
6814
7464
  }
6815
7465
  // An ordinary request must never be served by the grant a row test has
@@ -7046,6 +7696,17 @@ export class KeysManagerService {
7046
7696
  });
7047
7697
  }
7048
7698
  iterator = await service.als.run(store, () => next()[Symbol.asyncIterator]());
7699
+ // I-000120 / D8 + I-000201: this request is served without an
7700
+ // attribution, and this is the only site that counts one — but
7701
+ // only once downstream actually has an iterator. Both causes (the
7702
+ // bounded wait expired, the read had failed) still arrive here as
7703
+ // the same `false`, and the hydration pass never counts. A routing
7704
+ // or adapter failure that throws before dispatch served nothing, so
7705
+ // it must not inflate the count; `started` keeps one attempt to one
7706
+ // increment, and a later attempt that does dispatch counts then.
7707
+ if (!hydrated) {
7708
+ service.unattributedRequests.set(providerId, (service.unattributedRequests.get(providerId) ?? 0) + 1);
7709
+ }
7049
7710
  }
7050
7711
  if (iterator === undefined)
7051
7712
  throw new Error('managed stream has no iterator');