@everystack/cli 0.4.97 → 0.4.99

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.
@@ -155,7 +155,7 @@ export interface ReconcileOptions {
155
155
 
156
156
  const isRelation = (kind: DerivedKind): boolean => kind !== 'function';
157
157
 
158
- const ownerContext = (src: SourceObject, liveObj: LiveObject): GrantOwnerContext => ({
158
+ const ownerContext = (src: Pick<SourceObject, 'owner'>, liveObj: LiveObject): GrantOwnerContext => ({
159
159
  ...(src.owner !== undefined ? { declared: src.owner } : {}),
160
160
  ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}),
161
161
  });
@@ -178,7 +178,7 @@ const ownerContext = (src: SourceObject, liveObj: LiveObject): GrantOwnerContext
178
178
  * failure to ADD access (availability), never to remove it.
179
179
  */
180
180
  export function objectGrantDelta(
181
- src: SourceObject,
181
+ src: Pick<SourceObject, 'kind' | 'identity' | 'attachments' | 'owner'>,
182
182
  liveObj: LiveObject,
183
183
  liveGrants: Record<string, string[]> | undefined = liveObj.grants,
184
184
  ): (ObjectGrantDiff & { columnScoped: boolean }) | null {
@@ -25,6 +25,8 @@
25
25
  */
26
26
 
27
27
  import { createHash } from 'node:crypto';
28
+ import type { Attachment } from './derived-source';
29
+ import type { OwnerApplyMode } from './derived-apply';
28
30
  import type { ModelDescriptor } from '@everystack/model';
29
31
  import type { SchemaSnapshot, TableSchema } from './schema-introspect';
30
32
  import type { AuthzContract, SequenceGrantContract, DefaultAclScope } from './authz-contract';
@@ -39,7 +41,19 @@ import { fingerprintLive, fingerprintState, fingerprintModels, governedRolesForM
39
41
  import { compileDeclaredState } from './declared-diff';
40
42
  import { compileTableRenames, compileTableMoves, compileRenames } from './schema-compile';
41
43
 
42
- export const PLAN_VERSION = 2;
44
+ /**
45
+ * v3 adds `statePrerequisites`: SQL definitions db:apply must establish before the state edge.
46
+ * v4 makes each prerequisite a complete, governed derived-object create and carries the source
47
+ * metadata needed to record canonical catalog provenance in the same transaction as the edge.
48
+ * v5 adds the declared grant contract and owner mode for post-create ACL settlement and
49
+ * verification. A v4 reader skips those checks and must not apply this artifact.
50
+ * v6 records each prerequisite's declared search path, restored before state DDL. A v5 reader
51
+ * can bind argument defaults to the wrong function and must not apply this artifact.
52
+ * A v3 reader does not know how to enact or record those prerequisites, so the file formats are
53
+ * not compatible. The existing db:apply version refusal makes the mismatch a re-mint before any
54
+ * write, rather than letting a peer silently omit part of the reviewed artifact.
55
+ */
56
+ export const PLAN_VERSION = 6;
43
57
 
44
58
  /**
45
59
  * The db:apply WIRE CONTRACT between the CLI and the ops Lambda — distinct from PLAN_VERSION,
@@ -84,8 +98,28 @@ export const PLAN_VERSION = 2;
84
98
  * to remove, reintroduced silently across a version skew instead of refused cleanly. The BUMP
85
99
  * RULE covers exactly this: a field the CLIENT now depends on to decide something is part of the
86
100
  * contract. Peers refuse until the stage is redeployed.
101
+ *
102
+ * v6 (milestone plan-state-prerequisites-format): the plan in the payload gained
103
+ * `statePrerequisites`, definitions the server must establish before it applies the state edge.
104
+ * A v5 ops Lambda would not know to execute them and would silently skip a reviewed prerequisite
105
+ * before applying the rest of the plan. The BUMP RULE says that changed server work is part of
106
+ * the contract, so the peers refuse until the stage is redeployed.
107
+ *
108
+ * v7 (milestone atomic-state-prerequisites): the payload gained `stateOnly` and the ops Lambda
109
+ * refuses a prerequisite-bearing plan when it is true. A v6 Lambda would accept the request and
110
+ * create the prerequisite, contradicting `--state-only`'s promise to skip function writes.
111
+ *
112
+ * v8 (milestone governed-state-prerequisites): prerequisite application now records derived
113
+ * provenance before its transaction commits. A v7 ops Lambda could create a governed function
114
+ * without its provenance row, leaving the next catch-up to require a baseline.
115
+ *
116
+ * v9 settles and verifies prerequisite grants before commit. A v8 stage could leave an
117
+ * undeclared default EXECUTE grantee live while reporting a successful governed create.
118
+ *
119
+ * v10 applies the reviewed prerequisite search path and restores the transaction's original
120
+ * path before state DDL and grant verification. A v9 stage could bind defaults to other schemas.
87
121
  */
88
- export const APPLY_CONTRACT_VERSION = 5;
122
+ export const APPLY_CONTRACT_VERSION = 10;
89
123
 
90
124
  /** Classification counts (brick 9, decision 11): destructive = drops + narrowings + strips. */
91
125
  export interface PlanClassification {
@@ -107,6 +141,31 @@ export interface PlanClassification {
107
141
  unclassified?: number;
108
142
  }
109
143
 
144
+ /** A definition that db:apply must establish before it applies the state edge. */
145
+ export type StatePrerequisite = {
146
+ /** The function's full PostgreSQL identity: `schema.name(argtypes)`. */
147
+ identity: string;
148
+ /** The exact schema preparation, CREATE, ownership, grant, and comment SQL db:apply runs. */
149
+ sql: string;
150
+ /** The catalog condition db:apply must prove before it runs `sql`. */
151
+ expected: 'absent';
152
+ /** Full declared derived path, with quoted identifiers; null preserves the ambient all-public path. */
153
+ searchPath: string | null;
154
+ /** Reviewed grant declarations and the owner mechanism selected at mint. No source reload at apply. */
155
+ grantContract: {
156
+ attachments: Attachment[];
157
+ owner?: string;
158
+ ownerMode: OwnerApplyMode;
159
+ };
160
+ /** Source facts needed to record the catalog-derived provenance hash after this transaction creates it. */
161
+ provenance: {
162
+ srcHash: string;
163
+ bodyHash?: string;
164
+ kind: 'function';
165
+ dropSql?: string;
166
+ };
167
+ };
168
+
110
169
  export interface EdgePlan {
111
170
  v: number;
112
171
  /** The target's exact live fingerprint at mint time — the lock. */
@@ -169,6 +228,11 @@ export interface EdgePlan {
169
228
  declaredFingerprint: string;
170
229
  /** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
171
230
  statements: string[];
231
+ /**
232
+ * Definitions that must exist before the state edge can land. Optional because plans minted
233
+ * before this field existed have none; an absent field means no prerequisite was recorded.
234
+ */
235
+ statePrerequisites?: readonly StatePrerequisite[];
172
236
  executable: number;
173
237
  /** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
174
238
  destructive: number;
package/src/cli/index.ts CHANGED
@@ -317,7 +317,7 @@ Usage:
317
317
  everystack db:provision --rotate --stage <name> [--slot api|ops|both] [--grace 60] Rotate credentials live (A/B role swap, no deploy, no downtime)
318
318
  everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
319
319
  everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
320
- everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket as a restorable backup — the credential-free break-glass local→stage full replace (then: db:restore --from <id> --stage <target>)
320
+ everystack db:backup [--stage <name>] Logical pg_dump of the DB → private S3 backups bucket (prints the backup id). Add --database-url <local> --stage <target> to dump a LOCAL database and upload it into the target stage's bucket. Such a backup records no origin commit, so db:restore refuses it: restoring a local backup into a stage is closed until local backups can record a verified origin
321
321
  everystack db:backups [--stage <name>] List logical backups (id, size, created)
322
322
  everystack db:restore --from <id> [--stage <name>] --confirm Restore a backup INTO the stage's DB (DESTRUCTIVE)
323
323
  everystack db:backup:download <id> [--stage <name>] Presigned URL to download a backup's dump (valid 1h)
@@ -179,6 +179,11 @@ export function leaseSession(): string {
179
179
  * write of the verb. A verb that opens a SECOND connection mid-body has a writer the lease
180
180
  * does not cover.
181
181
  *
182
+ * That connection must fail CLOSED when its backend dies: hand this a fenced runner
183
+ * (connection-fence.ts — `createUrlRunner` and the server's mutation connection both are).
184
+ * An unfenced postgres.js pool reconnects onto a new backend after a loss, so the rest of the
185
+ * body would run without the lease; the lease's own release below would too.
186
+ *
182
187
  * HONEST LIMITATION (do not overstate): this is CONVENTIONALLY enforced, not runtime-enforced.
183
188
  * A PostgreSQL connection carries no read/write intent the connection layer can inspect, so
184
189
  * "reject a second connection" is unrealizable in-process. The counterexample is already in the
@@ -77,6 +77,16 @@ function head(statement: string): string {
77
77
  return body === '' ? statement.replace(/\s+/g, ' ').trim() : body;
78
78
  }
79
79
 
80
+ /**
81
+ * Match report marks by the whole statement with ONLY outer whitespace removed, on both sides.
82
+ * `head` is a description, not an identity: collapsing whitespace can change literals and
83
+ * function bodies. Internal formatting, comments, case and terminators must still match exactly.
84
+ * This key never replaces the original SQL passed to descriptions or returned to callers.
85
+ */
86
+ function statementKey(statement: string): string {
87
+ return statement.trim();
88
+ }
89
+
80
90
  /** The whole statement as one line, comments stripped and truncated — for the fallback description. */
81
91
  function oneLine(statement: string): string {
82
92
  const body = head(statement).replace(/;$/, '');
@@ -339,7 +349,8 @@ function objectKey(s: string): { verb: 'drop' | 'create'; key: string } | null {
339
349
  * definition edit. So the drop is removed and its create is marked `rebuilt`, which renders as
340
350
  * the update line it always was.
341
351
  *
342
- * Statements this does not recognise pass through untouched, in order.
352
+ * Statements this does not recognise pass through untouched, in order. Returned statements keep
353
+ * their original text; `rebuilt` holds statementKey values (outer whitespace removed).
343
354
  */
344
355
  export function collapseRebuilds(statements: readonly string[]): {
345
356
  statements: string[];
@@ -355,7 +366,7 @@ export function collapseRebuilds(statements: readonly string[]): {
355
366
  for (const s of statements) {
356
367
  const k = objectKey(head(s));
357
368
  if (k?.verb === 'drop' && created.has(k.key)) {
358
- rebuilt.add(created.get(k.key)!);
369
+ rebuilt.add(statementKey(created.get(k.key)!));
359
370
  continue;
360
371
  }
361
372
  kept.push(s);
@@ -378,8 +389,9 @@ const plural = (n: number, one: string, many: string): string => (n === 1 ? one
378
389
 
379
390
  /** One change line, indented, already carrying its mark. */
380
391
  function changeLine(statement: string, destructive: ReadonlySet<string>, rebuilt?: ReadonlySet<string>): string {
381
- if (destructive.has(statement)) return ` ${DESTRUCTIVE_MARK} — ${describeDestructive(statement)}`;
382
- const { symbol, text } = describeChange(statement, { rebuilt: rebuilt?.has(statement) === true });
392
+ const key = statementKey(statement);
393
+ if (destructive.has(key)) return ` ${DESTRUCTIVE_MARK} — ${describeDestructive(statement)}`;
394
+ const { symbol, text } = describeChange(statement, { rebuilt: rebuilt?.has(key) === true });
383
395
  return ` ${symbol} ${text}`;
384
396
  }
385
397
 
@@ -398,7 +410,7 @@ function changeLines(
398
410
  export interface PlanReportInput {
399
411
  /** The real DDL the plan will run — notices and held drops are not changes. */
400
412
  executable: readonly string[];
401
- /** The destructive subset, as the apply gate classifies it. Must be members of `executable`. */
413
+ /** The apply gate's destructive subset. Must match `executable` apart from outer whitespace. */
402
414
  destructive: readonly string[];
403
415
  /** What the plan is aimed at, in the reader's words: a stage name, or `local`. */
404
416
  target: string;
@@ -415,7 +427,7 @@ export function renderPlanReport(input: PlanReportInput): string[] {
415
427
  const { executable, target, stage, planPath, verbose = false } = input;
416
428
  if (executable.length === 0) return ['Up to date. Nothing to plan.'];
417
429
 
418
- const destructive = new Set(input.destructive);
430
+ const destructive = new Set(input.destructive.map(statementKey));
419
431
  const lines: string[] = [
420
432
  `Plan: ${executable.length} ${plural(executable.length, 'change', 'changes')} (${target})`,
421
433
  '',
@@ -440,6 +452,7 @@ export function renderPlanReport(input: PlanReportInput): string[] {
440
452
 
441
453
  export interface ApplyReceiptInput {
442
454
  executable: readonly string[];
455
+ /** The apply gate's destructive subset. Must match `executable` apart from outer whitespace. */
443
456
  destructive: readonly string[];
444
457
  /** The target was already where the plan ends — an idempotent re-apply. */
445
458
  alreadyApplied?: boolean;
@@ -461,7 +474,7 @@ export function renderApplyReceipt(input: ApplyReceiptInput): string[] {
461
474
  const n = executable.length;
462
475
  const tally = `Applied ${n} ${plural(n, 'change', 'changes')}.`;
463
476
  if (n === 0 || n > MAX_RECEIPT_CHANGES) return [tally];
464
- return [...changeLines(executable, new Set(input.destructive), MAX_RECEIPT_CHANGES, rebuilt), '', tally];
477
+ return [...changeLines(executable, new Set(input.destructive.map(statementKey)), MAX_RECEIPT_CHANGES, rebuilt), '', tally];
465
478
  }
466
479
 
467
480
  export interface CatchUpFailureInput {
@@ -529,7 +542,16 @@ function sentence(text: string): string {
529
542
  }
530
543
 
531
544
  /** db:check's default output: one line, always. */
532
- export function renderCheckVerdict(pending: number): string[] {
533
- if (pending === 0) return ['Up to date.'];
534
- return [`${pending} ${plural(pending, 'change', 'changes')} pending. Run db:plan to review.`];
545
+ export function renderCheckVerdict(pending: number, warnings = 0): string[] {
546
+ if (warnings === 0) {
547
+ if (pending === 0) return ['Up to date.'];
548
+ return [`${pending} ${plural(pending, 'change', 'changes')} pending. Run db:plan to review.`];
549
+ }
550
+ // Warnings never fail the gate, but a clean-looking line would hide them.
551
+ const warned = `${warnings} ${plural(warnings, 'warning', 'warnings')}`;
552
+ const readThem = `db:check --verbose to read ${warnings === 1 ? 'it' : 'them'}`;
553
+ if (pending === 0) return [`Up to date, ${warned}. Run ${readThem}.`];
554
+ return [
555
+ `${pending} ${plural(pending, 'change', 'changes')} pending, ${warned}. Run db:plan to review, db:check --verbose to read the ${plural(warnings, 'warning', 'warnings')}.`,
556
+ ];
535
557
  }
@@ -7,6 +7,7 @@
7
7
  * have — and an ability change shows up as runbook drift (`runbook --check`).
8
8
  */
9
9
 
10
+ import { ModelLinkError } from '@everystack/model';
10
11
  import type { Ability, FieldSpec, ModelDescriptor, Relation } from '@everystack/model';
11
12
 
12
13
  function typeLabel(spec: FieldSpec): string {
@@ -35,7 +36,8 @@ function referenceTarget(spec: FieldSpec): string | null {
35
36
  try {
36
37
  const target = spec.references() as { table?: string } | undefined;
37
38
  return target?.table ?? 'unknown';
38
- } catch {
39
+ } catch (err) {
40
+ if (err instanceof ModelLinkError) throw err;
39
41
  return 'unknown';
40
42
  }
41
43
  }
@@ -73,7 +75,8 @@ function relationLine(name: string, rel: Relation): string {
73
75
  let target = 'unknown';
74
76
  try {
75
77
  target = rel.target().table;
76
- } catch {
78
+ } catch (err) {
79
+ if (err instanceof ModelLinkError) throw err;
77
80
  /* forward reference that can't resolve here — label stays unknown */
78
81
  }
79
82
  const shape = rel.kind === 'belongsTo' ? 'belongs to' : 'has many';
@@ -0,0 +1,132 @@
1
+ /**
2
+ * A role that can write a table but holds no SELECT grant on it.
3
+ *
4
+ * `can('update', { role: 'moderator' })` alone compiles `GRANT UPDATE` and an UPDATE policy for
5
+ * moderator, but no SELECT grant. PostgreSQL needs SELECT to read the columns a WHERE or RETURNING
6
+ * names, so every `UPDATE t ... WHERE id = $1` by moderator fails with "permission denied for
7
+ * table t". The ability reads as working and is dead. The same holds for DELETE ... WHERE and
8
+ * INSERT ... RETURNING (measured: __tests__/integration/select-grant-advisory.test.ts).
9
+ *
10
+ * The rule reads the COMPILED grants (compileTableContract), not the abilities, so it judges what
11
+ * the database will hold: `manage` carries its SELECT, a bare `can('read')` covers anon and
12
+ * authenticated only, an owner read is authenticated's table SELECT, and a column-scoped SELECT
13
+ * counts. Two exceptions: a role with any ability carrying a raw `sql:` condition is skipped (a
14
+ * hand-written predicate is outside what this rule judges), and a write granted only through the
15
+ * model's `privileges:` is left to findNakedGrants, which owns that case.
16
+ *
17
+ * The rule cannot see role membership: a role that inherits SELECT from another role it is a
18
+ * member of is still reported, since no model declares memberships.
19
+ *
20
+ * The advice is a read SCOPED to the rows the write touches, never broader than the write: for an
21
+ * owner write a role-wide `can('read', { role })` would be a USING (true) policy for every
22
+ * signed-in user. A role-wide read is offered only for a role-wide write.
23
+ *
24
+ * Known limits: a table with `rls: false` whose `privileges:` grant a write but no SELECT is
25
+ * skipped here (findNakedGrants owns `privileges:` grants and does not name the missing SELECT);
26
+ * and a write scoped by `where:` alone gets the generic advice, which can be wider than the write.
27
+ *
28
+ * Advisory only — db:check reports it as a warning and never fails on it.
29
+ */
30
+
31
+ import type { Ability, ModelDescriptor } from '@everystack/model';
32
+ import { compileTableContract } from './authz-compile';
33
+ import type { TableContract } from './authz-contract';
34
+
35
+ export type WriteVerb = 'create' | 'delete' | 'update';
36
+
37
+ export interface SelectGrantGap {
38
+ /** `schema.table`. */
39
+ table: string;
40
+ role: string;
41
+ /** The ability verb whose grant is unusable without SELECT. */
42
+ verb: WriteVerb;
43
+ message: string;
44
+ }
45
+
46
+ /** Ability verb -> the privilege it compiles to, in report order. */
47
+ const WRITE_PRIVILEGES: ReadonlyArray<[WriteVerb, string]> = [
48
+ ['create', 'INSERT'],
49
+ ['delete', 'DELETE'],
50
+ ['update', 'UPDATE'],
51
+ ];
52
+
53
+ /** The statement shape that needs SELECT, per privilege. INSERT has no WHERE. */
54
+ const NEEDS_SELECT: Record<string, string> = {
55
+ INSERT: 'INSERT ... RETURNING',
56
+ DELETE: 'DELETE ... WHERE/RETURNING',
57
+ UPDATE: 'UPDATE ... WHERE/RETURNING',
58
+ };
59
+
60
+ /** Does the role hold this privilege on the table, table-wide or on any column? */
61
+ function holds(contract: TableContract, role: string, privilege: string): boolean {
62
+ return (contract.grants[role] ?? []).includes(privilege)
63
+ || Boolean(contract.columnGrants?.[role]?.[privilege]?.length);
64
+ }
65
+
66
+ /** The grantee an ability lands on — what compileGrants keys it under. */
67
+ function granteeOf(role: string | undefined): string {
68
+ if (!role) return 'authenticated';
69
+ return role.toLowerCase() === 'public' ? 'PUBLIC' : role;
70
+ }
71
+
72
+ /** The table privileges an ability compiles to (`manage` is all four). */
73
+ function privilegesOf(action: Ability['action']): string[] {
74
+ switch (action) {
75
+ case 'manage': return ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
76
+ case 'read': return ['SELECT'];
77
+ case 'create': return ['INSERT'];
78
+ case 'update': return ['UPDATE'];
79
+ case 'delete': return ['DELETE'];
80
+ default: return [];
81
+ }
82
+ }
83
+
84
+ /** The abilities whose grant puts `privilege` on `role` — none means it came from `privileges:`. */
85
+ function sources(model: ModelDescriptor, role: string, privilege: string): Ability[] {
86
+ return model.abilities.filter((a) => granteeOf(a.condition.role) === role && privilegesOf(a.action).includes(privilege));
87
+ }
88
+
89
+ /** The read to suggest: scoped like the write when the write is owner/via-scoped, else generic. */
90
+ function remedy(role: string, from: readonly Ability[]): string {
91
+ const scoped = from.find((a) => a.condition.owner || a.condition.via);
92
+ const lead = `Add a read ability for ${role} scoped to the rows it may touch`;
93
+ if (!scoped) {
94
+ return `${lead} (for example can('read', { owner: '<column>' }) for an owner write, `
95
+ + `or can('read', { role: '${role}' }) for a role-wide one).`;
96
+ }
97
+ const keys = [
98
+ ...(scoped.condition.role ? [`role: '${scoped.condition.role}'`] : []),
99
+ scoped.condition.owner ? `owner: '${scoped.condition.owner}'` : `via: '${scoped.condition.via}'`,
100
+ ];
101
+ return `${lead}, for example can('read', { ${keys.join(', ')} }).`;
102
+ }
103
+
104
+ export function findSelectGrantGaps(models: readonly ModelDescriptor[]): SelectGrantGap[] {
105
+ const out: SelectGrantGap[] = [];
106
+ for (const model of models) {
107
+ const contract = compileTableContract(model);
108
+ const suppressed = new Set(
109
+ model.abilities.filter((a) => a.condition.sql != null).map((a) => granteeOf(a.condition.role)),
110
+ );
111
+ const roles = new Set([...Object.keys(contract.grants), ...Object.keys(contract.columnGrants ?? {})]);
112
+ for (const role of [...roles].sort()) {
113
+ if (suppressed.has(role)) continue;
114
+ // A PUBLIC SELECT reaches every role, so it satisfies any of them.
115
+ if (holds(contract, role, 'SELECT') || holds(contract, 'PUBLIC', 'SELECT')) continue;
116
+ for (const [verb, privilege] of WRITE_PRIVILEGES) {
117
+ if (!holds(contract, role, privilege)) continue;
118
+ const from = sources(model, role, privilege);
119
+ if (from.length === 0) continue;
120
+ out.push({
121
+ table: contract.table,
122
+ role,
123
+ verb,
124
+ message: `${contract.table}: ${role} can ${privilege} but has no SELECT grant; `
125
+ + `${NEEDS_SELECT[privilege]} by ${role} fails with "permission denied for table ${model.table}". `
126
+ + remedy(role, from),
127
+ });
128
+ }
129
+ }
130
+ }
131
+ return out;
132
+ }
@@ -24,6 +24,8 @@
24
24
 
25
25
  import { invokeAction, lambdaSessionRunner } from './aws';
26
26
  import { pollTaskUntilStopped } from './task-poll';
27
+ import type { BackupLiveRead } from './apply-execute';
28
+ import type { AuthzManifest } from './authz-manifest';
27
29
 
28
30
  /** The verdict on a completed dump's fingerprint pair. Pure — the heart of the sandwich. */
29
31
  export type SandwichVerdict = { ok: true } | { ok: false; reason: string };
@@ -46,10 +48,14 @@ export function judgeSandwich(fpBefore: string, fpAfter: string): SandwichVerdic
46
48
  }
47
49
 
48
50
  export interface StageBackupDeps {
49
- /** Read the live base-schema fingerprint. Called twice, bracketing the dump. */
50
- readFingerprint: () => Promise<string>;
51
- /** Dispatch `db:backup` with the operator-computed fingerprint. */
52
- dispatch: (fingerprint: string) => Promise<{ runId: string; taskArn: string; id: string; key: string; warning?: string }>;
51
+ /**
52
+ * Read the live base-schema fingerprint and the target's origin commit, in one session. Called
53
+ * twice, bracketing the dump; the sandwich judges the fingerprints, and the OPENING read is what
54
+ * the dispatch carries.
55
+ */
56
+ readLive: () => Promise<BackupLiveRead>;
57
+ /** Dispatch `db:backup` with the operator-computed fingerprint and origin commit. */
58
+ dispatch: (live: BackupLiveRead) => Promise<{ runId: string; taskArn: string; id: string; key: string; warning?: string }>;
53
59
  /**
54
60
  * Wait for the task to stop; resolve ok, or a human reason it did not.
55
61
  *
@@ -59,6 +65,13 @@ export interface StageBackupDeps {
59
65
  awaitTask: (ids: { runId: string; taskArn: string; id: string }) => Promise<{ ok: true; bytes?: number; stampedFingerprint?: string } | { ok: false; reason: string }>;
60
66
  /** Delete a retracted artifact (the ops Lambda's db:backup:retract). */
61
67
  retract: (key: string) => Promise<void>;
68
+ /**
69
+ * Stamp the declared-authz manifest onto the finished backup (the ops Lambda's
70
+ * db:backup:stamp-authz). Optional: only a caller that rendered a manifest supplies it. Runs only
71
+ * on a PROVEN backup, and its failure is a warning, never a failed backup — the dump is valid, the
72
+ * manifest is metadata layered on top, and a restore treats a missing one as "no manifest".
73
+ */
74
+ stampAuthz?: (key: string) => Promise<void>;
62
75
  /** Progress output. Optional so tests stay quiet. */
63
76
  onStep?: (message: string) => void;
64
77
  }
@@ -78,18 +91,19 @@ export async function runStageBackup(deps: StageBackupDeps): Promise<StageBackup
78
91
  const step = deps.onStep ?? (() => {});
79
92
 
80
93
  step('Reading the live schema fingerprint (before the dump)...');
81
- let fpBefore: string;
94
+ let before: BackupLiveRead;
82
95
  try {
83
- fpBefore = await deps.readFingerprint();
96
+ before = await deps.readLive();
84
97
  } catch (err: any) {
85
98
  // Refuse BEFORE dispatching: a dump whose fingerprint cannot be proven is not a safety net, and
86
99
  // taking one anyway would burn the dump window and still leave nothing trustworthy.
87
100
  return { ok: false, reason: `the schema fingerprint could not be read, so no backup was dispatched: ${err?.message ?? String(err)}` };
88
101
  }
102
+ const fpBefore = before.fingerprint;
89
103
 
90
104
  let dispatched: { runId: string; taskArn: string; id: string; key: string; warning?: string };
91
105
  try {
92
- dispatched = await deps.dispatch(fpBefore);
106
+ dispatched = await deps.dispatch(before);
93
107
  } catch (err: any) {
94
108
  return { ok: false, reason: `the backup would not dispatch: ${err?.message ?? String(err)}` };
95
109
  }
@@ -127,7 +141,7 @@ export async function runStageBackup(deps: StageBackupDeps): Promise<StageBackup
127
141
  step('Re-reading the schema fingerprint (after the dump)...');
128
142
  let fpAfter: string;
129
143
  try {
130
- fpAfter = await deps.readFingerprint();
144
+ fpAfter = (await deps.readLive()).fingerprint;
131
145
  } catch (err: any) {
132
146
  // The dump exists but is unproven. Do NOT retract — an unreadable fingerprint is not evidence
133
147
  // the schema moved, and silently destroying a good backup is the worse error.
@@ -146,7 +160,16 @@ export async function runStageBackup(deps: StageBackupDeps): Promise<StageBackup
146
160
  const unverified = finished.stampedFingerprint === undefined
147
161
  ? 'the task did not report the fingerprint it stamped, so the artifact could not be verified against what the operator sent (the result-ledger write is best-effort)'
148
162
  : undefined;
149
- const warning = [dispatched.warning, unverified].filter(Boolean).join('; ') || undefined;
163
+ let unstamped: string | undefined;
164
+ if (deps.stampAuthz) {
165
+ step('Recording the declared authz manifest on the backup...');
166
+ try {
167
+ await deps.stampAuthz(dispatched.key);
168
+ } catch (err: any) {
169
+ unstamped = `the backup is valid, but its authz manifest was NOT recorded (${err?.message ?? String(err)}) — a restore from it cannot converge authz from the manifest; take another backup if you need one`;
170
+ }
171
+ }
172
+ const warning = [dispatched.warning, unverified, unstamped].filter(Boolean).join('; ') || undefined;
150
173
  return { ok: true, id: dispatched.id, key: dispatched.key, bytes: finished.bytes, fingerprint: fpBefore, warning };
151
174
  }
152
175
 
@@ -178,22 +201,31 @@ export function stageBackupDeps(
178
201
  config: { region: string },
179
202
  opsFn: string,
180
203
  stage: string | undefined,
181
- hooks: { onStep?: (message: string) => void; onDispatch?: (d: { runId: string; taskArn: string; id: string; warning?: string }) => void } = {},
204
+ hooks: {
205
+ onStep?: (message: string) => void;
206
+ onDispatch?: (d: { runId: string; taskArn: string; id: string; warning?: string }) => void;
207
+ /** The declared-authz manifest to stamp onto the finished backup (db:backup renders it). */
208
+ authzManifest?: AuthzManifest;
209
+ } = {},
182
210
  ): StageBackupDeps {
211
+ const { authzManifest } = hooks;
183
212
  return {
184
213
  // BOTH ends of the sandwich read over this ONE closure, so they cannot land on different lanes.
185
214
  // That matters: a before-read over a direct session and an after-read over the two-invoke
186
215
  // ops-Lambda blend could manufacture a false mismatch — or, worse, a false match. The symmetry
187
216
  // is structural here rather than asserted; if a caller ever needs to override one end, it has to
188
217
  // override both.
189
- readFingerprint: async () => {
190
- // The SAME helper db:apply's concurrency lock uses — that shared definition is the point.
191
- const { liveBaseFingerprint } = await import('./apply-execute');
192
- return (await liveBaseFingerprint(lambdaSessionRunner(config.region, opsFn))).hash;
218
+ readLive: async () => {
219
+ // The SAME one-session read db:apply's concurrency lock and descent proof use — the
220
+ // fingerprint and the ledger anchor come from one snapshot, one round trip.
221
+ const { readBackupLive } = await import('./apply-execute');
222
+ return readBackupLive(lambdaSessionRunner(config.region, opsFn));
193
223
  },
194
- dispatch: async (fingerprint) => {
224
+ dispatch: async ({ fingerprint, originCommit }) => {
225
+ // `originCommit` is sent as read: a string, `null` (proven none), or left out when the ledger
226
+ // read failed (JSON drops undefined), so the artifact never claims what the read did not prove.
195
227
  const dispatched: any = await invokeAction(config.region, opsFn, 'db:backup', {
196
- stage, actor: process.env.USER ?? null, fingerprint,
228
+ stage, actor: process.env.USER ?? null, fingerprint, originCommit,
197
229
  });
198
230
  if (dispatched?.error) throw new Error(dispatched.error);
199
231
  hooks.onDispatch?.(dispatched);
@@ -224,6 +256,15 @@ export function stageBackupDeps(
224
256
  if (res?.error) throw new Error(res.error);
225
257
  if (!res?.retracted) throw new Error(`the ops function did not confirm the retraction (returned ${JSON.stringify(res)})`);
226
258
  },
259
+ // Sent AFTER the dump, never in the dispatch: the task job is one ECS env override capped at
260
+ // 8192 chars, which a real manifest outgrows. Scoped server-side like the retraction.
261
+ ...(authzManifest ? {
262
+ stampAuthz: async (key: string) => {
263
+ const res: any = await invokeAction(config.region, opsFn, 'db:backup:stamp-authz', { key, stage, authzManifest });
264
+ if (res?.error) throw new Error(res.error);
265
+ if (!res?.stamped) throw new Error(`the ops function did not confirm the stamp (returned ${JSON.stringify(res)}) — deploy a server build that ships db:backup:stamp-authz`);
266
+ },
267
+ } : {}),
227
268
  onStep: hooks.onStep,
228
269
  };
229
270
  }