@cortexkit/antigravity-auth-core 3.0.9 → 3.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/common-auth-embedded/claustrum/consumer.d.ts +3 -1
  2. package/dist/common-auth-embedded/claustrum/consumer.js +25 -21
  3. package/dist/common-auth-embedded/claustrum/enrollment.d.ts +5 -0
  4. package/dist/common-auth-embedded/claustrum/enrollment.js +27 -0
  5. package/dist/common-auth-embedded/claustrum/index.d.ts +1 -0
  6. package/dist/common-auth-embedded/claustrum/index.js +1 -0
  7. package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +18 -0
  8. package/dist/common-auth-embedded/claustrum/quota-writer.js +253 -0
  9. package/dist/common-auth-embedded/claustrum/roster.d.ts +18 -2
  10. package/dist/common-auth-embedded/claustrum/roster.js +100 -43
  11. package/dist/common-auth-embedded/fs/private-files.d.ts +2 -0
  12. package/dist/common-auth-embedded/fs/private-files.js +49 -3
  13. package/dist/common-auth-embedded/fs/with-lock.d.ts +1 -0
  14. package/dist/common-auth-embedded/fs/with-lock.js +2 -0
  15. package/dist/common-auth-embedded/quota/format.d.ts +19 -0
  16. package/dist/common-auth-embedded/quota/format.js +50 -5
  17. package/dist/common-auth-embedded/quota/index.d.ts +3 -3
  18. package/dist/common-auth-embedded/quota/index.js +2 -2
  19. package/dist/common-auth-embedded/quota/map.d.ts +24 -1
  20. package/dist/common-auth-embedded/quota/map.js +31 -6
  21. package/dist/common-auth-embedded/quota/merge.d.ts +6 -1
  22. package/dist/common-auth-embedded/quota/merge.js +17 -3
  23. package/dist/common-auth-embedded/quota/projection.d.ts +3 -1
  24. package/dist/common-auth-embedded/quota/projection.js +3 -1
  25. package/dist/common-auth-embedded/source-output.json +89 -73
  26. package/dist/pre-response-deadline.d.ts.map +1 -1
  27. package/dist/pre-response-deadline.js +5 -1
  28. package/dist/pre-response-deadline.js.map +1 -1
  29. package/dist/quota-manager.d.ts +3 -3
  30. package/dist/quota-manager.d.ts.map +1 -1
  31. package/dist/quota-manager.js +59 -36
  32. package/dist/quota-manager.js.map +1 -1
  33. package/dist/transform/gemini.d.ts.map +1 -1
  34. package/dist/transform/gemini.js +12 -0
  35. package/dist/transform/gemini.js.map +1 -1
  36. package/dist/vault-enrollment-authority.d.ts +2 -0
  37. package/dist/vault-enrollment-authority.d.ts.map +1 -1
  38. package/dist/vault-enrollment-authority.js +2 -0
  39. package/dist/vault-enrollment-authority.js.map +1 -1
  40. package/package.json +1 -1
@@ -169,8 +169,10 @@ export declare class ClaustrumConsumer<Q = QuotaObservation> {
169
169
  * a served credential (an insula reading from `cachedInsulaQuotaForRoute`).
170
170
  * It lands only while the route is still bound to that known account, so no
171
171
  * credential fetch is needed and a replaced account's reading is dropped.
172
- * The in-memory roster is reloaded from the file, without a vault call.
172
+ * Memory updates immediately; durable publication is detached and coalesced.
173
173
  */
174
174
  recordAccountQuota(routeId: string, accountIdentity: string, observation: QuotaObservation): Promise<boolean>;
175
175
  close(): void;
176
+ /** Close immediately; the last consumer also waits for the writer's bounded drain. */
177
+ dispose(): Promise<void>;
176
178
  }
@@ -4,7 +4,8 @@ import { ClaustrumScopedCustody, decideScopedRetryAfter401, } from './custody.js
4
4
  import { readClaustrumEnrollmentToken } from './enrollment.js';
5
5
  import { ClaustrumConsumerError } from './errors.js';
6
6
  import { acquireInsulaQuotaSource, } from './insula.js';
7
- import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
7
+ import { pendingVaultQuota, retainVaultQuotaWriter } from './quota-writer.js';
8
+ import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from './roster.js';
8
9
  import { SAFETY_POLL_MS } from './watch.js';
9
10
  /**
10
11
  * One plugin host's view of its vault accounts. It turns vault credentials
@@ -35,9 +36,13 @@ export class ClaustrumConsumer {
35
36
  #insulaSkips = new Map();
36
37
  /** When this instance last completed a discovery, by `now`. */
37
38
  #discoveredAt;
39
+ #releaseQuotaWriter;
38
40
  constructor(options) {
39
41
  this.#options = options;
40
42
  this.#logger = options.logger ?? createLogger('claustrum');
43
+ this.#releaseQuotaWriter = retainVaultQuotaWriter(options.rosterPath, (publication) => {
44
+ this.#roster = applyVaultQuota(this.#roster, publication).roster;
45
+ });
41
46
  }
42
47
  #assertOpen(signal) {
43
48
  this.#shutdown.signal.throwIfAborted();
@@ -176,11 +181,9 @@ export class ClaustrumConsumer {
176
181
  let discovered;
177
182
  const roster = await refreshVaultRoster({
178
183
  path: this.#options.rosterPath,
179
- custody: {
180
- discover: async (signal) => {
181
- discovered = await custody.discover(signal);
182
- return discovered;
183
- },
184
+ custody,
185
+ onInventory: (inventory) => {
186
+ discovered = inventory;
184
187
  },
185
188
  isActive: () => this.#active(),
186
189
  signal: this.#shutdown.signal,
@@ -193,7 +196,7 @@ export class ClaustrumConsumer {
193
196
  });
194
197
  this.#assertOpen();
195
198
  const changed = roster?.view !== this.#roster?.view;
196
- this.#roster = roster;
199
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, roster);
197
200
  custody.retain(roster?.rows ?? []);
198
201
  // Each discovery replaces the previous value, including with unknown. A
199
202
  // refresh that served a peer's roster discovered nothing and leaves it;
@@ -349,7 +352,7 @@ export class ClaustrumConsumer {
349
352
  return undefined;
350
353
  try {
351
354
  await readClaustrumEnrollmentToken(this.#options.tokenPath);
352
- const roster = await readVaultRoster(this.#options.rosterPath);
355
+ const roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
353
356
  const row = roster?.rows.find((entry) => entry.routeId === routeId);
354
357
  return this.#normalizeInsulaQuota(row?.accountIdentity);
355
358
  }
@@ -364,7 +367,7 @@ export class ClaustrumConsumer {
364
367
  return { miss: 'unreachable' };
365
368
  try {
366
369
  await readClaustrumEnrollmentToken(this.#options.tokenPath);
367
- const roster = await readVaultRoster(this.#options.rosterPath);
370
+ const roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
368
371
  const account = roster?.rows.find((row) => row.routeId === routeId)?.accountIdentity;
369
372
  if (!account || !this.#insula)
370
373
  return { miss: 'unreachable' };
@@ -419,7 +422,7 @@ export class ClaustrumConsumer {
419
422
  this.#insula = undefined;
420
423
  throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
421
424
  }
422
- const current = await readVaultRoster(this.#options.rosterPath);
425
+ const current = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
423
426
  this.#assertOpen(signal);
424
427
  const observed = roster.rows.find((row) => row.routeId === routeId);
425
428
  const configured = current?.rows.find((row) => row.routeId === routeId);
@@ -511,13 +514,13 @@ export class ClaustrumConsumer {
511
514
  async decline(routeId) {
512
515
  this.#assertOpen();
513
516
  await declineVaultRoute(this.#options.rosterPath, routeId);
514
- this.#roster = await readVaultRoster(this.#options.rosterPath);
517
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
515
518
  this.#custody?.retain(this.#roster?.rows ?? []);
516
519
  }
517
520
  async accept(routeId) {
518
521
  this.#assertOpen();
519
522
  await acceptVaultRoute(this.#options.rosterPath, routeId);
520
- this.#roster = await readVaultRoster(this.#options.rosterPath);
523
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
521
524
  }
522
525
  /**
523
526
  * Store a quota or profile observation for a vault route. The receipt the
@@ -527,7 +530,7 @@ export class ClaustrumConsumer {
527
530
  */
528
531
  async recordQuota(routeId, observation, attempt) {
529
532
  this.#assertOpen();
530
- const kept = await recordVaultQuota(this.#options.rosterPath, {
533
+ return recordVaultQuota(this.#options.rosterPath, {
531
534
  routeId,
532
535
  observation,
533
536
  credentialId: attempt.credentialId,
@@ -539,27 +542,21 @@ export class ClaustrumConsumer {
539
542
  expectedAccountIdentity: attempt.expectedAccountIdentity,
540
543
  }),
541
544
  });
542
- if (kept)
543
- this.#roster = await readVaultRoster(this.#options.rosterPath);
544
- return kept;
545
545
  }
546
546
  /**
547
547
  * Store a background observation that belongs to an account rather than to
548
548
  * a served credential (an insula reading from `cachedInsulaQuotaForRoute`).
549
549
  * It lands only while the route is still bound to that known account, so no
550
550
  * credential fetch is needed and a replaced account's reading is dropped.
551
- * The in-memory roster is reloaded from the file, without a vault call.
551
+ * Memory updates immediately; durable publication is detached and coalesced.
552
552
  */
553
553
  async recordAccountQuota(routeId, accountIdentity, observation) {
554
554
  this.#assertOpen();
555
- const kept = await recordVaultAccountQuota(this.#options.rosterPath, {
555
+ return recordVaultAccountQuota(this.#options.rosterPath, {
556
556
  routeId,
557
557
  accountIdentity,
558
558
  observation,
559
559
  });
560
- if (kept)
561
- this.#roster = await readVaultRoster(this.#options.rosterPath);
562
- return kept;
563
560
  }
564
561
  close() {
565
562
  if (this.#shutdown.signal.aborted)
@@ -574,5 +571,12 @@ export class ClaustrumConsumer {
574
571
  this.#insula = undefined;
575
572
  this.#roster = undefined;
576
573
  this.#grantReach = undefined;
574
+ void this.#releaseQuotaWriter();
575
+ forgetPendingVaultDiscovery(this.#options.rosterPath);
576
+ }
577
+ /** Close immediately; the last consumer also waits for the writer's bounded drain. */
578
+ async dispose() {
579
+ this.close();
580
+ await this.#releaseQuotaWriter();
577
581
  }
578
582
  }
@@ -1,4 +1,9 @@
1
1
  import { type ClaustrumClientOptions as ClaustrumEnrollmentClientOptions, type EnrollmentPollOutcome, type EnrollmentTokenFile, writeEnrollmentTokenFile } from '@cortexkit/claustrum-client';
2
+ /**
3
+ * The code a vault without the enrollment ceremony answers a proposal with.
4
+ * Setup reports it as "this vault cannot connect plugins" instead of waiting.
5
+ */
6
+ export declare const UNSUPPORTED_ENROLLMENT_CODE = "unknown_method";
2
7
  /**
3
8
  * Codes the vault's closed enrollment-refusal vocabulary marks permanent. The
4
9
  * client labels some module error frames transient/retry regardless, so the
@@ -10,12 +10,22 @@ const ENROLLMENT_SCHEMA = 1;
10
10
  const ENROLLMENT_FILE_MAX_BYTES = 16 * 1024;
11
11
  const ENROLLMENT_LOCK_TTL_MS = 30_000;
12
12
  const TOKEN_RE = /^[0-9a-f]{64}$/;
13
+ /**
14
+ * The code a vault without the enrollment ceremony answers a proposal with.
15
+ * Setup reports it as "this vault cannot connect plugins" instead of waiting.
16
+ */
17
+ export const UNSUPPORTED_ENROLLMENT_CODE = 'unknown_method';
13
18
  /**
14
19
  * Codes the vault's closed enrollment-refusal vocabulary marks permanent. The
15
20
  * client labels some module error frames transient/retry regardless, so the
16
21
  * producer's code takes precedence over the client's action for these.
17
22
  */
18
23
  export const TERMINAL_ENROLLMENT_CODES = new Set([
24
+ // The daemon's answer to an operation it does not implement: a vault built
25
+ // before the enrollment ceremony. It arrives as a transport Error frame with
26
+ // no class, which claustrum-client before 0.13.0 labels transient/retry, but
27
+ // asking again can never succeed.
28
+ UNSUPPORTED_ENROLLMENT_CODE,
19
29
  'invalid_params',
20
30
  'pending_exists',
21
31
  'not_found',
@@ -638,6 +648,23 @@ export class ClaustrumEnrollmentManager {
638
648
  retryCode: refusal.code,
639
649
  };
640
650
  }
651
+ // A vault without the ceremony cannot hold a request for this
652
+ // proposal, now or from an earlier run (the state has no request
653
+ // id). So nothing is blocked and nothing is in progress: remove the
654
+ // pending state, still under the ceremony lock, so status reads as
655
+ // "not enrolled" and a reset or a later run starts afresh.
656
+ if (refusal.code === UNSUPPORTED_ENROLLMENT_CODE) {
657
+ await lock.assertOwned();
658
+ await unlink(this.#paths.statePath).catch((cause) => {
659
+ if (cause.code !== 'ENOENT')
660
+ throw cause;
661
+ });
662
+ return {
663
+ state: 'unavailable',
664
+ proposedName: state.proposedName,
665
+ code: refusal.code,
666
+ };
667
+ }
641
668
  return this.#block(state, refusal.code);
642
669
  }
643
670
  }
@@ -9,6 +9,7 @@ export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type Claustr
9
9
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
10
10
  export { acquireInsulaQuotaSource, type InsulaQuotaContext, type InsulaQuotaLookup, type InsulaQuotaMiss, type InsulaQuotaOptions, type InsulaQuotaReading, type InsulaQuotaResult, type InsulaQuotaSource, type InsulaUsageEntry, type InsulaUsageTransport, type InsulaUsageWatch, type InsulaUsageWindow, type InsulaWatchOptions, } from './insula.js';
11
11
  export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
12
+ export { drainVaultQuota } from './quota-writer.js';
12
13
  export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, type MutateVaultRosterOptions, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, vaultRoutingRows, } from './roster.js';
13
14
  export { type ChangeNotice, ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, type ScopedRosterWatchOptions, type ScopedWatchEnd, type ScopedWatchHandle, type ScopedWatchNoticeShape, type ScopedWatchSource, WATCH_RETRY_MS, type WatchMode, } from './watch.js';
14
15
  /**
@@ -14,6 +14,7 @@ export { ClaustrumConsumerError, } from './errors.js';
14
14
  export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
15
15
  export { acquireInsulaQuotaSource, } from './insula.js';
16
16
  export { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
17
+ export { drainVaultQuota } from './quota-writer.js';
17
18
  export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from './roster.js';
18
19
  export { ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, WATCH_RETRY_MS, } from './watch.js';
19
20
  /**
@@ -0,0 +1,18 @@
1
+ import { type QuotaObservation } from '../quota/index.js';
2
+ import { type QuotaReceipt, type VaultRosterFile } from './roster.js';
3
+ export type VaultQuotaPublication = {
4
+ routeId: string;
5
+ accountIdentity?: string;
6
+ receipt?: QuotaReceipt;
7
+ observation: QuotaObservation;
8
+ };
9
+ type Listener = (publication: VaultQuotaPublication) => void;
10
+ /** Retain the shared writer; the last consumer's close drains it. */
11
+ export declare function retainVaultQuotaWriter(path: string, listener: Listener): () => Promise<void>;
12
+ /** Enqueue without filesystem work; each quota field retains its own receipt fence. */
13
+ export declare function enqueueVaultQuota(path: string, publication: VaultQuotaPublication): Promise<boolean>;
14
+ /** Overlay uncommitted observations when a consumer reloads the durable roster. */
15
+ export declare function pendingVaultQuota(path: string, roster: VaultRosterFile | undefined): VaultRosterFile | undefined;
16
+ /** Wait at most timeoutMs for detached publication; shutdown loss is acceptable. */
17
+ export declare function drainVaultQuota(path: string, timeoutMs?: number): Promise<void>;
18
+ export {};
@@ -0,0 +1,253 @@
1
+ import { resolve } from 'node:path';
2
+ import { createLogger } from '../logger/index.js';
3
+ import { isQuotaObservation } from '../quota/index.js';
4
+ import { applyVaultQuota, mutateVaultRoster, } from './roster.js';
5
+ // Every consumer of the same file shares one mailbox and one lock waiter.
6
+ const writers = new Map();
7
+ const logger = createLogger('claustrum');
8
+ const COALESCE_MS = 100;
9
+ function writerFor(path) {
10
+ const key = resolve(path);
11
+ let writer = writers.get(key);
12
+ if (!writer) {
13
+ writer = {
14
+ path: key,
15
+ pending: new Map(),
16
+ listeners: new Set(),
17
+ wake: new Set(),
18
+ abort: new AbortController(),
19
+ failures: 0,
20
+ draining: false,
21
+ order: 0,
22
+ };
23
+ writers.set(key, writer);
24
+ }
25
+ return writer;
26
+ }
27
+ /** Retain the shared writer; the last consumer's close drains it. */
28
+ export function retainVaultQuotaWriter(path, listener) {
29
+ const writer = writerFor(path);
30
+ writer.listeners.add(listener);
31
+ let released;
32
+ return () => (released ??= (() => {
33
+ writer.listeners.delete(listener);
34
+ return writer.listeners.size ? Promise.resolve() : drainVaultQuota(path);
35
+ })());
36
+ }
37
+ // Split one observation into its independently dated parts: each rate-limit
38
+ // window (scope and label), each covered-but-unread window, the budget, each
39
+ // counter and the credits record. The mailbox keeps the newest of each part on
40
+ // its own, so a later observation that carries only some parts can't erase an
41
+ // older but still newest reading of another part, which is how
42
+ // mergeQuotaObservation treats them on disk. Each key also carries the
43
+ // receipt's identity, so the identity fence still applies to every part.
44
+ function fields(publication) {
45
+ const result = new Map();
46
+ const { observation } = publication;
47
+ const witness = JSON.stringify([
48
+ publication.routeId,
49
+ publication.receipt?.credentialId,
50
+ publication.receipt?.accountIdentitySource,
51
+ publication.receipt?.expectedAccountIdentity,
52
+ ]);
53
+ const add = (key, at, part) => result.set(`${witness}:${key}`, {
54
+ at,
55
+ order: 0,
56
+ publication: { ...publication, observation: part },
57
+ });
58
+ const keyFor = (scope, label) => JSON.stringify([scope ?? 'all', label]);
59
+ const read = new Set();
60
+ for (const reading of observation.readings ?? []) {
61
+ const key = keyFor(reading.scope, reading.label);
62
+ read.add(key);
63
+ add(key, observation.checkedAt, {
64
+ checkedAt: observation.checkedAt,
65
+ readings: [{ ...reading }],
66
+ });
67
+ }
68
+ for (const pair of observation.coverage ?? []) {
69
+ const key = keyFor(pair.scope, pair.label);
70
+ if (!read.has(key))
71
+ add(key, observation.checkedAt, {
72
+ checkedAt: observation.checkedAt,
73
+ coverage: [{ ...pair }],
74
+ });
75
+ }
76
+ if (observation.budget)
77
+ add('budget', observation.checkedAt, {
78
+ checkedAt: observation.checkedAt,
79
+ budget: { ...observation.budget },
80
+ });
81
+ for (const [name, counter] of Object.entries(observation.counters ?? {})) {
82
+ add(`counter:${name}`, counter.checkedAt, {
83
+ checkedAt: observation.checkedAt,
84
+ counters: { [name]: { ...counter } },
85
+ });
86
+ }
87
+ if (observation.credits)
88
+ add('credits', observation.credits.checkedAt, {
89
+ checkedAt: observation.checkedAt,
90
+ credits: { ...observation.credits },
91
+ });
92
+ // Even an empty observation can bind an account proved by its receipt.
93
+ if (!result.size)
94
+ add('empty', observation.checkedAt, { checkedAt: observation.checkedAt });
95
+ return result;
96
+ }
97
+ /** Enqueue without filesystem work; each quota field retains its own receipt fence. */
98
+ export function enqueueVaultQuota(path, publication) {
99
+ if (!isQuotaObservation(publication.observation))
100
+ return Promise.resolve(false);
101
+ const writer = writerFor(path);
102
+ const account = JSON.stringify([
103
+ publication.accountIdentity ?? null,
104
+ publication.accountIdentity === undefined ? publication.routeId : null,
105
+ ]);
106
+ const pending = writer.pending.get(account) ?? new Map();
107
+ for (const [key, field] of fields(publication)) {
108
+ const previous = pending.get(key);
109
+ if (!previous || field.at >= previous.at) {
110
+ field.order = ++writer.order;
111
+ // A covered disappearance after an unflushed reading is a retirement,
112
+ // not an absence. Retain just enough history to preserve that distinction.
113
+ if (field.publication.observation.coverage)
114
+ field.priorReading = previous?.publication.observation.readings
115
+ ? previous.publication
116
+ : previous?.priorReading;
117
+ pending.set(key, field);
118
+ }
119
+ }
120
+ if (pending.size)
121
+ writer.pending.set(account, pending);
122
+ for (const listener of writer.listeners)
123
+ listener(publication);
124
+ schedule(writer, COALESCE_MS);
125
+ return Promise.resolve(true);
126
+ }
127
+ function orderedFields(pending) {
128
+ // Equal observation times are last-enqueue-wins even across different
129
+ // credentials or account-only readings. Map insertion order cannot prove it.
130
+ return [...pending.values()]
131
+ .flatMap((account) => [...account.values()])
132
+ .sort((a, b) => a.order - b.order);
133
+ }
134
+ /** Overlay uncommitted observations when a consumer reloads the durable roster. */
135
+ export function pendingVaultQuota(path, roster) {
136
+ const writer = writers.get(resolve(path));
137
+ if (writer) {
138
+ for (const field of orderedFields(writer.pending)) {
139
+ if (field.priorReading)
140
+ roster = applyVaultQuota(roster, field.priorReading).roster;
141
+ roster = applyVaultQuota(roster, field.publication).roster;
142
+ }
143
+ }
144
+ return roster;
145
+ }
146
+ function schedule(writer, delay) {
147
+ if (writer.running ||
148
+ writer.timer ||
149
+ !writer.pending.size ||
150
+ writer.abort.signal.aborted)
151
+ return;
152
+ writer.timer = setTimeout(() => {
153
+ writer.timer = undefined;
154
+ flush(writer);
155
+ }, delay);
156
+ if (!writer.draining)
157
+ writer.timer.unref?.();
158
+ }
159
+ function flush(writer) {
160
+ if (writer.running || writer.abort.signal.aborted)
161
+ return;
162
+ const batch = new Map([...writer.pending].map(([account, pending]) => [
163
+ account,
164
+ new Map(pending),
165
+ ]));
166
+ writer.running = (async () => {
167
+ try {
168
+ await mutateVaultRoster(writer.path, (current) => {
169
+ let next = current;
170
+ for (const field of orderedFields(batch)) {
171
+ if (field.priorReading)
172
+ next = applyVaultQuota(next, field.priorReading).roster;
173
+ next = applyVaultQuota(next, field.publication).roster;
174
+ }
175
+ return { ...(next !== current && { next }), result: undefined };
176
+ }, { signal: writer.abort.signal });
177
+ for (const [account, fields] of batch) {
178
+ const pending = writer.pending.get(account);
179
+ for (const [key, field] of fields) {
180
+ // A newer enqueue must survive completion of an older flush.
181
+ if (pending?.get(key) === field)
182
+ pending.delete(key);
183
+ }
184
+ if (!pending?.size)
185
+ writer.pending.delete(account);
186
+ }
187
+ writer.failures = 0;
188
+ }
189
+ catch (error) {
190
+ if (!writer.abort.signal.aborted && writer.failures++ === 0) {
191
+ logger.warn('vault quota publication delayed; retrying', {
192
+ error: error instanceof Error ? error.message : String(error),
193
+ });
194
+ }
195
+ // Keep the batch in the mailbox, including any fresher fields enqueued meanwhile.
196
+ }
197
+ finally {
198
+ writer.running = undefined;
199
+ for (const wake of writer.wake)
200
+ wake();
201
+ const backoff = writer.failures
202
+ ? Math.min(5_000, 100 * 2 ** Math.min(writer.failures, 6)) *
203
+ (0.5 + Math.random())
204
+ : COALESCE_MS;
205
+ schedule(writer, backoff);
206
+ }
207
+ })();
208
+ }
209
+ /** Wait at most timeoutMs for detached publication; shutdown loss is acceptable. */
210
+ export async function drainVaultQuota(path, timeoutMs = 1_000) {
211
+ const key = resolve(path);
212
+ const writer = writers.get(key);
213
+ if (!writer)
214
+ return;
215
+ writer.draining = true;
216
+ clearTimeout(writer.timer);
217
+ writer.timer = undefined;
218
+ let deadline;
219
+ let wake;
220
+ const drained = new Promise((done) => {
221
+ wake = () => {
222
+ if (!writer.pending.size || writer.abort.signal.aborted)
223
+ done();
224
+ };
225
+ writer.wake.add(wake);
226
+ wake();
227
+ });
228
+ try {
229
+ if (writer.pending.size)
230
+ flush(writer);
231
+ await Promise.race([
232
+ drained,
233
+ new Promise((done) => {
234
+ deadline = setTimeout(done, Math.max(0, timeoutMs));
235
+ }),
236
+ ]);
237
+ }
238
+ finally {
239
+ clearTimeout(deadline);
240
+ if (wake)
241
+ writer.wake.delete(wake);
242
+ writer.draining = false;
243
+ // A still-open consumer owns the mailbox, so another consumer's disposal
244
+ // must not discard its observations or stop its retries.
245
+ if (!writer.listeners.size) {
246
+ writer.abort.abort();
247
+ clearTimeout(writer.timer);
248
+ writer.pending.clear();
249
+ if (writers.get(key) === writer)
250
+ writers.delete(key);
251
+ }
252
+ }
253
+ }
@@ -2,6 +2,8 @@ import { type QuotaMap, type QuotaObservation } from '../quota/index.js';
2
2
  import type { RoutingRow } from '../routing/index.js';
3
3
  import type { ClaustrumScopedAttempt, ClaustrumScopedCustody, SkippedVaultRecord, VaultCredential, VaultCredentialType, VaultInventory } from './custody.js';
4
4
  import { type DeclinedAccount } from './interlock.js';
5
+ import { type VaultQuotaPublication } from './quota-writer.js';
6
+ export { drainVaultQuota } from './quota-writer.js';
5
7
  /**
6
8
  * One vault account as the pool sees it. It carries no bearer material: the
7
9
  * access token is fetched from the vault for every send. Several vault
@@ -91,6 +93,7 @@ export interface VaultRosterLockContext {
91
93
  assertOwned: () => Promise<void>;
92
94
  }
93
95
  export interface MutateVaultRosterOptions {
96
+ signal?: AbortSignal;
94
97
  /**
95
98
  * Runs when the new roster is fully staged on disk, immediately before the
96
99
  * rename that publishes it, so a caller can confirm a lease of its own at
@@ -127,7 +130,10 @@ export type QuotaReceipt = Pick<ClaustrumScopedAttempt, 'credentialId' | 'accoun
127
130
  * write is fenced on the receipt the reading was taken with: it lands only
128
131
  * while the row still holds that receipt's credential (as representative or
129
132
  * alias) and account, so a slow read for a replaced account can never land on
130
- * its successor. Returns whether the observation was kept.
133
+ * its successor. Resolves false when the fence refuses the reading against the
134
+ * roster as it stands now (on disk, plus readings this process has not
135
+ * written yet), and true once the reading is queued for the next write. The
136
+ * write applies the fence again, so a rebind between the two still refuses.
131
137
  */
132
138
  export declare function recordVaultQuota(path: string, input: QuotaReceipt & {
133
139
  routeId: string;
@@ -139,19 +145,28 @@ export declare function recordVaultQuota(path: string, input: QuotaReceipt & {
139
145
  * lands only while the row is still bound to that exact known account, so a
140
146
  * reading taken before a replacement never reaches the successor. Unlike
141
147
  * `recordVaultQuota` it never binds an account: a row without a known account
142
- * is refused. Returns whether the observation was kept.
148
+ * is refused. Resolves false when the row is not bound to that account now,
149
+ * and true once the reading is queued, as `recordVaultQuota` does.
143
150
  */
144
151
  export declare function recordVaultAccountQuota(path: string, input: {
145
152
  routeId: string;
146
153
  accountIdentity: string;
147
154
  observation: QuotaObservation;
148
155
  }): Promise<boolean>;
156
+ /** Apply the same identity fence to memory overlays and durable quota batches. */
157
+ export declare function applyVaultQuota(current: VaultRosterFile | undefined, input: VaultQuotaPublication): {
158
+ roster: VaultRosterFile | undefined;
159
+ kept: boolean;
160
+ };
161
+ export declare function forgetPendingVaultDiscovery(path: string): void;
149
162
  /**
150
163
  * Discover and commit the roster. The lease covers the list call and the
151
164
  * commit, not just the write: an opaque view cannot tell a delayed old reply
152
165
  * from a newer one, so a run that lost its lease is refused at commit. A
153
166
  * failed list, or custody switching off mid-run, never writes anything; a
154
167
  * peer holding the lease means its last committed roster is served instead.
168
+ * A write-lock timeout keeps the discovered inventory for the next poll,
169
+ * unless a peer has superseded its base view in the meantime.
155
170
  */
156
171
  export declare function refreshVaultRoster(options: {
157
172
  path: string;
@@ -159,6 +174,7 @@ export declare function refreshVaultRoster(options: {
159
174
  isActive?: () => boolean | Promise<boolean>;
160
175
  signal?: AbortSignal;
161
176
  projection?: ProjectionOptions | (() => ProjectionOptions);
177
+ onInventory?: (inventory: VaultInventory) => void;
162
178
  }): Promise<VaultRosterFile | undefined>;
163
179
  /**
164
180
  * A host-owned main account as last verified from the vault: the plugin's