dsh-keys-manager 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -1,9 +1,26 @@
1
- import type { StateView } from './domain.js';
1
+ import type { AccountAttemptView, StateView } from './domain.js';
2
2
  import type { CredentialsLike, GenerateOptionsLike, HostContext, KeySelection, RequestErrorAction, AgentRequestPayload, RequestErrorPayload, SettingsLike, StreamChunkLike } from './host-types.js';
3
3
  import { KeysManagerStore } from './store.js';
4
4
  export declare const name = "keys-manager";
5
5
  export declare const inject: string[];
6
6
  export declare const provide: string[];
7
+ /** The credential-record scope pi-ai stores its per-provider grants under (llm-pi-ai/src/auth.ts:29). */
8
+ export declare const OAUTH_GRANT_RECORD_SCOPE = "llm-pi-ai";
9
+ /**
10
+ * Stable failure code for a harvest whose state flush rejected AND whose
11
+ * credential-side rollback then failed to write.
12
+ *
13
+ * The state flush is the durability boundary `run` enforces, and the rollback
14
+ * that follows it deletes/restores the credential records the rolled-back row
15
+ * referenced. When those compensating writes fail too, the attempt's reason
16
+ * starts with `<code>: ` and keeps the original flush error verbatim after it,
17
+ * so an upstream caller can branch on {@link isFlushRollbackCompensationFailure}
18
+ * instead of matching prose. A flush failure whose rollback completed keeps its
19
+ * original error unchanged — this code is the only added spelling.
20
+ */
21
+ export declare const FLUSH_ROLLBACK_COMPENSATION_FAILED = "FLUSH_ROLLBACK_COMPENSATION_FAILED";
22
+ /** Whether an attempt error carries {@link FLUSH_ROLLBACK_COMPENSATION_FAILED}. */
23
+ export declare function isFlushRollbackCompensationFailure(error: string | undefined): boolean;
7
24
  /** Service keybinding consumed by the Typert Gateway. */
8
25
  interface TypertRemoteBinding {
9
26
  service: KeysManagerService;
@@ -33,9 +50,230 @@ export declare class KeysManagerService {
33
50
  private readonly selectionQueues;
34
51
  private queue;
35
52
  private closing;
53
+ /** Interactive sign-in attempts, by attempt id. */
54
+ private readonly loginAttempts;
55
+ /** The provider → attempt id index for the single-attempt rule. */
56
+ private readonly loginsInFlight;
57
+ private loginSequence;
58
+ /** The account row whose grant payload currently IS the projection, per provider. */
59
+ private readonly activeAccounts;
60
+ /**
61
+ * Account rows committed to removal (C6-1/I1 removal-aware oracle), marked
62
+ * by {@link removeOAuthGrant} before its first awaited delete. A multi-row
63
+ * release deletes every row's credential first and the provider record
64
+ * last, and it never splices its rows, so while it runs the provider record
65
+ * and the row object both still look live even though the row's grant is
66
+ * already gone. Object identity alone therefore cannot tell "still serving"
67
+ * from "mid-removal"; this mark can. A successful removal leaves it set —
68
+ * the row it names is either spliced out (removeAccount/deleteKey) or left
69
+ * flagged `valueMissing` (release) — but a removal whose awaited steps
70
+ * THROW must clear it: the row is then still live and still managed, and a
71
+ * stale mark would silently skip its rotation sync until a restart. It
72
+ * marks the live object, so a row object recreated under the same id is
73
+ * unaffected.
74
+ */
75
+ private readonly retiringAccounts;
76
+ /**
77
+ * Per-provider projection generation — the write counter for
78
+ * {@link activeAccounts} claims (round-3 finding 3) and the attribution
79
+ * identity of a projected grant (round-6 finding SF1). It is bumped exactly
80
+ * when a grant becomes the provider's projection (a manager-side
81
+ * re-projection or the seam's committed grant at the settlement broadcast),
82
+ * never at a later bookkeeping step. A hydration result whose generation no
83
+ * longer matches is discarded instead of overwriting a newer projection
84
+ * with a stale read.
85
+ */
86
+ private readonly ownershipGenerations;
87
+ /**
88
+ * Per-account-row projection generation in effect when the row's CURRENT
89
+ * cooldown was earned (round-6 finding SF1). `applyFailureCooldown` stamps
90
+ * it at failure time, so a harvest can tell the previous grant's window
91
+ * from one the freshly projected grant earned while the harvest was still
92
+ * queued. A row with no entry carries a window that predates any observed
93
+ * attribution (one loaded from disk, for example); the next projection
94
+ * treats it as the older generation's.
95
+ */
96
+ private readonly cooldownGenerations;
97
+ /**
98
+ * Per-provider chain of pending grant reconciliations (round-9 finding
99
+ * P1): two refresh echoes for the same route can arrive before the first
100
+ * write-back settles, and the bodies run read → write → commit. Arrival
101
+ * order is preserved so the older observation can never commit after the
102
+ * newer one and leave the row on a stale generation.
103
+ *
104
+ * Round-8 finding I-000106: activation joins the same chain. The only
105
+ * writer of `row.oauth.grantRef` is {@link reconcileGrantRecord}, which runs
106
+ * outside the mutation queue, so activation's record read and its projection
107
+ * write must be mutually exclusive with that writer — otherwise a rotation
108
+ * landing between them leaves the projection on a generation the row no
109
+ * longer names.
110
+ */
111
+ private readonly grantReconciliations;
112
+ /**
113
+ * Providers whose projection deletion this manager initiated, mapped to
114
+ * the moment it was initiated. The `record-updated` echo of our own
115
+ * demotion consumes the marker (one-shot) and stays silent while the
116
+ * marker is inside its TTL window; past the window the marker is expired
117
+ * and a removal warns as genuinely out-of-band again.
118
+ */
119
+ private readonly intentionalProjectionRemovals;
120
+ /**
121
+ * In-flight projection demotions by provider (round-5 finding C1): the
122
+ * oauth failure path cools the account and demotes asynchronously, and the
123
+ * failover retry must not re-enter the stream while the projection still
124
+ * resolves — pi-ai prefers the stored grant, so the key pool would never be
125
+ * reached. The request-error listener awaits this entry before deciding.
126
+ */
127
+ private readonly projectionDemotions;
128
+ /**
129
+ * MF3 load-time hydration result (round-8 finding I-000108). The
130
+ * constructor starts the ownership read, but a restarted process can serve
131
+ * a stream before it settles. Until this provider's read lands,
132
+ * {@link activeAccounts} may not know the owner, so `wrapStream` would
133
+ * select and mutate a key (cursor, `lastUsedAt`, sticky binding) even though
134
+ * pi-ai serves the persisted grant, and an OAuth failure would be classified
135
+ * as externally authenticated instead of cooling the account.
136
+ *
137
+ * The promise never rejects (hydration catches and warns per provider), but
138
+ * it can stay pending forever. Requests therefore never subscribe to it
139
+ * (round-10 should-fix SF2: a per-call `then`/`await` would be retained on a
140
+ * promise that never settles); they wait per provider through
141
+ * {@link hydratedProviders} and {@link hydrationWaiters} instead.
142
+ */
143
+ private projectionHydration;
144
+ /**
145
+ * Upper bound on one stream's wait for its provider's hydration read
146
+ * (round-9 should-fix). The hydration read has no timeout of its own, so
147
+ * without a bound one stalled `readRecord` would gate every managed stream.
148
+ * Injectable so tests can exercise the bound quickly.
149
+ */
150
+ private hydrationWaitMs;
151
+ /**
152
+ * Providers whose hydration read has landed — owner found or not
153
+ * (round-10 should-fix SF1). Hydration walks the providers serially and
154
+ * writes each owner as it goes, so one stalled read must not make an
155
+ * already-read provider look unknown: a provider whose own read landed is
156
+ * ready immediately, and the bounded wait's "unknown" fallback is confined
157
+ * to the requested provider's own unresolved read.
158
+ */
159
+ private readonly hydratedProviders;
160
+ /**
161
+ * One resolver set per provider still being waited on (round-10 should-fix
162
+ * SF2). A wait registers exactly one entry here and removes it when the wait
163
+ * ends — on timeout or on the late landing — so a read that never settles
164
+ * cannot retain anything per request. {@link noteProviderHydrated} is the
165
+ * only notification channel; the shared hydration promise is never
166
+ * subscribed to.
167
+ */
168
+ private readonly hydrationWaiters;
169
+ /**
170
+ * True once the load-time hydration read is over (round-10 SF1). From then
171
+ * on no provider's read can land, so attribution is known for every provider
172
+ * — including one adopted after the read, whose OAuth rows did not exist
173
+ * when the projection was looked for.
174
+ */
175
+ private hydrationSettled;
176
+ /** Providers whose bounded-wait warning already fired (warn once). */
177
+ private readonly hydrationWaitWarned;
178
+ /** Channels whose lost-subscription warning already fired (warn once). */
179
+ private readonly subscriptionLostWarnings;
180
+ /** Disposer for the `authorization/settled` subscription. */
181
+ private settledDisposer;
182
+ /** Disposer for the `credentials/record-updated` subscription. */
183
+ private recordUpdatedDisposer;
36
184
  readonly typertRemote: TypertRemoteBinding;
37
185
  constructor(ctx: HostContext, store: KeysManagerStore);
38
- /** Log without ever echoing secret material. */
186
+ /**
187
+ * MF3 load-time hydration: a projection outlives this process — pi-ai
188
+ * wrote `llm-pi-ai/<provider>` during a previous run and it survived in
189
+ * the credential store. Match each persisted payload back to an account
190
+ * row (the exact rule the rotation reconciler applies, via
191
+ * {@link matchOwningAccount}) so removeAccount / deleteManagedCredentials
192
+ * demote the projection and `onGrantRecordUpdated` can mark valueMissing
193
+ * after a restart. An orphan projection — no matching row, e.g. a foreign
194
+ * or stale grant — is left untouched: the manager never adopts a grant it
195
+ * does not own, and out-of-band updates keep surfacing the native-
196
+ * management warning.
197
+ *
198
+ * Returns its own completion (round-8 finding I-000108), and the returned
199
+ * promise is already caught, so a failed hydration degrades to "no owner"
200
+ * instead of wedging every request. Request attribution does not subscribe
201
+ * to that promise (round-10 should-fix SF2 — it may stay pending forever);
202
+ * each request waits on its own provider's landing instead.
203
+ */
204
+ private hydrateProjectionOwnership;
205
+ /**
206
+ * Record that one provider's hydration read is over — owner or not (SF1) —
207
+ * and wake the requests waiting on that provider alone (SF2).
208
+ */
209
+ private noteProviderHydrated;
210
+ /** Release every provider's readiness wait when hydration cannot proceed. */
211
+ private settleHydrationWaits;
212
+ /**
213
+ * Wait for one provider's own hydration read to land, at most `ms`, and
214
+ * report whether it landed.
215
+ *
216
+ * Registers exactly one waiter entry for this wait and removes it when the
217
+ * wait ends either way (round-10 should-fix SF2): nothing is attached to the
218
+ * shared hydration promise, so a read that never settles cannot retain one
219
+ * reaction per request. A late landing still wakes the wait through
220
+ * {@link noteProviderHydrated}, and the generation guard in
221
+ * {@link hydrateProjectionOwnership} still decides whether its result lands
222
+ * (I-000108).
223
+ */
224
+ private waitForProviderHydration;
225
+ /**
226
+ * Live per-provider waiter entries. A documented internal counter for the
227
+ * round-10 SF2 invariant: a wait that ended must leave no entry behind, so
228
+ * this never grows with the number of timed-out requests.
229
+ */
230
+ private hydrationWaiterCount;
231
+ /**
232
+ * Wait (bounded) until projection-ownership hydration can attribute this
233
+ * request, and report whether it did.
234
+ *
235
+ * A provider without OAuth rows can never own a projection, so it never
236
+ * waits — a stalled read for some other provider must not delay it
237
+ * (round-9 should-fix: this used to gate every managed provider, API-key-
238
+ * only ones included).
239
+ *
240
+ * Returns false when the bound elapsed first. The caller then treats
241
+ * attribution as unknown (no owner) and keeps key state untouched: a
242
+ * projection may still be serving, and selecting/mutating a key here would
243
+ * repeat the I-000108 corruption. The hydration promise is not cancelled,
244
+ * so a late result still lands under the generation guard and later
245
+ * requests are attributed normally.
246
+ *
247
+ * Readiness is decided PER PROVIDER (round-10 should-fix SF1). Hydration
248
+ * writes owners progressively, so racing the one global promise would
249
+ * discard an owner this provider already confirmed just because another
250
+ * provider's read stalled. A provider whose own read has landed — with an
251
+ * owner or with none — is ready without waiting, and the "unknown" fallback
252
+ * is confined to the requested provider's own unresolved read.
253
+ */
254
+ private awaitProjectionOwnership;
255
+ /**
256
+ * Record that a newly committed grant became the provider's projection and
257
+ * return that projection's generation (round-6 finding SF1).
258
+ *
259
+ * The bump happens at the moment the projection is observable — pi-ai's
260
+ * committed grant at the settlement broadcast, or a manager-side
261
+ * re-projection (activation, rotation reconcile, harvest claim) — so every
262
+ * failure stamped afterwards belongs to that projection. It must never be
263
+ * deferred to a later bookkeeping step: the harvest waits behind the
264
+ * mutation queue and the projection read while the new grant already
265
+ * serves, and a failure landing there must not be mistaken for the
266
+ * previous grant's.
267
+ */
268
+ private noteProjectionCommitted;
269
+ /**
270
+ * Claim the projection owner for one account row, bumping the provider's
271
+ * ownership generation so an in-flight hydration read of an older state
272
+ * is discarded instead of overwriting the claim (round-3 finding 3).
273
+ */
274
+ private claimProjectionOwner;
275
+ /**
276
+ * Log without ever echoing secret material. */
39
277
  warn(message: string): void;
40
278
  /** Rebuild the broker-reference → provider index. */
41
279
  private rebuildBrokerMap;
@@ -55,6 +293,413 @@ export declare class KeysManagerService {
55
293
  private settingsService;
56
294
  /** Current raw credential provider. */
57
295
  private credentialsRaw;
296
+ /**
297
+ * The credential store's record half, reached through the raw provider:
298
+ * the broker wrappers only ever replace the reference methods, so the
299
+ * record methods of the mounted credential provider are always intact on
300
+ * the raw instance. `llm-pi-ai` writes its grants through exactly these
301
+ * methods, so the manager's activation projection must use them too.
302
+ */
303
+ private credentialRecords;
304
+ /**
305
+ * Resolve the OAuth authorization flow one provider offers.
306
+ *
307
+ * The flow key format is pinned by `llm-pi-ai/login.ts:149-171` +
308
+ * `auth.ts:29,36-38`: `recordKeyFor(providerId)` = `llm-pi-ai/<providerId>`,
309
+ * with the oauth method offered first (`login.ts:40-47`). A provider with
310
+ * no such flow (not OAuth-capable, or not installed) resolves to
311
+ * undefined, which callers must treat as "sign-in unavailable".
312
+ *
313
+ * Availability is read from the live seam on every call, never cached:
314
+ * the authorization service may mount after this plugin, so a route whose
315
+ * flow is not enumerable yet stays account-unsupported until the flow list
316
+ * exists (the client re-reads state after that ordering settles).
317
+ */
318
+ oauthFlowFor(provider: string): {
319
+ key: string;
320
+ method: string;
321
+ } | undefined;
322
+ /**
323
+ * C6-2: can this route's OAuth sign-in actually begin right now? Account
324
+ * capability is gated on this instead of the catalog's `nativeAuth.oauth`
325
+ * flag alone, so a route without a registered pi-ai flow never offers an
326
+ * Add account that would fail. The check is lazy — see {@link oauthFlowFor}.
327
+ */
328
+ private oauthFlowAvailable;
329
+ /**
330
+ * Warn once per channel: a lost subscription silently disables a whole
331
+ * feature (harvest never runs; rotations never sync), so the degradation
332
+ * must be observable instead of indistinguishable from success.
333
+ */
334
+ private warnSubscriptionLost;
335
+ /**
336
+ * Subscribe to the seam's settled broadcast (authorization/src/index.ts:57,
337
+ * :337-351). A settled attempt for a key this service is driving harvests
338
+ * the committed grant; everything else is ignored — another surface's
339
+ * sign-in is that surface's business, and the grant it leaves behind is
340
+ * reconciled through `credentials/record-updated` instead.
341
+ */
342
+ private watchAuthorizationSettled;
343
+ /** One settled broadcast: finalize the matching attempt, harvest on success. */
344
+ private onAuthorizationSettled;
345
+ /**
346
+ * Start one interactive OAuth sign-in. Returns immediately with the
347
+ * attempt view; the attempt walks `pending → (prompt ⇄ pending) →
348
+ * ready | failed | cancelled`, and every transition is observable through
349
+ * {@link getAccountAttempt} / {@link listAccountAttempts}.
350
+ */
351
+ beginAccountLogin(provider: string, alias?: string, targetAccountId?: string): Promise<AccountAttemptView>;
352
+ /** Resolve the attempt's parked prompt with the human's answer. */
353
+ answerAccountPrompt(attemptId: string, value: string): Promise<void>;
354
+ /** One attempt's serializable view, or undefined when unknown. */
355
+ getAccountAttempt(attemptId: string): AccountAttemptView | undefined;
356
+ /** Remote: poll one attempt's serializable state (T3 dialog channel). */
357
+ accountAttempt(attemptId: string): AccountAttemptView | undefined;
358
+ /** Every attempt, newest first, for T3 surfaces. */
359
+ listAccountAttempts(): AccountAttemptView[];
360
+ /**
361
+ * Detach the parked prompt. With a reason the flow's awaited promise is
362
+ * rejected too — a parked prompt dropped un-rejected would leave the
363
+ * seam's flow (and the provider's single-flight slot) wedged until its
364
+ * own timeout, so every attempt-ending path passes one.
365
+ */
366
+ private dropPrompt;
367
+ /** Finalize an attempt with a terminal status exactly once. */
368
+ private finishLoginAttempt;
369
+ /** Finalize an attempt as failed with the given reason. */
370
+ private failLoginAttempt;
371
+ /**
372
+ * Turn a committed sign-in into pool state: persist the grant pi-ai wrote
373
+ * to `llm-pi-ai/<provider>` according to the settlement-time capture,
374
+ * under the plugin-scope account ref, and upsert the `kind: 'oauth'` row.
375
+ * Any failure fails the attempt loudly — a login whose grant could not be
376
+ * captured must not look like success.
377
+ *
378
+ * `committed` is the F2 provenance promise started by
379
+ * {@link captureSettledProjection}: the projection payload observed at the
380
+ * settlement broadcast, never a later live read. The harvest persists
381
+ * exactly that payload and compensation may only revert exactly that
382
+ * payload, so a foreign grant cannot become this attempt's authority just
383
+ * because it landed before the queued mutation ran.
384
+ *
385
+ * `projectionGeneration` is the generation {@link noteProjectionCommitted}
386
+ * recorded at that same broadcast (R6-SF1): it names the projection whose
387
+ * failures own a cooldown, so the stale-window reset below clears only a
388
+ * window attributed to an OLDER projection.
389
+ */
390
+ private harvestLogin;
391
+ /**
392
+ * Capture this attempt's committed projection payload (F2 provenance).
393
+ *
394
+ * The seam reports `authorized` only after the flow's commit is observed
395
+ * and still present, so a read STARTED at that broadcast observes the
396
+ * record this attempt wrote. The read is deliberately not queued on
397
+ * `run`: a release parked ahead of the harvest must not delay provenance
398
+ * long enough for a foreign writer to become this attempt's authority.
399
+ *
400
+ * The seam offers no writer identity or record version, so this capture is
401
+ * the closest provenance the manager can obtain: `undefined` means the
402
+ * committed payload is unknown, and compensation then refuses to touch the
403
+ * projection instead of guessing. See I-000090 for the seam capability
404
+ * (compare-and-delete / versioned records) that would make it exact.
405
+ */
406
+ private captureSettledProjection;
407
+ /**
408
+ * Undo the in-memory half of a harvest whose state document was never made
409
+ * durable (a rejected `store.flush()` after the mutation resolved).
410
+ *
411
+ * Runs synchronously from the failure handler so no queued mutation,
412
+ * debounced write, or retry can observe — or persist — the rolled-back row
413
+ * between the rejected flush and this undo. The credential rollback that
414
+ * follows ({@link compensateFailedFlush}) deletes the grant the row
415
+ * referenced, so leaving the upsert in memory would make the next
416
+ * successful flush point at a deleted record.
417
+ */
418
+ private undoHarvestRow;
419
+ /**
420
+ * Undo the credential writes of a harvest whose state flush rejected.
421
+ *
422
+ * Differs from {@link compensateFailedHarvest} in one respect: the row
423
+ * upsert already happened, so the plugin-scope record cannot simply be
424
+ * deleted. A same-ref re-authentication overwrote the record the restored
425
+ * row references, and a changed-ref re-authentication deleted the
426
+ * superseded generation before the flush failed — both are reverted here,
427
+ * each write guarded by the exact payload this login committed so a
428
+ * foreign writer's record is never clobbered. The `llm-pi-ai` projection is
429
+ * then demoted exactly like any failed harvest (ownership-checked, never a
430
+ * foreign grant), so no committed credential outlives the rolled-back row
431
+ * without a pool owner.
432
+ *
433
+ * Resolves with the write failures the rollback hit, if any: a refusal to
434
+ * touch the surface when provenance is unknown is a deliberate skip, not a
435
+ * failure, but a rejected compensating write means the credential surface
436
+ * may still hold records the rolled-back row no longer owns. The caller
437
+ * tags the attempt with {@link FLUSH_ROLLBACK_COMPENSATION_FAILED} so that
438
+ * state is distinguishable from a clean rollback (each failure is also
439
+ * warned about here, message by message).
440
+ */
441
+ private compensateFailedFlush;
442
+ /**
443
+ * Roll a failed harvest back to a clean credential surface: the
444
+ * plugin-scope grant copy is deleted when it was written; the
445
+ * `llm-pi-ai` projection is deleted only while it still carries EXACTLY
446
+ * this login's settlement-time committed payload, so compensation can
447
+ * never delete a grant another account owns.
448
+ *
449
+ * Provenance unknown (the settlement-time capture found no usable record)
450
+ * refuses both deletions with a warning: without the committed payload
451
+ * there is no proof of ownership, and deleting "whatever is live" is the
452
+ * round-4 defect (I-000083).
453
+ */
454
+ private compensateFailedHarvest;
455
+ /**
456
+ * Delete the `llm-pi-ai` projection only while it still carries exactly
457
+ * this login's settlement-time committed payload. Shared by the
458
+ * failed-harvest compensation and the flush-failure rollback.
459
+ *
460
+ * Resolves `ok: false` only when the compare/delete writes themselves
461
+ * rejected; an ownership mismatch is the guard doing its job (the live
462
+ * record belongs to someone else) and counts as a clean skip.
463
+ */
464
+ private revertHarvestProjection;
465
+ /** Expire wedged sign-ins and drop long-settled attempt records. */
466
+ private pruneLoginAttempts;
467
+ /** Keep the attempt cache bounded: evict the oldest settled entries first. */
468
+ private capLoginAttempts;
469
+ /**
470
+ * Subscribe to the credential seam's post-commit record broadcast
471
+ * (credentials/src/index.ts:271-277; the event carries the key only, so
472
+ * the handler re-reads the record for its payload).
473
+ */
474
+ private watchGrantRecordUpdated;
475
+ /**
476
+ * Reconcile one `llm-pi-ai/<provider>` record change with the pool.
477
+ *
478
+ * The manager writes this record only through {@link activateAccount}
479
+ * (D1); pi-ai rewrites it on every lazy refresh and owns every login. A
480
+ * change whose payload belongs to the active account is a rotation and
481
+ * syncs back into the owning row; anything else the manager did not write
482
+ * is native management of the route and surfaces as a warning instead of
483
+ * being adopted silently.
484
+ *
485
+ * C6-1: reconciliation writes a plugin-scope record for the rotated
486
+ * generation and then mutates the owning row. removeAccount /
487
+ * releaseProvider mutate the same row but cannot be serialized against
488
+ * that write — the credential seam offers only a per-record lock
489
+ * (`modifyRecord`), never a transaction across the record write and the
490
+ * pool row. So {@link reconcileGrantRecord} revalidates the row immediately
491
+ * before AND after the awaited write and compensates by deleting the record
492
+ * it just wrote. An orphan record must never outlive a removed account.
493
+ *
494
+ * Round-9 finding P1: two echoes for the same provider can
495
+ * arrive before the first reconciliation commits. Their write-backs then
496
+ * race and an out-of-order completion could move the row back onto an
497
+ * older generation, stranding the newer record outside manager state. The
498
+ * bodies are therefore chained per provider: one observation's
499
+ * read → write → commit window is never interleaved with another's, and
500
+ * {@link reconcileGrantRecord} additionally re-checks that its generation
501
+ * is still current before committing.
502
+ */
503
+ private onGrantRecordUpdated;
504
+ /**
505
+ * Append one body to the provider's per-route grant chain and return it.
506
+ *
507
+ * The chain is the single serialization point for every operation that can
508
+ * repoint `row.oauth.grantRef` or reproject the route: the echo reconciler
509
+ * (round-9 P1 ordering) and, since round-8 finding I-000106, activation.
510
+ * The stored tail never rejects, so one failed body cannot stop the chain;
511
+ * callers that must observe the failure await the returned promise (the
512
+ * fire-and-forget echo caller attaches its own catch).
513
+ */
514
+ private scheduleGrantReconciliation;
515
+ /**
516
+ * The body of {@link onGrantRecordUpdated}. Pool state is read live, and
517
+ * every awaited step around the write is guarded by revalidation: a
518
+ * removal is allowed to complete while the write is in flight.
519
+ */
520
+ private reconcileGrantRecord;
521
+ /**
522
+ * C6-1 revalidation: the row captured before an awaited credential write
523
+ * must still be the provider's live row — same object identity, still an
524
+ * OAuth account. A remove/release landing in between detaches the row
525
+ * object; object identity is what tells "this account is still there"
526
+ * apart from "some same-id row was recreated".
527
+ *
528
+ * C6-1/I1: a removal that is still IN PROGRESS is not visible through
529
+ * identity alone. A multi-row release deletes each row's grant first and
530
+ * the provider record last, and it never splices its rows, so after row X's
531
+ * grant is deleted the provider record and row X both still look live while
532
+ * the release awaits later rows. Syncing a rotation into that window would
533
+ * commit `grantRef = rotatedRef` on a row nothing will serve, and the
534
+ * release's final provider deletion leaves that record orphaned. The
535
+ * removal-aware check is {@link retiringAccounts}, set before the removal's
536
+ * first awaited delete.
537
+ *
538
+ * R7-SF3: `valueMissing` is NOT a removal signal — it only says the row is
539
+ * currently not serving. A demoted row is still managed, and a projection
540
+ * that reappears for it must be re-adopted (the caller proves the
541
+ * projection belongs to this row before claiming it). This guard therefore
542
+ * keeps the credential-write semantics strict: writing to a demoted row is
543
+ * pointless until the adoption path has cleared the flag.
544
+ */
545
+ private accountRowStillCurrent;
546
+ /**
547
+ * R7-SF3 adoption proof: the row is live and still managed, whether or not
548
+ * it is currently serving. "Retiring" (removal in progress) refuses
549
+ * everything; "demoted" (`valueMissing`) refuses nothing here — the caller
550
+ * has already proven that the live projection belongs to this exact row
551
+ * ({@link matchOwningAccount}) and that no newer projection superseded the
552
+ * observation (generation guard).
553
+ */
554
+ private accountRowIsManaged;
555
+ /**
556
+ * Round-5 review (R5-MF1): whether a live account row of `provider` still
557
+ * names `ref` as its grant. The settled harvest can adopt the generation a
558
+ * reconciliation just wrote while that reconciliation is still awaiting its
559
+ * staleness probe — the row's `grantRef` then IS the record the cleanup
560
+ * would delete, so deleting it destroys the account's own live grant.
561
+ * A row already marked for removal does not count: its removal deletes the
562
+ * refs it names, so keeping the record would strand it instead.
563
+ */
564
+ private grantRefServed;
565
+ /**
566
+ * Delete a rotation record a reconciliation wrote but must not keep, unless
567
+ * a live row already serves it (R5-MF1). Cleanup is best-effort with a
568
+ * warning, exactly like the harvest-time superseded-record cleanup.
569
+ */
570
+ private discardRotationRecord;
571
+ /**
572
+ * Round-9 P1 revalidation: the generation a reconciliation observed is
573
+ * still the one the route holds, and the ownership claim this body made (if
574
+ * any) is still the newest. Two independent writes can supersede it while
575
+ * the plugin-scope write-back is in flight: pi-ai refreshing again (the
576
+ * projection's refresh digest — the ref's identity — moves on) and an
577
+ * activation or login claiming the projection (the ownership generation
578
+ * moves). Committing the older ref in either case would move the row
579
+ * backwards and strand the newer record outside manager state.
580
+ *
581
+ * A failed re-read cannot prove staleness, so it must not block the sync:
582
+ * the commit then proceeds exactly as it did before this guard existed.
583
+ */
584
+ private rotationStillCurrent;
585
+ /**
586
+ * Make one account's grant payload the route's stored credential
587
+ * (D1 activation projection).
588
+ *
589
+ * Round-8 finding I-000106 (must-fix): the decide snapshot used to be read
590
+ * before the seam's lock, and `reconcileGrantRecord` runs OUTSIDE the
591
+ * mutation queue — so a rotation echo could repoint `row.oauth.grantRef` and
592
+ * delete the record being projected between the read and the write, leaving
593
+ * the route on an older generation than the row names. The whole
594
+ * read → project → claim section therefore runs on the provider's grant
595
+ * chain ({@link scheduleGrantReconciliation}) — the same chain the
596
+ * reconciler bodies use. A rotation committed before this body is observed
597
+ * in `row.oauth.grantRef`; one that arrives while the body runs is queued
598
+ * behind it and reconciles the generation this body projected. Either way
599
+ * the projection holds exactly the generation the row names.
600
+ *
601
+ * The seam's per-key lock still serializes the read-decide-replace steps of
602
+ * `modifyRecord` (credentials/src/index.ts:236-250) against other writers.
603
+ * The account whose payload lands becomes the pool's active account and is
604
+ * cascade-selectable again.
605
+ */
606
+ activateAccount(provider: string, accountId: string): Promise<StateView>;
607
+ /**
608
+ * Demote the cascade to the key pool by deleting the projection record:
609
+ * pi-ai's per-request resolution prefers the stored credential and only
610
+ * falls through to the broker `apiKeyEnv` when none is stored
611
+ * (research, resolve.js:26-55).
612
+ *
613
+ * Recovery is deliberately manual: the cooled account stays demoted until
614
+ * the user re-activates it or signs in again. An automatic re-activation
615
+ * on cooldown expiry would replay the failing grant every request; the
616
+ * user (who can see whether the account recovered upstream) is the
617
+ * arbiter. `fallbackToKeys` marks every account row value-missing so the
618
+ * live arbitration — projection presence plus probe fail-skip over a
619
+ * kind-agnostic cursor ring — keeps serving only real candidates.
620
+ *
621
+ * `accountId` names the row whose failure triggers this demotion (R7-MF).
622
+ * It is resolved when the queued body runs, so the demotion deletes only a
623
+ * projection that still belongs to THAT row: activating another account
624
+ * while the failing request is in flight must not lose the new account's
625
+ * healthy projection.
626
+ *
627
+ * R14-M3: the targetless form (omitted `accountId`) is TEST-ONLY. It
628
+ * resolves the provider's active account when the queued body runs — the
629
+ * same execution-time race R7-MF removed from the failure path — so it is
630
+ * kept only because the tests drive the demotion without a failure event.
631
+ * Production callers must name the row; the failure path always passes
632
+ * `key.id`.
633
+ */
634
+ fallbackToKeys(provider: string, accountId?: string): Promise<void>;
635
+ /**
636
+ * Atomic decide-and-delete of the `llm-pi-ai/<provider>` projection for one
637
+ * row (round-3 finding 1): the payload compare runs INSIDE the credential
638
+ * seam's per-key lock — `modifyRecord` hands `mutate` the record as it
639
+ * stands while the write is exclusive, so a native sign-in that already
640
+ * replaced the projection is visible here and the delete is skipped. The
641
+ * seam exposes no delete-inside-modify primitive: a `mutate` may only
642
+ * return a replacement or `undefined` (leave untouched), and calling
643
+ * `deleteRecord` inside the mutate would deadlock the provider's own
644
+ * serialized operation queue (credentials-local `enqueue`). The closest
645
+ * equivalent — the exact primitives this method uses — are a decide-only
646
+ * modify (returns `undefined`, no write) and a second pre-delete re-read
647
+ * against the decided-against snapshot ({@link projectionStillMatches},
648
+ * F2 step-1 hardening): a native writer landing between the decide and
649
+ * the delete is therefore visible before the delete runs, and its grant
650
+ * survives. The residual window is the sub-microtask gap between the
651
+ * re-read releasing the lock and the delete's own queue entry; a native
652
+ * writer landing there is still deleted, because closing that gap needs a
653
+ * compare-and-delete primitive the seam does not expose (store issue
654
+ * I-000090). `tests/host.test.ts` ("a native grant landing after the final
655
+ * comparison is still lost") pins that exact outcome so the seam fix has a
656
+ * test to flip.
657
+ *
658
+ * This is the only delete authority: a natively replaced grant — another
659
+ * account signed in, a native api-key record, an unrecognized payload —
660
+ * never matches and must survive the demotion of a stale row, and the
661
+ * async hydration window must not decide ownership (round-2 review,
662
+ * steps 1–2).
663
+ */
664
+ private decideAndDeleteProjection;
665
+ /**
666
+ * F2 pre-delete re-read, shared by the demotion and harvest-compensation
667
+ * decide-then-delete pairs: `true` while the live projection still holds
668
+ * exactly the decided-against payload. Runs inside `modifyRecord`'s
669
+ * per-key lock (the same primitive as the decide), so a native writer
670
+ * that already replaced the record is visible here and the delete is
671
+ * skipped instead of destroying the foreign grant. The lock is released
672
+ * before the delete, so a writer landing in that final gap is still lost:
673
+ * closing it needs the seam's compare-and-delete (store issue I-000090).
674
+ */
675
+ private projectionStillMatches;
676
+ /**
677
+ * The fallback body; callers own the mutation queue. `target` narrows the
678
+ * ownership check to one explicit row: removal call sites pass the removed
679
+ * row object, the failure path passes the failing row's id (R7-MF) — an
680
+ * id is re-resolved here because the failure's demotion is queued and the
681
+ * row object may be detached by the time it runs. Without a target the
682
+ * provider's active account decides.
683
+ */
684
+ private fallbackToKeysUnqueued;
685
+ /**
686
+ * Drop one OAuth account's plugin-scope grant payload and — only while
687
+ * this row still owns the live projection — demote the cascade to the key
688
+ * pool first, so the route falls through to the API-key pool instead of
689
+ * serving a grant whose owning row is gone. The demotion re-reads
690
+ * `llm-pi-ai/<provider>` inside this mutation and runs it through the
691
+ * ownership oracle ({@link matchOwningAccount}); a natively replaced
692
+ * projection (another account signed in, a native api-key record) is left
693
+ * untouched. Non-oauth rows are a no-op; every OAuth-removal call site
694
+ * goes through this helper.
695
+ */
696
+ private removeOAuthGrant;
697
+ /**
698
+ * Drop one account: its plugin-scope grant payload, its row, and — when
699
+ * it was the active projection — the projection itself, so the route
700
+ * falls back to the key pool.
701
+ */
702
+ removeAccount(provider: string, accountId: string): Promise<StateView>;
58
703
  /** Install the broker layer on one credential provider instance. */
59
704
  attachCredentials(credentials: CredentialsLike): void;
60
705
  /** Restore the credential provider methods when our fiber unloads. */
@@ -147,12 +792,46 @@ export declare class KeysManagerService {
147
792
  private recordFailure;
148
793
  /** Record a successful terminal finish. */
149
794
  private recordSuccess;
795
+ /**
796
+ * Codex P2: record a projection-served success on the owning account row —
797
+ * `lastUsedAt`/`totalUses` move and the failure streak resets, symmetric to
798
+ * the MF2 failure attribution.
799
+ */
800
+ private recordProjectionSuccess;
150
801
  /** Mark one key cooling, preserving escalation across recovery windows. */
151
802
  private markFailure;
803
+ /**
804
+ * Queue one projection demotion for a failed account and publish its
805
+ * promise so the request-error handler can wait for the projection to be
806
+ * gone (round-5 finding C1). `keyId` is the failing row (R7-MF), so the
807
+ * queued body deletes only a projection that still belongs to it. R14-M1:
808
+ * both the cooldown-extension branch and the dedupe branch start the
809
+ * demotion; it is idempotent when the projection is already gone.
810
+ */
811
+ private demoteProjection;
812
+ /**
813
+ * Wait for any in-flight projection demotion on `providerId` (round-5
814
+ * finding C1). Resolves immediately when nothing is pending, and always
815
+ * resolves — the recorded promise already absorbed its own error.
816
+ */
817
+ private settleProjectionDemotion;
818
+ /**
819
+ * Whether the key pool can serve a request right now (round-5 finding C1):
820
+ * a selectable non-account row other than `excludeKeyId`. Account rows are
821
+ * excluded because their reference is not broker-resolvable — only a
822
+ * projected grant serves them.
823
+ */
824
+ private hasFailoverCandidate;
152
825
  /** Prune stale attempt records. */
153
826
  private pruneAttempts;
154
827
  /** Waterfall listener: bind a key to the request and observe its terminal chunk. */
155
828
  streamListener(options: GenerateOptionsLike, next: () => AsyncIterable<StreamChunkLike>): AsyncIterable<StreamChunkLike>;
829
+ /**
830
+ * The account row whose grant payload currently IS the live projection,
831
+ * verified against the provider's rows (MF2/MF3 read side). A stale map
832
+ * entry (row removed) resolves to null.
833
+ */
834
+ private oauthOwnerFor;
156
835
  /** Wrap one managed stream so credential resolution sees this key. */
157
836
  private wrapStream;
158
837
  /**