@coffre/vault 0.14.5 → 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 (43) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +341 -4
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/index.js +1 -1
  5. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  6. package/dist/node.d.ts +181 -6
  7. package/dist/node.js +358 -16
  8. package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
  9. package/dist/vault-kmtojYnW.js +14623 -0
  10. package/package.json +4 -5
  11. package/src/bulk.ts +89 -0
  12. package/src/changes.ts +478 -0
  13. package/src/checkpointer-node.ts +342 -0
  14. package/src/checkpointer-workers.ts +514 -0
  15. package/src/checkpointer.ts +1165 -0
  16. package/src/cloudflare.ts +207 -13
  17. package/src/config.ts +3 -2
  18. package/src/credentials.postgres.ts +78 -0
  19. package/src/credentials.sqlite.ts +240 -0
  20. package/src/credentials.ts +630 -0
  21. package/src/cutover.ts +275 -0
  22. package/src/decide.ts +437 -2
  23. package/src/inputs.ts +499 -0
  24. package/src/local.ts +20 -4
  25. package/src/log-checkpointer.ts +116 -0
  26. package/src/log-entry.ts +113 -0
  27. package/src/log-replay.ts +188 -0
  28. package/src/log-store.ts +695 -0
  29. package/src/node.ts +73 -9
  30. package/src/objects.ts +141 -0
  31. package/src/reads.ts +502 -0
  32. package/src/retention.ts +229 -0
  33. package/src/snapshot.postgres.ts +39 -9
  34. package/src/snapshot.sqlite.ts +28 -3
  35. package/src/snapshot.ts +64 -10
  36. package/src/store.ts +832 -36
  37. package/src/use.postgres.ts +111 -0
  38. package/src/use.sqlite.ts +542 -0
  39. package/src/use.ts +1060 -0
  40. package/src/vault.ts +1366 -220
  41. package/src/verifier.ts +136 -0
  42. package/src/witness-workers.ts +163 -0
  43. package/dist/vault-Cbi45kLB.js +0 -5197
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,43 +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,
94
125
  USE_METADATA_LENGTH,
95
126
  type Unwrapped,
127
+ type UseInput,
128
+ type NamedUseInput,
129
+ type UseManyInput,
130
+ type UseManyOutcome,
131
+ type NamedUseOutcome,
132
+ type UseOutcome,
96
133
  type UseRecord,
97
134
  type Wrapped,
98
135
  type Vault,
99
136
  type Via,
137
+ type ReadsVerification,
138
+ type TreeVerification,
100
139
  type VerifyLogInput,
140
+ type VouchedEntry,
141
+ type VouchInput,
101
142
  type WrapInput,
102
143
  type WrappedKey,
144
+ VOUCHED_AT_ONCE,
103
145
  } from '@coffre/core/vault';
104
146
  import type { Database, Queryable, Transaction } from '@coffre/db';
105
147
  import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
106
- import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
107
- import { appendEntries, lockLogHead, lockLogHeadQueued, 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';
108
150
 
151
+ import { Changes, keptObjects, Moved, NotLogged, prepareChanges, type ChangeKeys } from './changes.ts';
109
152
  import { signer, type Signer } from './checkpoint.ts';
110
- 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';
111
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';
112
164
  import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
113
165
  import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
166
+ import { principalObject } from './objects.ts';
114
167
  import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
115
168
  import { snapshot, type Asked, type Snapshot } from './snapshot.ts';
116
169
  import * as store from './store.ts';
117
170
  import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
118
- 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
+
119
175
 
120
176
  export type VaultOptions = {
121
177
  /**
@@ -127,8 +183,51 @@ export type VaultOptions = {
127
183
  clockOffset?: () => number;
128
184
  /** How far one call walks the log (`WALKS`); tests shorten them. */
129
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;
130
228
  };
131
229
 
230
+
132
231
  /**
133
232
  * A vault's configuration made ready to use, once per process, or once per
134
233
  * isolate on Workers: its signer, its log key, and how far it has verified
@@ -147,7 +246,23 @@ export type PreparedVault = {
147
246
  logKey: LogKey;
148
247
  /** Each signing key's log key: those after the first verify only entries before `since`. */
149
248
  logKeys: LogKey[];
150
- 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;
151
266
  /** How far the chain is rehashed, by any call of this process: its head must still be there. */
152
267
  verified: Position;
153
268
  /** What member rows are sealed under now; rows.ts. */
@@ -170,6 +285,12 @@ export type PreparedVault = {
170
285
  * then everyone the deployment names made an Owner (`#declare`).
171
286
  */
172
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;
173
294
  };
174
295
 
175
296
  export async function prepareVault(config: ResolvedVaultConfig, options: VaultOptions = {}): Promise<PreparedVault> {
@@ -177,6 +298,12 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
177
298
  config,
178
299
  ...(await keysOf(config.signingKeys)),
179
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 }),
180
307
  verified: START,
181
308
  reported: new Set(),
182
309
  since: null,
@@ -187,6 +314,18 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
187
314
  };
188
315
  }
189
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
+
190
329
  /** The vault's own keys, from each of its signing keys, the one it signs with first. */
191
330
  async function keysOf(seeds: readonly Uint8Array[]) {
192
331
  const signers = await Promise.all(seeds.map((seed) => signer(seed)));
@@ -195,9 +334,16 @@ async function keysOf(seeds: readonly Uint8Array[]) {
195
334
  return { signer: signers[0], signers, logKey: logKeys[0], logKeys, rowKey: rowKeys[0], rowKeys };
196
335
  }
197
336
 
198
- /** The vault over `db`. Cheap: on Workers, one per call, over that call's connections. */
199
- export function openVault(db: Database, prepared: PreparedVault): Vault {
200
- 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);
201
347
  }
202
348
 
203
349
  /** Every key operation of one call, together: a removal waits at most this long for a read at KMS. */
@@ -255,9 +401,6 @@ function within(deadline: number | undefined, ms: number): number {
255
401
  return Math.min(ms, left);
256
402
  }
257
403
 
258
- /** A member: `user:<email>` or `token:<name>`. */
259
- const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
260
-
261
404
  /** A person, and a service account: only a person sets one up (`givesService`). */
262
405
  const PERSON = /^user:/;
263
406
  const SERVICE = /^token:/;
@@ -265,6 +408,14 @@ const SERVICE = /^token:/;
265
408
  /** Who acts for the vault itself, as when it makes an Owner its deployment names. */
266
409
  const VAULT_ACTOR = 'system:vault';
267
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
+
268
419
  // A KEK's check: a known value, the size of a data key, wrapped under it in
269
420
  // a context no secret has (the nil UUID), and kept in a `key.check` entry
270
421
  // (@coffre/core/kek). Opening it again tells the vault its KEK is the one
@@ -280,53 +431,45 @@ const KEY_ROTATE = 'key.rotate';
280
431
  /** A new member row, before the decision seals it (`#seal`). */
281
432
  const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
282
433
 
283
- /** Why a change to a tampered member is refused. */
284
- const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
285
-
286
- /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
287
- const SETTINGS = 'settings.change';
288
-
289
- /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
290
- 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
+ }
291
438
 
292
- /** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
293
- 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
+ }
294
443
 
295
- /**
296
- * What a `policy.change` entry keeps: the roles an Owner edited or made,
297
- * each whole, and the protected environments. A role as it ships is kept by
298
- * none, so a release that changes a default changes it where nobody edited it.
299
- */
300
- 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
+ }
301
448
 
302
449
  /**
303
- * A protected environment, with its project's id, what a grant on the whole
304
- * project reaches of it, and when it was protected, by the vault's clock:
305
- * 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).
306
452
  */
307
- 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
+ }
308
457
 
458
+ function placeOf(grant: store.Place) {
459
+ return { projectId: grant.projectId ?? null, environmentId: grant.environmentId ?? null, environmentSlug: grant.environmentSlug ?? null };
460
+ }
309
461
 
310
- 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";
311
464
 
312
- /** A role holding nothing, which every role but Owner is under the closed policy. */
313
- 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";
314
467
 
315
- /**
316
- * The policy when the log has some, but none the vault wrote within reach:
317
- * whoever inserted rows may have hidden an Owner's narrowing, so no role
318
- * holds anything, a service account's neither, but Owner, which holds
319
- * everything always and so may write the policy again.
320
- */
321
- const CLOSED_POLICY: Policy = {
322
- roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])),
323
- instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])) as Policy['instanceRoles'],
324
- protected: [EVERY_ENVIRONMENT],
325
- protectedIn: {},
326
- };
468
+ /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
469
+ const SETTINGS = 'settings.change';
327
470
 
328
- /** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
329
- 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';
330
473
 
331
474
  /** Why a change to who is an Owner, scoped or not, is refused to anyone else: Owners make and unmake Owners. */
332
475
  const OWNERS_ONLY = 'only an Owner of the whole instance makes someone an Owner, or changes or removes one';
@@ -359,6 +502,9 @@ class Retry {}
359
502
  /** What each `vault.tampered` entry reports, to mark it logged once committed. */
360
503
  const REPORTED = new WeakMap<NewEntry, string>();
361
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
+
362
508
  /** A key service that did not answer, and the entries that record what it did do. */
363
509
  class Outage {
364
510
  readonly error: unknown;
@@ -377,29 +523,6 @@ function refusal(code: RefusalCode, message: string): Refusal {
377
523
  return { code, message };
378
524
  }
379
525
 
380
- const MESSAGES: Record<RefusalCode, string> = {
381
- removed: 'this member was removed',
382
- not_a_member: 'not a member',
383
- no_grant: 'no grant covers this',
384
- expired: 'the grant that covered this has expired',
385
- bulk_limit: 'too many secrets read in too short a time',
386
- bad_claim: 'the key does not belong to this secret',
387
- deleted: 'this project or environment was deleted',
388
- ended: 'the reference read through was ended',
389
- personal: "a personal config's values are its owner's alone",
390
- 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",
391
- not_owner_issued: 'a service account reads a sensitive value only on a token or trust binding an Owner of its project issued',
392
- 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",
393
- not_allowed: 'not allowed to change this',
394
- last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
395
- invalid: 'not something the rules allow',
396
- policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
397
- access_changed: 'what this member holds changed since the read was decided: try again',
398
- log_broken: 'the vault log does not hold from the last checkpoint',
399
- wrong_kek: "this vault's key does not open the data it holds",
400
- tampered: "this member's record failed the vault's integrity check",
401
- };
402
-
403
526
  /**
404
527
  * A decision in progress: its transaction, the member rows it locked, its
405
528
  * time, and what it will commit. Changes to members and grants wait until
@@ -435,6 +558,8 @@ type Decision = {
435
558
  * was deciding. A `Refused` there refuses the decision.
436
559
  */
437
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;
438
563
  };
439
564
 
440
565
  /**
@@ -523,11 +648,64 @@ class VaultService implements Vault {
523
648
  readonly #db: Database;
524
649
  readonly #prepared: PreparedVault;
525
650
  readonly #config: ResolvedVaultConfig;
651
+ readonly #hot: Database | undefined;
526
652
 
527
- constructor(db: Database, prepared: PreparedVault) {
653
+ constructor(db: Database, prepared: PreparedVault, hot?: Database) {
528
654
  this.#db = db;
529
655
  this.#prepared = prepared;
530
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);
531
709
  }
532
710
 
533
711
  async #now(db: Queryable): Promise<number> {
@@ -546,6 +724,7 @@ class VaultService implements Vault {
546
724
  ...rest,
547
725
  principals,
548
726
  head,
727
+ seals: this.#checksPlaces(),
549
728
  // A read waits for the head in one of a few places in line, or is refused at once.
550
729
  ...(reading ? { waiters: READ_HEAD_WAITERS } : {}),
551
730
  ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
@@ -574,6 +753,8 @@ class VaultService implements Vault {
574
753
 
575
754
  /** `#decide`, once the vault is settled. */
576
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();
577
758
  const reports: NewEntry[] = [];
578
759
  // A read's call is due from its first decision, unless its caller set when.
579
760
  const due = how.reads === true ? { ...how, deadline: how.deadline ?? Date.now() + READ_DEADLINE_MS } : how;
@@ -583,7 +764,8 @@ class VaultService implements Vault {
583
764
  } catch (error) {
584
765
  // A member who had no row when this decision locked theirs, and has
585
766
  // one now: another decision admitted them meanwhile. Again, with it.
586
- 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;
587
769
  throw error;
588
770
  }
589
771
  }
@@ -591,15 +773,19 @@ class VaultService implements Vault {
591
773
 
592
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>> {
593
775
  try {
594
- const result = await this.#db.transaction(async (tx) => {
776
+ const committed = await this.#db.transaction(async (tx) => {
595
777
  await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
596
778
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
597
779
  // Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
598
780
  const reads = await this.#snapshot(tx, principals, asked, !late, shared);
781
+ const log015 = this.#prepared.log015;
599
782
  const d: Decision = {
600
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),
601
785
  };
602
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);
603
789
  // A read is logged once per place, whichever way it ends (`readsByPlace`).
604
790
  const entries = [...reports, ...readsByPlace(d.log)];
605
791
  let at = d.at;
@@ -620,12 +806,27 @@ class VaultService implements Vault {
620
806
  const sealing = new Set([...d.touched, ...accessSeq.keys()]);
621
807
  // Under a share lock, a write to the row would wait for every other read holding it, and they for it.
622
808
  if (shared && sealing.size > 0) throw new Error('a read changes no member, and seals no row');
623
- for (const principal of sealing) await this.#seal(d, principal, accessSeq.get(principal));
624
- 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 };
625
822
  });
626
823
  this.#reported(reports);
627
- return { ok: true, ...result };
628
- } 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;
629
830
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
630
831
  const entries = [...reports, ...readsByPlace(error.entries)];
631
832
  if (entries.length > 0) {
@@ -649,19 +850,23 @@ class VaultService implements Vault {
649
850
  * naming `accessSeq`, their newest access entry, when it wrote one. The
650
851
  * table must hold exactly that: a grant written around the vault while it
651
852
  * decided (its row lock does not stop one) is refused, with the decision,
652
- * 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.
653
855
  */
654
- 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> {
655
857
  const row = await store.member(d.tx, principal);
656
- if (row === undefined) return;
858
+ if (row === undefined) return null;
657
859
  const decided = d.grants.get(principal);
658
860
  if (decided === undefined) throw new Error(`sealing ${principal} without the grants this decision verified`);
659
861
  if (!sameGrants(await store.grants(d.tx, principal), decided)) {
660
862
  this.#report(d.reports, principal, 'mac', row.mac.toString('hex'));
661
- throw new Refused(refusal('tampered', MESSAGES.tampered), []);
863
+ throw new Refused(refusal('tampered', REFUSAL_MESSAGES.tampered), []);
662
864
  }
663
865
  const next = { ...row, accessSeq: accessSeq ?? row.accessSeq };
664
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 };
665
870
  }
666
871
 
667
872
  /**
@@ -688,6 +893,89 @@ class VaultService implements Vault {
688
893
  return 'stale';
689
894
  }
690
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
+
691
979
  /** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
692
980
  #newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
693
981
  if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
@@ -745,6 +1033,18 @@ class VaultService implements Vault {
745
1033
  reports.push(entry);
746
1034
  }
747
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
+
748
1048
  /** Mark these reports as logged, once their transaction has committed. */
749
1049
  #reported(reports: readonly NewEntry[]): void {
750
1050
  for (const entry of reports) {
@@ -773,8 +1073,15 @@ class VaultService implements Vault {
773
1073
  // database; calls that race settle it alike, and a rotation they race
774
1074
  // to write goes in once (`#rotate`).
775
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
+ }
776
1082
  // A log with no entry of the vault's holds nothing to convert, and stays empty until someone acts.
777
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();
778
1085
  return prepared.superseded;
779
1086
  }
780
1087
 
@@ -806,6 +1113,30 @@ class VaultService implements Vault {
806
1113
  this.#prepared.converted = await this.#declare(this.#config.owners.map((email) => `user:${email}`));
807
1114
  }
808
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
+
809
1140
  async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
810
1141
  const row = d.members.get(principal);
811
1142
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
@@ -813,9 +1144,11 @@ class VaultService implements Vault {
813
1144
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
814
1145
  if (everyProject.length === 0) return;
815
1146
  if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
816
- // 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.
817
1150
  const before = roleOf(principal, row);
818
- 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));
819
1152
  const after = { role: conversion.role, scope: conversion.scope };
820
1153
  const replacedBy = { role: after.role, scope: after.scope, grants: conversion.grants.length };
821
1154
  d.grants.set(principal, [...subject.stored]);
@@ -829,6 +1162,7 @@ class VaultService implements Vault {
829
1162
 
830
1163
  for (const grant of everyProject) {
831
1164
  drop(grant);
1165
+ d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(grant) }, VAULT_ACTOR);
832
1166
  // A lapsed one goes with no entry: it changes nothing anyone holds.
833
1167
  if (!live(grant, d.at)) continue;
834
1168
  const narrowed = conversion.narrowed.filter((loss) => loss.grant.environmentSlug === grant.environmentSlug);
@@ -849,6 +1183,7 @@ class VaultService implements Vault {
849
1183
  reason: 'every-project',
850
1184
  }));
851
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);
852
1187
  }
853
1188
  for (const given of conversion.grants) {
854
1189
  const place = { projectId: given.projectId, environmentId: given.environmentId, environmentSlug: null };
@@ -860,6 +1195,7 @@ class VaultService implements Vault {
860
1195
  await store.insertGrant(d.tx, grant);
861
1196
  d.grants.get(principal)!.push(grant);
862
1197
  });
1198
+ d.changes?.stage(memberRef(principal), { op: 'access.grant', grant: inputGrant({ ...place, role: given.role, expiresAt: given.expiresAt }) }, VAULT_ACTOR);
863
1199
  d.log.push({
864
1200
  ...accessEntry(VAULT_ACTOR, 'access.grant', principal, 'allow', correlation, {
865
1201
  role: given.role,
@@ -952,7 +1288,8 @@ class VaultService implements Vault {
952
1288
  * is to a key it does not hold: a vault given a newer KEK has replaced
953
1289
  * this one, which writes nothing more. Checked under the head's lock, so
954
1290
  * that an instance still running with the replaced KEK, as during a
955
- * 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.
956
1293
  */
957
1294
  async #append(
958
1295
  tx: Transaction,
@@ -970,7 +1307,7 @@ class VaultService implements Vault {
970
1307
  throw new Error(prepared.superseded);
971
1308
  }
972
1309
  for (const check of underLock) await check(locked);
973
- }, head === undefined ? undefined : { head, last: head.last, at: held!.at });
1310
+ }, head === undefined ? undefined : { head, last: head.last, at: held!.at }, [], prepared.log015 !== null);
974
1311
  // The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
975
1312
  // then the decision's reports.
976
1313
  if (head !== undefined) {
@@ -997,7 +1334,14 @@ class VaultService implements Vault {
997
1334
  const items = loaded.versions.map((version) => ({ ...version, secret: { ...version.secret } }));
998
1335
  validateItems(items, 'wrapped');
999
1336
  const sealed = await this.#referencesAt(vias);
1000
- 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);
1001
1345
  // A reference reads only the current version of the source its entry names.
1002
1346
  const viaOk = items.map((item, i) => vias[i] === null || (sealed[i] !== null && sealed[i]!.source.secretId === item.secret.secretId && item.current));
1003
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,
@@ -1230,12 +1574,12 @@ class VaultService implements Vault {
1230
1574
  principal: string,
1231
1575
  code: 'bad_claim' | 'deleted' | 'invalid',
1232
1576
  entries: NewEntry[],
1233
- { message = MESSAGES[code], asked }: { message?: string; asked?: string } = {},
1577
+ { message = REFUSAL_MESSAGES[code], asked }: { message?: string; asked?: string } = {},
1234
1578
  ): Promise<Outcome<never>> {
1235
1579
  return this.#decide([principal], async (d) => {
1236
1580
  if (asked !== undefined) {
1237
1581
  const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
1238
- 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), []);
1239
1583
  }
1240
1584
  throw new Refused(refusal(code, message), entries);
1241
1585
  }, { reads: true });
@@ -1323,7 +1667,7 @@ class VaultService implements Vault {
1323
1667
  const standing = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads);
1324
1668
  // A read decided on an earlier answer about its reader, only while that is still the answer: nothing read, nothing logged.
1325
1669
  const asked = call.input.accessRevision;
1326
- 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), []);
1327
1671
  const reader = through(standing, call.input);
1328
1672
  // One read of the environments the decision is about, as the store has them, not as the app sent them:
1329
1673
  // each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
@@ -1331,6 +1675,9 @@ class VaultService implements Vault {
1331
1675
  const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
1332
1676
  // Each a personal config is a config of too, whose read its owner needs (`places`).
1333
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);
1334
1681
  owners.clear();
1335
1682
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1336
1683
  const needs = call.needs === undefined ? [] : await call.needs(d.tx);
@@ -1345,7 +1692,7 @@ class VaultService implements Vault {
1345
1692
  if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
1346
1693
  if (first !== null) {
1347
1694
  throw new Refused(
1348
- refusal(first, MESSAGES[first]),
1695
+ refusal(first, REFUSAL_MESSAGES[first]),
1349
1696
  secrets.map((secret, i) => entry(secret, 'deny', codes[i] ?? first)),
1350
1697
  );
1351
1698
  }
@@ -1427,7 +1774,7 @@ class VaultService implements Vault {
1427
1774
  const codes = await vet(locked, d.reads);
1428
1775
  const first = codes.find((code) => code !== null);
1429
1776
  if (first === undefined || first === null) return;
1430
- 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)));
1431
1778
  });
1432
1779
  }
1433
1780
  const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
@@ -1447,7 +1794,7 @@ class VaultService implements Vault {
1447
1794
  }
1448
1795
  if (expired) throw new Outage(new KekUnavailableError('key operation exceeded its deadline'), entries());
1449
1796
  if (done.length < outcomes.length) {
1450
- throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
1797
+ throw new Refused(refusal('bad_claim', REFUSAL_MESSAGES.bad_claim), entries());
1451
1798
  }
1452
1799
  if (late) {
1453
1800
  // Again under the log's head, the append's, taken a statement early: a
@@ -1461,7 +1808,7 @@ class VaultService implements Vault {
1461
1808
  await decided(d);
1462
1809
  const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1463
1810
  if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
1464
- 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')));
1465
1812
  }
1466
1813
  }
1467
1814
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
@@ -1544,15 +1891,18 @@ class VaultService implements Vault {
1544
1891
  const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
1545
1892
  const source = sources.get(item.source.secretId);
1546
1893
  return source === undefined ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
1547
- }));
1894
+ }), { seals: this.#checksPlaces() });
1895
+ // Each as 0.15's log holds its row (T159).
1896
+ this.#placesHold(environments, environments.keys(), d.reports);
1548
1897
  const codes = items.map((item): RefusalCode | null => {
1549
1898
  const source = sources.get(item.source.secretId);
1550
- 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';
1551
1901
  if (source.secretId === item.holder.secretId) return 'invalid';
1552
1902
  return referenceRefusal(reader, item.holder, source, environments, natures.get(source.secretId) ?? 'secret');
1553
1903
  });
1554
1904
  const first = codes.find((code) => code !== null) ?? null;
1555
- 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)));
1556
1906
  const entries = items.map((item) => entry(item, 'allow', null));
1557
1907
  d.log.push(...entries);
1558
1908
  const answer = { seqs: [] as number[] };
@@ -1573,7 +1923,9 @@ class VaultService implements Vault {
1573
1923
  validateCorrelation(input);
1574
1924
  if (reason !== 'replaced' && reason !== 'broken' && reason !== 'abandoned') throw new Error('a reference ends replaced, broken or abandoned');
1575
1925
  const vias = input.items.map((item) => validateVia(item)!);
1576
- 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
+ }
1577
1929
  if (vias.length === 0) return { ok: true, seqs: [] };
1578
1930
  const sealed = await this.#referencesAt(vias);
1579
1931
  // A holder's row is committed after its reference is sealed, if the write went through: an end names it only then.
@@ -1606,31 +1958,111 @@ class VaultService implements Vault {
1606
1958
  });
1607
1959
  if (sealed.some((seal) => seal === null)) {
1608
1960
  return this.#decide([principal], async () => {
1609
- 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')));
1610
1962
  });
1611
1963
  }
1612
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).
1613
1968
  const ended = async (db: Queryable) => {
1614
- 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!));
1615
1970
  return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
1616
1971
  };
1617
1972
  return this.#decide([principal], async (d) => {
1618
1973
  // Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
1619
1974
  const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
1620
1975
  // Ending is allowed in a deleted place: deleting one ends the references into and out of it.
1621
- const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
1622
- const codes: (RefusalCode | null)[] = seals.map((seal) => {
1623
- const holder = refuses(actor, 'secret.write', placeIn(seal.holder, environments), false);
1624
- // Abandoning is its maker's alone, for a write of theirs that stored nothing: the vault
1625
- // 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);
1626
1984
  if (reason === 'abandoned' && seal.createdBy !== principal) return 'not_allowed';
1627
- // Replacing or abandoning is the holder's side: write where it is held.
1628
1985
  if (holder === null || reason !== 'broken') return holder;
1629
- 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;
1630
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
+ }
1631
2017
  if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
1632
2018
  const first = codes.find((code) => code !== null) ?? null;
1633
- 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
+ }
1634
2066
  const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
1635
2067
  d.log.push(...entries);
1636
2068
  const answer = { seqs: [] as number[] };
@@ -1693,6 +2125,8 @@ class VaultService implements Vault {
1693
2125
  * and is asked about again at each of their requests. Whether it ran.
1694
2126
  */
1695
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;
1696
2130
  const at = await this.#now(this.#db);
1697
2131
  const due: string[] = [];
1698
2132
  for (const principal of principals) {
@@ -1740,7 +2174,13 @@ class VaultService implements Vault {
1740
2174
  const owner = { role: 'owner' as const, scope: EVERYWHERE };
1741
2175
  d.grants.set(principal, [...subject.stored]);
1742
2176
  d.touched.add(principal);
1743
- 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
+ }
1744
2184
  if (row === undefined && !tampered) {
1745
2185
  d.log.push(accessEntry(VAULT_ACTOR, 'member.add', principal, 'allow', correlation, { ...owner, reason }));
1746
2186
  d.writes.push((at) => store.insertMember(d.tx, {
@@ -1771,7 +2211,8 @@ class VaultService implements Vault {
1771
2211
  if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
1772
2212
  if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
1773
2213
  if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
1774
- 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 }));
1775
2216
  const { role, scope } = roleOf(principal, row);
1776
2217
  // Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
1777
2218
  const { policy } = await this.#policyAt(db, reports, at, reads);
@@ -1787,25 +2228,16 @@ class VaultService implements Vault {
1787
2228
  }
1788
2229
 
1789
2230
  #access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
1790
- const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
1791
- const access: Access = {
1792
- principal,
1793
- status: tampered ? 'tampered' : !PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
1794
- generation: row?.generation ?? 0,
1795
- ...(active ? roleOf(principal, row) : { role: 'member', scope: EVERYWHERE }),
1796
- grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
1797
- since: row ? iso(row.statusChangedAt) : null,
1798
- by: row?.statusChangedBy ?? null,
1799
- policy,
1800
- };
1801
- return { ...access, revision: accessRevision(access) };
2231
+ return answerFrom(principal, row, held, at, tampered, policy);
1802
2232
  }
1803
2233
 
1804
2234
  /** The revision of what `access` would answer about a standing a decision read: none but an active member's matches a kept answer. */
1805
2235
  #revisionOf(standing: Standing, d: Decision): string | null {
1806
2236
  const { policy } = standing.live;
1807
2237
  if (standing.status !== 'active' || policy === undefined) return null;
1808
- 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!;
1809
2241
  }
1810
2242
 
1811
2243
  /**
@@ -1830,11 +2262,13 @@ class VaultService implements Vault {
1830
2262
  /** `access`, as the rows are now. */
1831
2263
  async #read(principal: string, superseded: string | null): Promise<Access> {
1832
2264
  const read = async (db: Queryable, reports: NewEntry[]) => {
1833
- const reads = await snapshot(db, { principals: [principal], members: true });
2265
+ const reads = await snapshot(db, { principals: [principal], members: true, seals: this.#checksPlaces() });
1834
2266
  const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
1835
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;
1836
2270
  const { policy } = await this.#policyAt(db, reports, at, reads);
1837
- return this.#access(principal, row, held, at, fault !== null, policy);
2271
+ return this.#access(principal, row, holding, at, fault !== null, policy);
1838
2272
  };
1839
2273
  const found: NewEntry[] = [];
1840
2274
  const quick = await read(this.#db, found).catch((error: unknown) => {
@@ -1882,7 +2316,7 @@ class VaultService implements Vault {
1882
2316
  const action = changes.every((change) => change.role === null) ? 'access.revoke' : 'access.grant';
1883
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.
1884
2318
  const [only] = changes.length === 1 ? changes : [];
1885
- const refused = (code: RefusalCode, message = MESSAGES[code]) => {
2319
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) => {
1886
2320
  const where = only === undefined || code === 'invalid' ? { ids: {}, detail: {} } : located(only);
1887
2321
  return new Refused(refusal(code, message), [{
1888
2322
  ...accessEntry(actor, action, principal, 'deny', input, { changes: input.changes, ...where.detail }, code),
@@ -1920,11 +2354,10 @@ class VaultService implements Vault {
1920
2354
  throw refused('invalid', `${change.role} can only be granted on a project`);
1921
2355
  }
1922
2356
  }
1923
- const known = await store.places(
1924
- d.tx,
1925
- changes.map((change) => change.projectId!),
1926
- changes.flatMap((change) => (change.environmentId === null ? [] : [change.environmentId])),
1927
- );
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);
1928
2361
  const where: Place[] = changes.map(({ projectId, environmentId, role }) => {
1929
2362
  const environment = environmentId === null ? undefined : known.environments.get(environmentId);
1930
2363
  if (!known.projects.has(projectId!) || (environmentId !== null && environment?.projectId !== projectId)) {
@@ -1958,7 +2391,7 @@ class VaultService implements Vault {
1958
2391
  // The setting as of the log's head, which a change to it takes too.
1959
2392
  const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
1960
2393
  // One just admitted holds nothing yet, and is theirs.
1961
- 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);
1962
2395
  // Each place's new role they must hold, and what it held before: taking a grant away is changing it.
1963
2396
  const gives = changes.every((change, i) => {
1964
2397
  const before = subject.live.grants.find((grant) => placeKey({ ...grant, environmentSlug: null }) === placeKey(change));
@@ -1981,7 +2414,7 @@ class VaultService implements Vault {
1981
2414
  * not run the instance sets one up as they would through `admit`.
1982
2415
  */
1983
2416
  async #admitWith(d: Decision, acting: Standing, principal: string, row: Member | undefined, input: SetAccessInput): Promise<Admitted> {
1984
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2417
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
1985
2418
  new Refused(refusal(code, message), [accessEntry(acting.principal, 'member.add', principal, 'deny', input, { changes: input.changes }, code)]);
1986
2419
  if (!SERVICE.test(principal)) throw refused('invalid', 'only a service account is admitted with its grants: a person, by an instance role');
1987
2420
  if (!runsInstance(acting.live)) await this.#setsUp(d, acting, true, refused);
@@ -2023,6 +2456,7 @@ class VaultService implements Vault {
2023
2456
 
2024
2457
  if (change.role === null) {
2025
2458
  clear();
2459
+ if (existing !== undefined) d.changes?.stage(memberRef(principal), { op: 'access.revoke', place: placeOf(place) }, actor);
2026
2460
  if (current === undefined) return 'unchanged';
2027
2461
  entry('access.revoke', null);
2028
2462
  return 'revoked';
@@ -2035,6 +2469,8 @@ class VaultService implements Vault {
2035
2469
  await store.insertGrant(d.tx, grant);
2036
2470
  d.grants.get(principal)!.push(grant);
2037
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);
2038
2474
  entry('access.grant', role);
2039
2475
  return current === undefined ? 'created' : 'updated';
2040
2476
  }
@@ -2042,7 +2478,7 @@ class VaultService implements Vault {
2042
2478
  admit(input: AdmitInput): Promise<Outcome<Admitted>> {
2043
2479
  const { actor, principal } = input;
2044
2480
  const asked = input.role === undefined ? {} : { role: input.role, scope: input.scope ?? EVERYWHERE };
2045
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2481
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
2046
2482
  new Refused(refusal(code, message), [
2047
2483
  accessEntry(actor, 'member.add', principal, 'deny', input, asked, code),
2048
2484
  ]);
@@ -2082,6 +2518,7 @@ class VaultService implements Vault {
2082
2518
  if (changed) {
2083
2519
  d.log.push(accessEntry(actor, 'member.role', principal, 'allow', input, { role: wanted.role, scope: wanted.scope, previousRole: row.role, previousScope: row.scope }));
2084
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);
2085
2522
  return { created: false, ...wanted, generation: row.generation };
2086
2523
  }
2087
2524
  return { created: false, role: row.role, scope: row.scope, generation: row.generation };
@@ -2097,11 +2534,30 @@ class VaultService implements Vault {
2097
2534
  */
2098
2535
  async #setsUp(d: Decision, acting: Standing, service: boolean, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
2099
2536
  const setting = (await this.#settingsAt(d.tx, d.reports)).serviceAccounts;
2100
- 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)))) {
2101
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');
2102
2539
  }
2103
2540
  }
2104
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
+
2105
2561
  /**
2106
2562
  * Admit `principal`, who has no row or a removed one: a fresh start, as a
2107
2563
  * member unless given a role (`wanted`), at the generation a removal
@@ -2123,6 +2579,7 @@ class VaultService implements Vault {
2123
2579
  if (wanted?.role === 'owner' && !ownsInstance(acting.live)) throw refused('not_allowed', OWNERS_ONLY);
2124
2580
  const { role, scope } = wanted ?? { role: 'member' as const, scope: EVERYWHERE };
2125
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);
2126
2583
  d.writes.push(async (at) => {
2127
2584
  if (row === undefined) {
2128
2585
  await store.insertMember(d.tx, {
@@ -2147,7 +2604,7 @@ class VaultService implements Vault {
2147
2604
 
2148
2605
  remove(input: RemoveInput): Promise<Outcome<{ revoked: Grant[]; generation: number }>> {
2149
2606
  const { actor, principal } = input;
2150
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2607
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
2151
2608
  new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
2152
2609
  return this.#decide([actor, principal], async (d) => {
2153
2610
  validateCorrelation(input);
@@ -2166,7 +2623,7 @@ class VaultService implements Vault {
2166
2623
  if (!runsInstance(holder)) {
2167
2624
  // A service account its manager removes: they reach everything it holds (`managesService`).
2168
2625
  const theirs = SERVICE.test(principal) && PERSON.test(actor) && row?.status === 'active' &&
2169
- 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));
2170
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');
2171
2628
  }
2172
2629
  if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
@@ -2188,6 +2645,11 @@ class VaultService implements Vault {
2188
2645
  }
2189
2646
  const generation = row.generation + 1;
2190
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);
2191
2653
  d.touched.add(principal);
2192
2654
  d.writes.push(async (at) => {
2193
2655
  await store.deleteGrants(d.tx, principal);
@@ -2210,8 +2672,15 @@ class VaultService implements Vault {
2210
2672
  * environment by its slug, which the setting's scope matches, and who
2211
2673
  * admitted it, as its row says.
2212
2674
  */
2213
- async #account(db: Queryable, subject: Standing, row: Member): Promise<ServiceAccount> {
2214
- 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);
2215
2684
  return {
2216
2685
  grants: subject.live.grants.map((grant) => ({
2217
2686
  role: grant.role,
@@ -2239,15 +2708,16 @@ class VaultService implements Vault {
2239
2708
  * entries and none of its own within reach, the setting is closed, never
2240
2709
  * the default: otherwise whoever can insert rows could open it again.
2241
2710
  */
2242
- 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.
2243
2713
  const entry = await newestAuthentic(
2244
- (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)),
2245
2717
  this.#check(reports, null),
2246
2718
  );
2247
- if (entry === 'none') return DEFAULT_SETTINGS;
2248
- if (typeof entry === 'string') return CLOSED_SETTINGS;
2249
- const { serviceAccounts } = JSON.parse(entry.metadata) as { serviceAccounts?: unknown };
2250
- return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
2719
+ if (entry === 'none') return settingsFrom(null);
2720
+ return typeof entry === 'string' ? CLOSED_SETTINGS : settingsFrom(entry.metadata);
2251
2721
  }
2252
2722
 
2253
2723
  // --- the instance's access policy -------------------------------------------
@@ -2268,7 +2738,7 @@ class VaultService implements Vault {
2268
2738
  * no role holds anything but Owner, otherwise whoever can insert rows
2269
2739
  * could unprotect them. Its edits are then unknown, not none.
2270
2740
  */
2271
- 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 }> {
2272
2742
  // The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
2273
2743
  const entry = await newestAuthentic(
2274
2744
  (limit, before) => (reads !== undefined && before === undefined
@@ -2276,11 +2746,13 @@ class VaultService implements Vault {
2276
2746
  : store.newestVaultEntries(db, POLICY, limit, before)),
2277
2747
  this.#check(reports, null),
2278
2748
  );
2279
- 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 };
2280
2750
  const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
2281
- if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
2282
- const rows = entry === reads?.newestPolicy ? reads.environments : await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
2283
- 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 };
2284
2756
  }
2285
2757
 
2286
2758
  changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
@@ -2299,7 +2771,7 @@ class VaultService implements Vault {
2299
2771
  ...at,
2300
2772
  metadata: JSON.stringify({ ...detail, ...traced(input) }),
2301
2773
  });
2302
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2774
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
2303
2775
  new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
2304
2776
  return this.#decide([actor], async (d) => {
2305
2777
  validateCorrelation(input);
@@ -2319,6 +2791,8 @@ class VaultService implements Vault {
2319
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");
2320
2792
  // Those the policy no longer holds stay out: a deleted environment's id, and an unwritten fork's past its window,
2321
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 }[] = [];
2322
2796
  const next: PolicyEdits = {
2323
2797
  roles: { ...edits.roles },
2324
2798
  instanceRoles: { ...edits.instanceRoles },
@@ -2346,6 +2820,7 @@ class VaultService implements Vault {
2346
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`);
2347
2821
  }
2348
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 } });
2349
2824
  } else {
2350
2825
  const problem = definitionProblem(change.definition);
2351
2826
  if (problem !== null) throw refused('invalid', problem);
@@ -2357,6 +2832,7 @@ class VaultService implements Vault {
2357
2832
  const shipped = instance ? INSTANCE_ROLES[change.role as InstanceRole] : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
2358
2833
  if (shipped !== undefined && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
2359
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 } });
2360
2836
  }
2361
2837
  } else if (change?.kind === 'protect') {
2362
2838
  if (typeof change.projectId !== 'string' || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0
@@ -2366,28 +2842,52 @@ class VaultService implements Vault {
2366
2842
  if (!(await store.projectLive(d.tx, change.projectId))) throw refused('invalid', 'no such project');
2367
2843
  at.projectId = change.projectId;
2368
2844
  // Each a live environment of the project, or none yet: a fork's id, protected before its row is written.
2369
- 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);
2370
2850
  if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused('invalid', 'each environment is one of the project\'s');
2371
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;
2372
2854
  if (change.protected) {
2373
2855
  if (!allows(acting.live, 'environment.manage', { projectId: change.projectId })) throw refused('not_allowed', 'protecting an environment takes managing its project\'s environments');
2374
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 } });
2375
2860
  next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
2376
2861
  .sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
2377
2862
  } else {
2378
2863
  // Unprotecting widens what people hold there: an Owner of the project's to do, or of the whole instance's (R30).
2379
2864
  // It takes off only what the project protects: a fork's id, with no row yet, is another project's to unprotect.
2380
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
+ }
2381
2871
  next.protected = next.protected.filter((place) => place.projectId !== change.projectId || !change.environmentIds.includes(place.environmentId));
2382
2872
  }
2383
2873
  } else {
2384
2874
  throw refused('invalid', 'a change is to a role, an instance role, or protection');
2385
2875
  }
2386
2876
  // The policy now, and the newest change, which it is as of: this one's, when it changed anything.
2387
- 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
+ };
2388
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);
2389
2885
  const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
2390
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
+ });
2391
2891
  d.after.push((seqOf) => (answer.seq = seqOf(made)));
2392
2892
  }
2393
2893
  return answer;
@@ -2405,7 +2905,7 @@ class VaultService implements Vault {
2405
2905
  requestId: input.requestId ?? null,
2406
2906
  metadata: JSON.stringify({ ...detail, ...traced(input) }),
2407
2907
  });
2408
- const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2908
+ const refused = (code: RefusalCode, message = REFUSAL_MESSAGES[code]) =>
2409
2909
  new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
2410
2910
  return this.#decide([actor], async (d) => {
2411
2911
  validateCorrelation(input);
@@ -2691,7 +3191,7 @@ class VaultService implements Vault {
2691
3191
  // Checkpoints one at a time, each against the one before.
2692
3192
  const head = await lockLogHead(d.tx);
2693
3193
  const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
2694
- const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
3194
+ const refused = (code: RefusalCode, detail: Record<string, unknown>, message = REFUSAL_MESSAGES[code]) =>
2695
3195
  new Refused(refusal(code, message), [
2696
3196
  { actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
2697
3197
  ]);
@@ -2847,7 +3347,7 @@ class VaultService implements Vault {
2847
3347
  return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
2848
3348
  }
2849
3349
 
2850
- 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 }> {
2851
3351
  await this.#settled();
2852
3352
  const { signers, since } = this.#prepared;
2853
3353
  // A key it replaced vouches only for prefixes before its first entry under the current one: none, before it has one.
@@ -2857,7 +3357,682 @@ class VaultService implements Vault {
2857
3357
  owners: [...this.#config.owners],
2858
3358
  // For an app of 0.5 while it runs beside this vault: there are none, and its lists and checks need the field.
2859
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`);
2860
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),
4025
+ };
4026
+ }
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;
2861
4036
  }
2862
4037
 
2863
4038
  async keyChecks(): Promise<KeyChecks> {
@@ -3077,6 +4252,9 @@ class VaultService implements Vault {
3077
4252
 
3078
4253
  type Broken = Extract<LogVerification, { ok: false }>;
3079
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
+
3080
4258
  /**
3081
4259
  * Where a walk of the log resumes: the checkpoint's entry (null for the
3082
4260
  * first entry), the end of the prefix it signed, the tally through there,
@@ -3468,10 +4646,6 @@ function accessEntry(
3468
4646
  };
3469
4647
  }
3470
4648
 
3471
- function live(grant: GrantRow, at: number): boolean {
3472
- return grant.expiresAt === null || grant.expiresAt > at;
3473
- }
3474
-
3475
4649
  /**
3476
4650
  * Where an entry about a grant says it is: its project and environment; or,
3477
4651
  * for a grant on every project of 0.4, neither, and the place as a path in
@@ -3497,67 +4671,10 @@ function placeKey(place: store.Place): string {
3497
4671
  return place.projectId !== null ? `project:${place.projectId}` : `every-project:${place.environmentSlug ?? ''}`;
3498
4672
  }
3499
4673
 
3500
- /** A member's instance role, as their row has it: service accounts are members, whatever a row says. */
3501
- function roleOf(principal: string, row: Member): { role: InstanceRole; scope: Scope } {
3502
- return principal.startsWith('user:') ? { role: row.role, scope: row.scope } : { role: 'member', scope: EVERYWHERE };
3503
- }
3504
-
3505
- function view(grant: GrantRow): Grant {
3506
- return {
3507
- projectId: grant.projectId,
3508
- environmentId: grant.environmentId,
3509
- role: grant.role as Role,
3510
- expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
3511
- grantedAt: iso(grant.grantedAt),
3512
- grantedBy: grant.grantedBy,
3513
- };
3514
- }
3515
-
3516
4674
  function iso(ms: number): string {
3517
4675
  return new Date(ms).toISOString();
3518
4676
  }
3519
4677
 
3520
- /** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
3521
- function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
3522
- const edits = stored.policy as Partial<PolicyEdits> | undefined;
3523
- if (typeof edits !== 'object' || edits === null) return null;
3524
- const { roles, instanceRoles, protected: protectedIds } = edits;
3525
- if (typeof roles !== 'object' || roles === null || typeof instanceRoles !== 'object' || instanceRoles === null || !Array.isArray(protectedIds)) return null;
3526
- const defined = (table: object) => Object.values(table).every((definition) => definitionProblem(definition as RoleDefinition) === null);
3527
- if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
3528
- if (protectedIds.some((place) => typeof place !== 'object' || place === null || !UUID.test((place as ProtectedPlace).projectId) || !UUID.test((place as ProtectedPlace).environmentId)
3529
- || !Number.isSafeInteger((place as ProtectedPlace).at))) return null;
3530
- return { roles: roles as Record<string, RoleDefinition>, instanceRoles: instanceRoles as PolicyEdits['instanceRoles'], protected: protectedIds as ProtectedPlace[] };
3531
- }
3532
-
3533
- /**
3534
- * The policy `edits` say as of `at`: the roles as they ship, each edited
3535
- * one replaced and each made one added; and the protected environments, but
3536
- * for those deleted since, the project around one's included: a deleted
3537
- * prod protects nothing, and holds nobody back from giving a service
3538
- * account a role on all of its project (`givesService`). A fork's id with
3539
- * no row yet stays, protected before it is written, ten minutes: one with
3540
- * none after that is a fork refused or abandoned, never written. Each
3541
- * keeps its slug, which a scoped instance role's filters read. The edits
3542
- * keep them all: no write is needed.
3543
- */
3544
- function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
3545
- const live = edits.protected.filter((place) => {
3546
- const row = rows.get(place.environmentId);
3547
- return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
3548
- });
3549
- const slugOf = (place: ProtectedPlace) => rows.get(place.environmentId)?.slug ?? null;
3550
- return {
3551
- roles: { ...ROLES, ...edits.roles },
3552
- instanceRoles: { ...INSTANCE_ROLES, ...edits.instanceRoles },
3553
- protected: live.map((place) => place.environmentId),
3554
- protectedIn: live.reduce<Record<string, { environmentId: string; slug: string | null }[]>>(
3555
- (byProject, place) => ({ ...byProject, [place.projectId]: [...(byProject[place.projectId] ?? []), { environmentId: place.environmentId, slug: slugOf(place) }] }),
3556
- {},
3557
- ),
3558
- };
3559
- }
3560
-
3561
4678
  /** A change, as its entry names it. */
3562
4679
  function describeChange(change: PolicyChange | undefined): Record<string, unknown> | null {
3563
4680
  if (change === undefined || change === null || typeof change !== 'object') return null;
@@ -3599,3 +4716,32 @@ function serialisable(wrapped: { kekProvider: string; kekId: string; kekVersion:
3599
4716
  function base64(bytes: Uint8Array): string {
3600
4717
  return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString('base64');
3601
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
+ }