@coffre/vault 0.14.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +343 -9
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  8. package/dist/node.d.ts +182 -7
  9. package/dist/node.js +358 -16
  10. package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
  11. package/dist/vault-kmtojYnW.js +14623 -0
  12. package/package.json +4 -5
  13. package/src/bulk.ts +89 -0
  14. package/src/changes.ts +478 -0
  15. package/src/checkpointer-node.ts +342 -0
  16. package/src/checkpointer-workers.ts +514 -0
  17. package/src/checkpointer.ts +1165 -0
  18. package/src/cloudflare.ts +207 -13
  19. package/src/config.ts +15 -3
  20. package/src/credentials.postgres.ts +78 -0
  21. package/src/credentials.sqlite.ts +240 -0
  22. package/src/credentials.ts +630 -0
  23. package/src/cutover.ts +275 -0
  24. package/src/decide.ts +437 -2
  25. package/src/inputs.ts +499 -0
  26. package/src/local.ts +20 -4
  27. package/src/log-checkpointer.ts +116 -0
  28. package/src/log-entry.ts +113 -0
  29. package/src/log-replay.ts +188 -0
  30. package/src/log-store.ts +695 -0
  31. package/src/node.ts +73 -9
  32. package/src/objects.ts +141 -0
  33. package/src/reads.ts +502 -0
  34. package/src/retention.ts +229 -0
  35. package/src/snapshot.postgres.ts +39 -9
  36. package/src/snapshot.sqlite.ts +28 -3
  37. package/src/snapshot.ts +72 -12
  38. package/src/store.ts +862 -37
  39. package/src/use.postgres.ts +111 -0
  40. package/src/use.sqlite.ts +542 -0
  41. package/src/use.ts +1060 -0
  42. package/src/vault.ts +1513 -240
  43. package/src/verifier.ts +136 -0
  44. package/src/witness-workers.ts +163 -0
  45. package/dist/vault-BBdXsny3.js +0 -5077
package/src/vault.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { EXCHANGE_SKEW_SECONDS, RunProofRefused, UPLOADED_KEYS, verifyProof, verifyRunToken, WORKLOAD_SIGN_IN_PATH, type RunProof, type WorkloadProfile } from '@coffre/core/identity';
1
2
  import { randomUUID, timingSafeEqual } from 'node:crypto';
2
3
 
3
4
  import {
@@ -5,7 +6,6 @@ import {
5
6
  assignableToEnvironment,
6
7
  DEFAULT_POLICY,
7
8
  definitionProblem,
8
- EVERY_ENVIRONMENT,
9
9
  EVERYWHERE,
10
10
  FIXED_ROLES,
11
11
  givesService,
@@ -14,6 +14,7 @@ import {
14
14
  INSTANCE_ROLES,
15
15
  isBuiltinRole,
16
16
  isCredentialScope,
17
+ type CredentialScope,
17
18
  isInstanceRole,
18
19
  isRole,
19
20
  isScope,
@@ -29,7 +30,6 @@ import {
29
30
  ROLES,
30
31
  runsInstance,
31
32
  setsUpServices,
32
- UNWRITTEN_FORK_MS,
33
33
  type Holdings,
34
34
  type InstanceRole,
35
35
  type Permission,
@@ -39,8 +39,11 @@ import {
39
39
  type RoleDefinition,
40
40
  type Scope,
41
41
  type ServiceAccount,
42
+ type ServiceSetting,
43
+ readScope,
42
44
  } from '@coffre/core/access';
43
45
  import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
46
+ import { openPair, type LogEntry, type PairNotes } from '@coffre/core/merkle';
44
47
  import { checkContext, ownerKey, type SecretContext } from '@coffre/core/envelope';
45
48
  import { shownMember } from '@coffre/core/schemas';
46
49
  import {
@@ -58,11 +61,23 @@ import {
58
61
  } from '@coffre/core/kek';
59
62
  import {
60
63
  checkpointMessage,
64
+ REFUSAL_MESSAGES,
61
65
  checkpointVerifier,
62
66
  describeAccessFault,
63
67
  POLICY_REVISION,
64
- accessRevision,
68
+ answerFrom,
69
+ CLOSED_POLICY,
70
+ live,
71
+ PRINCIPAL,
72
+ CLOSED_SETTINGS,
73
+ livePolicy,
74
+ NO_EDITS,
65
75
  policyRevision,
76
+ readEdits,
77
+ roleOf,
78
+ settingsFrom,
79
+ view,
80
+ type PolicyEdits,
66
81
  type Access,
67
82
  type AccessChange,
68
83
  type AccessFault,
@@ -79,40 +94,84 @@ import {
79
94
  type LogVerification,
80
95
  type Outcome,
81
96
  type PolicyChange,
97
+ type Prepared,
98
+ type PrepareInput,
82
99
  type Purpose,
83
100
  type ReferenceInput,
84
101
  type Refusal,
85
102
  type RefusalCode,
86
103
  type RemoveInput,
104
+ type IssueTokenInput,
105
+ type ChangeBindingsInput,
106
+ type ChangeBindingsOutcome,
107
+ type CutoverInput,
108
+ type CutoverOutcome,
109
+ type ChangeBindingsRefusal,
110
+ type IssueTokenOutcome,
111
+ type IssueTokenRefusal,
112
+ type RevokeCredentialInput,
113
+ type RevokeCredentialOutcome,
87
114
  type RewrapInput,
88
115
  type SecretRef,
89
116
  type SetAccessInput,
90
117
  type SetSettingsInput,
91
118
  type Settings,
119
+ type Sealing,
120
+ type SignInInput,
121
+ type SignInOutcome,
122
+ type SignInRefusal,
92
123
  type Through,
93
124
  type UnwrapInput,
125
+ USE_METADATA_LENGTH,
126
+ type Unwrapped,
127
+ type UseInput,
128
+ type NamedUseInput,
129
+ type UseManyInput,
130
+ type UseManyOutcome,
131
+ type NamedUseOutcome,
132
+ type UseOutcome,
133
+ type UseRecord,
94
134
  type Wrapped,
95
135
  type Vault,
96
136
  type Via,
137
+ type ReadsVerification,
138
+ type TreeVerification,
97
139
  type VerifyLogInput,
140
+ type VouchedEntry,
141
+ type VouchInput,
98
142
  type WrapInput,
99
143
  type WrappedKey,
144
+ VOUCHED_AT_ONCE,
100
145
  } from '@coffre/core/vault';
101
146
  import type { Database, Queryable, Transaction } from '@coffre/db';
102
147
  import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
103
- import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
104
- import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
148
+ import { everyProjectConversion, newestAccess, reportStands, roleColumns, storedRole } from '@coffre/db/grants';
149
+ import { appendEntries, holdFence, holdIssues, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
105
150
 
151
+ import { Changes, keptObjects, Moved, NotLogged, prepareChanges, type ChangeKeys } from './changes.ts';
106
152
  import { signer, type Signer } from './checkpoint.ts';
107
- import { keyRefusals, placeIn, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type SecretPlace } from './decide.ts';
153
+ import { CutoverRefused, cutoverPayload, cutoverRow, genesisCalls, Moved as VouchMoved, planCutover } from './cutover.ts';
154
+ import { bindingsCall, changeBindings, checkBinding, checkChangeBindings, checkIssue, issueCall, issueToken, revokeCredential, signIn, weighIssue, weighService, type Answer, type IssueRead } from './credentials.ts';
155
+ import { bindingObject, credentialObject } from './objects.ts';
156
+ import { proveIn, type SealThrough } from './checkpointer.ts';
157
+ import { keyRefusals, placeIn, readerOf, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type Reader, type SecretPlace } from './decide.ts';
108
158
  import type { ResolvedVaultConfig } from './config.ts';
159
+ import { limitChange, noEdits, objectKey, POLICY_REF, ROLES_REF, sealed as sealedObject, sealKey, type CredentialInput, type EnvironmentInput, type Input, type Op, type PrincipalInput, type Ref } from './inputs.ts';
160
+ import { TICK_MS } from './log-checkpointer.ts';
161
+ import { authentic, changeEntry, logMacKey, useEntry as usedEntry, type LogMacKey } from './log-entry.ts';
162
+ import { logStore } from './log-store.ts';
163
+ import { checkReads, cursorHolds, readHolds, type ReadRow } from './reads.ts';
109
164
  import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
110
165
  import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
166
+ import { principalObject } from './objects.ts';
111
167
  import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
112
168
  import { snapshot, type Asked, type Snapshot } from './snapshot.ts';
113
169
  import * as store from './store.ts';
114
170
  import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
115
- import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
171
+ import { ACCESS_ACTIONS, type EnvironmentRow, type EnvironmentSeal, type GrantRow, type Member } from './store.ts';
172
+ import { use, UseCache, useLimits, useMany, useNamed, type UseLimits, type UsePath } from './use.ts';
173
+ import { verifierKeys, verifyTrees, type VerifierKeys } from './verifier.ts';
174
+
116
175
 
117
176
  export type VaultOptions = {
118
177
  /**
@@ -124,8 +183,51 @@ export type VaultOptions = {
124
183
  clockOffset?: () => number;
125
184
  /** How far one call walks the log (`WALKS`); tests shorten them. */
126
185
  walks?: Partial<Walks>;
186
+ /**
187
+ * Which log the vault keeps (0.15's plan, "Behind one switch"): 0.15's,
188
+ * the default since its flip (plan PR 10), where each decision that
189
+ * changes an input also logs it as a change of 0.15's log (`changes.ts`),
190
+ * or 0.14's alone, for the suites until 0.16 removes it. Not a
191
+ * deployment's to set: once cut over, the database refuses 0.14's writes.
192
+ */
193
+ log?: '0.14' | '0.15';
194
+ /**
195
+ * Under 0.15's log, what a change waits for before it answers: its entry
196
+ * sealed in a signed checkpoint, and witnessed or past the witnesses'
197
+ * bound (R51): the checkpointer's `sealThrough` (checkpointer.ts). A
198
+ * change whose entry it does not seal fails, though committed. Required
199
+ * under 0.15's log.
200
+ */
201
+ sealThrough?: SealThrough;
202
+ /**
203
+ * Under 0.15's log, the pairs held where no database writer reaches: the
204
+ * signer's memory (`durableHeldOutside` on Workers, the Node runner's
205
+ * `heldOutside`), later a witness's. The verifier holds every walk to
206
+ * them; without, a database rolled back whole to an earlier pair would
207
+ * verify until the signer's next tick refuses it (verifier.ts). Required
208
+ * under 0.15's log, as `sealThrough` is (T151).
209
+ */
210
+ heldOutside?: () => Promise<readonly PairNotes[]>;
211
+ /**
212
+ * Under 0.15's log, whether the checkpointer seals (T169), bounded: what
213
+ * `sealing()` answers, and `/readyz` asks. The Node runner's `sealing`;
214
+ * on Workers the entrypoint asks the object itself. Without, it seals.
215
+ */
216
+ sealing?: () => Promise<Sealing> | Sealing;
217
+ /**
218
+ * 0.15's uses, per isolate (use.ts): how many calls in flight at once, by
219
+ * default the hot path's pool, `connections`, and how long a cover holds.
220
+ */
221
+ uses?: Partial<UseLimits> & { connections?: number };
222
+ /**
223
+ * Whether a call's I/O ends with its request, as on Workers, where a call whose request has ended may never settle:
224
+ * then a use's place in admission lapses past its deadline, settled or not (use.ts, `HELD_PAST_DEADLINE_MS`), its
225
+ * connection closed by then (cloudflare.ts). Not a deployment's to set.
226
+ */
227
+ callsEndWithRequest?: boolean;
127
228
  };
128
229
 
230
+
129
231
  /**
130
232
  * A vault's configuration made ready to use, once per process, or once per
131
233
  * isolate on Workers: its signer, its log key, and how far it has verified
@@ -144,7 +246,23 @@ export type PreparedVault = {
144
246
  logKey: LogKey;
145
247
  /** Each signing key's log key: those after the first verify only entries before `since`. */
146
248
  logKeys: LogKey[];
147
- options: Required<Omit<VaultOptions, 'walks'>> & { walks: Walks };
249
+ options: Required<Omit<VaultOptions, 'walks' | 'log' | 'sealThrough' | 'heldOutside' | 'sealing' | 'uses' | 'callsEndWithRequest'>> & { walks: Walks };
250
+ /**
251
+ * 0.15's log, when it is on: the keys its changes are sealed and MACed
252
+ * with, from the vault's signing keys, and what each waits on to answer.
253
+ * Null under 0.14's alone.
254
+ */
255
+ log015: (ChangeKeys & { sealThrough: SealThrough; heldOutside: () => Promise<readonly PairNotes[]>; sealing: () => Promise<Sealing> | Sealing }) | null;
256
+ /** Whether this process wrote its bulk cap into 0.15's log, or found it there (`#limit`). */
257
+ limited: boolean;
258
+ /** Whether 0.15's log is cut over from 0.14, as this process found it (`cutover`): until then, nothing of 0.15's runs. */
259
+ cutover: boolean;
260
+ /**
261
+ * Whether this process found 0.15's log's cutover written, sealed or not
262
+ * yet (`#settled`): from then on every environment row is sealed, and the
263
+ * vault checks each one it decides on (`#placeFault`).
264
+ */
265
+ marked: boolean;
148
266
  /** How far the chain is rehashed, by any call of this process: its head must still be there. */
149
267
  verified: Position;
150
268
  /** What member rows are sealed under now; rows.ts. */
@@ -167,6 +285,12 @@ export type PreparedVault = {
167
285
  * then everyone the deployment names made an Owner (`#declare`).
168
286
  */
169
287
  converted: boolean;
288
+ /** What 0.15's verifier checks the log with (verifier.ts), once a call first needs them. */
289
+ verifier: VerifierKeys | null;
290
+ /** The log's MAC key under each signing key, which `vouch` checks an entry with: made once a call first needs them. */
291
+ vouchKeys?: LogMacKey[];
292
+ /** This isolate's 0.15 uses: their admission count, seal cache and covers (use.ts). */
293
+ uses: UseCache;
170
294
  };
171
295
 
172
296
  export async function prepareVault(config: ResolvedVaultConfig, options: VaultOptions = {}): Promise<PreparedVault> {
@@ -174,6 +298,12 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
174
298
  config,
175
299
  ...(await keysOf(config.signingKeys)),
176
300
  options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0), walks: { ...WALKS, ...options.walks } },
301
+ log015: (options.log ?? '0.15') === '0.15' ? log015(config.signingKeys, options.sealThrough, options.heldOutside, options.sealing) : null,
302
+ limited: false,
303
+ cutover: false,
304
+ marked: false,
305
+ verifier: null,
306
+ uses: new UseCache(useLimits(options.uses?.connections ?? DEFAULT_CONNECTIONS, options.uses), { callsEndWithRequest: options.callsEndWithRequest === true }),
177
307
  verified: START,
178
308
  reported: new Set(),
179
309
  since: null,
@@ -184,6 +314,18 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
184
314
  };
185
315
  }
186
316
 
317
+ /** 0.15's change keys from the vault's signing keys, the first sealing and MACing, each checking a seal. */
318
+ function log015(
319
+ seeds: readonly Uint8Array[],
320
+ sealThrough: SealThrough | undefined,
321
+ heldOutside: (() => Promise<readonly PairNotes[]>) | undefined,
322
+ sealing: (() => Promise<Sealing> | Sealing) = () => ({ ok: true }),
323
+ ): NonNullable<PreparedVault['log015']> {
324
+ if (sealThrough === undefined) throw new Error("0.15's log answers a change once its entry is sealed (R51): give the vault the checkpointer's sealThrough");
325
+ if (heldOutside === undefined) throw new Error("0.15's log is verified against the pairs held where no database writer reaches: give the vault the checkpointer's heldOutside");
326
+ return { seals: seeds.map((seed) => sealKey(seed)), mac: logMacKey(seeds[0]!), sealThrough, heldOutside, sealing };
327
+ }
328
+
187
329
  /** The vault's own keys, from each of its signing keys, the one it signs with first. */
188
330
  async function keysOf(seeds: readonly Uint8Array[]) {
189
331
  const signers = await Promise.all(seeds.map((seed) => signer(seed)));
@@ -192,9 +334,16 @@ async function keysOf(seeds: readonly Uint8Array[]) {
192
334
  return { signer: signers[0], signers, logKey: logKeys[0], logKeys, rowKey: rowKeys[0], rowKeys };
193
335
  }
194
336
 
195
- /** The vault over `db`. Cheap: on Workers, one per call, over that call's connections. */
196
- export function openVault(db: Database, prepared: PreparedVault): Vault {
197
- return new VaultService(db, prepared);
337
+ /** A pool's connections, unless its transport says (pg's default). */
338
+ const DEFAULT_CONNECTIONS = 10;
339
+
340
+ /**
341
+ * The vault over `db`, and, for 0.15's uses, over `hot`, the hot path's own
342
+ * login (`coffre_vault_hot`: its lock and statement timeouts, generic
343
+ * plans; schema 0022). Cheap: on Workers, one per call, over that call's connections.
344
+ */
345
+ export function openVault(db: Database, prepared: PreparedVault, hot?: Database): Required<Vault> {
346
+ return new VaultService(db, prepared, hot);
198
347
  }
199
348
 
200
349
  /** Every key operation of one call, together: a removal waits at most this long for a read at KMS. */
@@ -212,8 +361,45 @@ const LOCK_TIMEOUT_MS = 15_000;
212
361
  */
213
362
  const READ_LOCK_TIMEOUT_MS = 3_000;
214
363
 
215
- /** A member: `user:<email>` or `token:<name>`. */
216
- const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
364
+ /**
365
+ * How long a whole read call may take, from its start to its answer, its
366
+ * refusal's entries, the record of a vault key's check and, with a key
367
+ * service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
368
+ * past it the call fails as unavailable (`Overdue`) rather than start
369
+ * another. So a caller hears within it, ok or 503, never tens of seconds later.
370
+ */
371
+ export const READ_DEADLINE_MS = 5_000;
372
+
373
+ /**
374
+ * How many reads may wait for the log's head at once, through all of the
375
+ * vault's connections; the next is refused at once, as unavailable, holding
376
+ * no connection while it waits (`lockLogHeadQueued`). A read holds the head
377
+ * for a few round trips, about 150 ms through Hyperdrive, so this many
378
+ * clear within a read's bound (2.4 s of its 3), and no more than this many
379
+ * of the pool's connections wait. A burst of 50 reads at a 2-vCPU database
380
+ * a 30 ms round trip away: 17 read within 2.2 s, 33 refused at once.
381
+ */
382
+ export const READ_HEAD_WAITERS = 16;
383
+
384
+ /** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
385
+ const RELEASES_PRUNED = 10_000;
386
+
387
+ /** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
388
+ class Overdue extends Error {
389
+ readonly code = '57014';
390
+ constructor() {
391
+ super('this read took longer than it may: ask again shortly');
392
+ this.name = 'Overdue';
393
+ }
394
+ }
395
+
396
+ /** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
397
+ function within(deadline: number | undefined, ms: number): number {
398
+ if (deadline === undefined) return ms;
399
+ const left = deadline - Date.now();
400
+ if (left <= 0) throw new Overdue();
401
+ return Math.min(ms, left);
402
+ }
217
403
 
218
404
  /** A person, and a service account: only a person sets one up (`givesService`). */
219
405
  const PERSON = /^user:/;
@@ -222,6 +408,14 @@ const SERVICE = /^token:/;
222
408
  /** Who acts for the vault itself, as when it makes an Owner its deployment names. */
223
409
  const VAULT_ACTOR = 'system:vault';
224
410
 
411
+ /** The cutover's own entry in 0.14's log (`cutover`): the head its change in 0.15's names, or, denied, why it did not cut over. */
412
+ const CUTOVER = 'log.cutover';
413
+
414
+ /** Genesis changes a statement: eleven parameters each, well within Postgres's 65,535 a statement. */
415
+ const GENESIS_BATCH = 2000;
416
+
417
+ const NOT_CUT_OVER = "0.15's log is not cut over from 0.14 yet: the app asks the vault to (`cutover`) before anything else";
418
+
225
419
  // A KEK's check: a known value, the size of a data key, wrapped under it in
226
420
  // a context no secret has (the nil UUID), and kept in a `key.check` entry
227
421
  // (@coffre/core/kek). Opening it again tells the vault its KEK is the one
@@ -237,53 +431,45 @@ const KEY_ROTATE = 'key.rotate';
237
431
  /** A new member row, before the decision seals it (`#seal`). */
238
432
  const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
239
433
 
240
- /** Why a change to a tampered member is refused. */
241
- const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
242
-
243
- /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
244
- const SETTINGS = 'settings.change';
245
-
246
- /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
247
- const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
434
+ /** A member's object in 0.15's log (inputs.ts). */
435
+ function memberRef(principal: string) {
436
+ return { kind: 'principal' as const, id: principal };
437
+ }
248
438
 
249
- /** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
250
- const POLICY = 'policy.change';
439
+ /** An instance role and its scope as a member's row gives them back (`roleColumns`, then `storedRole`): what 0.15's ops carry, so that the object they make is the row's. */
440
+ function asStored(role: InstanceRole, scope: Scope): { role: InstanceRole; scope: Scope } {
441
+ return storedRole(roleColumns(role, scope));
442
+ }
251
443
 
252
- /**
253
- * What a `policy.change` entry keeps: the roles an Owner edited or made,
254
- * each whole, and the protected environments. A role as it ships is kept by
255
- * none, so a release that changes a default changes it where nobody edited it.
256
- */
257
- type PolicyEdits = { roles: Record<string, RoleDefinition>; instanceRoles: Partial<Record<InstanceRole, RoleDefinition>>; protected: ProtectedPlace[] };
444
+ /** A grant's place, and what it holds, as a member's object in 0.15's log has it. */
445
+ function inputGrant(grant: Pick<GrantRow, 'projectId' | 'environmentId' | 'environmentSlug' | 'role' | 'expiresAt'>) {
446
+ return { projectId: grant.projectId, environmentId: grant.environmentId, environmentSlug: grant.environmentSlug, role: grant.role, expiresAt: grant.expiresAt };
447
+ }
258
448
 
259
449
  /**
260
- * A protected environment, with its project's id, what a grant on the whole
261
- * project reaches of it, and when it was protected, by the vault's clock:
262
- * an id with no row ten minutes later is a fork that was never written.
450
+ * The environments an issue was weighed on, each once, with the slug it was weighed with first: what `issue_token`
451
+ * holds under the issue lock (IssuePolicyHeld).
263
452
  */
264
- type ProtectedPlace = { projectId: string; environmentId: string; at: number };
453
+ function weighed(slugs: readonly { environmentId: string; slug: string | null }[]): { environmentId: string; slug: string | null }[] {
454
+ const seen = new Set<string>();
455
+ return slugs.filter(({ environmentId }) => !seen.has(environmentId) && seen.add(environmentId) !== undefined);
456
+ }
265
457
 
458
+ function placeOf(grant: store.Place) {
459
+ return { projectId: grant.projectId ?? null, environmentId: grant.environmentId ?? null, environmentSlug: grant.environmentSlug ?? null };
460
+ }
266
461
 
267
- const NO_EDITS: PolicyEdits = { roles: {}, instanceRoles: {}, protected: [] };
462
+ /** Why a change to a tampered member is refused. */
463
+ const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
268
464
 
269
- /** A role holding nothing, which every role but Owner is under the closed policy. */
270
- const NOTHING_HELD = (definition: RoleDefinition): RoleDefinition => ({ ...definition, permissions: [], protected: [] });
465
+ /** Why a decision on a place whose row is not as 0.15's log holds it is refused (T159): the row, not the member, is at fault. */
466
+ const TAMPERED_PLACE = "an environment it is about failed the vault's integrity check: its row is not as the log holds it";
271
467
 
272
- /**
273
- * The policy when the log has some, but none the vault wrote within reach:
274
- * whoever inserted rows may have hidden an Owner's narrowing, so no role
275
- * holds anything, a service account's neither, but Owner, which holds
276
- * everything always and so may write the policy again.
277
- */
278
- const CLOSED_POLICY: Policy = {
279
- roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])),
280
- instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])) as Policy['instanceRoles'],
281
- protected: [EVERY_ENVIRONMENT],
282
- protectedIn: {},
283
- };
468
+ /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
469
+ const SETTINGS = 'settings.change';
284
470
 
285
- /** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
286
- const CLOSED_SETTINGS: Settings = { serviceAccounts: { projects: { only: [] }, environments: 'all' } };
471
+ /** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
472
+ const POLICY = 'policy.change';
287
473
 
288
474
  /** Why a change to who is an Owner, scoped or not, is refused to anyone else: Owners make and unmake Owners. */
289
475
  const OWNERS_ONLY = 'only an Owner of the whole instance makes someone an Owner, or changes or removes one';
@@ -316,6 +502,9 @@ class Retry {}
316
502
  /** What each `vault.tampered` entry reports, to mark it logged once committed. */
317
503
  const REPORTED = new WeakMap<NewEntry, string>();
318
504
 
505
+ /** Each environment row's check, once made (`#placeFault`): one HMAC a row, however many parts of a decision read it. */
506
+ const PLACE_FAULTS = new WeakMap<EnvironmentSeal, 'tampered' | 'stale' | null>();
507
+
319
508
  /** A key service that did not answer, and the entries that record what it did do. */
320
509
  class Outage {
321
510
  readonly error: unknown;
@@ -334,29 +523,6 @@ function refusal(code: RefusalCode, message: string): Refusal {
334
523
  return { code, message };
335
524
  }
336
525
 
337
- const MESSAGES: Record<RefusalCode, string> = {
338
- removed: 'this member was removed',
339
- not_a_member: 'not a member',
340
- no_grant: 'no grant covers this',
341
- expired: 'the grant that covered this has expired',
342
- bulk_limit: 'too many secrets read in too short a time',
343
- bad_claim: 'the key does not belong to this secret',
344
- deleted: 'this project or environment was deleted',
345
- ended: 'the reference read through was ended',
346
- personal: "a personal config's values are its owner's alone",
347
- sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
348
- not_owner_issued: 'a service account reads a sensitive value only on a token or trust binding an Owner of its project issued',
349
- use_only: "a workload uses this value through coffre and never holds it: list it in its binding's reads, where the value is held, if it must",
350
- not_allowed: 'not allowed to change this',
351
- last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
352
- invalid: 'not something the rules allow',
353
- policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
354
- access_changed: 'what this member holds changed since the read was decided: try again',
355
- log_broken: 'the vault log does not hold from the last checkpoint',
356
- wrong_kek: "this vault's key does not open the data it holds",
357
- tampered: "this member's record failed the vault's integrity check",
358
- };
359
-
360
526
  /**
361
527
  * A decision in progress: its transaction, the member rows it locked, its
362
528
  * time, and what it will commit. Changes to members and grants wait until
@@ -392,6 +558,8 @@ type Decision = {
392
558
  * was deciding. A `Refused` there refuses the decision.
393
559
  */
394
560
  underLock: ((locked: Queryable) => Promise<void>)[];
561
+ /** Under 0.15's log, the changes its writes make, each staged beside its write (`changes.ts`); null under 0.14's alone. */
562
+ changes: Changes | null;
395
563
  };
396
564
 
397
565
  /**
@@ -406,7 +574,12 @@ type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { princip
406
574
  * is what it reads besides its members, and `reads` says it changes none
407
575
  * of them (`#decide`).
408
576
  */
409
- type How = { late?: boolean; asked?: Reads; reads?: boolean };
577
+ /**
578
+ * How a decision is made. `deadline`, a read's (`reads`): when its whole
579
+ * call must have answered, every wait in every transaction it takes
580
+ * included (`READ_DEADLINE_MS`).
581
+ */
582
+ type How = { late?: boolean; asked?: Reads; reads?: boolean; deadline?: number };
410
583
 
411
584
  /** The actions of the entries the vault writes about keys. */
412
585
  type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
@@ -475,11 +648,64 @@ class VaultService implements Vault {
475
648
  readonly #db: Database;
476
649
  readonly #prepared: PreparedVault;
477
650
  readonly #config: ResolvedVaultConfig;
651
+ readonly #hot: Database | undefined;
478
652
 
479
- constructor(db: Database, prepared: PreparedVault) {
653
+ constructor(db: Database, prepared: PreparedVault, hot?: Database) {
480
654
  this.#db = db;
481
655
  this.#prepared = prepared;
482
656
  this.#config = prepared.config;
657
+ this.#hot = hot;
658
+ }
659
+
660
+ /** 0.15's use (use.ts), on the hot path's login. */
661
+ async use(input: UseInput): Promise<UseOutcome> {
662
+ return use(this.#usePath(), input);
663
+ }
664
+
665
+ /** 0.15's use as the request names it (use.ts, `useNamed`), on the hot path's login. */
666
+ async useNamed(input: NamedUseInput): Promise<NamedUseOutcome> {
667
+ return useNamed(this.#usePath(), input);
668
+ }
669
+
670
+ /** A request's several keys as one call (use.ts, `useMany`; T167), on the hot path's login. */
671
+ async useMany(input: UseManyInput): Promise<UseManyOutcome> {
672
+ return useMany(this.#usePath(), input);
673
+ }
674
+
675
+ /** What 0.15's uses run on, every way they are asked (use.ts, `UsePath`). */
676
+ #usePath(): UsePath {
677
+ // Behind the switch: a vault keeping 0.14's log alone has no 0.15 objects to decide on.
678
+ const { log015, uses: cache } = this.#prepared;
679
+ if (log015 === null) throw new Error("0.15's uses need 0.15's log: the vault's log option is '0.14'");
680
+ if (this.#hot === undefined) throw new Error("0.15's uses need the hot path's login: configure the vault's hotDatabase");
681
+ // An isolate's first uses read the cutover's marker (`#cutOverYet`) within their admission, as any call holds a connection.
682
+ const ready = this.#prepared.cutover ? undefined : () => this.#cutOverYet();
683
+ return {
684
+ db: this.#hot, sealKeys: log015.seals, macKey: log015.mac, keks: this.#config.keks, cache, ...(ready === undefined ? {} : { ready }),
685
+ oldWrite: (principal, old, through) => this.#oldWrite(principal, old, through),
686
+ };
687
+ }
688
+
689
+ /**
690
+ * Whether 0.14's newest access entry about `principal` that carries the
691
+ * vault's MAC, at or below `through`, is past `old`, what the principal's
692
+ * newest change accounts for (Migration015.tla's UsesSeeOldWrites, of the
693
+ * newest authentic write): asked by a use whose probe found `through` in
694
+ * the vault's name past it, which, but for a write the cutover refused to
695
+ * let land, is one forged there. Read from `through` down, never above: the
696
+ * use's statement committed before this reads, and a change's entry since
697
+ * is none it decided on (review r1). Each forged one is passed over; past
698
+ * `LOOK_BACK` of them, the use is refused as if it were not. It writes
699
+ * nothing: no report, which would wait for 0.14's head, or for the
700
+ * vault's settling, past the use's deadline (reviews r3, r4). The next
701
+ * decision about the principal reports what is forged (`#integrity`).
702
+ */
703
+ async #oldWrite(principal: string, old: bigint | null, through: bigint): Promise<boolean> {
704
+ // On Workers the use's vault is opened over its one hot connection (cloudflare.ts, `openVault(hot, prepared, hot)`):
705
+ // this reads on it, never a second connection in the call.
706
+ const newest = await newestAccess(this.#db, principal, { authentic: (entry) => this.#authentic(entry), forged: () => {} }, through);
707
+ if (newest === 'unreached') return true;
708
+ return typeof newest !== 'string' && (old === null || newest.seq > old);
483
709
  }
484
710
 
485
711
  async #now(db: Queryable): Promise<number> {
@@ -492,12 +718,15 @@ class VaultService implements Vault {
492
718
  }
493
719
 
494
720
  /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
495
- #snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
721
+ #snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean, reading = false): Promise<Snapshot> {
496
722
  const { released, ...rest } = asked;
497
723
  return snapshot(tx, {
498
724
  ...rest,
499
725
  principals,
500
726
  head,
727
+ seals: this.#checksPlaces(),
728
+ // A read waits for the head in one of a few places in line, or is refused at once.
729
+ ...(reading ? { waiters: READ_HEAD_WAITERS } : {}),
501
730
  ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
502
731
  });
503
732
  }
@@ -524,30 +753,39 @@ class VaultService implements Vault {
524
753
 
525
754
  /** `#decide`, once the vault is settled. */
526
755
  async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
756
+ // Every decision of 0.15's log's after its cutover, whichever path it comes by (`#declare`, `#convert`, `#limit`).
757
+ await this.#cutOverYet();
527
758
  const reports: NewEntry[] = [];
759
+ // A read's call is due from its first decision, unless its caller set when.
760
+ const due = how.reads === true ? { ...how, deadline: how.deadline ?? Date.now() + READ_DEADLINE_MS } : how;
528
761
  for (let attempt = 1; ; attempt += 1) {
529
762
  try {
530
- return await this.#decideOnce(principals, decide, reports, how);
763
+ return await this.#decideOnce(principals, decide, reports, due);
531
764
  } catch (error) {
532
765
  // A member who had no row when this decision locked theirs, and has
533
766
  // one now: another decision admitted them meanwhile. Again, with it.
534
- if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
767
+ // A 0.15 change's row moved from the seal it read (ChangeCAS): read it again (ChangeRereads).
768
+ if (attempt < 3 && (error instanceof Retry || error instanceof Moved || isUniqueViolation(error))) continue;
535
769
  throw error;
536
770
  }
537
771
  }
538
772
  }
539
773
 
540
- async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false }: How): Promise<Outcome<T>> {
774
+ async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false, deadline }: How): Promise<Outcome<T>> {
541
775
  try {
542
- const result = await this.#db.transaction(async (tx) => {
543
- await store.boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
776
+ const committed = await this.#db.transaction(async (tx) => {
777
+ await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
544
778
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
545
779
  // Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
546
- const reads = await this.#snapshot(tx, principals, asked, !late);
780
+ const reads = await this.#snapshot(tx, principals, asked, !late, shared);
781
+ const log015 = this.#prepared.log015;
547
782
  const d: Decision = {
548
783
  tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
784
+ changes: log015 === null ? null : new Changes(log015),
549
785
  };
550
786
  const result = await decide(d);
787
+ // Each object 0.15's log will change, read and checked before the writes change its rows.
788
+ await d.changes?.prepare(tx);
551
789
  // A read is logged once per place, whichever way it ends (`readsByPlace`).
552
790
  const entries = [...reports, ...readsByPlace(d.log)];
553
791
  let at = d.at;
@@ -568,18 +806,36 @@ class VaultService implements Vault {
568
806
  const sealing = new Set([...d.touched, ...accessSeq.keys()]);
569
807
  // Under a share lock, a write to the row would wait for every other read holding it, and they for it.
570
808
  if (shared && sealing.size > 0) throw new Error('a read changes no member, and seals no row');
571
- for (const principal of sealing) await this.#seal(d, principal, accessSeq.get(principal));
572
- return result;
809
+ const written = new Map<string, Input>();
810
+ for (const principal of sealing) {
811
+ const resealed = await this.#seal(d, principal, accessSeq.get(principal));
812
+ if (resealed === null) continue;
813
+ written.set(objectKey(memberRef(principal)), resealed.object);
814
+ d.changes?.cite(memberRef(principal), resealed.accessSeq);
815
+ }
816
+ // Last: the fence, which they take exclusive (0.14's append took it already, OldWritesFence), is held from
817
+ // here to the commit, so as few round trips as can be. The append comes first, and its trigger took the issue
818
+ // lock before the fence (0026): a change taking the fence before the issue lock would wait in a circle with the
819
+ // barrier, which takes them in that order (Migration015.tla, IssueBeforeFence).
820
+ const numbers = d.changes === null ? [] : await d.changes.commit(tx, at, written);
821
+ return { result, numbers };
573
822
  });
574
823
  this.#reported(reports);
575
- return { ok: true, ...result };
576
- } catch (error) {
824
+ // A change answers once its entries are sealed (R51; HotPathOne's CommitBeforeAnswer): the newest of them, with
825
+ // every number below it.
826
+ if (committed.numbers.length > 0) await this.#prepared.log015!.sealThrough(committed.numbers.reduce((a, b) => (a > b ? a : b)));
827
+ return { ok: true, ...committed.result };
828
+ } catch (caught) {
829
+ const error = caught instanceof NotLogged ? this.#notLogged(reports, caught) : caught;
577
830
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
578
831
  const entries = [...reports, ...readsByPlace(error.entries)];
579
832
  if (entries.length > 0) {
580
- // A refusal's entries are written whatever the decision's bound: it answers only once they are.
833
+ // A refusal's entries are written whatever the decision's bound: it answers only once they are. A read's, within
834
+ // its call's deadline: past it, the call fails as unavailable, having released nothing.
581
835
  await this.#db.transaction(async (tx) => {
582
- await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
836
+ await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
837
+ // In one of the head's places in line, as the read's decision waited, or refused at once.
838
+ if (shared) await lockLogHeadQueued(tx, READ_HEAD_WAITERS);
583
839
  await this.#append(tx, entries);
584
840
  });
585
841
  }
@@ -594,19 +850,23 @@ class VaultService implements Vault {
594
850
  * naming `accessSeq`, their newest access entry, when it wrote one. The
595
851
  * table must hold exactly that: a grant written around the vault while it
596
852
  * decided (its row lock does not stop one) is refused, with the decision,
597
- * rather than sealed in.
853
+ * rather than sealed in. It answers the member's object as the rows now
854
+ * hold it, and the access entry their row names, for 0.15's log.
598
855
  */
599
- async #seal(d: Decision, principal: string, accessSeq: bigint | undefined): Promise<void> {
856
+ async #seal(d: Decision, principal: string, accessSeq: bigint | undefined): Promise<{ object: PrincipalInput; accessSeq: bigint } | null> {
600
857
  const row = await store.member(d.tx, principal);
601
- if (row === undefined) return;
858
+ if (row === undefined) return null;
602
859
  const decided = d.grants.get(principal);
603
860
  if (decided === undefined) throw new Error(`sealing ${principal} without the grants this decision verified`);
604
861
  if (!sameGrants(await store.grants(d.tx, principal), decided)) {
605
862
  this.#report(d.reports, principal, 'mac', row.mac.toString('hex'));
606
- throw new Refused(refusal('tampered', MESSAGES.tampered), []);
863
+ throw new Refused(refusal('tampered', REFUSAL_MESSAGES.tampered), []);
607
864
  }
608
865
  const next = { ...row, accessSeq: accessSeq ?? row.accessSeq };
609
866
  await store.updateMember(d.tx, principal, { accessSeq: next.accessSeq, mac: memberMac(this.#prepared.rowKey, next, decided) });
867
+ // The member's object as the rows hold it now, which 0.15's change of it must have left, and the access entry its
868
+ // row names, which that change accounts for (`changes.ts`).
869
+ return { object: principalObject(row, decided), accessSeq: next.accessSeq };
610
870
  }
611
871
 
612
872
  /**
@@ -633,6 +893,89 @@ class VaultService implements Vault {
633
893
  return 'stale';
634
894
  }
635
895
 
896
+ /**
897
+ * Whether the vault checks the environment rows it decides on against
898
+ * 0.15's log (T159): under it, once its cutover is written, which seals
899
+ * every row. Every read of them asks their seals then (`seals`).
900
+ */
901
+ #checksPlaces(): boolean {
902
+ const { log015, cutover, marked } = this.#prepared;
903
+ return log015 !== null && (cutover || marked);
904
+ }
905
+
906
+ /**
907
+ * Why environment `id`'s row, as a decision read it, is not as 0.15's log
908
+ * holds it (T159; R53, every decision input sealed): its seal is not the
909
+ * vault's over its object (`tampered`: re-parented, renamed, its tombstone
910
+ * set or cleared around the log), or not its object's newest change's
911
+ * (`stale`: put back from before a change, or written with none). A live
912
+ * object in a project whose slug is a tombstone is `tampered` too: a
913
+ * project's deletion under 0.15's log deletes each of its environments, as
914
+ * the cutover sealed them, so only a writer leaves one so (review r2). Each
915
+ * is reported, once, as a change refused on it is (`#notLogged`), the row's
916
+ * and not any member's. Null when it is as the log holds it, and for a row
917
+ * read without its seals: under 0.14's log alone, or before the cutover.
918
+ */
919
+ #placeFault(id: string, row: EnvironmentRow, reports: NewEntry[]): 'tampered' | 'stale' | null {
920
+ const read = row.sealed;
921
+ if (read === undefined) return null;
922
+ let fault = PLACE_FAULTS.get(read);
923
+ if (fault === undefined) {
924
+ fault = !sealedObject(this.#prepared.log015!.seals, { kind: 'environment', id }, read.object, read.seal) || (row.deleted && !read.object.deleted) ? 'tampered'
925
+ : read.newest === null || !read.newest.equals(read.seal!) ? 'stale' : null;
926
+ PLACE_FAULTS.set(read, fault);
927
+ }
928
+ if (fault !== null) this.#report(reports, null, fault === 'tampered' ? 'mac' : 'stale', `log.environment:${id}`);
929
+ return fault;
930
+ }
931
+
932
+ /**
933
+ * A member's grants whose places hold: each on an environment, on a row
934
+ * as 0.15's log holds it, which its project is read from (the grant's row
935
+ * names none of its own). One on a row that is not, or on no row, holds
936
+ * nothing (T159): where it is is a writer's. Their other grants stand.
937
+ * Each row from `rows` when there, else read, with its seals.
938
+ */
939
+ async #heldGrants(db: Queryable, grants: readonly GrantRow[], reports: NewEntry[], rows?: ReadonlyMap<string, EnvironmentRow>): Promise<GrantRow[]> {
940
+ if (!this.#checksPlaces()) return [...grants];
941
+ const ids = [...new Set(grants.flatMap((grant) => (grant.environmentId === null ? [] : [grant.environmentId])))];
942
+ const missing = ids.filter((id) => !rows?.has(id));
943
+ const read = missing.length === 0 ? new Map<string, EnvironmentRow>() : await store.environmentsById(db, missing, { seals: true });
944
+ const faulty = new Set(ids.filter((id) => {
945
+ const row = rows?.get(id) ?? read.get(id);
946
+ if (row !== undefined) return this.#placeFault(id, row, reports) !== null;
947
+ this.#report(reports, null, 'stale', `log.environment:${id}`);
948
+ return true;
949
+ }));
950
+ return grants.filter((grant) => grant.environmentId === null || !faulty.has(grant.environmentId));
951
+ }
952
+
953
+ /**
954
+ * Refuse a decision about places whose rows are not as 0.15's log holds
955
+ * them (`#placeFault`), each reported: what it would decide on them is a
956
+ * writer's (T159). Ids with no row are left to the decision, which knows
957
+ * no such place.
958
+ */
959
+ #placesHold(rows: ReadonlyMap<string, EnvironmentRow>, ids: Iterable<string>, reports: NewEntry[]): void {
960
+ let hold = true;
961
+ for (const id of new Set(ids)) {
962
+ const row = rows.get(id);
963
+ if (row !== undefined && this.#placeFault(id, row, reports) !== null) hold = false;
964
+ }
965
+ if (!hold) throw new Refused(refusal('tampered', TAMPERED_PLACE), []);
966
+ }
967
+
968
+ /**
969
+ * The policy `edits` make over the rows of the environments they protect
970
+ * (`livePolicy`), a row not as 0.15's log holds it taken as still
971
+ * protected, its slug unknown: one deleted or renamed around the log
972
+ * narrows what anyone holds there, never widens it (T159).
973
+ */
974
+ #livePolicy(rows: ReadonlyMap<string, EnvironmentRow>, edits: PolicyEdits, at: number, reports: NewEntry[]): Policy {
975
+ const held = new Map([...rows].map(([id, row]) => [id, this.#placeFault(id, row, reports) === null ? row : { ...row, slug: null, deleted: false }]));
976
+ return livePolicy(held, edits, at);
977
+ }
978
+
636
979
  /** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
637
980
  #newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
638
981
  if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
@@ -690,6 +1033,18 @@ class VaultService implements Vault {
690
1033
  reports.push(entry);
691
1034
  }
692
1035
 
1036
+ /**
1037
+ * A change refused because 0.15's log does not hold its object as its row
1038
+ * is (`changes.ts`): a row put back, or written around the vault. Reported
1039
+ * as a tampered row is, and refused: adoption or a reseal (plan PR 9)
1040
+ * makes it the log's again, never a change on top of it.
1041
+ */
1042
+ #notLogged(reports: NewEntry[], error: NotLogged): Refused {
1043
+ const { ref } = error;
1044
+ this.#report(reports, ref?.kind === 'principal' ? ref.id : null, error.fault === 'tampered' ? 'mac' : 'stale', ref === null ? 'log.object' : `log.${ref.kind}:${ref.id}`);
1045
+ return new Refused(refusal('tampered', error.message), []);
1046
+ }
1047
+
693
1048
  /** Mark these reports as logged, once their transaction has committed. */
694
1049
  #reported(reports: readonly NewEntry[]): void {
695
1050
  for (const entry of reports) {
@@ -718,8 +1073,15 @@ class VaultService implements Vault {
718
1073
  // database; calls that race settle it alike, and a rotation they race
719
1074
  // to write goes in once (`#rotate`).
720
1075
  if (prepared.since === null && prepared.superseded === null) await this.#settle();
1076
+ // Under 0.15's log, nothing of its own before the cutover (`cutover`): the conversion and the Owners it names, and
1077
+ // the bulk cap, after it, as decisions of 0.15's log.
1078
+ if (prepared.superseded === null && prepared.log015 !== null && !prepared.cutover && !prepared.marked) {
1079
+ if (!(await store.cutOver(this.#db))) return prepared.superseded;
1080
+ prepared.marked = true;
1081
+ }
721
1082
  // A log with no entry of the vault's holds nothing to convert, and stays empty until someone acts.
722
1083
  if (prepared.superseded === null && prepared.since !== null && !prepared.converted) await this.#convert();
1084
+ if (prepared.superseded === null && prepared.log015 !== null && !prepared.limited) await this.#limit();
723
1085
  return prepared.superseded;
724
1086
  }
725
1087
 
@@ -751,6 +1113,30 @@ class VaultService implements Vault {
751
1113
  this.#prepared.converted = await this.#declare(this.#config.owners.map((email) => `user:${email}`));
752
1114
  }
753
1115
 
1116
+ /**
1117
+ * Under 0.15's log, the deployment's bulk cap written into it before the
1118
+ * vault serves anything (plan PR 3, `limitChange`): a `bulk.limit` change
1119
+ * whenever the log's policy says otherwise, as any change, under the head
1120
+ * and the fence. Uses read the cap from it (CapSealed), never from the
1121
+ * configuration. Once per process; until it lands, each call tries again.
1122
+ * A cap the log already holds is read first, with no lock: every isolate
1123
+ * that starts finds it there, and takes neither 0.14's head nor the fence
1124
+ * for it, which every other start would queue behind (PR 5b's bench).
1125
+ */
1126
+ async #limit(): Promise<void> {
1127
+ const { row: logged } = await store.readObject(this.#db, POLICY_REF as Ref & { kind: 'policy' });
1128
+ if (limitChange(logged?.kind === 'policy' ? logged : null, this.#config.bulkLimit) === null) {
1129
+ this.#prepared.limited = true;
1130
+ return;
1131
+ }
1132
+ const outcome = await this.#decideNow([], async (d) => {
1133
+ const { row } = await store.readObject(d.tx, POLICY_REF as Ref & { kind: 'policy' });
1134
+ const op = limitChange(row?.kind === 'policy' ? row : null, this.#config.bulkLimit);
1135
+ if (op !== null) d.changes!.stage(POLICY_REF as Ref & { kind: 'policy' }, op, VAULT_ACTOR);
1136
+ });
1137
+ if (outcome.ok) this.#prepared.limited = true;
1138
+ }
1139
+
754
1140
  async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
755
1141
  const row = d.members.get(principal);
756
1142
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
@@ -758,9 +1144,11 @@ class VaultService implements Vault {
758
1144
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
759
1145
  if (everyProject.length === 0) return;
760
1146
  if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
761
- // The projects there are now, read under the log's head, which a deletion holds.
1147
+ // The projects there are now, read under the log's head, which a deletion holds; each environment by its slug as 0.15's
1148
+ // log holds its row (T159, review r7): a grant on a slug in every project becomes one on each place that answers to
1149
+ // it, never on one a writer renamed into it.
762
1150
  const before = roleOf(principal, row);
763
- const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await store.liveProjects(d.tx));
1151
+ const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await this.#livePlaces(d));
764
1152
  const after = { role: conversion.role, scope: conversion.scope };
765
1153
  const replacedBy = { role: after.role, scope: after.scope, grants: conversion.grants.length };
766
1154
  d.grants.set(principal, [...subject.stored]);
@@ -774,6 +1162,7 @@ class VaultService implements Vault {
774
1162
 
775
1163
  for (const grant of everyProject) {
776
1164
  drop(grant);
1165
+ d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(grant) }, VAULT_ACTOR);
777
1166
  // A lapsed one goes with no entry: it changes nothing anyone holds.
778
1167
  if (!live(grant, d.at)) continue;
779
1168
  const narrowed = conversion.narrowed.filter((loss) => loss.grant.environmentSlug === grant.environmentSlug);
@@ -794,6 +1183,7 @@ class VaultService implements Vault {
794
1183
  reason: 'every-project',
795
1184
  }));
796
1185
  d.writes.push(() => store.updateMember(d.tx, principal, after));
1186
+ d.changes?.stage(memberRef(principal), { op: 'member.role', ...asStored(after.role, after.scope) }, VAULT_ACTOR);
797
1187
  }
798
1188
  for (const given of conversion.grants) {
799
1189
  const place = { projectId: given.projectId, environmentId: given.environmentId, environmentSlug: null };
@@ -805,6 +1195,7 @@ class VaultService implements Vault {
805
1195
  await store.insertGrant(d.tx, grant);
806
1196
  d.grants.get(principal)!.push(grant);
807
1197
  });
1198
+ d.changes?.stage(memberRef(principal), { op: 'access.grant', grant: inputGrant({ ...place, role: given.role, expiresAt: given.expiresAt }) }, VAULT_ACTOR);
808
1199
  d.log.push({
809
1200
  ...accessEntry(VAULT_ACTOR, 'access.grant', principal, 'allow', correlation, {
810
1201
  role: given.role,
@@ -897,7 +1288,8 @@ class VaultService implements Vault {
897
1288
  * is to a key it does not hold: a vault given a newer KEK has replaced
898
1289
  * this one, which writes nothing more. Checked under the head's lock, so
899
1290
  * that an instance still running with the replaced KEK, as during a
900
- * deploy, cannot write after the rotation.
1291
+ * deploy, cannot write after the rotation. Under 0.15's log, its entries
1292
+ * say so (`log015`): once cut over, the log refuses a write without it.
901
1293
  */
902
1294
  async #append(
903
1295
  tx: Transaction,
@@ -915,7 +1307,7 @@ class VaultService implements Vault {
915
1307
  throw new Error(prepared.superseded);
916
1308
  }
917
1309
  for (const check of underLock) await check(locked);
918
- }, head === undefined ? undefined : { head, last: head.last, at: held!.at });
1310
+ }, head === undefined ? undefined : { head, last: head.last, at: held!.at }, [], prepared.log015 !== null);
919
1311
  // The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
920
1312
  // then the decision's reports.
921
1313
  if (head !== undefined) {
@@ -928,8 +1320,9 @@ class VaultService implements Vault {
928
1320
 
929
1321
  // Raw DEKs are cleared on every path. JSON and base64 leave strings that
930
1322
  // cannot be wiped, so this is best-effort memory hygiene.
931
- async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[]; accessRevision?: string }>> {
1323
+ async unwrap(input: UnwrapInput): Promise<Outcome<Unwrapped>> {
932
1324
  const { principal } = input;
1325
+ const use = input.use === undefined ? null : useEntry(principal, input, input.use);
933
1326
  validateText(input.purpose);
934
1327
  if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
935
1328
  if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
@@ -941,7 +1334,14 @@ class VaultService implements Vault {
941
1334
  const items = loaded.versions.map((version) => ({ ...version, secret: { ...version.secret } }));
942
1335
  validateItems(items, 'wrapped');
943
1336
  const sealed = await this.#referencesAt(vias);
944
- const forks = await store.environmentsById(this.#db, throughs.flatMap((through) => (through === null ? [] : [through])));
1337
+ // Settled first, as a decision is: a new process learns here whether the log is cut over, and so whether a fork's row
1338
+ // is held to its seal (review r1).
1339
+ await this.#settled();
1340
+ const forks = await store.environmentsById(this.#db, throughs.flatMap((through) => (through === null ? [] : [through])), { seals: this.#checksPlaces() });
1341
+ // A fork whose row is not as 0.15's log holds it is no place to read through (T159), reported.
1342
+ const forkReports: NewEntry[] = [];
1343
+ for (const [id, fork] of forks) if (this.#placeFault(id, fork, forkReports) !== null) forks.delete(id);
1344
+ await this.#record(forkReports);
945
1345
  // A reference reads only the current version of the source its entry names.
946
1346
  const viaOk = items.map((item, i) => vias[i] === null || (sealed[i] !== null && sealed[i]!.source.secretId === item.secret.secretId && item.current));
947
1347
  // A fork an inherited value is read in is a live environment of the same project as the value's, and not its own,
@@ -975,6 +1375,17 @@ class VaultService implements Vault {
975
1375
  if (throughOk.includes(false)) {
976
1376
  return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
977
1377
  }
1378
+ // The app's record of a use names where the read was, which the vault vouches for once it appends it: the place an
1379
+ // item was read at, the fork it is read in, else a reference's holder, else its own, with its project. Another is
1380
+ // a claim the read does not bear out, refused and logged as one; no use is appended.
1381
+ if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
1382
+ const named = items.some((item, i) => {
1383
+ const fork = throughs[i] === null ? undefined : forks.get(throughs[i]!);
1384
+ const place = fork === undefined ? sealed[i]?.holder ?? item.secret : { projectId: fork.projectId, environmentId: throughs[i]! };
1385
+ return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
1386
+ });
1387
+ if (!named) return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
1388
+ }
978
1389
  const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
979
1390
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
980
1391
  return this.#keys(
@@ -993,6 +1404,7 @@ class VaultService implements Vault {
993
1404
  remote,
994
1405
  entry,
995
1406
  input,
1407
+ ...(use === null ? {} : { also: [use] }),
996
1408
  },
997
1409
  (contextOf) =>
998
1410
  items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
@@ -1009,6 +1421,8 @@ class VaultService implements Vault {
1009
1421
  }),
1010
1422
  // Named back: the read was decided on that answer, which this vault checked.
1011
1423
  ...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
1424
+ // And the use, appended with the read: the app has nothing left to append.
1425
+ ...(use === null ? {} : { used: true as const }),
1012
1426
  }),
1013
1427
  );
1014
1428
  }
@@ -1160,12 +1574,12 @@ class VaultService implements Vault {
1160
1574
  principal: string,
1161
1575
  code: 'bad_claim' | 'deleted' | 'invalid',
1162
1576
  entries: NewEntry[],
1163
- { message = MESSAGES[code], asked }: { message?: string; asked?: string } = {},
1577
+ { message = REFUSAL_MESSAGES[code], asked }: { message?: string; asked?: string } = {},
1164
1578
  ): Promise<Outcome<never>> {
1165
1579
  return this.#decide([principal], async (d) => {
1166
1580
  if (asked !== undefined) {
1167
1581
  const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
1168
- if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
1582
+ if (this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', REFUSAL_MESSAGES.access_changed), []);
1169
1583
  }
1170
1584
  throw new Refused(refusal(code, message), entries);
1171
1585
  }, { reads: true });
@@ -1224,6 +1638,8 @@ class VaultService implements Vault {
1224
1638
  viaReference?: readonly boolean[];
1225
1639
  remote: boolean;
1226
1640
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
1641
+ /** Entries appended with the release, after its own, only once the keys are released: the app's record of a use. */
1642
+ also?: readonly NewEntry[];
1227
1643
  input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
1228
1644
  },
1229
1645
  /**
@@ -1236,11 +1652,14 @@ class VaultService implements Vault {
1236
1652
  ): Promise<Outcome<R>> {
1237
1653
  const { action, principal, secrets, entry } = call;
1238
1654
  for (const secret of secrets) entry(secret, 'allow', null);
1239
- const wrongKek = await this.#kekMismatch();
1655
+ // One deadline for the call: the record of the KEK's check, the decisions, the intent's and the keys', and, with a
1656
+ // key service, its own budget besides, spent from it too.
1657
+ const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
1658
+ const wrongKek = await this.#kekMismatch(deadline);
1240
1659
  if (wrongKek !== null) {
1241
1660
  return this.#decide([], async () => {
1242
1661
  throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
1243
- });
1662
+ }, { reads: true, deadline });
1244
1663
  }
1245
1664
  // Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
1246
1665
  const owners = new Map<string, string>();
@@ -1248,7 +1667,7 @@ class VaultService implements Vault {
1248
1667
  const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
1249
1668
  // A read decided on an earlier answer about its reader, only while that is still the answer: nothing read, nothing logged.
1250
1669
  const asked = call.input.accessRevision;
1251
- if (asked !== undefined && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', MESSAGES.access_changed), []);
1670
+ if (asked !== undefined && this.#revisionOf(standing, d) !== asked) throw new Refused(refusal('access_changed', REFUSAL_MESSAGES.access_changed), []);
1252
1671
  const reader = through(standing, call.input);
1253
1672
  // One read of the environments the decision is about, as the store has them, not as the app sent them:
1254
1673
  // each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
@@ -1256,6 +1675,9 @@ class VaultService implements Vault {
1256
1675
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1257
1676
  // Each a personal config is a config of too, whose read its owner needs (`places`).
1258
1677
  const environments = d.reads.environments;
1678
+ // Each as 0.15's log holds its row (T159): its project, its slug, its tombstone, a personal config's owner and parent.
1679
+ const decided = [...secrets, ...at].map((place) => place.environmentId);
1680
+ this.#placesHold(environments, [...decided, ...decided.flatMap((id) => (environments.get(id)?.owner == null ? [] : [environments.get(id)!.parentId!]))], d.reports);
1259
1681
  owners.clear();
1260
1682
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1261
1683
  const needs = call.needs === undefined ? [] : await call.needs(d.tx);
@@ -1270,7 +1692,7 @@ class VaultService implements Vault {
1270
1692
  if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
1271
1693
  if (first !== null) {
1272
1694
  throw new Refused(
1273
- refusal(first, MESSAGES[first]),
1695
+ refusal(first, REFUSAL_MESSAGES[first]),
1274
1696
  secrets.map((secret, i) => entry(secret, 'deny', codes[i] ?? first)),
1275
1697
  );
1276
1698
  }
@@ -1319,7 +1741,7 @@ class VaultService implements Vault {
1319
1741
  }),
1320
1742
  }], [], d.reads);
1321
1743
  return { seq: appended.seqStart };
1322
- }, { asked: reads, reads: true });
1744
+ }, { asked: reads, reads: true, deadline });
1323
1745
  if (!intent.ok) return intent;
1324
1746
  intentSeq = intent.seq;
1325
1747
  }
@@ -1352,10 +1774,10 @@ class VaultService implements Vault {
1352
1774
  const codes = await vet(locked, d.reads);
1353
1775
  const first = codes.find((code) => code !== null);
1354
1776
  if (first === undefined || first === null) return;
1355
- throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
1777
+ throw new Refused(refusal(first, REFUSAL_MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
1356
1778
  });
1357
1779
  }
1358
- const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
1780
+ const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
1359
1781
  const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
1360
1782
  try {
1361
1783
  const entries = () => secrets.map((secret, i) =>
@@ -1372,7 +1794,7 @@ class VaultService implements Vault {
1372
1794
  }
1373
1795
  if (expired) throw new Outage(new KekUnavailableError('key operation exceeded its deadline'), entries());
1374
1796
  if (done.length < outcomes.length) {
1375
- throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
1797
+ throw new Refused(refusal('bad_claim', REFUSAL_MESSAGES.bad_claim), entries());
1376
1798
  }
1377
1799
  if (late) {
1378
1800
  // Again under the log's head, the append's, taken a statement early: a
@@ -1381,16 +1803,16 @@ class VaultService implements Vault {
1381
1803
  // A read through a reference: its holder's place too, which a deletion may have taken.
1382
1804
  // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1383
1805
  // since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
1384
- d.reads = await this.#snapshot(d.tx, [principal], reads, true);
1806
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
1385
1807
  d.at = this.#at(d.reads);
1386
1808
  await decided(d);
1387
1809
  const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1388
1810
  if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
1389
- throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1811
+ throw new Refused(refusal('deleted', REFUSAL_MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1390
1812
  }
1391
1813
  }
1392
1814
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
1393
- d.log.push(...released);
1815
+ d.log.push(...released, ...(call.also ?? []));
1394
1816
  const answer = result(done);
1395
1817
  // A wrap's answer names its entries, which the app's writes refer to.
1396
1818
  if (action !== 'secret.read') d.after.push((seqOf) => Object.assign(answer as object, { seqs: released.map(seqOf) }));
@@ -1398,7 +1820,7 @@ class VaultService implements Vault {
1398
1820
  } finally {
1399
1821
  for (const value of done) value.wipe();
1400
1822
  }
1401
- }, { late, asked: reads, reads: true });
1823
+ }, { late, asked: reads, reads: true, deadline });
1402
1824
  }
1403
1825
 
1404
1826
  /**
@@ -1469,15 +1891,18 @@ class VaultService implements Vault {
1469
1891
  const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
1470
1892
  const source = sources.get(item.source.secretId);
1471
1893
  return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
1472
- }));
1894
+ }), { seals: this.#checksPlaces() });
1895
+ // Each as 0.15's log holds its row (T159).
1896
+ this.#placesHold(environments, environments.keys(), d.reports);
1473
1897
  const codes = items.map((item): RefusalCode | null => {
1474
1898
  const source = sources.get(item.source.secretId);
1475
- if (source === undefined || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return 'bad_claim';
1899
+ // The holder's project as the row this decision checked says it (review r4): the read before only names the entry.
1900
+ if (source === undefined || environments.get(item.holder.environmentId)?.projectId !== item.holder.projectId) return 'bad_claim';
1476
1901
  if (source.secretId === item.holder.secretId) return 'invalid';
1477
1902
  return referenceRefusal(reader, item.holder, source, environments, natures.get(source.secretId) ?? 'secret');
1478
1903
  });
1479
1904
  const first = codes.find((code) => code !== null) ?? null;
1480
- if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
1905
+ if (first !== null) throw new Refused(refusal(first, REFUSAL_MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
1481
1906
  const entries = items.map((item) => entry(item, 'allow', null));
1482
1907
  d.log.push(...entries);
1483
1908
  const answer = { seqs: [] as number[] };
@@ -1498,7 +1923,9 @@ class VaultService implements Vault {
1498
1923
  validateCorrelation(input);
1499
1924
  if (reason !== 'replaced' && reason !== 'broken' && reason !== 'abandoned') throw new Error('a reference ends replaced, broken or abandoned');
1500
1925
  const vias = input.items.map((item) => validateVia(item)!);
1501
- if (vias.some((via) => via === null) || new Set(vias.map((via) => via.seq)).size !== vias.length) throw new Error('each reference to end, once');
1926
+ if (vias.some((via) => via === null) || new Set(vias.map((via) => via.seq)).size !== vias.length || new Set(vias.map((via) => via!.reference)).size !== vias.length) {
1927
+ throw new Error('each reference to end, once');
1928
+ }
1502
1929
  if (vias.length === 0) return { ok: true, seqs: [] };
1503
1930
  const sealed = await this.#referencesAt(vias);
1504
1931
  // A holder's row is committed after its reference is sealed, if the write went through: an end names it only then.
@@ -1531,31 +1958,111 @@ class VaultService implements Vault {
1531
1958
  });
1532
1959
  if (sealed.some((seal) => seal === null)) {
1533
1960
  return this.#decide([principal], async () => {
1534
- throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i]!, 'deny', 'bad_claim')));
1961
+ throw new Refused(refusal('bad_claim', REFUSAL_MESSAGES.bad_claim), vias.map((via, i) => entry(via, sealed[i]!, 'deny', 'bad_claim')));
1535
1962
  });
1536
1963
  }
1537
1964
  const seals = sealed as Sealed[];
1965
+ // Ended already: by an authentic end only. A forged one (a row with the vault's name and no valid MAC) refuses a
1966
+ // read, the safe side there (`endedReferences`); here it would stop a genuine break, and with 0.15's log leave the
1967
+ // holder serving, its end written nowhere (review r9).
1538
1968
  const ended = async (db: Queryable) => {
1539
- const done = await store.endedReferences(db, seals.map((seal) => seal.seq));
1969
+ const done = new Set((await store.referenceEnds(db, seals.map((seal) => seal.seq))).filter((end) => this.#authentic(end)).map((end) => end.relatedSeq!));
1540
1970
  return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
1541
1971
  };
1542
1972
  return this.#decide([principal], async (d) => {
1543
1973
  // Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
1544
1974
  const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
1545
1975
  // Ending is allowed in a deleted place: deleting one ends the references into and out of it.
1546
- const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
1547
- const codes: (RefusalCode | null)[] = seals.map((seal) => {
1548
- const holder = refuses(actor, 'secret.write', placeIn(seal.holder, environments), false);
1549
- // Abandoning is its maker's alone, for a write of theirs that stored nothing: the vault
1550
- // never reads the app's rows, so whose seal it is decides, and no one else's end says it.
1976
+ const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId), { seals: this.#checksPlaces() });
1977
+ // Each as 0.15's log holds its row (T159).
1978
+ this.#placesHold(environments, environments.keys(), d.reports);
1979
+ // Who may end each: write where it is held; for a break, managing the source's project's access too; abandoning,
1980
+ // its maker alone, for a write of theirs that stored nothing (the vault never reads the app's rows, so whose
1981
+ // seal it is decides, and no one else's end says it).
1982
+ const mayEnd = (reader: Reader, places: ReadonlyMap<string, { projectId: string; slug: string }>) => seals.map((seal): RefusalCode | null => {
1983
+ const holder = refuses(reader, 'secret.write', placeIn(seal.holder, places), false);
1551
1984
  if (reason === 'abandoned' && seal.createdBy !== principal) return 'not_allowed';
1552
- // Replacing or abandoning is the holder's side: write where it is held.
1553
1985
  if (holder === null || reason !== 'broken') return holder;
1554
- return refuses(actor, 'grant.manage', { projectId: seal.source.projectId }, false) === null ? null : holder;
1986
+ return refuses(reader, 'grant.manage', { projectId: seal.source.projectId }, false) === null ? null : holder;
1555
1987
  });
1988
+ const codes: (RefusalCode | null)[] = mayEnd(actor, environments);
1989
+ // With 0.15's log, also as 0.15 decides it, on sealed objects alone (R53): the actor's principal object, the
1990
+ // roles object and each holder environment's object, each as its log has it and checked against its seal
1991
+ // (`readerOf`, as a use's reader). 0.14's reader reads rows no seal covers (an environment's slug, a project's
1992
+ // tombstone, the protection they make): a writer who swapped one could let a member end what they may not
1993
+ // (reviews r10 and r11). An end needs both to allow it, so an unsealed row can only refuse one. An object the log
1994
+ // does not bear out refuses the end. Per reference: one whose holder environment, or the actor, 0.14's log alone
1995
+ // holds (before the cutover) is 0.14's reader's alone, and no other reference of the call with it (review r12).
1996
+ const allowed14 = !codes.some((code) => code !== null);
1997
+ if (d.changes !== null && allowed14) {
1998
+ const member = await d.changes.held(d.tx, { kind: 'principal', id: principal });
1999
+ const roles = await d.changes.held(d.tx, ROLES_REF as Ref & { kind: 'roles' });
2000
+ const places = new Map<string, EnvironmentInput>();
2001
+ for (const id of new Set(seals.map((seal) => seal.holder.environmentId))) {
2002
+ const held = await d.changes.held(d.tx, { kind: 'environment', id });
2003
+ if (held.log === 'live' && held.object.kind === 'environment') places.set(id, held.object);
2004
+ }
2005
+ if (member.log !== 'unlogged') {
2006
+ const account = member.log === 'live' && member.object.kind === 'principal' ? member.object : null;
2007
+ // The way the actor came in, as a reader takes it: its kind and scope (its generation weighs nothing here).
2008
+ const credential: CredentialInput = {
2009
+ kind: 'credential', id: principal, principal, caller: input.caller, scope: input.credentialScope ?? null, expiresAt: null, revoked: false, generation: account?.generation ?? 0,
2010
+ };
2011
+ const reader = readerOf(account, credential, roles.log === 'live' && roles.object.kind === 'roles' ? roles.object : noEdits(), places.values(), d.at);
2012
+ mayEnd(reader, places).forEach((code, i) => {
2013
+ if (places.has(seals[i]!.holder.environmentId)) codes[i] = code;
2014
+ });
2015
+ }
2016
+ }
1556
2017
  if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
1557
2018
  const first = codes.find((code) => code !== null) ?? null;
1558
- if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
2019
+ if (first !== null) throw new Refused(refusal(first, REFUSAL_MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
2020
+ // With 0.15's log, each reference ended ends in its row, beside 0.14's entry, in this transaction (0029;
2021
+ // formal/SealedObjects.tla, EndInRow and EndWithEntry). Decided only on what the vault authenticates (R53): that
2022
+ // it has not ended is 0.14's log's (`ended` above), and what its holder reads is the holder's object, as its log
2023
+ // has it and checked against its seal (`heldByLog`: ReferenceInObject, LivenessFromLog), never its rows, which
2024
+ // a writer may rename, plant or mark archived. A holder whose rows the log does not bear out refuses the end
2025
+ // (`tampered`, `stale`).
2026
+ // - A holder whose object reads its source through this reference changes: `secret.write`, no source, whose
2027
+ // newest row, this one, `change_input` ends.
2028
+ // - Any other ends this row alone, by its id (`end_reference`): an abandoned reference's stored none, and its
2029
+ // end touches no other. A holder with no object has only rows, which decide nothing here: 0.14's alone
2030
+ // before the cutover, which holds each reference row to 0.14's `reference.create` and `reference.end` (plan
2031
+ // PR 9); or archived, whose unarchive restores what its log last sealed (`keptObjects`, T134), never its rows.
2032
+ // - So an archived holder whose kept object reads through this reference has that object ended too: written
2033
+ // ended, then deleted again, still no object (EndWhileArchived). Otherwise its unarchive would restore it
2034
+ // live, the row's end cleared by a writer, or refuse an honest one for the end in its rows. Its rows must make
2035
+ // the kept object, as a live holder's must make its sealed one: else the end is refused, reported.
2036
+ if (d.changes !== null) {
2037
+ const changes = d.changes;
2038
+ const rows: string[] = [];
2039
+ const staged = new Set<string>();
2040
+ const end = (ref: Ref & { kind: 'secret' }, at: { projectId: string; environmentId: string }, archived: boolean) => {
2041
+ if (staged.has(ref.id)) return;
2042
+ staged.add(ref.id);
2043
+ changes.stage(ref, { op: 'secret.write', projectId: at.projectId, environmentId: at.environmentId, version: '', nature: 'secret', key: null, digest: null, source: null, reference: null }, principal);
2044
+ if (archived) changes.stage(ref, { op: 'secret.delete' }, principal);
2045
+ };
2046
+ for (const [i, { holder }] of seals.entries()) {
2047
+ const reference = vias[i]!.reference;
2048
+ const ref = { kind: 'secret' as const, id: holder.secretId };
2049
+ const held = await changes.held(d.tx, ref);
2050
+ if (held.log === 'live' && held.object.kind === 'secret' && held.object.reference === reference) {
2051
+ end(ref, holder, false);
2052
+ continue;
2053
+ }
2054
+ if (held.log === 'gone') {
2055
+ const kept = await changes.kept(d.tx, ref);
2056
+ if (kept?.kind === 'secret' && kept.reference === reference) {
2057
+ // Its rows must make that object, or its end would write none of them: refused, reported, as a live one's.
2058
+ await changes.rowsAsKept(d.tx, ref, kept);
2059
+ end(ref, kept, true);
2060
+ }
2061
+ }
2062
+ rows.push(reference);
2063
+ }
2064
+ if (rows.length > 0) d.writes.push((at) => store.endReferenceRows(d.tx, rows, at));
2065
+ }
1559
2066
  const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
1560
2067
  d.log.push(...entries);
1561
2068
  const answer = { seqs: [] as number[] };
@@ -1618,6 +2125,8 @@ class VaultService implements Vault {
1618
2125
  * and is asked about again at each of their requests. Whether it ran.
1619
2126
  */
1620
2127
  async #declare(principals: readonly string[]): Promise<boolean> {
2128
+ // Under 0.15's log, no Owner made before the cutover: nothing of the log's precedes its change (AnchorHolds).
2129
+ if (!(await this.#cutOverNow())) return false;
1621
2130
  const at = await this.#now(this.#db);
1622
2131
  const due: string[] = [];
1623
2132
  for (const principal of principals) {
@@ -1665,7 +2174,13 @@ class VaultService implements Vault {
1665
2174
  const owner = { role: 'owner' as const, scope: EVERYWHERE };
1666
2175
  d.grants.set(principal, [...subject.stored]);
1667
2176
  d.touched.add(principal);
1668
- if (tampered) await this.#startOver(d, VAULT_ACTOR, principal, row, subject.fault!, correlation, (code, message) => new Refused(refusal(code, message ?? MESSAGES[code]), []));
2177
+ if (tampered) await this.#startOver(d, VAULT_ACTOR, principal, row, subject.fault!, correlation, (code, message) => new Refused(refusal(code, message ?? REFUSAL_MESSAGES[code]), []));
2178
+ // Started over, their row is one 0.15's log does not hold, and changes nothing there: adoption or a reseal does
2179
+ // (plan PR 9). Otherwise, the change the writes below make.
2180
+ if (!tampered) {
2181
+ const op = row === undefined ? 'member.add' : row.status === 'removed' ? 'member.restore' : 'member.role';
2182
+ d.changes?.stage(memberRef(principal), { op, ...asStored(owner.role, owner.scope) }, VAULT_ACTOR);
2183
+ }
1669
2184
  if (row === undefined && !tampered) {
1670
2185
  d.log.push(accessEntry(VAULT_ACTOR, 'member.add', principal, 'allow', correlation, { ...owner, reason }));
1671
2186
  d.writes.push((at) => store.insertMember(d.tx, {
@@ -1696,7 +2211,8 @@ class VaultService implements Vault {
1696
2211
  if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
1697
2212
  if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
1698
2213
  if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
1699
- const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
2214
+ // What they hold: their grants but those on a place whose row is not as 0.15's log holds it (T159). Kept as stored.
2215
+ const held = (await this.#heldGrants(db, grants, reports, reads?.environments)).map((grant) => ({ ...grant, role: grant.role as Role }));
1700
2216
  const { role, scope } = roleOf(principal, row);
1701
2217
  // Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
1702
2218
  const { policy } = await this.#policyAt(db, reports, at, reads);
@@ -1712,25 +2228,16 @@ class VaultService implements Vault {
1712
2228
  }
1713
2229
 
1714
2230
  #access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
1715
- const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
1716
- const access: Access = {
1717
- principal,
1718
- status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
1719
- generation: row?.generation ?? 0,
1720
- ...(active ? roleOf(principal, row) : { role: 'member', scope: EVERYWHERE }),
1721
- grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
1722
- since: row ? iso(row.statusChangedAt) : null,
1723
- by: row?.statusChangedBy ?? null,
1724
- policy,
1725
- };
1726
- return { ...access, revision: accessRevision(access) };
2231
+ return answerFrom(principal, row, held, at, tampered, policy);
1727
2232
  }
1728
2233
 
1729
2234
  /** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
1730
2235
  #revisionOf(standing: Standing, d: Decision): string | null {
1731
2236
  const { policy } = standing.live;
1732
2237
  if (standing.status !== 'active' || policy === undefined) return null;
1733
- return this.#access(standing.principal, d.members.get(standing.principal), standing.stored, d.at, false, policy).revision!;
2238
+ // Its grants that hold, as `access` answers them (`#heldGrants`): those `all` keeps.
2239
+ const held = standing.stored.filter((grant) => grant.environmentId === null || standing.all.grants.some((kept) => kept.environmentId === grant.environmentId));
2240
+ return this.#access(standing.principal, d.members.get(standing.principal), held, d.at, false, policy).revision!;
1734
2241
  }
1735
2242
 
1736
2243
  /**
@@ -1755,11 +2262,13 @@ class VaultService implements Vault {
1755
2262
  /** `access`, as the rows are now. */
1756
2263
  async #read(principal: string, superseded: string | null): Promise<Access> {
1757
2264
  const read = async (db: Queryable, reports: NewEntry[]) => {
1758
- const reads = await snapshot(db, { principals: [principal], members: true });
2265
+ const reads = await snapshot(db, { principals: [principal], members: true, seals: this.#checksPlaces() });
1759
2266
  const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
1760
2267
  const fault = await this.#integrity(db, principal, row, held, reports, reads);
2268
+ // Their grants but those on a place whose row is not as 0.15's log holds it, which hold nothing (T159).
2269
+ const holding = fault === null ? await this.#heldGrants(db, held, reports, reads.environments) : held;
1761
2270
  const { policy } = await this.#policyAt(db, reports, at, reads);
1762
- return this.#access(principal, row, held, at, fault !== null, policy);
2271
+ return this.#access(principal, row, holding, at, fault !== null, policy);
1763
2272
  };
1764
2273
  const found: NewEntry[] = [];
1765
2274
  const quick = await read(this.#db, found).catch((error: unknown) => {
@@ -1807,7 +2316,7 @@ class VaultService implements Vault {
1807
2316
  const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
1808
2317
  // One place refused is shown where it is, to whoever reads that place's log; an invalid one may be no place at all.
1809
2318
  const [only] = changes.length === 1 ? changes : [];
1810
- const refused = (code: RefusalCode, message = MESSAGES[code]) => {
2319
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) => {
1811
2320
  const where = only === undefined || code === 'invalid' ? { ids: {}, detail: {} } : located(only);
1812
2321
  return new Refused(refusal(code, message), [{
1813
2322
  ...accessEntry(actor, action, principal, 'deny', input, { changes: input.changes, ...where.detail }, code),
@@ -1845,11 +2354,10 @@ class VaultService implements Vault {
1845
2354
  throw refused('invalid', `${change.role} can only be granted on a project`);
1846
2355
  }
1847
2356
  }
1848
- const known = await store.places(
1849
- d.tx,
1850
- changes.map((change) => change.projectId!),
1851
- changes.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
1852
- );
2357
+ const named = changes.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId]));
2358
+ const known = await store.places(d.tx, changes.map((change) => change.projectId!), named, { seals: this.#checksPlaces() });
2359
+ // Each environment by its project and slug as 0.15's log holds its row (T159): what a grant there is weighed on.
2360
+ this.#placesHold(known.environments, named, d.reports);
1853
2361
  const where: Place[] = changes.map(({ projectId, environmentId, role }) => {
1854
2362
  const environment = environmentId === null ? undefined : known.environments.get(environmentId);
1855
2363
  if (!known.projects.has(projectId!) || (environmentId !== null && environment?.projectId !== projectId)) {
@@ -1883,7 +2391,7 @@ class VaultService implements Vault {
1883
2391
  // The setting as of the log's head, which a change to it takes too.
1884
2392
  const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
1885
2393
  // One just admitted holds nothing yet, and is theirs.
1886
- const account = admitted !== undefined || row === undefined ? { grants: [], admittedBy: actor } : await this.#account(d.tx, subject, row);
2394
+ const account = admitted !== undefined || row === undefined ? { grants: [], admittedBy: actor } : await this.#account(d.tx, subject, row, d.reads, d.reports);
1887
2395
  // Each place's new role they must hold, and what it held before: taking a grant away is changing it.
1888
2396
  const gives = changes.every((change, i) => {
1889
2397
  const before = subject.live.grants.find((grant) => placeKey({ ...grant, environmentSlug: null }) === placeKey(change));
@@ -1906,7 +2414,7 @@ class VaultService implements Vault {
1906
2414
  * not run the instance sets one up as they would through `admit`.
1907
2415
  */
1908
2416
  async #admitWith(d: Decision, acting: Standing, principal: string, row: Member | undefined, input: SetAccessInput): Promise<Admitted> {
1909
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2417
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
1910
2418
  new Refused(refusal(code, message), [accessEntry(acting.principal, 'member.add', principal, 'deny', input, { changes: input.changes }, code)]);
1911
2419
  if (!SERVICE.test(principal)) throw refused('invalid', 'only a service account is admitted with its grants: a person, by an instance role');
1912
2420
  if (!runsInstance(acting.live)) await this.#setsUp(d, acting, true, refused);
@@ -1948,6 +2456,7 @@ class VaultService implements Vault {
1948
2456
 
1949
2457
  if (change.role === null) {
1950
2458
  clear();
2459
+ if (existing !== undefined) d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(place) }, actor);
1951
2460
  if (current === undefined) return 'unchanged';
1952
2461
  entry('access.revoke', null);
1953
2462
  return 'revoked';
@@ -1960,6 +2469,8 @@ class VaultService implements Vault {
1960
2469
  await store.insertGrant(d.tx, grant);
1961
2470
  d.grants.get(principal)!.push(grant);
1962
2471
  });
2472
+ // In place of whatever it held there, lapsed or not, as clearing it then inserting this does.
2473
+ d.changes?.stage(memberRef(principal), { op: 'access.grant', grant: inputGrant({ ...placeOf(place), role, expiresAt }) }, actor);
1963
2474
  entry('access.grant', role);
1964
2475
  return current === undefined ? 'created' : 'updated';
1965
2476
  }
@@ -1967,7 +2478,7 @@ class VaultService implements Vault {
1967
2478
  admit(input: AdmitInput): Promise<Outcome<Admitted>> {
1968
2479
  const { actor, principal } = input;
1969
2480
  const asked = input.role === undefined ? {} : { role: input.role, scope: input.scope ?? EVERYWHERE };
1970
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2481
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
1971
2482
  new Refused(refusal(code, message), [
1972
2483
  accessEntry(actor, 'member.add', principal, 'deny', input, asked, code),
1973
2484
  ]);
@@ -2007,6 +2518,7 @@ class VaultService implements Vault {
2007
2518
  if (changed) {
2008
2519
  d.log.push(accessEntry(actor, 'member.role', principal, 'allow', input, { role: wanted.role, scope: wanted.scope, previousRole: row.role, previousScope: row.scope }));
2009
2520
  d.writes.push(() => store.updateMember(d.tx, principal, { role: wanted.role, scope: wanted.scope }));
2521
+ d.changes?.stage(memberRef(principal), { op: 'member.role', ...asStored(wanted.role, wanted.scope) }, actor);
2010
2522
  return { created: false, ...wanted, generation: row.generation };
2011
2523
  }
2012
2524
  return { created: false, role: row.role, scope: row.scope, generation: row.generation };
@@ -2022,11 +2534,30 @@ class VaultService implements Vault {
2022
2534
  */
2023
2535
  async #setsUp(d: Decision, acting: Standing, service: boolean, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
2024
2536
  const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
2025
- if (!service || !PERSON.test(acting.principal) || !setsUpServices(acting.live, setting, placesOf(await store.liveProjects(d.tx)))) {
2537
+ if (!service || !PERSON.test(acting.principal) || !setsUpServices(acting.live, setting, placesOf(await this.#livePlaces(d)))) {
2026
2538
  throw refused('not_allowed', 'only admins and owners of the whole instance may add people or set their roles, and a service account takes access somewhere the instance lets people set one up');
2027
2539
  }
2028
2540
  }
2029
2541
 
2542
+ /**
2543
+ * Every live project and its environments, each environment by its
2544
+ * project, slug and tombstone as 0.15's log holds its row (T159): one that
2545
+ * is not is no place anyone sets a service account up in, nor that a
2546
+ * grant on every project of 0.4 becomes one on. Each read once,
2547
+ * in the statement whose rows are checked, so what is checked is what is
2548
+ * weighed (review r3: a row renamed between two reads, and back).
2549
+ */
2550
+ async #livePlaces(d: Decision): Promise<Awaited<ReturnType<typeof store.liveProjects>>> {
2551
+ const projects = await store.liveProjects(d.tx);
2552
+ if (!this.#checksPlaces()) return projects;
2553
+ const rows = await store.environmentsById(d.tx, 'all', { seals: true });
2554
+ const held = [...rows].filter(([id, row]) => this.#placeFault(id, row, d.reports) === null && !row.deleted);
2555
+ return projects.map((project) => ({
2556
+ id: project.id,
2557
+ environments: held.filter(([, row]) => row.projectId === project.id).map(([id, row]) => ({ id, slug: row.slug })).sort((a, b) => (a.id < b.id ? -1 : 1)),
2558
+ }));
2559
+ }
2560
+
2030
2561
  /**
2031
2562
  * Admit `principal`, who has no row or a removed one: a fresh start, as a
2032
2563
  * member unless given a role (`wanted`), at the generation a removal
@@ -2048,6 +2579,7 @@ class VaultService implements Vault {
2048
2579
  if (wanted?.role === 'owner' && !ownsInstance(acting.live)) throw refused('not_allowed', OWNERS_ONLY);
2049
2580
  const { role, scope } = wanted ?? { role: 'member' as const, scope: EVERYWHERE };
2050
2581
  d.log.push(accessEntry(actor, row === undefined ? 'member.add' : 'member.restore', principal, 'allow', correlation, { role, scope }));
2582
+ d.changes?.stage(memberRef(principal), { op: row === undefined ? 'member.add' : 'member.restore', ...asStored(role, scope) }, actor);
2051
2583
  d.writes.push(async (at) => {
2052
2584
  if (row === undefined) {
2053
2585
  await store.insertMember(d.tx, {
@@ -2072,7 +2604,7 @@ class VaultService implements Vault {
2072
2604
 
2073
2605
  remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>> {
2074
2606
  const { actor, principal } = input;
2075
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2607
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
2076
2608
  new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
2077
2609
  return this.#decide([actor, principal], async (d) => {
2078
2610
  validateCorrelation(input);
@@ -2091,7 +2623,7 @@ class VaultService implements Vault {
2091
2623
  if (!runsInstance(holder)) {
2092
2624
  // A service account its manager removes: they reach everything it holds (`managesService`).
2093
2625
  const theirs = SERVICE.test(principal) && PERSON.test(actor) && row?.status === 'active' &&
2094
- managesService(holder, (await this.#settingsAt(d.tx, d.reports)).serviceAccounts, actor, await this.#account(d.tx, subject, row));
2626
+ managesService(holder, (await this.#settingsAt(d.tx, d.reports)).serviceAccounts, actor, await this.#account(d.tx, subject, row, d.reads, d.reports));
2095
2627
  if (!theirs) throw refused('not_allowed', 'only admins and owners of the whole instance may remove members, and a service account whoever manages it');
2096
2628
  }
2097
2629
  if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
@@ -2113,6 +2645,11 @@ class VaultService implements Vault {
2113
2645
  }
2114
2646
  const generation = row.generation + 1;
2115
2647
  d.log.push(accessEntry(actor, 'member.remove', principal, 'allow', input, { revoked: revoked.length, generation }));
2648
+ // In 0.15's log, what the writes below do, op by op: every grant revoked, lapsed ones too, the role back to a
2649
+ // Member's, then the removal.
2650
+ for (const grant of held) d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(grant) }, actor);
2651
+ if (row.role !== 'member') d.changes?.stage(memberRef(principal), { op: 'member.role', ...asStored('member', EVERYWHERE) }, actor);
2652
+ d.changes?.stage(memberRef(principal), { op: 'member.remove' }, actor);
2116
2653
  d.touched.add(principal);
2117
2654
  d.writes.push(async (at) => {
2118
2655
  await store.deleteGrants(d.tx, principal);
@@ -2135,8 +2672,15 @@ class VaultService implements Vault {
2135
2672
  * environment by its slug, which the setting's scope matches, and who
2136
2673
  * admitted it, as its row says.
2137
2674
  */
2138
- async #account(db: Queryable, subject: Standing, row: Member): Promise<ServiceAccount> {
2139
- const slugs = await store.environmentsById(db, subject.live.grants.flatMap((grant) => (grant.environmentId === null ? [] : [grant.environmentId])));
2675
+ async #account(db: Queryable, subject: Standing, row: Member, reads: Snapshot, reports: NewEntry[]): Promise<ServiceAccount> {
2676
+ // Every place it holds a grant on, lapsed ones too, as 0.15's log holds its row (T159): one that is not holds nothing,
2677
+ // and so would leave the account easier to manage, not harder. Refused instead.
2678
+ const ids = subject.stored.flatMap((grant) => (grant.environmentId === null ? [] : [grant.environmentId]));
2679
+ // The decision's own read of them, their grants' places among it once 0.15's log is cut over (`asked.seals`); else read.
2680
+ const missing = ids.filter((id) => !reads.environments.has(id));
2681
+ const slugs = missing.length === 0 ? reads.environments : new Map([...reads.environments, ...await store.environmentsById(db, missing, { seals: this.#checksPlaces() })]);
2682
+ if (this.#checksPlaces() && ids.some((id) => !slugs.has(id))) throw new Refused(refusal('tampered', TAMPERED_PLACE), []);
2683
+ this.#placesHold(slugs, ids, reports);
2140
2684
  return {
2141
2685
  grants: subject.live.grants.map((grant) => ({
2142
2686
  role: grant.role,
@@ -2164,15 +2708,16 @@ class VaultService implements Vault {
2164
2708
  * entries and none of its own within reach, the setting is closed, never
2165
2709
  * the default: otherwise whoever can insert rows could open it again.
2166
2710
  */
2167
- async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
2711
+ async #settingsAt(db: Queryable, reports: NewEntry[], reads?: Snapshot): Promise<Settings> {
2712
+ // A snapshot's newest first, when it read it (`asked.settings`); the pages before it, as they are asked.
2168
2713
  const entry = await newestAuthentic(
2169
- (limit, before) => store.newestVaultEntries(db, SETTINGS, limit, before),
2714
+ (limit, before) => (reads?.newestSettings !== undefined && before === undefined
2715
+ ? Promise.resolve(reads.newestSettings === null ? [] : [reads.newestSettings])
2716
+ : store.newestVaultEntries(db, SETTINGS, limit, before)),
2170
2717
  this.#check(reports, null),
2171
2718
  );
2172
- if (entry === 'none') return DEFAULT_SETTINGS;
2173
- if (typeof entry === 'string') return CLOSED_SETTINGS;
2174
- const { serviceAccounts } = JSON.parse(entry.metadata) as { serviceAccounts?: unknown };
2175
- return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
2719
+ if (entry === 'none') return settingsFrom(null);
2720
+ return typeof entry === 'string' ? CLOSED_SETTINGS : settingsFrom(entry.metadata);
2176
2721
  }
2177
2722
 
2178
2723
  // --- the instance's access policy -------------------------------------------
@@ -2193,7 +2738,7 @@ class VaultService implements Vault {
2193
2738
  * no role holds anything but Owner, otherwise whoever can insert rows
2194
2739
  * could unprotect them. Its edits are then unknown, not none.
2195
2740
  */
2196
- async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
2741
+ async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean; seq: bigint | null }> {
2197
2742
  // The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
2198
2743
  const entry = await newestAuthentic(
2199
2744
  (limit, before) => (reads !== undefined && before === undefined
@@ -2201,11 +2746,13 @@ class VaultService implements Vault {
2201
2746
  : store.newestVaultEntries(db, POLICY, limit, before)),
2202
2747
  this.#check(reports, null),
2203
2748
  );
2204
- if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
2749
+ if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true, seq: null };
2205
2750
  const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
2206
- if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
2207
- const rows = entry === reads?.newestPolicy ? reads.environments : await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
2208
- return { policy: livePolicy(rows, edits, at), edits, readable: true };
2751
+ if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false, seq: null };
2752
+ const rows = entry === reads?.newestPolicy
2753
+ ? reads.environments
2754
+ : await store.environmentsById(db, edits.protected.map((place) => place.environmentId), { seals: this.#checksPlaces() });
2755
+ return { policy: this.#livePolicy(rows, edits, at, reports), edits, readable: true, seq: (entry as StoredEntry).seq };
2209
2756
  }
2210
2757
 
2211
2758
  changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
@@ -2224,7 +2771,7 @@ class VaultService implements Vault {
2224
2771
  ...at,
2225
2772
  metadata: JSON.stringify({ ...detail, ...traced(input) }),
2226
2773
  });
2227
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2774
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
2228
2775
  new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
2229
2776
  return this.#decide([actor], async (d) => {
2230
2777
  validateCorrelation(input);
@@ -2244,6 +2791,8 @@ class VaultService implements Vault {
2244
2791
  if (!readable && !owner) throw refused('not_allowed', "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
2245
2792
  // Those the policy no longer holds stay out: a deleted environment's id, and an unwritten fork's past its window,
2246
2793
  // neither of which comes back, the window being one a fork writes its row well within.
2794
+ // What 0.15's log changes with it: the roles object, or each environment whose protection moves.
2795
+ const changes: { ref: Ref & { kind: 'roles' | 'environment' }; op: Op }[] = [];
2247
2796
  const next: PolicyEdits = {
2248
2797
  roles: { ...edits.roles },
2249
2798
  instanceRoles: { ...edits.instanceRoles },
@@ -2271,6 +2820,7 @@ class VaultService implements Vault {
2271
2820
  throw refused('invalid', `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
2272
2821
  }
2273
2822
  delete table[change.role];
2823
+ changes.push({ ref: ROLES_REF as Ref & { kind: 'roles' }, op: { op: 'role.define', table: instance ? 'instanceRoles' : 'roles', role: change.role, definition: null } });
2274
2824
  } else {
2275
2825
  const problem = definitionProblem(change.definition);
2276
2826
  if (problem !== null) throw refused('invalid', problem);
@@ -2282,6 +2832,7 @@ class VaultService implements Vault {
2282
2832
  const shipped = instance ? INSTANCE_ROLES[change.role as InstanceRole] : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
2283
2833
  if (shipped !== undefined && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
2284
2834
  else table[change.role] = definition;
2835
+ changes.push({ ref: ROLES_REF as Ref & { kind: 'roles' }, op: { op: 'role.define', table: instance ? 'instanceRoles' : 'roles', role: change.role, definition } });
2285
2836
  }
2286
2837
  } else if (change?.kind === 'protect') {
2287
2838
  if (typeof change.projectId !== 'string' || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0
@@ -2291,28 +2842,52 @@ class VaultService implements Vault {
2291
2842
  if (!(await store.projectLive(d.tx, change.projectId))) throw refused('invalid', 'no such project');
2292
2843
  at.projectId = change.projectId;
2293
2844
  // Each a live environment of the project, or none yet: a fork's id, protected before its row is written.
2294
- const found = await store.environmentsById(d.tx, change.environmentIds);
2845
+ const found = await store.environmentsById(d.tx, change.environmentIds, { seals: this.#checksPlaces() });
2846
+ // Each as 0.15's log holds its row (T159, review r1): its project and whether it was deleted decide what changes, and
2847
+ // a tombstone set around the log would keep a protection's end from its object. One the log has never held (no
2848
+ // seal, no change) is 0.14's alone, which the change path takes as such.
2849
+ this.#placesHold(found, [...found].flatMap(([id, row]) => (row.sealed?.seal == null && row.sealed?.newest == null ? [] : [id])), d.reports);
2295
2850
  if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused('invalid', 'each environment is one of the project\'s');
2296
2851
  if (change.environmentIds.length === 1 && found.has(change.environmentIds[0]!)) at.environmentId = change.environmentIds[0];
2852
+ // An environment whose object 0.15's log changes with this: one with a row, not deleted (`environment.protect`).
2853
+ const live015 = (id: string) => found.get(id)?.deleted === false;
2297
2854
  if (change.protected) {
2298
2855
  if (!allows(acting.live, 'environment.manage', { projectId: change.projectId })) throw refused('not_allowed', 'protecting an environment takes managing its project\'s environments');
2299
2856
  const known = new Set(next.protected.map((place) => place.environmentId));
2857
+ // An id with no row yet, a fork's, is no object of 0.15's log: its creation carries its protection (plan PR 4b).
2858
+ // A deleted one changes no more there: 0.14 keeps its id in the edits, which its policy reads past.
2859
+ for (const id of change.environmentIds) if (!known.has(id) && live015(id)) changes.push({ ref: { kind: 'environment', id }, op: { op: 'environment.protect', protected: true } });
2300
2860
  next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
2301
2861
  .sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
2302
2862
  } else {
2303
2863
  // Unprotecting widens what people hold there: an Owner of the project's to do, or of the whole instance's (R30).
2304
2864
  // It takes off only what the project protects: a fork's id, with no row yet, is another project's to unprotect.
2305
2865
  if (!ownsProject(acting.live, change.projectId)) throw refused('not_allowed', 'only an Owner of its project, or of the whole instance, unprotects an environment');
2866
+ for (const place of next.protected) {
2867
+ if (place.projectId === change.projectId && change.environmentIds.includes(place.environmentId) && live015(place.environmentId)) {
2868
+ changes.push({ ref: { kind: 'environment', id: place.environmentId }, op: { op: 'environment.protect', protected: false } });
2869
+ }
2870
+ }
2306
2871
  next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
2307
2872
  }
2308
2873
  } else {
2309
2874
  throw refused('invalid', 'a change is to a role, an instance role, or protection');
2310
2875
  }
2311
2876
  // The policy now, and the newest change, which it is as of: this one's, when it changed anything.
2312
- const answer = { policy: livePolicy(await store.environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at), seq: newest === null ? null : Number(newest) };
2877
+ const answer = {
2878
+ policy: this.#livePolicy(await store.environmentsById(d.tx, next.protected.map((place) => place.environmentId), { seals: this.#checksPlaces() }), next, d.at, d.reports),
2879
+ seq: newest === null ? null : Number(newest),
2880
+ };
2313
2881
  if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
2882
+ // With 0.15's log, ordered against the vault's issues in flight, which check the policy under the issue lock shared:
2883
+ // exclusive, its rows written, before its append's trigger takes the fence (`holdIssues`).
2884
+ if (this.#prepared.log015 !== null) await holdIssues(d.tx);
2314
2885
  const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
2315
2886
  d.log.push(made);
2887
+ for (const { ref, op } of changes) d.changes?.stage(ref, op, actor);
2888
+ d.after.push((seqOf) => {
2889
+ for (const { ref } of changes) d.changes?.cite(ref, BigInt(seqOf(made)));
2890
+ });
2316
2891
  d.after.push((seqOf) => (answer.seq = seqOf(made)));
2317
2892
  }
2318
2893
  return answer;
@@ -2330,7 +2905,7 @@ class VaultService implements Vault {
2330
2905
  requestId: input.requestId ?? null,
2331
2906
  metadata: JSON.stringify({ ...detail, ...traced(input) }),
2332
2907
  });
2333
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2908
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
2334
2909
  new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
2335
2910
  return this.#decide([actor], async (d) => {
2336
2911
  validateCorrelation(input);
@@ -2404,10 +2979,10 @@ class VaultService implements Vault {
2404
2979
  * service that cannot answer leaves it undecided: the error is thrown,
2405
2980
  * and the next call asks again.
2406
2981
  */
2407
- async #kekMismatch(): Promise<string | null> {
2982
+ async #kekMismatch(deadline?: number): Promise<string | null> {
2408
2983
  const prepared = this.#prepared;
2409
2984
  if (!prepared.kekChecked) {
2410
- const wrong = await this.#checkKeks();
2985
+ const wrong = await this.#checkKeks(deadline);
2411
2986
  if (!prepared.kekChecked) {
2412
2987
  prepared.wrongKek = wrong;
2413
2988
  prepared.kekChecked = true;
@@ -2422,14 +2997,16 @@ class VaultService implements Vault {
2422
2997
  * keys it wrapped, if there are any; then its check value is recorded.
2423
2998
  * A KEK that opens neither is not the one that wrapped the data.
2424
2999
  */
2425
- async #checkKeks(): Promise<string | null> {
3000
+ async #checkKeks(deadline?: number): Promise<string | null> {
2426
3001
  // A vault that may not write cannot record a check either; why it may not is the answer.
2427
3002
  const superseded = await this.#settled();
2428
3003
  if (superseded !== null) return superseded;
2429
3004
  const checks = await this.#checkValues(this.#db);
2430
3005
  const budget = this.#prepared.options.keyBudgetMs;
2431
3006
  for (const kek of this.#config.keks.all) {
2432
- const operation = { deadline: Date.now() + budget, signal: AbortSignal.timeout(budget) };
3007
+ // A read's, within what is left of its deadline.
3008
+ const spend = within(deadline, budget);
3009
+ const operation = { deadline: Date.now() + spend, signal: AbortSignal.timeout(spend) };
2433
3010
  const opens = async (wrapped: WrappedDek, context: SecretContext, expected?: Buffer) => {
2434
3011
  try {
2435
3012
  const key = await kek.unwrap(wrapped, context, operation);
@@ -2460,6 +3037,8 @@ class VaultService implements Vault {
2460
3037
  // The key it opened, if any, is in the log, as every key the vault opens is. Another call that
2461
3038
  // checked at the same time may have recorded one first: under the log's lock, the first stays.
2462
3039
  await this.#db.transaction(async (tx) => {
3040
+ // Once a process, by its first key operation: a read's waits as the read's, within its deadline.
3041
+ await store.boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
2463
3042
  await lockLogHead(tx);
2464
3043
  if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
2465
3044
  await this.#append(tx, [
@@ -2607,12 +3186,12 @@ class VaultService implements Vault {
2607
3186
  stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
2608
3187
  }
2609
3188
  }
2610
- return this.#decide([], async (d) => {
3189
+ const decided = await this.#decide([], async (d) => {
2611
3190
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
2612
3191
  // Checkpoints one at a time, each against the one before.
2613
3192
  const head = await lockLogHead(d.tx);
2614
3193
  const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
2615
- const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
3194
+ const refused = (code: RefusalCode, detail: Record<string, unknown>, message = REFUSAL_MESSAGES[code]) =>
2616
3195
  new Refused(refusal(code, message), [
2617
3196
  { actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
2618
3197
  ]);
@@ -2683,6 +3262,15 @@ class VaultService implements Vault {
2683
3262
  d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
2684
3263
  return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
2685
3264
  });
3265
+ // Then the bulk count's upkeep, which no read waits on, after a checkpoint signed, never instead of a refusal: the
3266
+ // releases no window can count again, kept a day and an hour (`RELEASES_KEPT_MS`), longer than any window, up to
3267
+ // `RELEASES_PRUNED` of them, in one statement of its own, holding no lock any read takes but the rows it deletes,
3268
+ // and none an append holds. What is kept is the database's to say, not the caller's: no login forgets a release a
3269
+ // window counts. A failure leaves it to the next checkpoint, and says so.
3270
+ if (!decided.ok) return decided;
3271
+ const pruned = await store.pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error: Error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
3272
+ if (pruned === null) return decided;
3273
+ return { ...decided, warning: decided.warning === undefined ? pruned : `${decided.warning}; ${pruned}` };
2686
3274
  }
2687
3275
 
2688
3276
  /**
@@ -2759,7 +3347,7 @@ class VaultService implements Vault {
2759
3347
  return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
2760
3348
  }
2761
3349
 
2762
- async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
3350
+ async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: []; log?: '0.15'; uses?: true; batchUses?: true; sealing?: true; credentials?: true; issues?: true }> {
2763
3351
  await this.#settled();
2764
3352
  const { signers, since } = this.#prepared;
2765
3353
  // A key it replaced vouches only for prefixes before its first entry under the current one: none, before it has one.
@@ -2769,9 +3357,684 @@ class VaultService implements Vault {
2769
3357
  owners: [...this.#config.owners],
2770
3358
  // For an app of 0.5 while it runs beside this vault: there are none, and its lists and checks need the field.
2771
3359
  rootAdmins: [],
3360
+ // With it, the vault makes sign-ins through bindings and revokes credentials itself (credentials.ts).
3361
+ ...(this.#prepared.log015 === null ? {} : { log: '0.15' as const, sealing: true as const, credentials: true as const, issues: true as const, binds: true as const }),
3362
+ // 0.15's uses need its log and the hot path's login: the app opens values through them only then (`use`).
3363
+ // A request's several keys in one call too (`useMany`, T167), said so that an app newer than its vault asks for it
3364
+ // only of a vault that has it.
3365
+ ...(this.#prepared.log015 === null || this.#hot === undefined ? {} : { uses: true as const, batchUses: true as const }),
3366
+ };
3367
+ }
3368
+
3369
+ /**
3370
+ * 0.15's cutover from 0.14 (cutover.ts; plan PR 9; formal/Migration015.tla).
3371
+ * Done once: the marker read with no lock first, so that each isolate's
3372
+ * start stalls nothing once it is. Without `input`, where 0.14's chain is
3373
+ * verified from, for the app to check its own entries from there.
3374
+ *
3375
+ * With it: 0.14's chain walked on from its newest checkpoint, outside any
3376
+ * transaction (VerifyChain); then one transaction, under every member's
3377
+ * row, 0.14's head, the issue lock shared and the fence (RowFirst,
3378
+ * CutoverHeadLock, CutoverIssueFirst, CutoverFence), the marker read again
3379
+ * (Marker), the rest of the chain to the head, what the app checked still
3380
+ * the chain's and nothing of the app's since (VouchedHeld), every row read
3381
+ * and checked (`planCutover`), the places 0.14 deleted made as 0.15 leaves
3382
+ * them, each object's genesis, its own 0.14 entry, `log.cutover`, and the
3383
+ * cutover's change naming it. Answers once that change is sealed (R51).
3384
+ */
3385
+ async cutover(input?: CutoverInput | null): Promise<CutoverOutcome> {
3386
+ const log015 = this.#log015();
3387
+ const prepared = this.#prepared;
3388
+ if (prepared.cutover || (await this.#cutoverSealed())) {
3389
+ prepared.cutover = true;
3390
+ return { ok: true, done: true };
3391
+ }
3392
+ if (prepared.since === null && prepared.superseded === null) await this.#settle();
3393
+ if (prepared.superseded !== null) return { ok: false, refused: 'refused', why: prepared.superseded };
3394
+ const from = await this.#resume(this.#db, 'newest');
3395
+ if (!from.ok) return this.#cutoverRefused(`0.14's chain does not verify at entry ${from.failedAtSeq}: ${from.reason}`);
3396
+ const mark = { seq: from.at.seq.toString(), hash: from.at.hash.toString('hex') };
3397
+ // Over RPC or a socket, no input arrives as null.
3398
+ if (input == null) return { ok: true, done: false, from: mark };
3399
+ // The app checked its entries from where this walk starts, or another checkpoint came since: it checks again.
3400
+ if (input.checked?.from?.seq !== mark.seq || input.checked.from.hash !== mark.hash) return { ok: false, refused: 'moved', why: "0.14's newest checkpoint is not the one the app checked from" };
3401
+ // 0.14's chain whole, outside any transaction (VerifyChain): the prefix its newest checkpoint signed, rehashed from the
3402
+ // first entry, which must come to the hash it signed (an entry edited, deleted or moved before it does not: review
3403
+ // r4), each entry there MAC'd when that checkpoint was signed; then the entries since, by the vault's MACs. Once a
3404
+ // cutover: hashes alone, no MAC, no tally.
3405
+ const signed = await relink(this.#db, START, from.at.seq, Infinity);
3406
+ if (!signed.ok) return this.#cutoverRefused(`0.14's chain does not verify at entry ${signed.failedAtSeq}: ${signed.reason}`);
3407
+ if (signed.at.seq !== from.at.seq || !signed.at.hash.equals(from.at.hash)) return this.#cutoverRefused(`0.14's chain up to entry ${from.at.seq} is not the prefix its newest checkpoint signed`);
3408
+ const walked = await this.#walkOn(from, Infinity);
3409
+ if (!walked.ok) return this.#cutoverRefused(`0.14's chain does not verify at entry ${walked.failedAtSeq}: ${walked.reason}`);
3410
+ const reports: NewEntry[] = [];
3411
+ let number: bigint;
3412
+ try {
3413
+ number = await this.#db.transaction((tx) => this.#cutOver(tx, input, walked, reports));
3414
+ } catch (error) {
3415
+ if (error instanceof VouchMoved || error instanceof Retry) return { ok: false, refused: 'moved', why: error instanceof VouchMoved ? error.message : 'a member was admitted meanwhile' };
3416
+ if (error instanceof CutoverRefused) return this.#cutoverRefused(error.message);
3417
+ throw error;
3418
+ }
3419
+ this.#reported(reports);
3420
+ // Done once sealed (R51), whichever vault's change it is: one found under the locks too.
3421
+ await log015.sealThrough(number);
3422
+ prepared.cutover = true;
3423
+ return { ok: true, done: true };
3424
+ }
3425
+
3426
+ /** The cutover's transaction (`cutover`): its change's number, another vault's if it cut over first. */
3427
+ async #cutOver(tx: Transaction, input: CutoverInput, walked: Walked, reports: NewEntry[]): Promise<bigint> {
3428
+ const log015 = this.#log015();
3429
+ const { logKeys } = this.#prepared;
3430
+ await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
3431
+ const principals = (await store.allMembers(tx)).map((row) => row.principal);
3432
+ const rows = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
3433
+ const head = await lockLogHead(tx);
3434
+ await holdFence(tx);
3435
+ const found = await store.cutoverNumber(tx);
3436
+ if (found !== null) return found;
3437
+ if ((await store.allMembers(tx)).length !== rows.size) throw new Retry();
3438
+ // 0.14's chain, to the head it holds: what the walk read still there, and the rest by the vault's MACs.
3439
+ if (!(await carries(tx, headOf(walked.at)))) throw new CutoverRefused(`the log up to entry ${walked.at.seq} changed while the vault walked it`);
3440
+ const walk = new Walk(walked.tally, walked.digest);
3441
+ const rest = await rehash(tx, logKeys, walked.at, walk, {});
3442
+ if (!rest.ok) throw new CutoverRefused(`0.14's chain does not verify at entry ${rest.failedAtSeq}: ${rest.reason}`);
3443
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
3444
+ if (unsigned !== null) throw new CutoverRefused(`0.14's chain does not verify at entry ${unsigned.failedAtSeq}: ${unsigned.reason}`);
3445
+ if (rest.at.seq !== head.nextSeq - 1n || !rest.at.hash.equals(head.headHash)) throw new CutoverRefused("0.14's chain head does not name its last entry");
3446
+ // What the app checked by its MACs is the chain's, and it wrote nothing since: what it vouched for stands (VouchedHeld).
3447
+ const to = { seq: Number(input.checked.to.seq), hash: input.checked.to.hash };
3448
+ if (!Number.isSafeInteger(to.seq) || to.seq < -1 || BigInt(to.seq) > rest.at.seq || !(await carries(tx, to))) throw new VouchMoved("the chain the app checked is not 0.14's now");
3449
+ if (await store.appEntriesAfter(tx, BigInt(to.seq))) throw new VouchMoved('the app wrote to 0.14\'s log since it vouched for its rows');
3450
+
3451
+ const at = await this.#now(tx);
3452
+ const { edits, readable, seq: policySeq } = await this.#policyAt(tx, reports, at);
3453
+ if (!readable) throw new CutoverRefused("0.14's newest policy is not the vault's: its roles and protection are not known");
3454
+ const logged = await store.loggedObjects(tx);
3455
+ const held = new Map<string, GrantRow[]>();
3456
+ for (const grant of await store.grants(tx)) held.set(grant.principal, [...(held.get(grant.principal) ?? []), grant]);
3457
+ const seals = await store.memberSeals(tx);
3458
+ // Each member's newest access entry, all in one read: a row whose MAC holds and names it, the vault's, is as 0.14's
3459
+ // `#integrity` takes it; any other is checked by it, one read each, and reported.
3460
+ const newest = await store.newestAccessEntries(tx);
3461
+ const members = [];
3462
+ for (const member of rows.values()) {
3463
+ const grants = held.get(member.principal) ?? [];
3464
+ const seal = seals.get(member.principal) ?? null;
3465
+ // A member 0.15's log holds already is its log's: not checked as 0.14's again.
3466
+ const fresh = seal === null && !logged.has(objectKey(memberRef(member.principal)));
3467
+ const entry = newest.get(member.principal);
3468
+ const plain = entry !== undefined && entry.seq === member.accessSeq && this.#authentic(entry) && this.#sealed(member, grants);
3469
+ members.push({ member, grants, seal, genuine: !fresh || plain || (await this.#integrity(tx, member.principal, member, grants, reports)) === null });
3470
+ }
3471
+ const secrets = await store.everySecret(tx);
3472
+ const references = await this.#referencesMade(tx, secrets);
3473
+ const plan = planCutover({
3474
+ logged,
3475
+ config: await store.configRows(tx),
3476
+ members,
3477
+ edits,
3478
+ policySeq,
3479
+ bulk: this.#config.bulkLimit,
3480
+ projects: await store.everyProject(tx),
3481
+ environments: await store.everyEnvironment(tx),
3482
+ secrets,
3483
+ references,
3484
+ input,
3485
+ credentials: await store.credentialsAt(tx, input.credentials.map((columns) => columns.id)),
3486
+ bindings: await store.bindingsAt(tx, input.bindings.map((columns) => columns.id)),
3487
+ connections: await store.connectionsAt(tx, input.connections.map((columns) => columns.id)),
3488
+ });
3489
+ // Reported as a change refused on them would be (`#notLogged`), once: a member's by their check (`#integrity`).
3490
+ for (const { ref, fault } of plan.skipped) if (ref.kind !== 'principal') this.#report(reports, null, fault === 'tampered' ? 'mac' : 'stale', `log.${ref.kind}:${ref.id}`);
3491
+
3492
+ await store.endReferenceRows(tx, plan.ends, at);
3493
+ await store.cutoverPlaces(tx, at, plan.rename, plan.archive);
3494
+ // Its own entry last, the head its change names (the anchor): the trigger's issue lock and fence held already.
3495
+ const appended = await this.#append(tx, [...reports, {
3496
+ actor: VAULT_ACTOR, action: CUTOVER, decision: 'allow',
3497
+ metadata: JSON.stringify({ from: { seq: walked.from.at.seq.toString(), hash: walked.from.at.hash.toString('hex') }, sealed: plan.genesis.length + plan.bindings.length, skipped: plan.skipped.length }),
3498
+ }]);
3499
+ const calls = genesisCalls(log015, plan.genesis, at);
3500
+ for (let i = 0; i < calls.length; i += GENESIS_BATCH) {
3501
+ const numbers = await store.changeInputs(tx, calls.slice(i, i + GENESIS_BATCH));
3502
+ if (numbers.some((n) => n === null)) throw new VouchMoved('an object\'s row moved under the cutover\'s locks');
3503
+ }
3504
+ for (const call of genesisCalls(log015, plan.bindings, at)) {
3505
+ if ((await store.sealBinding(tx, call)) === null) throw new VouchMoved(`the binding ${call.ref.id}'s row moved under the cutover's locks`);
3506
+ }
3507
+ const anchor = { seq: appended.nextSeq - 1n, hash: appended.headHash };
3508
+ return store.logCutover(tx, cutoverRow(log015, at, cutoverPayload(anchor, walked.from.at)));
3509
+ }
3510
+
3511
+ /**
3512
+ * Each key's newest reference row's `reference.create`, by its number:
3513
+ * whether it is the vault's, by its MAC, about that row, holder and
3514
+ * source; and whether an authentic `reference.end` ended it.
3515
+ */
3516
+ async #referencesMade(db: Queryable, secrets: readonly store.SecretRead[]): Promise<Map<string, { authentic: boolean; ended: boolean }>> {
3517
+ const rows = secrets.flatMap((secret) => (secret.reference === null ? [] : [{ secret, reference: secret.reference }]));
3518
+ const made = await store.entriesAt(db, rows.map(({ reference }) => reference.createdSeq));
3519
+ // An entry of these the vault's MAC does not hold is damaged history, read under the cutover's locks: refused, never
3520
+ // taken for no end (a reference 0.14 holds ended would be sealed live: review r4) nor for no reference.
3521
+ const damaged = (entry: StoredEntry) => {
3522
+ throw new CutoverRefused(`0.14's ${entry.action} at entry ${entry.seq} does not carry the vault's MAC: its history is damaged`);
3523
+ };
3524
+ const ends = await store.referenceEnds(db, rows.map(({ reference }) => reference.createdSeq));
3525
+ for (const entry of ends) if (!this.#authentic(entry)) damaged(entry);
3526
+ const ended = new Set(ends.map((entry) => entry.relatedSeq!.toString()));
3527
+ return new Map(rows.map(({ secret, reference }) => {
3528
+ const entry = made.get(reference.createdSeq);
3529
+ const created = entry !== undefined && entry.author === 'vault' && entry.action === 'reference.create' && entry.decision === 'allow';
3530
+ if (created && !this.#authentic(entry)) damaged(entry);
3531
+ const sealed = !created ? null : sealedReference(entry, reference.id);
3532
+ // The holder where the vault made it: a row moved around it, its id and reference row kept, would read the source
3533
+ // from another place, where 0.14's read through it is refused.
3534
+ const authentic = sealed !== null && sealed.holder.secretId === secret.id && sealed.holder.projectId === secret.projectId
3535
+ && sealed.holder.environmentId === secret.environmentId && sealed.source.secretId === reference.source;
3536
+ return [reference.createdSeq.toString(), { authentic, ended: ended.has(reference.createdSeq.toString()) }];
3537
+ }));
3538
+ }
3539
+
3540
+ /** A cutover refused, recorded once a process (`log.cutover`, denied), and answered. */
3541
+ async #cutoverRefused(why: string): Promise<CutoverOutcome> {
3542
+ const reports: NewEntry[] = [];
3543
+ const key = `cutover|${why}`;
3544
+ if (!this.#prepared.reported.has(key)) {
3545
+ const entry: NewEntry = { actor: VAULT_ACTOR, action: CUTOVER, decision: 'deny', code: 'log_broken', metadata: JSON.stringify({ reason: why }) };
3546
+ REPORTED.set(entry, key);
3547
+ reports.push(entry);
3548
+ }
3549
+ if (reports.length > 0) await this.#db.transaction((tx) => this.#append(tx, reports));
3550
+ this.#reported(reports);
3551
+ return { ok: false, refused: 'refused', why };
3552
+ }
3553
+
3554
+ /**
3555
+ * Under 0.15's log, that it is cut over from 0.14 (`cutover`): nothing of
3556
+ * 0.15's is decided, changed or used before, so that the cutover's change
3557
+ * precedes every other (AnchorHolds) and nothing decides on rows it has
3558
+ * not sealed. The marker read once, with no lock, until it is there.
3559
+ */
3560
+ async #cutOverYet(): Promise<void> {
3561
+ if (!(await this.#cutOverNow())) throw new Error(NOT_CUT_OVER);
3562
+ }
3563
+
3564
+ /** Whether the vault may serve 0.15's log: kept by 0.14's alone, or cut over, its change sealed (`#cutoverSealed`). */
3565
+ async #cutOverNow(): Promise<boolean> {
3566
+ const prepared = this.#prepared;
3567
+ if (prepared.log015 === null || prepared.cutover) return true;
3568
+ if (!(await this.#cutoverSealed())) return false;
3569
+ prepared.cutover = true;
3570
+ return true;
3571
+ }
3572
+
3573
+ /**
3574
+ * Whether the log is cut over, its change sealed: the marker read with no
3575
+ * lock, then, found, its seal waited for (R51), as the vault that wrote it
3576
+ * waits before it answers. Committed and not sealed yet, as when that vault
3577
+ * waits or failed before its seal, it serves nothing until it is. Sealed
3578
+ * long ago, as it is but in a deployment's first moments, a pair this
3579
+ * vault's key signed past it says so (T169): an isolate's first calls, its
3580
+ * uses among them, ask no checkpointer, which may be the one thing that
3581
+ * does not answer.
3582
+ */
3583
+ async #cutoverSealed(): Promise<boolean> {
3584
+ const number = await store.cutoverNumber(this.#db);
3585
+ if (number === null) return false;
3586
+ if (!(await this.#signedPast(number))) await this.#log015().sealThrough(number);
3587
+ return true;
3588
+ }
3589
+
3590
+ /**
3591
+ * Whether the database's newest checkpoint is a pair this vault's key signed in whose changes tree change `number`
3592
+ * is proved, as its seal proves it: a pair covering its number may cover a gap a writer moved the change into
3593
+ * (review r1). Anything short of that goes to the seal, which alarms on what it refuses.
3594
+ */
3595
+ async #signedPast(number: bigint): Promise<boolean> {
3596
+ const store = logStore(this.#db);
3597
+ const newest = await store.newest();
3598
+ if (newest === null || newest.upto < number) return false;
3599
+ const pair = await openPair(newest.notes, (await this.#verifierKeys()).verifiers);
3600
+ if (pair === null || pair.upto !== newest.upto) return false;
3601
+ const proof = await proveIn(store, number, { pair, notes: newest.notes }, { tree: 'changes' }).catch(() => null);
3602
+ return proof !== null;
3603
+ }
3604
+
3605
+ /**
3606
+ * The app's changes, sealed (`prepareChanges`, plan PR 4b): no lock,
3607
+ * nothing written, and no read but, for an unarchive, what its key's log
3608
+ * last sealed (`keptObjects`, T134); but the report of a row that is not
3609
+ * the vault's, which refuses them all (`tampered`, `stale`), as a decision
3610
+ * of its own would.
3611
+ */
3612
+ async prepare(input: PrepareInput): Promise<Outcome<Prepared>> {
3613
+ const log015 = this.#log015();
3614
+ if (typeof input?.actor !== 'string' || input.actor.length === 0 || !Array.isArray(input.changes)) throw new Error('prepare takes an actor and the changes the app will write');
3615
+ // Before the cutover, a change of 0.14's rows alone, which logs nothing (`prepareChanges`): what the app key's
3616
+ // rotation makes as it signs sessions out, which the app's door finishes before it cuts over (review r3). One that
3617
+ // makes an object, or changes one the log holds, waits for the cutover.
3618
+ if (input.changes.some((change) => change.before === null || change.before.seal !== null)) await this.#cutOverYet();
3619
+ try {
3620
+ const restored = input.changes.flatMap((change) => (change.kind === 'secret' && change.restores === true ? [{ kind: 'secret' as const, id: change.id }] : []));
3621
+ const kept = restored.length === 0 ? new Map() : await keptObjects(log015.seals, this.#db, restored);
3622
+ return { ok: true, changes: prepareChanges(log015, Date.now() + this.#prepared.options.clockOffset(), input.actor, input.changes, kept) };
3623
+ } catch (error) {
3624
+ if (!(error instanceof NotLogged)) throw error;
3625
+ const reports: NewEntry[] = [];
3626
+ const refused = this.#notLogged(reports, error);
3627
+ await this.#record(reports);
3628
+ return { ok: false, refusal: refused.refusal };
3629
+ }
3630
+ }
3631
+
3632
+ /** A sign-in through a trust binding, made by the vault (credentials.ts, T149): one statement, then the seal wait (R51). */
3633
+ async signIn(input: SignInInput): Promise<SignInOutcome> {
3634
+ const log015 = this.#log015();
3635
+ await this.#cutOverYet();
3636
+ const at = Date.now() + this.#prepared.options.clockOffset();
3637
+ // The proof, verified here on the sealed binding (D148): what the credential is held to comes of the token, not the app.
3638
+ const proven = await this.#proven(log015, input, at);
3639
+ if ('refused' in proven) return proven;
3640
+ const answer = await signIn(this.#db, log015, at, proven);
3641
+ if (answer.number == null) return { ok: false, refused: answer.state as Exclude<SignInRefusal, 'proof'> };
3642
+ const sealed = await log015.sealThrough(BigInt(answer.number));
3643
+ return { ok: true, number: answer.number, witnessed: sealed.witnessed ?? null };
3644
+ }
3645
+
3646
+ /**
3647
+ * A credential revoked by the vault (credentials.ts, T149): one statement,
3648
+ * then the seal wait. A row whose seal is not the vault's, or not its
3649
+ * object's newest change's, is reported, as `prepare` reports one; one
3650
+ * whose policy moved since its revoke was weighed is refused, nothing
3651
+ * reported.
3652
+ */
3653
+ async revokeCredential(input: RevokeCredentialInput): Promise<RevokeCredentialOutcome> {
3654
+ const log015 = this.#log015();
3655
+ await this.#cutOverYet();
3656
+ const notLogged = async (error: NotLogged) => {
3657
+ const reports: NewEntry[] = [];
3658
+ this.#notLogged(reports, error);
3659
+ await this.#record(reports);
3660
+ return { ok: false as const, refused: error.fault };
3661
+ };
3662
+ let answer: Answer;
3663
+ try {
3664
+ answer = await revokeCredential(this.#db, log015, Date.now() + this.#prepared.options.clockOffset(), input);
3665
+ } catch (error) {
3666
+ if (error instanceof NotLogged) return notLogged(error);
3667
+ throw error;
3668
+ }
3669
+ if (answer.state === 'stale') return notLogged(new NotLogged({ kind: 'credential', id: input.before.columns.id }, 'stale'));
3670
+ if (answer.state === 'moved' || answer.state === 'busy' || answer.state === 'policy') return { ok: false, refused: answer.state };
3671
+ if (answer.number == null) throw new Error('revoke_credential answered neither a change nor why it made none');
3672
+ const sealed = await log015.sealThrough(BigInt(answer.number));
3673
+ return { ok: true, number: answer.number, witnessed: sealed.witnessed ?? null };
3674
+ }
3675
+
3676
+ /**
3677
+ * What of a sign-in the vault checks itself (D148, PR 3b; formal/Lifecycle.tla's
3678
+ * VaultVerifiesToken, AudienceSealed, Fetched), asking no one: only on a
3679
+ * binding whose seal is its own (BindingSealed: the issuer, keys, audience
3680
+ * and claims it trusts are the ones a manager made it with); a workload's
3681
+ * DPoP proof, whose key's thumbprint names the session's key, the
3682
+ * credential's `jkt` and, where its platform lets the caller choose the
3683
+ * token's audience, that audience, never the app's; and, when the sealed
3684
+ * binding carries its issuer's keys, the token, on those keys
3685
+ * (`verifyRunToken`), the sign-in's spend, times and run then taken from
3686
+ * it. A binding of keys its issuer publishes, which the vault does not
3687
+ * fetch, and a proof that is no ID token (an AWS signed request) take the
3688
+ * app's word for the token, as their binding's seal says.
3689
+ */
3690
+ async #proven(log015: NonNullable<PreparedVault['log015']>, input: SignInInput, at: number): Promise<SignInInput | { ok: false; refused: 'unsealed' } | { ok: false; refused: 'proof'; reason: string; message: string; kid: string | null }> {
3691
+ const binding = checkBinding(input?.binding?.columns);
3692
+ const seal = typeof input.binding.seal === 'string' ? Buffer.from(input.binding.seal, 'base64') : null;
3693
+ if (seal === null || binding.revoked || binding.principal !== input.principal || !sealedObject(log015.seals, { kind: 'binding', id: binding.id }, bindingObject(binding), seal)) {
3694
+ return { ok: false, refused: 'unsealed' };
3695
+ }
3696
+ const refuse = (reason: string, message: string, kid: string | null = null) => ({ ok: false as const, refused: 'proof' as const, reason, message, kid });
3697
+ const { idToken, dpop } = input;
3698
+ if ((idToken !== null && (typeof idToken?.token !== 'string' || typeof idToken.jwks !== 'string')) || (dpop !== null && typeof dpop !== 'string')) {
3699
+ throw new Error('a sign-in names the run\'s ID token and its binding\'s uploaded keys, or neither, and a DPoP proof or none');
3700
+ }
3701
+ const workload = input.credential.kind === 'workload';
3702
+ // A workload's key: the one its DPoP proof is signed by, for this instance's sign-in, by the binding's own origin.
3703
+ let jkt: string | null = null;
3704
+ if (workload) {
3705
+ if (dpop === null) return refuse('dpop', 'a DPoP header, a proof signed by the key the session will be bound to, comes with the sign-in');
3706
+ const proof = await verifyProof(dpop, { method: 'POST', url: `${new URL(binding.origin).origin}${WORKLOAD_SIGN_IN_PATH}`, skewSeconds: EXCHANGE_SKEW_SECONDS, now: at });
3707
+ if (!proof.ok) return refuse('dpop', `the DPoP proof does not hold: ${proof.message}`);
3708
+ jkt = proof.jkt;
3709
+ if (input.credential.jkt !== jkt) return refuse('dpop', 'the session is bound to the key its DPoP proof is signed by, no other');
3710
+ } else if (dpop !== null) {
3711
+ return refuse('malformed', 'a CI run\'s sign-in takes no DPoP proof');
3712
+ }
3713
+ // The token the vault verifies itself only on keys its binding's seal digests: it fetches none (Lifecycle.tla's Fetched).
3714
+ if (!binding.jwksUri.startsWith(UPLOADED_KEYS)) {
3715
+ if (idToken !== null) return refuse('malformed', 'a binding of keys its issuer publishes takes the app\'s word for its token, which the vault does not verify');
3716
+ return input;
3717
+ }
3718
+ if (idToken === null) return refuse('malformed', 'a sign-in through a binding of uploaded keys comes with its token, for the vault to verify');
3719
+ let proven: RunProof;
3720
+ try {
3721
+ const run = { profile: binding.profile as WorkloadProfile, issuer: binding.issuer, origin: binding.origin, jwksUri: binding.jwksUri, jwks: idToken.jwks, claims: binding.claims, policy: binding.policy };
3722
+ proven = await verifyRunToken(idToken.token, run, { now: new Date(at), workload: jkt === null ? null : { jkt } });
3723
+ } catch (error) {
3724
+ if (error instanceof RunProofRefused) return refuse(error.reason, error.message, error.kid);
3725
+ throw error;
3726
+ }
3727
+ if (workload && proven.notAfter !== null && input.credential.notAfter !== null && input.credential.notAfter > proven.notAfter) {
3728
+ return refuse('expired', 'the session ends no later than the token it was signed in with');
3729
+ }
3730
+ return {
3731
+ ...input,
3732
+ spent: proven.spent === null ? null : proven.spent.toString('base64'),
3733
+ validUntil: proven.validUntil,
3734
+ record: { ...input.record, issuer: binding.issuer, run: proven.run },
3735
+ };
3736
+ }
3737
+
3738
+ /**
3739
+ * A token issued by the vault (credentials.ts, T149 PR 2; formal/Lifecycle.tla's
3740
+ * IssueRead, IssueRows): what it is weighed on read, the issuer and the
3741
+ * account in one snapshot and the credential the request came in on
3742
+ * beside it, each row checked as `access` checks it; the issue weighed
3743
+ * with the app's own rules on those reads (`weighIssue`); then one
3744
+ * statement that holds the rows as read, and the seal wait. Nothing is
3745
+ * held between the read and the statement. A row that moved in between
3746
+ * (IssuerCAS) has it read and weighed again, as the model's IssueCheck
3747
+ * does, up to three times; then `moved`, and the app asks again.
3748
+ */
3749
+ async issueToken(input: IssueTokenInput): Promise<IssueTokenOutcome> {
3750
+ const log015 = this.#log015();
3751
+ await this.#settled();
3752
+ await this.#cutOverYet();
3753
+ const scope = checkIssue(input);
3754
+ for (let tries = 0; ; tries += 1) {
3755
+ const outcome = await this.#issueOnce(log015, input, scope);
3756
+ if (outcome.ok || outcome.refused !== 'moved' || tries === 2) return outcome;
3757
+ }
3758
+ }
3759
+
3760
+ async #issueOnce(log015: NonNullable<PreparedVault['log015']>, input: IssueTokenInput, scope: CredentialScope): Promise<IssueTokenOutcome> {
3761
+ const read = await this.#issuerRead(log015, { ...input, scope, service: input.credential.kind === 'service' });
3762
+ if ('refused' in read) return read;
3763
+ const why = weighIssue(input, scope, read.holder, read.places, read.via?.columns ?? null, read.issue.at, read.service);
3764
+ if (why !== null) {
3765
+ await this.#record(read.reports);
3766
+ return { ok: false, refused: 'refused', why };
3767
+ }
3768
+ await this.#record(read.reports);
3769
+ const answer = await issueToken(this.#db, issueCall(log015, input, read.issue));
3770
+ if (answer.number == null) return { ok: false, refused: answer.state === 'expired' ? 'refused' : answer.state as IssueTokenRefusal, ...(answer.state === 'expired' ? { why: 'its end is past' } : {}) };
3771
+ const sealedAt = await log015.sealThrough(BigInt(answer.number));
3772
+ return { ok: true, number: answer.number, witnessed: sealedAt.witnessed ?? null };
3773
+ }
3774
+
3775
+ /**
3776
+ * What a grant to `principal` by `actor` is weighed on, read and checked
3777
+ * as `access` checks each row (a token's issue, PR 2; a binding's, PR 3a):
3778
+ * the members, their grants, the policy, a service's setting and the
3779
+ * places in one snapshot, the credential the request came in on beside it;
3780
+ * then the issuer's standing through that credential, the account's, and
3781
+ * the scope's places. A refusal records what the reads reported; else the
3782
+ * caller records them before it writes.
3783
+ */
3784
+ async #issuerRead(
3785
+ log015: NonNullable<PreparedVault['log015']>,
3786
+ input: Pick<IssueTokenInput, 'actor' | 'via' | 'principal' | 'generation' | 'actorAccess' | 'policy'> & { scope: CredentialScope; service: boolean },
3787
+ ): Promise<{ ok: false; refused: IssueTokenRefusal; why?: string } | {
3788
+ issue: IssueRead; holder: Holdings; places: (Place | null)[]; via: Awaited<ReturnType<typeof store.issuingCredential>>;
3789
+ service: { setting: ServiceSetting | null; account: ServiceAccount } | null; reports: NewEntry[];
3790
+ }> {
3791
+ const { actor, principal, scope } = input;
3792
+ const environmentIds = [...new Set([
3793
+ ...scope.flatMap((grant) => (grant.environmentId == null ? [] : [grant.environmentId])),
3794
+ ...input.policy.protected.map(({ environmentId }) => environmentId),
3795
+ ])];
3796
+ const reports: NewEntry[] = [];
3797
+ // The members, their grants, the policy, the setting and the places in one statement; the credential beside it.
3798
+ const [reads, via] = await Promise.all([
3799
+ snapshot(this.#db, { principals: [...new Set([actor, principal])], members: true, settings: input.service, environments: environmentIds, seals: this.#checksPlaces() }),
3800
+ input.via === null ? Promise.resolve(null) : store.issuingCredential(this.#db, input.via),
3801
+ ]);
3802
+ // The settings from the snapshot's newest entry; the pages before it read only when it fails its check.
3803
+ const settings = input.service ? await this.#settingsAt(this.#db, reports, reads) : null;
3804
+ const at = this.#at(reads);
3805
+ const refuse = async (refused: IssueTokenRefusal, why?: string) => {
3806
+ await this.#record(reports);
3807
+ return { ok: false as const, refused, ...(why === undefined ? {} : { why }) };
3808
+ };
3809
+ // Each environment read, as 0.15's log holds its row (T159): a protected one that is not stays protected, its slug
3810
+ // unknown, as `access` answers it (`#livePolicy`); one the scope names refuses the issue; a member's grant on one
3811
+ // holds nothing (`#standing`), and the account's refuses whoever manages it (`#account`).
3812
+ const faulty = new Set([...reads.environments].flatMap(([id, row]) => (this.#placeFault(id, row, reports) === null ? [] : [id])));
3813
+ // The policy the app weighed the scope on, as `policyStill` holds a write to it: one moved since says so, first.
3814
+ const newestPolicy = reads.newestPolicy === null ? null : String(reads.newestPolicy.seq);
3815
+ const renamed = input.policy.protected.some(({ environmentId, slug }) => {
3816
+ const environment = reads.environments.get(environmentId);
3817
+ return (environment === undefined || faulty.has(environmentId) || environment.deleted ? null : environment.slug) !== slug;
3818
+ });
3819
+ if (newestPolicy !== input.policy.seq || renamed) return refuse('policy');
3820
+ const actorRow = reads.members.get(actor);
3821
+ const acting = await this.#standing(this.#db, actor, actorRow, at, reports, reads);
3822
+ if (acting.status !== 'active' || actorRow === undefined) return refuse('ended');
3823
+ if (input.actorAccess !== null && actorRow.accessSeq.toString() !== input.actorAccess) return refuse('access');
3824
+ // The credential the request came in on: the log's (its seal the vault's, and its object's newest change's), live, its issuer's.
3825
+ let viaScope: CredentialScope | null = null;
3826
+ if (input.via !== null) {
3827
+ if (via === null) return refuse('ended');
3828
+ // One 0.14 made, which the log never held, has nothing the vault can authenticate: the app issues on it as before.
3829
+ if (via.seal === null && via.newest === null) return refuse('unlogged');
3830
+ const object = credentialObject(via.columns);
3831
+ const logged = via.seal !== null && via.newest !== null && via.newest.equals(via.seal) && sealedObject(log015.seals, { kind: 'credential', id: input.via }, object, via.seal);
3832
+ if (!logged || via.columns.principal !== actor || via.columns.revoked || (via.columns.expiresAt ?? Infinity) <= at || via.columns.generation !== actorRow.generation) {
3833
+ return refuse('ended');
3834
+ }
3835
+ viaScope = object.scope;
3836
+ }
3837
+ const caller = actor.startsWith('user:') ? 'person' as const : via?.columns.kind === 'workload' ? 'workload' as const : 'bearer' as const;
3838
+ const holder = through(acting, { credentialScope: viaScope, caller }).live;
3839
+ const principalRow = reads.members.get(principal);
3840
+ const subject = principal === actor ? acting : await this.#standing(this.#db, principal, principalRow, at, reports, reads);
3841
+ if (subject.status !== 'active' || principalRow === undefined || principalRow.generation !== input.generation) {
3842
+ return refuse(principal === actor ? 'ended' : 'gone');
3843
+ }
3844
+ if (scope.some((grant) => grant.environmentId != null && faulty.has(grant.environmentId))) return refuse('refused', TAMPERED_PLACE);
3845
+ // Each place by its environment's slug, which scopes match: one deleted, or of another project, is no place. The
3846
+ // protected ones' slugs are held to the policy under the issue lock (`issue_token`), but those whose rows are not as
3847
+ // the log holds them, which it weighed with none: no slug moves that.
3848
+ const places = scope.map((grant): Place | null => {
3849
+ if (grant.environmentId == null) return { projectId: grant.projectId! };
3850
+ const environment = reads.environments.get(grant.environmentId);
3851
+ return environment === undefined || environment.deleted || environment.projectId !== grant.projectId
3852
+ ? null
3853
+ : { projectId: grant.projectId!, environmentId: grant.environmentId, environmentSlug: environment.slug };
3854
+ });
3855
+ let service: { setting: ServiceSetting | null; account: ServiceAccount } | null = null;
3856
+ try {
3857
+ if (settings !== null) service = { setting: settings.serviceAccounts, account: await this.#account(this.#db, subject, principalRow, reads, reports) };
3858
+ } catch (error) {
3859
+ if (error instanceof Refused) return refuse('refused', error.refusal.message);
3860
+ throw error;
3861
+ }
3862
+ return {
3863
+ holder, places, via, service, reports,
3864
+ issue: {
3865
+ at,
3866
+ actor: { generation: actorRow.generation, accessSeq: actorRow.accessSeq },
3867
+ principal: { generation: principalRow.generation, accessSeq: principalRow.accessSeq },
3868
+ via: via === null ? null : { columns: via.columns, seal: via.seal },
3869
+ // The protected environments' slugs, and the scope's as weighed: none moves before the number (Fable on PR 2).
3870
+ slugs: weighed([
3871
+ ...input.policy.protected.filter(({ environmentId }) => !faulty.has(environmentId)),
3872
+ ...places.flatMap((place) => (place?.environmentId == null ? [] : [{ environmentId: place.environmentId, slug: place.environmentSlug ?? null }])),
3873
+ // The places of the account's grants, by whose slugs `#account` weighed who manages it (review r2).
3874
+ ...(service === null ? [] : subject.live.grants.flatMap((grant) => {
3875
+ const row = grant.environmentId === null ? undefined : reads.environments.get(grant.environmentId);
3876
+ return row === undefined ? [] : [{ environmentId: grant.environmentId!, slug: row.deleted ? null : row.slug }];
3877
+ })),
3878
+ ]),
3879
+ },
3880
+ };
3881
+ }
3882
+
3883
+
3884
+ /**
3885
+ * A service's bindings changed by the vault (credentials.ts, T149 PR 3a;
3886
+ * formal/Lifecycle.tla's bind, as an issue, and unbind): a binding made is
3887
+ * weighed as a service token's issue is, on the same reads (`#issuerRead`,
3888
+ * `weighService`), and those it ends, with every credential they issued,
3889
+ * must be the log's (their seals the vault's). One statement, which holds
3890
+ * all of it as read (`change_bindings`), then the seal wait. A row that
3891
+ * moved meanwhile answers `moved`: the app reads again, as the rows it
3892
+ * ends are its reads.
3893
+ */
3894
+ async changeBindings(input: ChangeBindingsInput): Promise<ChangeBindingsOutcome> {
3895
+ const log015 = this.#log015();
3896
+ await this.#settled();
3897
+ await this.#cutOverYet();
3898
+ checkChangeBindings(input);
3899
+ const { actor, make } = input;
3900
+ let read: IssueRead | null = null;
3901
+ let reports: NewEntry[] = [];
3902
+ if (make !== null) {
3903
+ const scope = readScope(make.scope);
3904
+ if (scope === null || scope.length === 0) throw new Error('a binding made reaches what its scope names, at least one place');
3905
+ const weighed = await this.#issuerRead(log015, {
3906
+ actor, via: input.via, principal: make.principal, generation: make.generation, actorAccess: input.actorAccess, policy: input.policy, scope, service: true,
3907
+ });
3908
+ if ('refused' in weighed) return weighed;
3909
+ const why = make.createdBy !== actor.slice(actor.indexOf(':') + 1) ? 'a binding names who made it, its maker'
3910
+ : weighed.places.some((place) => place === null) ? 'a place the binding names is no longer there'
3911
+ : weighService(actor, make.principal, scope, weighed.holder, weighed.places as Place[], weighed.service);
3912
+ if (why !== null) {
3913
+ await this.#record(weighed.reports);
3914
+ return { ok: false, refused: 'refused', why };
3915
+ }
3916
+ read = weighed.issue;
3917
+ reports = weighed.reports;
3918
+ }
3919
+ const call = bindingsCall(log015, input, read?.at ?? Date.now() + this.#prepared.options.clockOffset(), read);
3920
+ await this.#record(reports);
3921
+ if ('refused' in call) return { ok: false, refused: call.refused };
3922
+ const answer = await changeBindings(this.#db, call);
3923
+ if (answer.number == null) return { ok: false, refused: answer.state as ChangeBindingsRefusal };
3924
+ // Only rows the log never held were ended: no change of the log's to wait for.
3925
+ if (answer.number === '0') return { ok: true, number: '0', witnessed: null };
3926
+ const sealedAt = await log015.sealThrough(BigInt(answer.number));
3927
+ return { ok: true, number: answer.number, witnessed: sealedAt.witnessed ?? null };
3928
+ }
3929
+
3930
+ /** Once entry `number` is sealed and witnessed, or its witnesses past their 2 s (R51): what a change the app made waits for. */
3931
+ async waitSealed(input: { number: string }): Promise<Outcome<{ witnessed: boolean | null }>> {
3932
+ const log015 = this.#log015();
3933
+ if (typeof input?.number !== 'string' || !/^[1-9][0-9]{0,18}$/.test(input.number)) throw new Error('waitSealed takes an entry\'s number, in decimal');
3934
+ const sealed = await log015.sealThrough(BigInt(input.number));
3935
+ return { ok: true, witnessed: sealed.witnessed ?? null };
3936
+ }
3937
+
3938
+ /**
3939
+ * 0.15's log checked (verifier.ts): the newest pair and every entry above
3940
+ * it, held to the pairs held outside. With 0.14's log alone, there is
3941
+ * none to check. The signer remembers a pair before the database has it
3942
+ * (RememberFirst), so a check that lands in between finds the database
3943
+ * behind (`Verdict.behind`): it is checked again 50 ms later, then a
3944
+ * tick later (reviews r4, r5). A rollback is still behind then.
3945
+ */
3946
+ /** Whether 0.15's checkpointer seals (T169): what `/readyz` asks. Under 0.14's log alone, nothing waits on one. */
3947
+ async sealing(): Promise<Sealing> {
3948
+ const log015 = this.#prepared.log015;
3949
+ if (log015 === null) return { ok: true };
3950
+ try {
3951
+ return await log015.sealing();
3952
+ } catch (error) {
3953
+ return { ok: false, reason: (error as Error).message };
3954
+ }
3955
+ }
3956
+
3957
+ async verifyTrees(): Promise<TreeVerification> {
3958
+ await this.#settled();
3959
+ if (this.#prepared.log015 === null) return { kept: false };
3960
+ const anchors = await this.#prepared.log015.heldOutside();
3961
+ const keys = await this.#verifierKeys();
3962
+ const walk = () => verifyTrees(this.#db, keys, { anchors });
3963
+ let walked = await walk();
3964
+ // The signer's write lands a moment after it remembers: asked again soon, then once more a tick later, for a write
3965
+ // that failed and is made again at the next tick (PR 8 review r5).
3966
+ for (const wait of [50, TICK_MS + 250]) {
3967
+ if (!walked.behind) break;
3968
+ await new Promise((resolve) => setTimeout(resolve, wait));
3969
+ walked = await walk();
3970
+ }
3971
+ // What crosses to the app: the verdict, never the retry's signal.
3972
+ const { behind: _behind, ...verdict } = walked;
3973
+ return verdict;
3974
+ }
3975
+
3976
+ /**
3977
+ * The entries of 0.15's log, as an audit page read them, whose MAC holds
3978
+ * under none of the vault's signing keys over the columns it shows (T166):
3979
+ * forged, or edited, around coffre. A malformed one is named too. Pure
3980
+ * CPU: about 3 µs an entry, `VOUCHED_AT_ONCE` at most.
3981
+ */
3982
+ async vouch(input: VouchInput): Promise<{ forged: string[] }> {
3983
+ const entries: unknown = (input as { entries?: unknown } | undefined)?.entries;
3984
+ if (!Array.isArray(entries) || entries.length > VOUCHED_AT_ONCE) throw new Error(`vouch takes up to ${VOUCHED_AT_ONCE} entries`);
3985
+ const macs = (this.#prepared.vouchKeys ??= this.#config.signingKeys.map((seed) => logMacKey(seed)));
3986
+ return {
3987
+ forged: entries.flatMap((entry: VouchedEntry) => {
3988
+ const named = typeof entry?.number === 'string' ? entry.number : '?';
3989
+ if (entry?.tree === 'cursor') {
3990
+ const at = typeof entry.through === 'string' && /^[0-9]{1,19}$/.test(entry.through) && typeof entry.pair === 'string' && /^[0-9]{1,19}$/.test(entry.pair) && Number.isSafeInteger(entry.at);
3991
+ const mac = entry.mac === null ? null : typeof entry.mac === 'string' && /^[0-9a-f]{32}$/.test(entry.mac) ? Buffer.from(entry.mac, 'hex') : undefined;
3992
+ const cursor = at && mac !== undefined ? { through: BigInt(entry.through), pair: BigInt(entry.pair), at: entry.at, mac } : null;
3993
+ return cursor !== null && macs.some((key) => cursorHolds(key, cursor)) ? [] : [named];
3994
+ }
3995
+ if (entry?.tree === 'reads') {
3996
+ const read = vouchedRead(entry);
3997
+ return read !== null && macs.some((key) => readHolds(key, read)) ? [] : [named];
3998
+ }
3999
+ const logged = vouchedEntry(entry as Exclude<VouchedEntry, { tree: 'reads' | 'cursor' }>);
4000
+ return logged !== null && macs.some((key) => authentic(key)(logged)) ? [] : [named];
4001
+ }),
4002
+ };
4003
+ }
4004
+
4005
+ /**
4006
+ * A stretch of the reads' check (reads.ts, `checkReads`): from `start`, or
4007
+ * a stretch's `next` (a pair's number), in a snapshot of its own. Flat:
4008
+ * a checkpoint's range, whatever the log's length.
4009
+ */
4010
+ async verifyReads(input: { from: string }): Promise<ReadsVerification> {
4011
+ await this.#settled();
4012
+ if (this.#prepared.log015 === null) return { kept: false };
4013
+ const from = (input as { from?: unknown } | undefined)?.from;
4014
+ const at = typeof from === 'string' ? /^(?:start|([0-9]{1,19}))$/.exec(from) : null;
4015
+ if (at === null) throw new Error('verifyReads starts at start, or at a stretch\'s next');
4016
+ const keys = await this.#verifierKeys();
4017
+ const checked = await this.#db.transaction((tx) => checkReads(logStore(tx as unknown as Database), keys, at[1] === undefined ? 0n : BigInt(at[1])), SNAPSHOT);
4018
+ return {
4019
+ kept: true,
4020
+ ok: checked.problems.length === 0,
4021
+ problems: checked.problems,
4022
+ through: String(checked.through),
4023
+ listed: String(checked.listed),
4024
+ next: checked.next === null ? null : String(checked.next),
2772
4025
  };
2773
4026
  }
2774
4027
 
4028
+ async #verifierKeys(): Promise<VerifierKeys> {
4029
+ return (this.#prepared.verifier ??= await verifierKeys(this.#config.signingKeys));
4030
+ }
4031
+
4032
+ #log015(): NonNullable<PreparedVault['log015']> {
4033
+ const log015 = this.#prepared.log015;
4034
+ if (log015 === null) throw new Error("this vault keeps 0.14's log: the app writes its rows as 0.14 does (`about().log`)");
4035
+ return log015;
4036
+ }
4037
+
2775
4038
  async keyChecks(): Promise<KeyChecks> {
2776
4039
  // A vault key with no check yet gets one first, as before the first key operation.
2777
4040
  await this.#kekMismatch();
@@ -2989,6 +4252,9 @@ class VaultService implements Vault {
2989
4252
 
2990
4253
  type Broken = Extract<LogVerification, { ok: false }>;
2991
4254
 
4255
+ /** 0.14's chain as the cutover walked it from its newest checkpoint, outside any transaction (`#walkOn`). */
4256
+ type Walked = { from: Resumed; tally: Tally; digest: string; at: Position };
4257
+
2992
4258
  /**
2993
4259
  * Where a walk of the log resumes: the checkpoint's entry (null for the
2994
4260
  * first entry), the end of the prefix it signed, the tally through there,
@@ -3088,6 +4354,45 @@ function validateText(value: string): void {
3088
4354
 
3089
4355
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
3090
4356
 
4357
+ /**
4358
+ * The app's `credential.use` entry, as the vault appends it beside a read
4359
+ * (`UnwrapInput.use`): what the app wrote, checked to be what an entry
4360
+ * holds, in its reader's name. The app wrote it under the head itself
4361
+ * before 0.14.5; it describes the read, and decides nothing.
4362
+ */
4363
+ function useEntry(principal: string, correlation: Correlation, use: UseRecord): NewEntry {
4364
+ const id = (value: unknown, name: string) => {
4365
+ if (value !== null && (typeof value !== 'string' || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
4366
+ return value;
4367
+ };
4368
+ // The bound first: nothing past it is read.
4369
+ if (typeof use.metadata !== 'string' || use.metadata.length > USE_METADATA_LENGTH) {
4370
+ throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
4371
+ }
4372
+ if (use.sourceIp !== null) validateText(use.sourceIp);
4373
+ validateText(use.metadata);
4374
+ let metadata: unknown;
4375
+ try {
4376
+ metadata = JSON.parse(use.metadata);
4377
+ } catch {
4378
+ metadata = undefined;
4379
+ }
4380
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
4381
+ throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
4382
+ }
4383
+ return {
4384
+ actor: principal,
4385
+ action: 'credential.use',
4386
+ decision: 'allow',
4387
+ projectId: id(use.projectId, 'projectId'),
4388
+ environmentId: id(use.environmentId, 'environmentId'),
4389
+ operationId: correlation.operationId ?? null,
4390
+ requestId: correlation.requestId ?? null,
4391
+ sourceIp: use.sourceIp,
4392
+ metadata: use.metadata,
4393
+ };
4394
+ }
4395
+
3091
4396
  /** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
3092
4397
  function validateCorrelation(input: Correlation): void {
3093
4398
  if (input.requestId != null) validateText(input.requestId);
@@ -3341,10 +4646,6 @@ function accessEntry(
3341
4646
  };
3342
4647
  }
3343
4648
 
3344
- function live(grant: GrantRow, at: number): boolean {
3345
- return grant.expiresAt === null || grant.expiresAt > at;
3346
- }
3347
-
3348
4649
  /**
3349
4650
  * Where an entry about a grant says it is: its project and environment; or,
3350
4651
  * for a grant on every project of 0.4, neither, and the place as a path in
@@ -3370,67 +4671,10 @@ function placeKey(place: store.Place): string {
3370
4671
  return place.projectId !== null ? `project:${place.projectId}` : `every-project:${place.environmentSlug ?? ''}`;
3371
4672
  }
3372
4673
 
3373
- /** A member's instance role, as their row has it: service accounts are members, whatever a row says. */
3374
- function roleOf(principal: string, row: Member): { role: InstanceRole; scope: Scope } {
3375
- return principal.startsWith('user:') ? { role: row.role, scope: row.scope } : { role: 'member', scope: EVERYWHERE };
3376
- }
3377
-
3378
- function view(grant: GrantRow): Grant {
3379
- return {
3380
- projectId: grant.projectId,
3381
- environmentId: grant.environmentId,
3382
- role: grant.role as Role,
3383
- expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
3384
- grantedAt: iso(grant.grantedAt),
3385
- grantedBy: grant.grantedBy,
3386
- };
3387
- }
3388
-
3389
4674
  function iso(ms: number): string {
3390
4675
  return new Date(ms).toISOString();
3391
4676
  }
3392
4677
 
3393
- /** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
3394
- function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
3395
- const edits = stored.policy as Partial<PolicyEdits> | undefined;
3396
- if (typeof edits !== 'object' || edits === null) return null;
3397
- const { roles, instanceRoles, protected: protectedIds } = edits;
3398
- if (typeof roles !== 'object' || roles === null || typeof instanceRoles !== 'object' || instanceRoles === null || !Array.isArray(protectedIds)) return null;
3399
- const defined = (table: object) => Object.values(table).every((definition) => definitionProblem(definition as RoleDefinition) === null);
3400
- if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
3401
- if (protectedIds.some((place) => typeof place !== 'object' || place === null || !UUID.test((place as ProtectedPlace).projectId) || !UUID.test((place as ProtectedPlace).environmentId)
3402
- || !Number.isSafeInteger((place as ProtectedPlace).at))) return null;
3403
- return { roles: roles as Record<string, RoleDefinition>, instanceRoles: instanceRoles as PolicyEdits['instanceRoles'], protected: protectedIds as ProtectedPlace[] };
3404
- }
3405
-
3406
- /**
3407
- * The policy `edits` say as of `at`: the roles as they ship, each edited
3408
- * one replaced and each made one added; and the protected environments, but
3409
- * for those deleted since, the project around one's included: a deleted
3410
- * prod protects nothing, and holds nobody back from giving a service
3411
- * account a role on all of its project (`givesService`). A fork's id with
3412
- * no row yet stays, protected before it is written, ten minutes: one with
3413
- * none after that is a fork refused or abandoned, never written. Each
3414
- * keeps its slug, which a scoped instance role's filters read. The edits
3415
- * keep them all: no write is needed.
3416
- */
3417
- function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
3418
- const live = edits.protected.filter((place) => {
3419
- const row = rows.get(place.environmentId);
3420
- return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
3421
- });
3422
- const slugOf = (place: ProtectedPlace) => rows.get(place.environmentId)?.slug ?? null;
3423
- return {
3424
- roles: { ...ROLES, ...edits.roles },
3425
- instanceRoles: { ...INSTANCE_ROLES, ...edits.instanceRoles },
3426
- protected: live.map((place) => place.environmentId),
3427
- protectedIn: live.reduce<Record<string, { environmentId: string; slug: string | null }[]>>(
3428
- (byProject, place) => ({ ...byProject, [place.projectId]: [...(byProject[place.projectId] ?? []), { environmentId: place.environmentId, slug: slugOf(place) }] }),
3429
- {},
3430
- ),
3431
- };
3432
- }
3433
-
3434
4678
  /** A change, as its entry names it. */
3435
4679
  function describeChange(change: PolicyChange | undefined): Record<string, unknown> | null {
3436
4680
  if (change === undefined || change === null || typeof change !== 'object') return null;
@@ -3472,3 +4716,32 @@ function serialisable(wrapped: { kekProvider: string; kekId: string; kekVersion:
3472
4716
  function base64(bytes: Uint8Array): string {
3473
4717
  return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
3474
4718
  }
4719
+
4720
+ /** An entry as `vouch` was given it, as the trees take it; null for one of no shape a row has. */
4721
+ function vouchedEntry(entry: Exclude<VouchedEntry, { tree: 'reads' | 'cursor' }>): LogEntry | null {
4722
+ const hex = (value: unknown) => (typeof value === 'string' && /^(?:[0-9a-f]{2})*$/.test(value) ? Buffer.from(value, 'hex') : null);
4723
+ const text = (value: unknown) => typeof value === 'string';
4724
+ if (typeof entry !== 'object' || entry === null || !text(entry.number) || !/^[0-9]{1,19}$/.test(entry.number) || !Number.isSafeInteger(entry.at) || !text(entry.requestId)) return null;
4725
+ const mac = hex(entry.mac);
4726
+ if (mac === null) return null;
4727
+ const number = BigInt(entry.number);
4728
+ if (entry.tree === 'changes') {
4729
+ const seal = entry.seal === null ? null : hex(entry.seal);
4730
+ if (!text(entry.object) || !text(entry.actor) || !text(entry.payload) || (entry.seal !== null && seal === null)) return null;
4731
+ return changeEntry({ number, at: entry.at, requestId: entry.requestId, object: entry.object, actor: entry.actor, payload: entry.payload, seal, mac });
4732
+ }
4733
+ if (entry.tree !== 'uses' || !/^[0-9]{1,19}$/.test(entry.requestId) || !text(entry.credential) || !text(entry.secret) || !Number.isSafeInteger(entry.purpose)
4734
+ || (entry.place !== null && !text(entry.place))) return null;
4735
+ return usedEntry({ number, at: entry.at, requestId: BigInt(entry.requestId), credential: entry.credential, secret: entry.secret, purpose: entry.purpose, place: entry.place, mac });
4736
+ }
4737
+
4738
+ /** A read as `vouch` was given it (`log_reads`, T166); null for one of no shape the projector writes. */
4739
+ function vouchedRead(entry: Extract<VouchedEntry, { tree: 'reads' }>): ReadRow | null {
4740
+ const id = (value: unknown) => typeof value === 'string' && value.length > 0;
4741
+ const maybe = (value: unknown): value is string | null => value === null || id(value);
4742
+ const mac = typeof entry.mac === 'string' && /^[0-9a-f]{32}$/.test(entry.mac) ? Buffer.from(entry.mac, 'hex') : null;
4743
+ if (mac === null || typeof entry.number !== 'string' || !/^[0-9]{1,19}$/.test(entry.number) || !Number.isSafeInteger(entry.at) || !Number.isSafeInteger(entry.purpose)
4744
+ || !id(entry.credential) || !id(entry.principal) || !id(entry.secret) || !id(entry.environment) || !id(entry.project)
4745
+ || !maybe(entry.place) || !maybe(entry.source) || !maybe(entry.sourceEnvironment) || !maybe(entry.sourceProject) || !maybe(entry.version)) return null;
4746
+ return { ...entry, number: BigInt(entry.number), mac };
4747
+ }