@coffre/vault 0.10.0 → 0.10.2

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/src/vault.ts CHANGED
@@ -99,17 +99,18 @@ import {
99
99
  type WrappedKey,
100
100
  } from '@coffre/core/vault';
101
101
  import type { Database, Queryable, Transaction } from '@coffre/db';
102
- import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
102
+ import { isUniqueViolation, isUnreachable, SNAPSHOT } from '@coffre/db/dialect';
103
103
  import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
104
- import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, newestVaultSeq, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
104
+ import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
105
105
 
106
- import { KeyAccounting } from './accounting.ts';
107
106
  import { signer, type Signer } from './checkpoint.ts';
108
107
  import type { ResolvedVaultConfig } from './config.ts';
109
- import { carries, forward, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, withCause, type Anchor } from './log.ts';
108
+ import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
110
109
  import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
111
110
  import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
111
+ import { snapshot, type Asked, type Snapshot } from './snapshot.ts';
112
112
  import * as store from './store.ts';
113
+ import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
113
114
  import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
114
115
 
115
116
  export type VaultOptions = {
@@ -120,6 +121,8 @@ export type VaultOptions = {
120
121
  keyBudgetMs?: number;
121
122
  /** Milliseconds added to the database's clock where the vault decides by it. Tests move time with it. */
122
123
  clockOffset?: () => number;
124
+ /** How far one call walks the log (`WALKS`); tests shorten them. */
125
+ walks?: Partial<Walks>;
123
126
  };
124
127
 
125
128
  /**
@@ -140,9 +143,9 @@ export type PreparedVault = {
140
143
  logKey: LogKey;
141
144
  /** Each signing key's log key: those after the first verify only entries before `since`. */
142
145
  logKeys: LogKey[];
143
- options: Required<VaultOptions>;
144
- /** How far the log is verified; `verifyChain` in log.ts. The furthest any call got to. */
145
- verified: Anchor;
146
+ options: Required<Omit<VaultOptions, 'walks'>> & { walks: Walks };
147
+ /** How far the chain is rehashed, by any call of this process: its head must still be there. */
148
+ verified: Position;
146
149
  /** What member rows are sealed under now; rows.ts. */
147
150
  rowKey: Buffer;
148
151
  /** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
@@ -169,8 +172,8 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
169
172
  return {
170
173
  config,
171
174
  ...(await keysOf(config.signingKeys)),
172
- options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
173
- verified: UNVERIFIED,
175
+ options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0), walks: { ...WALKS, ...options.walks } },
176
+ verified: START,
174
177
  reported: new Set(),
175
178
  since: null,
176
179
  superseded: null,
@@ -227,9 +230,6 @@ const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
227
230
  /** Why a change to a tampered member is refused. */
228
231
  const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
229
232
 
230
- /** The action of the vault's entry that signs a prefix of the log. */
231
- const CHECKPOINT = 'audit.checkpoint';
232
-
233
233
  /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
234
234
  const SETTINGS = 'settings.change';
235
235
 
@@ -289,13 +289,6 @@ const NOT_THEIRS = 'a person gives a service account at most what they hold, whe
289
289
  /** Who asks for checkpoints: the app's scheduled job. */
290
290
  const SCHEDULER = 'system:coffre-scheduler';
291
291
 
292
- /**
293
- * How often a checkpoint recomputes the chain from its first entry: the
294
- * first of each hour, by the vault's clock. The others resume from the
295
- * prefix the last one signed (`#recompute`).
296
- */
297
- const FULL_RECOMPUTE_MS = 60 * 60 * 1000;
298
-
299
292
  /** A refusal and the entries that record it. */
300
293
  class Refused {
301
294
  readonly refusal: Refusal;
@@ -362,6 +355,8 @@ type Decision = {
362
355
  members: Map<string, Member>;
363
356
  /** The database's clock, read once the members are locked. */
364
357
  at: number;
358
+ /** What the decision reads, in one statement once the members are locked: under the head, unless it takes the head late. */
359
+ reads: Snapshot;
365
360
  log: NewEntry[];
366
361
  writes: ((at: number) => Promise<void>)[];
367
362
  /** Members whose row or grants the writes change: sealed again once they have run. */
@@ -386,6 +381,13 @@ type Decision = {
386
381
  underLock: ((locked: Queryable) => Promise<void>)[];
387
382
  };
388
383
 
384
+ /**
385
+ * What a decision reads besides its members' standing, with it (`Snapshot`):
386
+ * places, references' ends, and the distinct keys a principal would hold
387
+ * released in the vault's window, with `keys` too: the bulk limit's count.
388
+ */
389
+ type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { principal: string; keys: readonly string[] } };
390
+
389
391
  /** The actions of the entries the vault writes about keys. */
390
392
  type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
391
393
 
@@ -456,6 +458,22 @@ class VaultService implements Vault {
456
458
  return (await store.now(db)) + this.#prepared.options.clockOffset();
457
459
  }
458
460
 
461
+ /** A snapshot's clock, as the vault keeps time (`#now`). */
462
+ #at(reads: Snapshot): number {
463
+ return reads.at + this.#prepared.options.clockOffset();
464
+ }
465
+
466
+ /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
467
+ #snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
468
+ const { released, ...rest } = asked;
469
+ return snapshot(tx, {
470
+ ...rest,
471
+ principals,
472
+ head,
473
+ ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
474
+ });
475
+ }
476
+
459
477
  /**
460
478
  * Decide in one transaction, with `principals`' rows locked first, then
461
479
  * the log's head. A `Refused` or an `Outage` rolls back all but its own
@@ -468,18 +486,18 @@ class VaultService implements Vault {
468
486
  * alone takes the head later (`late`), once its key service has answered,
469
487
  * which it must not wait on holding it, and decides again then (`#keys`).
470
488
  */
471
- async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
489
+ async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
472
490
  const superseded = await this.#settled();
473
491
  if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
474
- return this.#decideNow(principals, decide, late);
492
+ return this.#decideNow(principals, decide, late, asked);
475
493
  }
476
494
 
477
495
  /** `#decide`, once the vault is settled. */
478
- async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
496
+ async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
479
497
  const reports: NewEntry[] = [];
480
498
  for (let attempt = 1; ; attempt += 1) {
481
499
  try {
482
- return await this.#decideOnce(principals, decide, reports, late);
500
+ return await this.#decideOnce(principals, decide, reports, late, asked);
483
501
  } catch (error) {
484
502
  // A member who had no row when this decision locked theirs, and has
485
503
  // one now: another decision admitted them meanwhile. Again, with it.
@@ -489,14 +507,15 @@ class VaultService implements Vault {
489
507
  }
490
508
  }
491
509
 
492
- async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
510
+ async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean, asked: Reads): Promise<Outcome<T>> {
493
511
  try {
494
512
  const result = await this.#db.transaction(async (tx) => {
495
513
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
496
514
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
497
- if (!late) await lockLogHead(tx);
515
+ // Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
516
+ const reads = await this.#snapshot(tx, principals, asked, !late);
498
517
  const d: Decision = {
499
- tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
518
+ tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
500
519
  };
501
520
  const result = await decide(d);
502
521
  // A read is logged once per place, whichever way it ends (`readsByPlace`).
@@ -505,7 +524,9 @@ class VaultService implements Vault {
505
524
  // Each member's newest access entry, which their row names (rows.ts).
506
525
  const accessSeq = new Map<string, bigint>();
507
526
  if (entries.length > 0) {
508
- const appended = await this.#append(tx, entries, d.underLock);
527
+ // Under the head the decision's reads took: a remote key's decision takes it late, and reads again with it.
528
+ if (d.reads.head === null) throw new Error('a decision appends only under the head it read with');
529
+ const appended = await this.#append(tx, entries, d.underLock, d.reads);
509
530
  at = appended.occurredAt;
510
531
  entries.forEach((entry, i) => {
511
532
  if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
@@ -564,12 +585,12 @@ class VaultService implements Vault {
564
585
  * MAC are passed over, and reported: otherwise whoever can insert a row
565
586
  * could lock any member out.
566
587
  */
567
- async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[]): Promise<Fault | null> {
588
+ async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[], reads?: Snapshot): Promise<Fault | null> {
568
589
  if (row !== undefined && !this.#sealed(row, grants)) {
569
590
  this.#report(reports, principal, 'mac', row.mac.toString('hex'));
570
591
  return 'mac';
571
592
  }
572
- const newest = await this.#newestAccess(db, principal, reports);
593
+ const newest = await this.#newestAccess(db, principal, reports, reads);
573
594
  // No row, and no entry of the vault's own: someone never admitted. No
574
595
  // row, but one: one deleted, unless it was admitted since the row was
575
596
  // read. Too many forged to see past: neither a row nor the lack of one holds.
@@ -581,8 +602,14 @@ class VaultService implements Vault {
581
602
  }
582
603
 
583
604
  /** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
584
- #newestAccess(db: Queryable, principal: string, reports: NewEntry[]): Promise<Newest> {
585
- return newestAccess(db, principal, this.#check(reports, principal));
605
+ #newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
606
+ if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
607
+ // The snapshot's newest first, read with the rest; the pages before it, as they are asked.
608
+ const newest = reads.newestAccess.get(principal);
609
+ return newestAuthentic(
610
+ (limit, before) => (before === undefined ? Promise.resolve(newest === undefined ? [] : [newest]) : store.accessEntriesAbout(db, principal, limit, before)),
611
+ this.#check(reports, principal),
612
+ );
586
613
  }
587
614
 
588
615
  /** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
@@ -595,9 +622,13 @@ class VaultService implements Vault {
595
622
 
596
623
  /** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
597
624
  #authentic(entry: StoredEntry): boolean {
625
+ return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) }).ok;
626
+ }
627
+
628
+ /** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
629
+ #keysFor(entry: StoredEntry): LogKey[] {
598
630
  const { logKeys, since } = this.#prepared;
599
- const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
600
- return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys }).ok;
631
+ return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
601
632
  }
602
633
 
603
634
  /**
@@ -690,7 +721,7 @@ class VaultService implements Vault {
690
721
 
691
722
  async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
692
723
  const row = d.members.get(principal);
693
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
724
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
694
725
  if (row === undefined || subject.status !== 'active') return;
695
726
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
696
727
  if (everyProject.length === 0) return;
@@ -836,16 +867,29 @@ class VaultService implements Vault {
836
867
  * that an instance still running with the replaced KEK, as during a
837
868
  * deploy, cannot write after the rotation.
838
869
  */
839
- #append(tx: Transaction, entries: readonly NewEntry[], underLock: readonly ((locked: Queryable) => Promise<void>)[] = []): Promise<Appended> {
870
+ async #append(
871
+ tx: Transaction,
872
+ entries: readonly NewEntry[],
873
+ underLock: readonly ((locked: Queryable) => Promise<void>)[] = [],
874
+ /** A snapshot that holds the head: what it read under it is read again by nothing. */
875
+ held?: Snapshot,
876
+ ): Promise<Appended> {
840
877
  const prepared = this.#prepared;
841
- return appendEntries(tx, prepared.logKey, entries, async (locked) => {
842
- const rotation = await store.latestVaultEntry(locked, [KEY_ROTATE]);
878
+ const head = held?.head ?? undefined;
879
+ const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
880
+ const rotation = head === undefined ? await store.latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? undefined;
843
881
  if (rotation !== undefined && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
844
882
  prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
845
883
  throw new Error(prepared.superseded);
846
884
  }
847
885
  for (const check of underLock) await check(locked);
848
- });
886
+ }, head === undefined ? undefined : { head, last: head.last, at: held!.at });
887
+ // The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
888
+ // then the decision's reports.
889
+ if (head !== undefined) {
890
+ held!.head = { ...head, nextSeq: appended.nextSeq, headHash: appended.headHash, last: { seq: appended.nextSeq - 1n, hash: appended.headHash } };
891
+ }
892
+ return appended;
849
893
  }
850
894
 
851
895
  // --- keys -------------------------------------------------------------------
@@ -909,10 +953,8 @@ class VaultService implements Vault {
909
953
  secrets: items.map((item) => item.secret),
910
954
  // Through a reference, the reader's grants on the holder's environment decide.
911
955
  places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
912
- vet: ends.length === 0 ? undefined : async (db) => {
913
- const ended = await store.endedReferences(db, ends);
914
- return sealed.map((seal) => (seal !== null && ended.has(seal.seq) ? 'ended' : null));
915
- },
956
+ references: ends,
957
+ vet: ends.length === 0 ? undefined : async (_, reads) => sealed.map((seal) => (seal !== null && reads.ended.has(seal.seq) ? 'ended' : null)),
916
958
  remote,
917
959
  entry,
918
960
  input,
@@ -1098,11 +1140,14 @@ class VaultService implements Vault {
1098
1140
  secrets: readonly SecretRef[];
1099
1141
  /** Where each secret's permission is checked, when not where it is: a reference's holder. */
1100
1142
  places?: readonly SecretPlace[];
1143
+ /** The `reference.create` entries `vet` asks about: read with the decision (`Snapshot`'s `ended`). */
1144
+ references?: readonly bigint[];
1101
1145
  /**
1102
1146
  * What else refuses each secret, checked in the decision and again
1103
- * once the log's head is locked, before its entries append.
1147
+ * once the log's head is locked, before its entries append: from the
1148
+ * decision's reads, or the database.
1104
1149
  */
1105
- vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
1150
+ vet?: (db: Queryable, reads: Snapshot) => Promise<(RefusalCode | null)[]>;
1106
1151
  /**
1107
1152
  * Any one of which each secret takes besides `permission`, where it
1108
1153
  * is, read in the decision and again under the head; none for null.
@@ -1131,15 +1176,13 @@ class VaultService implements Vault {
1131
1176
  // Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
1132
1177
  const owners = new Map<string, string>();
1133
1178
  const check = async (d: Decision) => {
1134
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
1179
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
1135
1180
  // One read of the environments the decision is about, as the store has them, not as the app sent them:
1136
1181
  // each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
1137
1182
  // matches the slug there, and a place deleted on either side opens for none.
1138
1183
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1139
- const environments = await store.environmentsById(d.tx, [...secrets, ...at].map((place) => place.environmentId));
1140
- // A personal config's parent, whose read its owner needs.
1141
- const parents = [...environments.values()].flatMap((environment) => (environment.owner === null || environment.parentId === null ? [] : [environment.parentId]));
1142
- for (const [id, parent] of await store.environmentsById(d.tx, parents)) environments.set(id, parent);
1184
+ // Each a personal config is a config of too, whose read its owner needs (`places`).
1185
+ const environments = d.reads.environments;
1143
1186
  const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
1144
1187
  owners.clear();
1145
1188
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
@@ -1159,7 +1202,7 @@ class VaultService implements Vault {
1159
1202
  return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
1160
1203
  });
1161
1204
  if (call.vet !== undefined && !codes.some((code) => code !== null)) {
1162
- (await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
1205
+ (await call.vet(d.tx, d.reads)).forEach((code, i) => (codes[i] = code));
1163
1206
  }
1164
1207
  // A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
1165
1208
  // a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
@@ -1172,7 +1215,7 @@ class VaultService implements Vault {
1172
1215
  });
1173
1216
  }
1174
1217
  let first = codes.find((code) => code !== null) ?? null;
1175
- if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
1218
+ if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
1176
1219
  if (first !== null) {
1177
1220
  throw new Refused(
1178
1221
  refusal(first, MESSAGES[first]),
@@ -1181,6 +1224,12 @@ class VaultService implements Vault {
1181
1224
  }
1182
1225
  };
1183
1226
 
1227
+ // Every decision below reads the places it is about with the reader's standing; a read, the reader's releases too.
1228
+ const reads: Reads = {
1229
+ environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
1230
+ references: call.references ?? [],
1231
+ ...(action === 'secret.read' ? { released: { principal, keys: secrets.map((secret) => secret.secretId) } } : {}),
1232
+ };
1184
1233
  // The intent's own identity; `operationId` is the app's, for the whole action.
1185
1234
  const intentId = call.remote ? randomUUID() : null;
1186
1235
  let intentSeq: bigint | null = null;
@@ -1200,7 +1249,7 @@ class VaultService implements Vault {
1200
1249
  if (call.remote) {
1201
1250
  const intent = await this.#decide([principal], async (d) => {
1202
1251
  await check(d);
1203
- const at = await this.#now(d.tx);
1252
+ const at = d.at;
1204
1253
  const appended = await this.#append(d.tx, [{
1205
1254
  actor: principal,
1206
1255
  action: 'key.intent',
@@ -1216,14 +1265,19 @@ class VaultService implements Vault {
1216
1265
  ...traced(call.input),
1217
1266
  keys: secrets.map((secret, item) => ({ item, subject: secret.path, secretId: secret.secretId, version: secret.version })),
1218
1267
  }),
1219
- }]);
1268
+ }], [], d.reads);
1220
1269
  return { seq: appended.seqStart };
1221
- });
1270
+ }, false, reads);
1222
1271
  if (!intent.ok) return intent;
1223
1272
  intentSeq = intent.seq;
1224
1273
  }
1225
1274
 
1226
- return this.#decide([principal], async (d) => {
1275
+ // Under the reader's row: the check, the key operations, and their outcome. With a key in memory, under the
1276
+ // log's head too, from the start (`#decideOnce`): what the check read stands until the commit, as every change it
1277
+ // reads appends under the head, and the head waits on nothing slow. With a key service, which nothing may wait on
1278
+ // holding the head, the head is taken once the keys are open, and what the check read is read again under it.
1279
+ const late = call.remote;
1280
+ const decided = async (d: Decision) => {
1227
1281
  try {
1228
1282
  await check(d);
1229
1283
  } catch (error) {
@@ -1233,11 +1287,14 @@ class VaultService implements Vault {
1233
1287
  }
1234
1288
  throw error;
1235
1289
  }
1290
+ };
1291
+ return this.#decide([principal], async (d) => {
1292
+ await decided(d);
1236
1293
  // A reference ended while this decided refuses it, rather than a read logged after its end.
1237
1294
  const { vet } = call;
1238
- if (vet !== undefined) {
1295
+ if (late && vet !== undefined) {
1239
1296
  d.underLock.push(async (locked) => {
1240
- const codes = await vet(locked);
1297
+ const codes = await vet(locked, d.reads);
1241
1298
  const first = codes.find((code) => code !== null);
1242
1299
  if (first === undefined || first === null) return;
1243
1300
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
@@ -1262,25 +1319,20 @@ class VaultService implements Vault {
1262
1319
  if (done.length < outcomes.length) {
1263
1320
  throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
1264
1321
  }
1265
- // Again under the log's head, the append's, taken a statement early: a
1266
- // deletion renames its place under it, so one that committed since the
1267
- // check above is seen here, and nothing is released after it.
1268
- // A read through a reference: its holder's place too, which a deletion may have taken.
1269
- await lockLogHead(d.tx);
1270
- // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1271
- // since the check above is seen here, and nothing it refuses now is released.
1272
- try {
1273
- await check(d);
1274
- } catch (error) {
1275
- if (error instanceof Refused) {
1276
- throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', error.entries[item].code ?? error.refusal.code)));
1322
+ if (late) {
1323
+ // Again under the log's head, the append's, taken a statement early: a
1324
+ // deletion renames its place under it, so one that committed since the
1325
+ // check above is seen here, and nothing is released after it.
1326
+ // A read through a reference: its holder's place too, which a deletion may have taken.
1327
+ // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1328
+ // since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
1329
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true);
1330
+ d.at = this.#at(d.reads);
1331
+ await decided(d);
1332
+ const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1333
+ if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
1334
+ throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1277
1335
  }
1278
- throw error;
1279
- }
1280
- const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1281
- const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
1282
- if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
1283
- throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1284
1336
  }
1285
1337
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
1286
1338
  d.log.push(...released);
@@ -1291,13 +1343,17 @@ class VaultService implements Vault {
1291
1343
  } finally {
1292
1344
  for (const value of done) value.wipe();
1293
1345
  }
1294
- }, true);
1346
+ }, late, reads);
1295
1347
  }
1296
1348
 
1297
- /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
1298
- async #overBulkLimit(d: Decision, principal: string, n: number): Promise<boolean> {
1299
- const { count, windowMs } = this.#config.bulkLimit;
1300
- return (await store.releasesSince(d.tx, principal, d.at - windowMs)) + n > count;
1349
+ /**
1350
+ * Whether this read's keys would take the reader past the bulk limit: the
1351
+ * distinct keys released to them in the window, these among them. A key
1352
+ * read again exposes nothing new, so it counts once, whatever its version.
1353
+ * Counted under their row's lock, so exactly (`Reads`).
1354
+ */
1355
+ #overBulkLimit(d: Decision): boolean {
1356
+ return d.reads.released > this.#config.bulkLimit.count;
1301
1357
  }
1302
1358
 
1303
1359
  // --- references ---------------------------------------------------------------
@@ -1349,7 +1405,7 @@ class VaultService implements Vault {
1349
1405
  return this.#decide([principal], async (d) => {
1350
1406
  // Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
1351
1407
  // nor a role narrowed, before this commits.
1352
- const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1408
+ const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
1353
1409
  // Each source's nature, its current version's, under the head: what its maker must hold there.
1354
1410
  const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
1355
1411
  // Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
@@ -1436,7 +1492,7 @@ class VaultService implements Vault {
1436
1492
  };
1437
1493
  return this.#decide([principal], async (d) => {
1438
1494
  // Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
1439
- const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1495
+ const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
1440
1496
  // Ending is allowed in a deleted place: deleting one ends the references into and out of it.
1441
1497
  const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
1442
1498
  const codes: (RefusalCode | null)[] = seals.map((seal) => {
@@ -1552,7 +1608,7 @@ class VaultService implements Vault {
1552
1608
  */
1553
1609
  async #makeOwner(d: Decision, principal: string, reason: 'owners'): Promise<boolean> {
1554
1610
  const row = d.members.get(principal);
1555
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1611
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1556
1612
  if (subject.status === 'active' && ownsInstance(subject.live)) return false;
1557
1613
  const tampered = subject.status === 'tampered';
1558
1614
  if (tampered && (await this.#logged(d.tx, principal)) === undefined) return false;
@@ -1584,17 +1640,17 @@ class VaultService implements Vault {
1584
1640
  * What `principal` holds, their row checked first (`#integrity`): a row
1585
1641
  * that fails holds nothing, and is `tampered`.
1586
1642
  */
1587
- async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
1643
+ async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[], reads?: Snapshot): Promise<Standing> {
1588
1644
  const none = NOTHING;
1589
- const grants = row === undefined ? [] : await store.grants(db, principal);
1590
- const fault = await this.#integrity(db, principal, row, grants, reports);
1645
+ const grants = row === undefined ? [] : reads?.grants.get(principal) ?? (await store.grants(db, principal));
1646
+ const fault = await this.#integrity(db, principal, row, grants, reports, reads);
1591
1647
  if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
1592
1648
  if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
1593
1649
  if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
1594
1650
  const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
1595
1651
  const { role, scope } = roleOf(principal, row);
1596
1652
  // Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
1597
- const { policy } = await this.#policyAt(db, reports, at);
1653
+ const { policy } = await this.#policyAt(db, reports, at, reads);
1598
1654
  const service = !principal.startsWith('user:');
1599
1655
  return {
1600
1656
  principal,
@@ -1642,9 +1698,10 @@ class VaultService implements Vault {
1642
1698
  /** `access`, as the rows are now. */
1643
1699
  async #read(principal: string, superseded: string | null): Promise<Access> {
1644
1700
  const read = async (db: Queryable, reports: NewEntry[]) => {
1645
- const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
1646
- const fault = await this.#integrity(db, principal, row, held, reports);
1647
- const { policy } = await this.#policyAt(db, reports, at);
1701
+ const reads = await snapshot(db, { principals: [principal], members: true });
1702
+ const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
1703
+ const fault = await this.#integrity(db, principal, row, held, reports, reads);
1704
+ const { policy } = await this.#policyAt(db, reports, at, reads);
1648
1705
  return this.#access(principal, row, held, at, fault !== null, policy);
1649
1706
  };
1650
1707
  const found: NewEntry[] = [];
@@ -1724,7 +1781,7 @@ class VaultService implements Vault {
1724
1781
  // renames the place, so the place is as it commits.
1725
1782
  // The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
1726
1783
  // granted as one that ships, and one deleted meanwhile is granted by nobody.
1727
- const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
1784
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
1728
1785
  for (const change of changes) {
1729
1786
  if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
1730
1787
  if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
@@ -1748,7 +1805,7 @@ class VaultService implements Vault {
1748
1805
  // An environment by its slug as the store has it, which scopes match.
1749
1806
  return environmentId === null ? { projectId: projectId! } : { projectId: projectId!, environmentId, environmentSlug: environment!.slug };
1750
1807
  });
1751
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
1808
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
1752
1809
  if (acting.status === 'tampered') throw refused('tampered');
1753
1810
  // Where they manage access, anything; otherwise only a person to a service account (`givesService`).
1754
1811
  const manages = where.every((place) => mayManageAccess(acting.live, place));
@@ -1759,7 +1816,7 @@ class VaultService implements Vault {
1759
1816
  }
1760
1817
 
1761
1818
  const row = d.members.get(principal);
1762
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1819
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1763
1820
  if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
1764
1821
  // A service account admitted with its first grants, as `admit` would: all of it, or none.
1765
1822
  const admitted = input.admit === true && row?.status !== 'active' ? await this.#admitWith(d, acting, principal, row, input) : undefined;
@@ -1859,7 +1916,7 @@ class VaultService implements Vault {
1859
1916
  ]);
1860
1917
  return this.#decide([actor, principal], async (d) => {
1861
1918
  validateCorrelation(input);
1862
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
1919
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
1863
1920
  if (acting.status === 'tampered') throw refused('tampered');
1864
1921
  // A service account, with no grant yet, any person who could give it one sets up (`setsUpServices`).
1865
1922
  const service = SERVICE.test(principal) && (input.role ?? 'member') === 'member' && input.scope === undefined;
@@ -1872,7 +1929,7 @@ class VaultService implements Vault {
1872
1929
  throw refused('invalid', 'service accounts hold project grants only, never an instance role');
1873
1930
  }
1874
1931
  const row = d.members.get(principal);
1875
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1932
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1876
1933
  if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
1877
1934
  d.touched.add(principal);
1878
1935
  d.grants.set(principal, [...subject.stored]);
@@ -1964,10 +2021,10 @@ class VaultService implements Vault {
1964
2021
  validateCorrelation(input);
1965
2022
  if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
1966
2023
  const row = d.members.get(principal);
1967
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2024
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
1968
2025
  if (acting.status === 'tampered') throw refused('tampered');
1969
2026
  const holder = acting.live;
1970
- const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
2027
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
1971
2028
  const held = subject.stored;
1972
2029
  if (this.#listed(principal)) throw refused('not_allowed', declaredOwner(principal));
1973
2030
  if (subject.status === 'tampered') {
@@ -2052,7 +2109,7 @@ class VaultService implements Vault {
2052
2109
  */
2053
2110
  async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
2054
2111
  const entry = await newestAuthentic(
2055
- (before) => store.newestVaultEntries(db, SETTINGS, LOOK_BACK.page, before),
2112
+ (limit, before) => store.newestVaultEntries(db, SETTINGS, limit, before),
2056
2113
  this.#check(reports, null),
2057
2114
  );
2058
2115
  if (entry === 'none') return DEFAULT_SETTINGS;
@@ -2079,14 +2136,19 @@ class VaultService implements Vault {
2079
2136
  * no role holds anything but Owner, otherwise whoever can insert rows
2080
2137
  * could unprotect them. Its edits are then unknown, not none.
2081
2138
  */
2082
- async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
2139
+ async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
2140
+ // The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
2083
2141
  const entry = await newestAuthentic(
2084
- (before) => store.newestVaultEntries(db, POLICY, LOOK_BACK.page, before),
2142
+ (limit, before) => (reads !== undefined && before === undefined
2143
+ ? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy])
2144
+ : store.newestVaultEntries(db, POLICY, limit, before)),
2085
2145
  this.#check(reports, null),
2086
2146
  );
2087
2147
  if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
2088
2148
  const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
2089
- return edits === null ? { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false } : { policy: await livePolicy(db, edits, at), edits, readable: true };
2149
+ if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
2150
+ const rows = entry === reads?.newestPolicy ? reads.environments : await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
2151
+ return { policy: livePolicy(rows, edits, at), edits, readable: true };
2090
2152
  }
2091
2153
 
2092
2154
  changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
@@ -2109,14 +2171,15 @@ class VaultService implements Vault {
2109
2171
  new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
2110
2172
  return this.#decide([actor], async (d) => {
2111
2173
  validateCorrelation(input);
2112
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2174
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2113
2175
  if (acting.status === 'tampered') throw refused('tampered');
2114
2176
  if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
2115
2177
  // Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
2116
2178
  // caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
2117
2179
  // from that one, or is refused, to be asked again: a protected prod renamed since is a policy changed too.
2118
- const newest = await newestVaultSeq(d.tx, POLICY);
2119
- const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
2180
+ // The newest change in the vault's name, read with the decision, as the app reads it: by its number, unchecked.
2181
+ const newest = d.reads.newestPolicy?.seq ?? null;
2182
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
2120
2183
  if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
2121
2184
  const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
2122
2185
  // Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
@@ -2188,7 +2251,7 @@ class VaultService implements Vault {
2188
2251
  throw refused('invalid', 'a change is to a role, an instance role, or protection');
2189
2252
  }
2190
2253
  // The policy now, and the newest change, which it is as of: this one's, when it changed anything.
2191
- const answer = { policy: await livePolicy(d.tx, next, d.at), seq: newest === null ? null : Number(newest) };
2254
+ const answer = { policy: livePolicy(await store.environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at), seq: newest === null ? null : Number(newest) };
2192
2255
  if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
2193
2256
  const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
2194
2257
  d.log.push(made);
@@ -2213,7 +2276,7 @@ class VaultService implements Vault {
2213
2276
  new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
2214
2277
  return this.#decide([actor], async (d) => {
2215
2278
  validateCorrelation(input);
2216
- const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2279
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
2217
2280
  if (acting.status === 'tampered') throw refused('tampered');
2218
2281
  if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
2219
2282
  if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
@@ -2363,73 +2426,137 @@ class VaultService implements Vault {
2363
2426
 
2364
2427
  // --- checkpoints and the log --------------------------------------------------
2365
2428
 
2366
- /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
2367
- async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint; keyId: string } | null> {
2368
- const row = await store.latestVaultEntry(db, [CHECKPOINT]);
2369
- return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq, keyId: row.keyId };
2429
+ /**
2430
+ * Where a walk of the log resumes: the first entry (`start`), a
2431
+ * checkpoint's entry that keeps a tally, or the newest such entry, the
2432
+ * first entry while there is none. The entry must be the vault's, by its
2433
+ * MAC, and its tally whole: the members and grants it keeps, or the
2434
+ * earlier checkpoint it names keeps, match the digest it carries.
2435
+ *
2436
+ * Its signature is left to the walk: the entry comes after the prefix it
2437
+ * signs, so a walk from there reads it, as it reads every other.
2438
+ */
2439
+ async #resume(db: Queryable, from: 'start' | 'newest' | bigint): Promise<Resumed | Broken> {
2440
+ if (from === 'start') return FIRST_ENTRY;
2441
+ const entry = from === 'newest' ? await store.latestTallied(db) : (await store.entriesAt(db, [from])).get(from);
2442
+ if (entry === undefined) return from === 'newest' ? FIRST_ENTRY : { ok: false, failedAtSeq: Number(from), reason: 'no checkpoint at this entry' };
2443
+ return (await this.#resumeEach(db, [entry]))[0]!;
2444
+ }
2445
+
2446
+ /** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
2447
+ async #resumeEach(db: Queryable, entries: readonly StoredEntry[]): Promise<(Resumed | Broken)[]> {
2448
+ const reads = entries.map((entry) => this.#tallied(entry));
2449
+ const named = reads.flatMap((read) => (typeof read !== 'string' && 'at' in read.tally.access ? [BigInt(read.tally.access.at)] : []));
2450
+ const carriers = new Map([...(await store.entriesAt(db, named))].map(([seq, held]) => [seq, this.#tallied(held)]));
2451
+ return entries.map((entry, i): Resumed | Broken => {
2452
+ const broken = (reason: string): Broken => ({ ok: false, failedAtSeq: Number(entry.seq), reason });
2453
+ const read = reads[i]!;
2454
+ if (typeof read === 'string') return broken(read);
2455
+ let carrier = entry.seq;
2456
+ let { access } = read.tally;
2457
+ if ('at' in access) {
2458
+ carrier = BigInt(access.at);
2459
+ const holder = carrier < entry.seq ? carriers.get(carrier) : undefined;
2460
+ if (holder === undefined || typeof holder === 'string' || 'at' in holder.tally.access) {
2461
+ return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
2462
+ }
2463
+ access = holder.tally.access;
2464
+ }
2465
+ const kept = { members: access.members, grants: access.grants, newest: access.newest };
2466
+ if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
2467
+ return {
2468
+ ok: true,
2469
+ entry: entry.seq,
2470
+ at: { seq: BigInt(read.seq), hash: Buffer.from(read.hash, 'hex') },
2471
+ tally: { ...read.tally, access: kept },
2472
+ digest: read.tally.access.digest,
2473
+ carrier,
2474
+ scrub: read.scrub === null ? null : { at: positionOf(read.scrub.at), end: positionOf(read.scrub.end) },
2475
+ };
2476
+ });
2477
+ }
2478
+
2479
+ /** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
2480
+ #tallied(entry: StoredEntry): TalliedCheckpoint | string {
2481
+ const not = "not a checkpoint of the vault's that keeps a tally";
2482
+ if (entry.author !== 'vault' || entry.action !== CHECKPOINT || entry.decision !== 'allow' || entry.relatedSeq === null) return not;
2483
+ const authentic = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) });
2484
+ if (!authentic.ok) return withCause(authentic.reason);
2485
+ const read = metadataOf(entry) as Partial<TalliedCheckpoint> | null;
2486
+ if (read?.tally?.v !== TALLY_VERSION || read.scrub === undefined || typeof read.seq !== 'number' || BigInt(read.seq) !== entry.relatedSeq) return not;
2487
+ return read as TalliedCheckpoint;
2488
+ }
2489
+
2490
+ /** Which of `heads` the log still holds where they were, in one read. */
2491
+ async #holding(db: Queryable, heads: readonly LogHead[]): Promise<(head: LogHead) => boolean> {
2492
+ const hashes = await store.hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
2493
+ return (head) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
2370
2494
  }
2371
2495
 
2372
2496
  /**
2373
- * The chain recomputed to its last entry, for a checkpoint to sign: from
2374
- * its first entry at the first checkpoint of each hour, and otherwise from
2375
- * the prefix the last checkpoint signed, if that one was signed this hour.
2497
+ * Sign the log up to its last entry, from the newest checkpoint's tally.
2376
2498
  *
2377
- * Resuming never signs what the vault did not recompute. The vault
2378
- * recomputed the last checkpoint's prefix itself, from entry 0 at the
2379
- * hour's first checkpoint and onward at each one since, and the entry
2380
- * that holds it is the first one checked again, by its MAC. The next
2381
- * signature extends that same chain.
2499
+ * In one snapshot, and without the log's lock, so that no append waits on
2500
+ * it: every member's row checked, since rows changed around the vault
2501
+ * write nothing to the log; the prefix the newest checkpoint signed still
2502
+ * there, its hash where it was, which a rewrite chained again changes, and
2503
+ * every entry of it, which a cut changes; the chain recomputed from there,
2504
+ * every hash from content and every vault entry by its MAC, no further
2505
+ * than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
2506
+ * before rehashed (`#scrub`). Then, under the lock, the entries appended
2507
+ * since the snapshot, so that it signs the head.
2382
2508
  *
2383
- * What resuming does not do is recompute the rows of that prefix again.
2384
- * They can change while the hash stored at its end stays, so it first
2385
- * checks the two things that are cheap: that hash is still there, which a
2386
- * prefix rewritten and chained again changes, and every entry of the
2387
- * prefix is, which a cut changes. Either failing, it recomputes from entry
2388
- * 0, which names what broke. An entry edited in place, its hash left as
2389
- * it was, is the one change left to the next full pass, within the hour.
2390
- *
2391
- * It resumes only from a checkpoint under the key the vault writes with
2392
- * now: once the log reaches that key, no other writes again (`forward`).
2393
- * After a rotation, the next pass is full.
2509
+ * A walk cut short signs where it got to, behind the head: the log after
2510
+ * an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
2511
+ * from the first entry, one walk a call, and `/readyz` stays red until the
2512
+ * checkpoints reach the newest heartbeat.
2394
2513
  */
2395
- async #recompute(tx: Queryable): Promise<{ verification: LogVerification; anchor: Anchor }> {
2396
- const { logKeys } = this.#prepared;
2397
- const latest = await this.#latest(tx);
2398
- const hour = (ms: number) => Math.floor(ms / FULL_RECOMPUTE_MS);
2399
- const resumable =
2400
- latest !== null &&
2401
- latest.keyId === logKeys[0].keyId &&
2402
- latest.seq === BigInt(latest.checkpoint.seq) + 1n &&
2403
- hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) &&
2404
- (await carries(tx, latest.checkpoint)) &&
2405
- (await store.complete(tx, BigInt(latest.checkpoint.seq)));
2406
- if (!resumable) return verifyChain(tx, logKeys, [], UNVERIFIED);
2407
- const { seq, hash } = latest.checkpoint;
2408
- const signed: Anchor = { nextSeq: BigInt(seq) + 1n, hash: Buffer.from(hash, 'hex'), vaultEntries: 0, vaultKeys: [logKeys[0].keyId] };
2409
- return verifyChain(tx, logKeys, [], signed);
2410
- }
2411
-
2412
- async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
2514
+ async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint; warning?: string }>> {
2413
2515
  await this.#settled();
2414
2516
  // A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
2415
2517
  // own, so it writes nothing more and never waits on a key service; a check under way, or none yet, is no verdict.
2416
- const { wrongKek } = this.#prepared;
2417
- // Then, in one snapshot and without the log's lock, so that no append
2418
- // waits on it: every member's row checked, since rows changed around the
2419
- // vault write nothing to the log; and the chain recomputed, every hash
2420
- // from content and every vault entry by its MAC, from the first entry
2421
- // once an hour, since an entry cut from the middle leaves the hashes
2422
- // around a later checkpoint as they were (`#recompute`).
2518
+ const { wrongKek, logKeys } = this.#prepared;
2519
+ const { walks } = this.#prepared.options;
2423
2520
  const found: NewEntry[] = [];
2424
- const whole = await this.#db.transaction(async (tx) => {
2521
+ // The newest checkpoint entry, signed or refused, as this call's snapshot found it.
2522
+ let seen: bigint | null = null;
2523
+ const walked = await this.#db.transaction(async (tx) => {
2524
+ seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
2425
2525
  await this.#sweep(tx, found);
2426
- return this.#recompute(tx);
2526
+ const from = await this.#resume(tx, 'newest');
2527
+ if (!from.ok) return from;
2528
+ const signed = headOf(from.at);
2529
+ if (!(await this.#holding(tx, [signed]))(signed)) {
2530
+ return { ok: false as const, failedAtSeq: null, reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed` };
2531
+ }
2532
+ const gap = await missingEntry(tx, from.at.seq);
2533
+ if (gap !== null) return { ok: false as const, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
2534
+ const walk = new Walk(from.tally, from.digest);
2535
+ const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
2536
+ if (!chain.ok) return chain;
2537
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
2538
+ if (unsigned !== null) return unsigned;
2539
+ return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
2427
2540
  }, SNAPSHOT);
2541
+ // The past rehashed, in a snapshot of its own. The database failing it leaves the stretch for the
2542
+ // next call, where it was, and the checkpoint is signed all the same, saying why (`warning`); a bug
2543
+ // throws, as anywhere else.
2544
+ let scrub: Scrubbed = { ok: true, next: walked.ok ? walked.from.scrub : null };
2545
+ let stalled: string | null = null;
2546
+ if (walked.ok) {
2547
+ try {
2548
+ scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
2549
+ } catch (error) {
2550
+ // Only the database not answering, or a statement it gave up on: anything else is coffre's to fix.
2551
+ if (!isUnreachable(error) && !timedOut(error)) throw error;
2552
+ stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
2553
+ }
2554
+ }
2428
2555
  return this.#decide([], async (d) => {
2429
2556
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
2430
2557
  // Checkpoints one at a time, each against the one before.
2431
2558
  const head = await lockLogHead(d.tx);
2432
- const latest = await this.#latest(d.tx);
2559
+ const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
2433
2560
  const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
2434
2561
  new Refused(refusal(code, message), [
2435
2562
  { actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
@@ -2437,33 +2564,104 @@ class VaultService implements Vault {
2437
2564
  // Logging the refusal would give the next call something to sign.
2438
2565
  if (head.nextSeq === 0n) throw new Refused(refusal('invalid', 'the log is empty'), []);
2439
2566
  if (wrongKek !== null) throw refused('wrong_kek', { reason: wrongKek }, wrongKek);
2440
- if (!whole.verification.ok) {
2441
- throw refused('log_broken', { failedAtSeq: whole.verification.failedAtSeq, reason: whole.verification.reason });
2567
+ if (!walked.ok) throw refused('log_broken', { failedAtSeq: walked.failedAtSeq, reason: walked.reason });
2568
+ if (!scrub.ok) throw refused('log_broken', { failedAtSeq: scrub.failedAtSeq, reason: scrub.reason });
2569
+ const newest = latest === undefined ? null : (JSON.parse(latest.metadata) as Checkpoint);
2570
+ // Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
2571
+ // could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
2572
+ // and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
2573
+ if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
2574
+ const standing = await this.#standingRefusal(d.tx);
2575
+ if (standing !== null) throw refused('log_broken', standing);
2576
+ if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
2442
2577
  }
2443
- // Nothing since the last one: it is still the newest prefix.
2444
- if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
2578
+ // Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
2579
+ if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
2445
2580
  // Under the lock, the rest: the prefix signed last still where it was,
2446
- // and every entry since the snapshot. A rewrite is never signed over.
2447
- if (latest !== null && !(await carries(d.tx, latest.checkpoint))) {
2448
- throw refused('log_broken', { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
2581
+ // and every entry since the snapshot, unless the walk is catching up,
2582
+ // when what it walked must still be there. A rewrite is never signed over.
2583
+ if (newest !== null && !(await carries(d.tx, newest))) {
2584
+ throw refused('log_broken', { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
2449
2585
  }
2450
- const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
2451
- if (!held.ok) throw refused('log_broken', { failedAtSeq: held.failedAtSeq, reason: held.reason });
2452
- // It signs the entry it verified to, which the head, locked, must name.
2453
- if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) {
2454
- throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
2586
+ const walk = new Walk(walked.tally, walked.digest);
2587
+ let { at } = walked;
2588
+ if (walked.done) {
2589
+ // Where the snapshot's walk ended still has the hash it found there: what follows links to it.
2590
+ if (!(await carries(d.tx, headOf(at)))) {
2591
+ throw refused('log_broken', { failedAtSeq: Number(at.seq), reason: `the log up to entry ${at.seq} changed while the vault walked it` });
2592
+ }
2593
+ const rest = await rehash(d.tx, logKeys, at, walk, {});
2594
+ if (!rest.ok) throw refused('log_broken', { failedAtSeq: rest.failedAtSeq, reason: rest.reason });
2595
+ const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
2596
+ if (unsigned !== null) throw refused('log_broken', { failedAtSeq: unsigned.failedAtSeq, reason: unsigned.reason });
2597
+ at = rest.at;
2598
+ // It signs the entry it verified to, which the head, locked, must name.
2599
+ if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) {
2600
+ throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
2601
+ }
2602
+ } else if (!(await carries(d.tx, headOf(at)))) {
2603
+ throw refused('log_broken', { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
2455
2604
  }
2456
- const signed = { seq: Number(verified.nextSeq - 1n), hash: verified.hash.toString('hex'), signedAt: iso(d.at) };
2605
+ // Another call signed as far, or further, while this one walked behind the head: that is the newest prefix, never one shorter.
2606
+ if (newest !== null && latest!.relatedSeq !== null && latest!.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
2607
+ this.#prepared.verified = further(this.#prepared.verified, at);
2608
+ const tally = walk.tally();
2609
+ // Its members and grants once, in the first checkpoint after they change; the others name that one.
2610
+ const digest = walk.digest();
2611
+ const { from } = walked;
2612
+ const access = digest === from.digest && from.carrier !== null ? { digest, at: Number(from.carrier) } : { digest, ...tally.access };
2613
+ const signed = { seq: Number(at.seq), hash: at.hash.toString('hex'), signedAt: iso(d.at) };
2457
2614
  const checkpoint = {
2458
2615
  ...signed,
2459
2616
  keyId: this.#prepared.signer.keyId,
2460
2617
  signature: await this.#prepared.signer.sign(checkpointMessage(signed)),
2461
2618
  };
2462
- d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', metadata: JSON.stringify(checkpoint) });
2463
- return { checkpoint };
2619
+ const lap = scrub.next === null ? null : { at: headOf(scrub.next.at), end: headOf(scrub.next.end) };
2620
+ const kept: TalliedCheckpoint = { ...checkpoint, tally: { v: TALLY_VERSION, ...tally, access }, scrub: lap };
2621
+ d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
2622
+ return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
2464
2623
  });
2465
2624
  }
2466
2625
 
2626
+ /**
2627
+ * The prefixes signed before, rehashed a stretch at a time, in laps: a lap
2628
+ * runs from the first entry to the prefix the newest checkpoint signed
2629
+ * when it began, which it keeps (`end`), `walks.scrub` entries a call, each
2630
+ * hash recomputed from its entry and each link checked (`relink`); the
2631
+ * next lap runs to the prefix signed then. A checkpoint resumes from the
2632
+ * newest one's tally and never reads what it signed again, and an entry
2633
+ * before it edited in place, its stored hash left as it was, keeps the
2634
+ * chain: this is what finds it, within two laps at most, each the length
2635
+ * of the log when it began over `walks.scrub` beats, however fast the log
2636
+ * grows meanwhile. The lap as it goes on, null between laps.
2637
+ */
2638
+ async #scrub(db: Queryable, newest: Resumed): Promise<Scrubbed> {
2639
+ const lap = newest.scrub ?? { at: START, end: newest.at };
2640
+ if (lap.end.seq < 0n) return { ok: true, next: null };
2641
+ const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
2642
+ if (!relinked.ok) return relinked;
2643
+ if (relinked.at.seq < lap.end.seq) return { ok: true, next: { at: relinked.at, end: lap.end } };
2644
+ // A lap ends at the prefix a checkpoint signed, or it was not the chain the checkpoints signed.
2645
+ if (!relinked.at.hash.equals(lap.end.hash)) {
2646
+ return { ok: false, failedAtSeq: Number(lap.end.seq), reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed` };
2647
+ }
2648
+ return { ok: true, next: null };
2649
+ }
2650
+
2651
+ /**
2652
+ * The newest refusal for a broken log since the newest checkpoint was
2653
+ * signed, by the vault's MAC, as it said: it stands, for every
2654
+ * verification and checkpoint, until a checkpoint is signed, which takes
2655
+ * the check that found it running again and passing. A refusal of
2656
+ * another kind since, a wrong KEK, hides none.
2657
+ */
2658
+ async #standingRefusal(db: Queryable): Promise<{ failedAtSeq?: number | null; reason?: string } | null> {
2659
+ const signed = await store.latestVaultEntry(db, [CHECKPOINT]);
2660
+ const refused = await store.newestRefusal(db, CHECKPOINT, 'log_broken', signed?.seq ?? -1n);
2661
+ if (refused === undefined || !this.#authentic(refused)) return null;
2662
+ return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
2663
+ }
2664
+
2467
2665
  async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
2468
2666
  await this.#settled();
2469
2667
  const { signers, since } = this.#prepared;
@@ -2487,41 +2685,123 @@ class VaultService implements Vault {
2487
2685
  return { current: { kekProvider: provider, kekId: keyId }, checks };
2488
2686
  }
2489
2687
 
2490
- verifyLog(input: VerifyLogInput): Promise<LogVerification> {
2491
- return this.#verifyAll(input.upTo ?? null);
2492
- }
2493
-
2494
2688
  /**
2495
- * The whole log, in one snapshot and one pass over the vault's entries:
2496
- * each by its MAC, the keys it moved through, and what the other checks
2497
- * need of it, gathered as it goes (`Findings`); then every checkpoint, the
2498
- * heads that must still be there, the key batches accounted for, and the
2499
- * members and grants replayed.
2689
+ * The log from a checkpoint's tally, in one snapshot: the vault's
2690
+ * entries since, each by its MAC, the keys it moved through, and what the
2691
+ * other checks need of it (`Walk`); then every checkpoint walked, the
2692
+ * heads that must still be there, and, for a walk that ends at the
2693
+ * present, the key batches accounted for and the members and grants
2694
+ * replayed against their rows.
2500
2695
  *
2501
2696
  * With `upTo`, the head the app verified to, the links and hashes of the
2502
- * chain are the app's to recompute, as it just has, over its own snapshot,
2503
- * through that head; `carries(upTo)` ties this snapshot to that one. So
2504
- * the vault reads only its own entries, each checked against its own hash
2505
- * and MAC over the link it names. Without `upTo`, nobody else recomputed
2506
- * the chain: the vault reads it all, and rehashes every link.
2697
+ * chain are the app's to recompute, as it just has, over its own
2698
+ * snapshot, through that head; holding `upTo` ties this snapshot to that
2699
+ * one. So the vault reads only its own entries, each checked against its
2700
+ * own hash and MAC over the link it names. Without `upTo`, nobody else
2701
+ * recomputed the chain: the vault reads it all, and rehashes every link,
2702
+ * no further than one walk (`walks.verify`).
2507
2703
  */
2508
- async #verifyAll(upTo: LogHead | null): Promise<LogVerification> {
2704
+ async verifyLog(input: VerifyLogInput): Promise<LogVerification> {
2509
2705
  await this.#settled();
2510
- return this.#db.transaction(async (tx) => {
2706
+ const upTo = input.upTo ?? null;
2707
+ const { logKeys } = this.#prepared;
2708
+ const { walks } = this.#prepared.options;
2709
+ return this.#db.transaction(async (tx): Promise<LogVerification> => {
2511
2710
  const remembered = this.#prepared.verified;
2512
- const findings = new Findings();
2513
- const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
2514
- if (!read.ok) return read;
2515
- // Each head the log must still hold, in one read: the app's, every checkpoint's, and the last this vault rehashed.
2516
- const heads: LogHead[] = [...(upTo === null ? [] : [upTo]), ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
2517
- const rememberedHead = remembered.nextSeq > 0n ? { seq: Number(remembered.nextSeq - 1n), hash: remembered.hash.toString('hex') } : null;
2518
- if (rememberedHead !== null) heads.push(rememberedHead);
2519
- const hashes = await store.hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
2520
- const holds = (head: LogHead) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
2711
+ const from = await this.#resume(tx, input.from === undefined ? 'newest' : input.from === 'start' ? 'start' : BigInt(input.from));
2712
+ if (!from.ok) return from;
2713
+ // Where a stretch ends, the last entry its checkpoint names: that checkpoint, and each it passes on the
2714
+ // way, are held to the walk as it gets there, so a fault on the way is named where it is.
2715
+ const to = input.to === undefined ? null : BigInt(input.to);
2716
+ const ends = to === null ? null : (await store.entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
2717
+ if (ends !== null && (ends <= from.at.seq || (upTo !== null && BigInt(upTo.seq) !== ends))) {
2718
+ return { ok: false, failedAtSeq: Number(to), reason: 'not a checkpoint after the one the walk starts from, whose prefix ends where the app verified to' };
2719
+ }
2720
+ // The prefix the walk starts from still there, as it was signed, and whole: a rewrite chained again
2721
+ // changes its hash, and a cut its count.
2722
+ if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) {
2723
+ return { ok: false, failedAtSeq: Number(from.entry), reason: `the log up to entry ${from.at.seq} is not the prefix this checkpoint signed: it was rewritten` };
2724
+ }
2725
+ const gap = await missingEntry(tx, from.at.seq);
2726
+ if (gap !== null) return { ok: false, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
2727
+ // Every checkpoint after it whose prefix the walk may pass, a stretch's end among them, signed while
2728
+ // the app walked or not: each tally is held to the walk where it gets there (`Walk.expect`).
2729
+ const walk = new Walk(from.tally, from.digest);
2730
+ const later = await store.talliedAfter(tx, from.entry ?? -1n, walks.verify);
2731
+ const passed = later.filter(({ seq }) => to === null || seq <= to);
2732
+ if (to !== null && passed.at(-1)?.seq !== to) {
2733
+ return { ok: false, failedAtSeq: Number(to), reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner` };
2734
+ }
2735
+ // Each one's tally as what the walk must come to, a few at a time, so that members and grants kept whole
2736
+ // in many of them are never all in memory: the digest it keeps of them stands for them.
2737
+ // Each one's members and grants are as its digest says, kept whole in it or in the earlier checkpoint it
2738
+ // names, each such checkpoint read once: what a walk resuming from it would find.
2739
+ const holding = new Map<bigint, string | null>();
2740
+ const carried = async (carrier: bigint): Promise<string | null> => {
2741
+ if (!holding.has(carrier)) {
2742
+ const entry = (await store.entriesAt(tx, [carrier])).get(carrier);
2743
+ const read = entry === undefined ? 'missing' : this.#tallied(entry);
2744
+ holding.set(carrier, typeof read === 'string' || 'at' in read.tally.access ? null : keptDigest(read.tally.access));
2745
+ }
2746
+ return holding.get(carrier)!;
2747
+ };
2748
+ const expected: Expected[] = [];
2749
+ // The hash the stretch's own checkpoint signed, as it is read among the others.
2750
+ let endSigned: string | null = null;
2751
+ for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== undefined && after < last; ) {
2752
+ const batch = await store.talliedWithin(tx, after, last, walks.verify + 1);
2753
+ if (batch.length === 0) break;
2754
+ for (const entry of batch) {
2755
+ const read = this.#tallied(entry);
2756
+ const point = { entry: entry.seq, through: entry.relatedSeq! };
2757
+ if (typeof read === 'string') {
2758
+ expected.push({ ...point, unreadable: read });
2759
+ continue;
2760
+ }
2761
+ if (entry.seq === to) endSigned = read.hash;
2762
+ const { access } = read.tally;
2763
+ if (!('at' in access)) holding.set(entry.seq, keptDigest(access));
2764
+ const kept = 'at' in access ? (BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null) : holding.get(entry.seq)!;
2765
+ expected.push(kept === access.digest
2766
+ ? { ...point, key: expectedKey(read.tally) }
2767
+ : { ...point, unreadable: kept === null ? `its tally keeps its members and grants in entry ${'at' in access ? access.at : entry.seq}, which holds none` : "its tally's members and grants do not match their digest" });
2768
+ }
2769
+ after = batch.at(-1)!.seq;
2770
+ }
2771
+ walk.expect(expected);
2772
+ // How far the walk got, the hash there, and whether that is the present: the rows and the key batches
2773
+ // are held to the log only when the walk read every entry of the vault's there is.
2774
+ let through = from.at.seq;
2775
+ let reached = headOf(from.at).hash;
2776
+ let present = false;
2777
+ if (upTo === null) {
2778
+ const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
2779
+ if (!chain.ok) return chain;
2780
+ [through, reached, present] = [chain.at.seq, headOf(chain.at).hash, chain.done && ends === null];
2781
+ this.#prepared.verified = further(this.#prepared.verified, chain.at);
2782
+ } else {
2783
+ // On past the app's head, to the vault's last entry in this snapshot: the rows are as they are at
2784
+ // this one's. The app's head bounds what is verified; the vault's own walk, what the rows meet.
2785
+ const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
2786
+ if (!own.ok) return own;
2787
+ // An app older than 0.10.1 names no `from`, and takes an ok for the whole of `upTo`: it gets one only for that.
2788
+ if (!own.done && input.from === undefined) {
2789
+ return { ok: false, failedAtSeq: null, reason: `the vault verifies no more than ${walks.verify} of its entries a call, and its checkpoints are catching up: update the app` };
2790
+ }
2791
+ // Short of its own last entry, it has not held the rows to the log: never say it verified all the app did.
2792
+ const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
2793
+ [through, reached, present] = [own.done ? BigInt(upTo.seq) : short, upTo.hash, own.done && ends === null];
2794
+ }
2795
+ walk.finish(ends ?? through);
2796
+ // Each head the log must still hold, in one read: the app's, every
2797
+ // checkpoint's walked, and the last this vault rehashed.
2798
+ const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
2799
+ const heads = [...(upTo === null ? [] : [upTo]), ...(rememberedHead === null ? [] : [rememberedHead])];
2800
+ const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
2521
2801
  if (upTo !== null && !holds(upTo)) {
2522
2802
  return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
2523
2803
  }
2524
- const unsigned = await this.#checkpointFault(findings, holds);
2804
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
2525
2805
  if (unsigned !== null) return unsigned;
2526
2806
  // What this vault last verified must still be there: whoever holds
2527
2807
  // its key can seal a rewrite, but cannot put back the head it saw. A
@@ -2529,57 +2809,46 @@ class VaultService implements Vault {
2529
2809
  if (rememberedHead !== null && !holds(rememberedHead)) {
2530
2810
  return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
2531
2811
  }
2812
+ if (walk.mismatch !== null) return { ok: false, failedAtSeq: Number(walk.mismatch.entry), reason: walk.mismatch.reason };
2813
+ if (ends !== null) {
2814
+ // The walk ends at the prefix the stretch's checkpoint signed, by its hash too: a chain made again from before it is not.
2815
+ if ((upTo === null && through !== ends) || endSigned !== reached) {
2816
+ return { ok: false, failedAtSeq: Number(to), reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten` };
2817
+ }
2818
+ return { ok: true, entries: walk.entries, through: Number(ends) };
2819
+ }
2820
+ // A checkpoint refused since the newest was signed, for a broken log: what it found, the stretch of the
2821
+ // past it rehashed among it, which no walk from a tally reads again.
2822
+ const standing = await this.#standingRefusal(tx);
2823
+ if (standing !== null) {
2824
+ return { ok: false, failedAtSeq: standing.failedAtSeq ?? null, reason: `the last checkpoint was refused: ${standing.reason ?? 'the log is broken'}` };
2825
+ }
2826
+ const verified = { ok: true as const, entries: walk.entries, through: Number(through) };
2827
+ if (!present) return verified;
2532
2828
  const at = await this.#now(tx);
2533
- const accounting = findings.accounting.result(at);
2829
+ const accounting = walk.accounting.result(at);
2534
2830
  if (!accounting.ok) return accounting;
2535
2831
  const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
2536
- const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
2832
+ const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
2537
2833
  if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
2538
- const verified = { ok: true as const, entries: findings.entries };
2539
2834
  return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
2540
2835
  }, SNAPSHOT);
2541
2836
  }
2542
2837
 
2543
2838
  /**
2544
- * The vault's own entries, oldest first: each against its own hash, and by
2545
- * its MAC, over the link it names, which the app checked; and the vault's
2546
- * keys moving only forward through them.
2839
+ * Every checkpoint walked, not only the newest: each signed by the key
2840
+ * whose log key wrote its entry, over a prefix the log still holds,
2841
+ * entry for entry, which one that keeps a tally names as related. Its
2842
+ * entry's MAC is checked by now, and one under a key the vault replaced
2843
+ * comes before the rotation.
2547
2844
  */
2548
- async #ownEntries(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
2549
- const { logKeys } = this.#prepared;
2550
- let keys: readonly string[] = [];
2551
- for (let after = -1n; ; ) {
2552
- const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
2553
- if (batch.length === 0) return { ok: true };
2554
- for (const entry of batch) {
2555
- const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
2556
- if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
2557
- }
2558
- const moved = forward(batch, keys, logKeys[0]!.keyId);
2559
- if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
2560
- keys = moved.keys;
2561
- for (const entry of batch) findings.add(entry);
2562
- after = batch[batch.length - 1]!.seq;
2563
- }
2564
- }
2565
-
2566
- /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
2567
- async #rehashed(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
2568
- const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
2569
- for (const entry of batch) if (entry.author === 'vault') findings.add(entry);
2570
- });
2571
- if (!verification.ok) return verification;
2572
- this.#prepared.verified = further(this.#prepared.verified, anchor);
2573
- return { ok: true };
2574
- }
2575
-
2576
- /**
2577
- * Every checkpoint, not only the newest: each signed by the key whose log
2578
- * key wrote its entry, over a prefix the log still holds (`holds`), entry
2579
- * for entry. Its entry's MAC is checked by now, and one under a key the
2580
- * vault replaced comes before the rotation.
2581
- */
2582
- async #checkpointFault(findings: Findings, holds: (head: LogHead) => boolean): Promise<Extract<LogVerification, { ok: false }> | null> {
2845
+ async #checkpointFault(
2846
+ db: Queryable,
2847
+ checkpoints: Walk['checkpoints'],
2848
+ known?: (head: LogHead) => boolean,
2849
+ ): Promise<Broken | null> {
2850
+ if (checkpoints.length === 0) return null;
2851
+ const holds = known ?? (await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint)));
2583
2852
  const { logKeys, signers } = this.#prepared;
2584
2853
  // Each signer's key imported once, for this check only.
2585
2854
  const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
@@ -2588,7 +2857,6 @@ class VaultService implements Vault {
2588
2857
  if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
2589
2858
  return verifier.then((verify) => verify(checkpoint));
2590
2859
  };
2591
- const { checkpoints } = findings;
2592
2860
  for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
2593
2861
  const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
2594
2862
  // The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
@@ -2596,7 +2864,7 @@ class VaultService implements Vault {
2596
2864
  const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
2597
2865
  if (by?.keyId !== checkpoint.keyId) return `a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`;
2598
2866
  if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
2599
- if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) {
2867
+ if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || (entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq))) {
2600
2868
  return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
2601
2869
  }
2602
2870
  return null;
@@ -2609,8 +2877,8 @@ class VaultService implements Vault {
2609
2877
 
2610
2878
  /**
2611
2879
  * The first member whose row fails its MAC, or names an older access
2612
- * entry than the log's newest about them (`newest`, from the full check's
2613
- * pass, where each entry's MAC was checked).
2880
+ * entry than the log's newest about them (`newest`, from the walk, where
2881
+ * each entry's MAC was checked, on from the tally).
2614
2882
  */
2615
2883
  #unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
2616
2884
  for (const row of rows) {
@@ -2622,40 +2890,80 @@ class VaultService implements Vault {
2622
2890
  }
2623
2891
  }
2624
2892
 
2893
+ type Broken = Extract<LogVerification, { ok: false }>;
2894
+
2625
2895
  /**
2626
- * What one pass over the vault's entries gathers for the other checks of a
2627
- * full verification, so that the log is read once: the key batches, the
2628
- * access entries replayed, each member's newest, and the checkpoints. Each
2629
- * entry comes after its MAC was checked.
2896
+ * Where a walk of the log resumes: the checkpoint's entry (null for the
2897
+ * first entry), the end of the prefix it signed, the tally through there,
2898
+ * its members and grants' digest and the entry that keeps them, and where
2899
+ * the background rehash goes on from (`#scrub`).
2630
2900
  */
2631
- class Findings {
2632
- readonly accounting = new KeyAccounting();
2633
- readonly replayed: Replayed = { members: new Map(), held: new Map() };
2634
- /** The first access entry the replay could not apply. */
2635
- replayFault: AccessFault | null = null;
2636
- /** Each member's newest allowed access entry, by seq. */
2637
- readonly newestAccess = new Map<string, bigint>();
2638
- /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
2639
- readonly checkpoints: { entry: { seq: bigint; keyId: string }; checkpoint: Checkpoint }[] = [];
2640
- /** The vault's entries read. */
2641
- entries = 0;
2642
-
2643
- add(entry: StoredEntry): void {
2644
- this.entries += 1;
2645
- this.accounting.add(entry);
2646
- if (entry.decision !== 'allow') return;
2647
- if (ACCESS.has(entry.action)) {
2648
- this.replayFault ??= apply(this.replayed, entry);
2649
- if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
2650
- } else if (entry.action === CHECKPOINT) {
2651
- this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId }, checkpoint: JSON.parse(entry.metadata) as Checkpoint });
2652
- }
2901
+ type Resumed = {
2902
+ ok: true;
2903
+ entry: bigint | null;
2904
+ at: Position;
2905
+ tally: Tally;
2906
+ digest: string;
2907
+ carrier: bigint | null;
2908
+ scrub: Lap | null;
2909
+ };
2910
+
2911
+ /** A lap of the rehash of the past: the last entry rehashed, and the prefix it rehashes to, signed when it began. */
2912
+ type Lap = { at: Position; end: Position };
2913
+
2914
+ /** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
2915
+ function timedOut(error: unknown): boolean {
2916
+ for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
2917
+ if ((cause as { code?: unknown }).code === '57014') return true;
2653
2918
  }
2919
+ return false;
2920
+ }
2921
+
2922
+ /** Where the next rehash of the past goes on from, null for the first entry; or what it found. */
2923
+ type Scrubbed = { ok: true; next: Lap | null } | Broken;
2924
+
2925
+ const FIRST_ENTRY: Resumed = { ok: true, entry: null, at: START, tally: EMPTY_TALLY, digest: accessDigest(EMPTY_TALLY.access), carrier: null, scrub: null };
2926
+
2927
+ /** How far one call walks the log, in entries: a checkpoint's walk from the last, its rehash of the past, and a verification's own walk. */
2928
+ export type Walks = { checkpoint: number; scrub: number; verify: number };
2929
+
2930
+ /**
2931
+ * A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
2932
+ * with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
2933
+ * of catching up, about a second, and a team's instance writes fewer than
2934
+ * a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
2935
+ * and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
2936
+ */
2937
+ const WALKS: Walks = { checkpoint: 10_000, scrub: 100, verify: 10_000 };
2938
+
2939
+ /** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
2940
+ function keptDigest({ members, grants, newest }: AccessTally): string {
2941
+ return accessDigest({ members, grants, newest });
2654
2942
  }
2655
2943
 
2656
- const ACCESS = new Set<string>(ACCESS_ACTIONS);
2944
+ /** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
2945
+ const parsed = new WeakMap<StoredEntry, unknown>();
2946
+ function metadataOf(entry: StoredEntry): unknown {
2947
+ if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
2948
+ return parsed.get(entry);
2949
+ }
2950
+
2951
+ /** A head, as an entry keeps it, as a walk goes on from it. */
2952
+ function positionOf(head: LogHead): Position {
2953
+ return { seq: BigInt(head.seq), hash: Buffer.from(head.hash, 'hex') };
2954
+ }
2955
+
2956
+ /** A position as the log's heads are named, in hex. */
2957
+ function headOf(position: Position): LogHead {
2958
+ return { seq: Number(position.seq), hash: position.hash.toString('hex') };
2959
+ }
2960
+
2961
+ /** What a checkpoint's signature covers, and nothing it keeps beside it. */
2962
+ function signedPart({ seq, hash, signedAt, keyId, signature }: Checkpoint): Checkpoint {
2963
+ return { seq, hash, signedAt, keyId, signature };
2964
+ }
2657
2965
 
2658
- /** Checkpoint signatures checked at once, in a full verification. */
2966
+ /** Checkpoint signatures checked at once. */
2659
2967
  const SIGNATURES_AT_ONCE = 256;
2660
2968
 
2661
2969
  /** Settle every operation before releasing the member lock, including cancelled requests. */
@@ -3075,8 +3383,7 @@ function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
3075
3383
  * keeps its slug, which a scoped instance role's filters read. The edits
3076
3384
  * keep them all: no write is needed.
3077
3385
  */
3078
- async function livePolicy(db: Queryable, edits: PolicyEdits, at: number): Promise<Policy> {
3079
- const rows = await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
3386
+ function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
3080
3387
  const live = edits.protected.filter((place) => {
3081
3388
  const row = rows.get(place.environmentId);
3082
3389
  return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;