@everystack/cli 0.4.104 → 0.4.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,37 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.106] - 2026-10-31
8
+
9
+ ### Added
10
+
11
+ - declare request roles with defineModule({ roles: { request } })
12
+
13
+ ### Fixed
14
+
15
+ - a granted definer role is not a request role in the lifecycle check
16
+ - lifecycle rule 2 judges schema owners and the deploy history tables
17
+ - lifecycle reach counts mixed-option membership chains
18
+ - move the database when an app role owns public through pg_database_owner
19
+ - refuse an empty request-role list on the reconcile wire
20
+
21
+ ### Documentation
22
+
23
+ - say why a granted owner counts, and document roles.request
24
+ - state what an older server skips under the role declaration
25
+
26
+ ## [0.4.105] - 2026-10-31
27
+
28
+ ### Fixed
29
+
30
+ - rule 5 judges who can replace a trigger function, not its security mode
31
+ - judge declared trigger-function owners before the derived layer lands
32
+ - re-run the catalog check after the derived layer lands
33
+ - read the reconcile's builder on its own connection
34
+ - everystack --version prints the version
35
+ - run the post-check on the reconcile's own connection
36
+ - say the tables were applied only when this run applied them
37
+
7
38
  ## [0.4.104] - 2026-10-30
8
39
 
9
40
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.104",
3
+ "version": "0.4.106",
4
4
  "description": "CLI and OTA updates for Expo apps on everystack",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Scalable Technology, Inc. <licensing@scalable.technology>",
@@ -145,7 +145,7 @@
145
145
  "structured-headers": "1.0.1",
146
146
  "tsx": "4.21.0",
147
147
  "typescript": "5.9.3",
148
- "@everystack/model": "0.4.25"
148
+ "@everystack/model": "0.4.26"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@everystack/server": ">=0.4.0",
@@ -192,7 +192,7 @@
192
192
  "react": "19.2.0",
193
193
  "ts-jest": "29.4.9",
194
194
  "zod": "3.25.67",
195
- "@everystack/server": "0.4.40"
195
+ "@everystack/server": "0.4.42"
196
196
  },
197
197
  "scripts": {
198
198
  "test": "jest",
@@ -77,7 +77,7 @@ import { readStageStateTwice } from '../stage-read-consistency';
77
77
  import { renderApplyReceipt, renderCatchUpFailure } from '../plain-output';
78
78
  import { renderFkOrphanAdvisory, ownershipColumns } from '../fk-orphan-advisory';
79
79
  import { catchUpDerived, type CatchUpVenue } from '../derived-catchup';
80
- import { lifecycleApplyRefusal, lifecycleStateOnlyRefusal } from '../lifecycle-preflight';
80
+ import { lifecycleApplyRefusal, lifecycleRolesOf, lifecycleStateOnlyRefusal, LifecycleFindingsError, unclassifiedGranteesWarning } from '../lifecycle-preflight';
81
81
  import { buildReconcileReport } from './db-reconcile';
82
82
  import { step, success, fail, info, warn, line as printLine } from '../output';
83
83
 
@@ -269,6 +269,8 @@ async function runCatchUp(
269
269
  ...(flags.models ? { models: flags.models } : {}),
270
270
  onStart: () => step('Catching the functions, views and triggers up to the checkout...'),
271
271
  onRefusal,
272
+ // A lifecycle refusal says the tables were applied only when this run applied some.
273
+ stateApplied: applied > 0,
272
274
  });
273
275
  if (outcome.run) {
274
276
  if (verbose) {
@@ -289,6 +291,8 @@ async function runCatchUp(
289
291
  return { ok: true, statements: outcome.statements };
290
292
  } catch (err: any) {
291
293
  const cause = String(err?.message ?? err);
294
+ // A lifecycle finding's report already says what committed and what did not, one line each.
295
+ if (err instanceof LifecycleFindingsError) return { ok: false, lines: cause.split('\n') };
292
296
  return {
293
297
  ok: false,
294
298
  // The cause is printed ONCE, at every verbosity: its headline on the report line, and
@@ -515,7 +519,9 @@ async function applyPlanViaStage(
515
519
  const { region } = config;
516
520
  const fn = opsFunction(config);
517
521
  const declared = await loadDeclaredDerived(flags.models);
518
- const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [])
522
+ const unclassified = unclassifiedGranteesWarning(declared?.models ?? [], lifecycleRolesOf(declared));
523
+ if (unclassified) warn(unclassified);
524
+ const lifecycleRefusal = await lifecycleApplyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? [], lifecycleRolesOf(declared))
519
525
  ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(lambdaQueryRunner(region, fn), declared?.models ?? []) : null);
520
526
  if (lifecycleRefusal) {
521
527
  fail(lifecycleRefusal);
@@ -952,7 +958,9 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
952
958
  const declaredDb = (): ReturnType<typeof loadDeclaredDerived> =>
953
959
  declaredDbPromise ??= loadDeclaredDerived(flags.models);
954
960
  // Read-only, before the rollback point: a refused apply must not first take a snapshot, backup or dump.
955
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [])
961
+ const unclassified = unclassifiedGranteesWarning((await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
962
+ if (unclassified) warn(unclassified);
963
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()))
956
964
  ?? (flags['state-only'] === 'true' ? await lifecycleStateOnlyRefusal(runner, (await declaredDb())?.models ?? []) : null);
957
965
  if (lifecycleRefusal) {
958
966
  fail(lifecycleRefusal);
@@ -1109,7 +1117,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
1109
1117
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
1110
1118
  async () => {
1111
1119
  // Re-checked under the lease, where the writes happen.
1112
- const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? []);
1120
+ const leasedRefusal = await lifecycleApplyRefusal(runner, (await declaredDb())?.models ?? [], lifecycleRolesOf(await declaredDb()));
1113
1121
  if (leasedRefusal) {
1114
1122
  fail(leasedRefusal);
1115
1123
  process.exit(1);
@@ -41,6 +41,7 @@ import { resolveDbSource, type DbSource } from '../db-source';
41
41
  import { resolveModelsPath } from '../models-path';
42
42
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
43
43
  import { loadModels } from './db-generate';
44
+ import { lifecycleRolesOf } from '../lifecycle-preflight';
44
45
  import { step, success, fail, info, warn } from '../output';
45
46
 
46
47
  function requireUrl(flags: Record<string, string>, verb: string): string {
@@ -134,6 +135,7 @@ export async function dbTemplateRefreshCommand(flags: Record<string, string>): P
134
135
  const result = await executeTemplateRefresh(url, models, {
135
136
  declared: declaredDb?.objects,
136
137
  sequences: declaredDb?.sequences,
138
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
137
139
  actor: process.env.USER ?? null,
138
140
  gitRef: currentGitRef(),
139
141
  seed: seedCommand === null ? null : (templateUrl) => {
@@ -26,6 +26,7 @@
26
26
 
27
27
  import type { ModelDescriptor } from '@everystack/model';
28
28
  import { buildIntoDatabase } from '../db-build';
29
+ import { LifecycleFindingsError, lifecycleRolesOf } from '../lifecycle-preflight';
29
30
  import { createUrlRunner } from '../db-source';
30
31
  import { resolveModelsPath } from '../models-path';
31
32
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
@@ -123,11 +124,13 @@ export async function dbBuildCommand(flags: Record<string, string>): Promise<voi
123
124
  declared: declaredDb?.objects,
124
125
  sequences: declaredDb?.sequences,
125
126
  extensions: declaredDb?.extensions,
127
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
126
128
  actor: process.env.USER ?? null,
127
129
  gitRef: currentGitRef(),
128
130
  });
129
131
  } catch (err: any) {
130
- fail(`Build failed: ${err.message}`);
132
+ // A lifecycle finding says itself what was and was not written.
133
+ fail(err instanceof LifecycleFindingsError ? err.message : `Build failed: ${err.message}`);
131
134
  process.exit(1);
132
135
  }
133
136
 
@@ -44,6 +44,7 @@ import { findUnexportedModels, scanModelDirectory, type ModelFileScan } from '..
44
44
  import { loadDeclaredDerived, retiredSqlDirAnywhere, retiredSqlDirFlagRefusal, type DeclaredDerived } from '../declared-derived';
45
45
  import type { SequenceDescriptor } from '@everystack/model';
46
46
  import type { SourceObject } from '../derived-source';
47
+ import { lifecycleRolesOf, unclassifiedGranteesWarning, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
47
48
  import { findReadAuthzGaps, findNakedGrants } from '../authz-lint';
48
49
  import { findSelectGrantGaps } from '../select-grant-advisory';
49
50
  import { parseBaseline, renderBaseline, BASELINE_FILE } from '../authz-baseline';
@@ -125,6 +126,8 @@ export interface StaticCheckInput {
125
126
  * Absent (undefined) = the caller did not read it (older callers): no map finding at all.
126
127
  */
127
128
  modelMapSource?: string | null;
129
+ /** The modules' role declaration (lifecycleRolesOf). Its unclassified grantees are a warning. */
130
+ lifecycleRoles?: LifecycleRoleDeclaration;
128
131
  }
129
132
 
130
133
  export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
@@ -285,6 +288,11 @@ export function runStaticChecks(input: StaticCheckInput): CheckFinding[] {
285
288
  }
286
289
  }
287
290
 
291
+ // A grantee the role declaration leaves unclassified counts as a request role in the lifecycle
292
+ // catalog check. Visible, never fatal: classifying it is the operator's call.
293
+ const unclassified = unclassifiedGranteesWarning(input.models, input.lifecycleRoles ?? {});
294
+ if (unclassified) findings.push({ level: 'warn', area: 'authz', message: unclassified });
295
+
288
296
  if (input.artifactSource === null && input.artifactOrigin === 'flag') {
289
297
  findings.push({
290
298
  level: 'fail',
@@ -369,7 +377,7 @@ export interface EphemeralComposeResult {
369
377
  export async function executeEphemeralCompose(
370
378
  adminUrl: string,
371
379
  models: ModelDescriptor[],
372
- opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[] } = {},
380
+ opts: { actor?: string | null; gitRef?: string | null; declared?: SourceObject[]; sequences?: SequenceDescriptor[]; extensions?: string[]; lifecycleRoles?: LifecycleRoleDeclaration } = {},
373
381
  ): Promise<EphemeralComposeResult> {
374
382
  const database = `escheck_${process.pid}_${Date.now()}`;
375
383
  await createDatabase(adminUrl, database);
@@ -378,6 +386,7 @@ export async function executeEphemeralCompose(
378
386
  declared: opts.declared,
379
387
  sequences: opts.sequences,
380
388
  extensions: opts.extensions,
389
+ lifecycleRoles: opts.lifecycleRoles,
381
390
  actor: opts.actor ?? 'db:check',
382
391
  gitRef: opts.gitRef ?? null,
383
392
  });
@@ -478,6 +487,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
478
487
  moduleModels: declaredDb?.models ?? null,
479
488
  baselineSource,
480
489
  modelMapPath: mapPath, modelMapSource,
490
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
481
491
  });
482
492
  // Per-finding detail is the --verbose report. The quiet default answers the one question a
483
493
  // human asked — is anything pending? — and names the verb that shows it.
@@ -504,6 +514,7 @@ export async function dbCheckCommand(flags: Record<string, string>): Promise<voi
504
514
  declared: declaredDb?.objects,
505
515
  sequences: declaredDb?.sequences,
506
516
  extensions,
517
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
507
518
  });
508
519
  if (verbose) for (const line of ephemeral.report) info(` ${line}`);
509
520
  } catch (err: any) {
@@ -63,7 +63,7 @@ import { resolveModelsPath } from '../models-path';
63
63
  import { resolveConfig, opsFunction } from '../config';
64
64
  import { resolveOperatorUrlViaStage } from '../direct-venue';
65
65
  import { lambdaQueryRunner, lambdaSessionRunner } from '../aws';
66
- import { lifecycleApplyRefusal } from '../lifecycle-preflight';
66
+ import { lifecycleApplyRefusal, lifecycleRolesOf, unclassifiedGranteesWarning } from '../lifecycle-preflight';
67
67
  import { loadModels } from './db-generate';
68
68
  import { loadDeclaredDerived, type DeclaredDerived } from '../declared-derived';
69
69
  import { introspectDerived } from '../derived-introspect';
@@ -238,8 +238,14 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
238
238
  }
239
239
 
240
240
  try {
241
- const lifecycleNote = await lifecycleApplyRefusal(runner, models);
242
- if (lifecycleNote) warn(lifecycleNote);
241
+ // The modules' role declaration, as db:apply reads it. A barrel that does not compose leaves it
242
+ // undeclared here (the check as before); the paths that need the derived layer report the error.
243
+ const lifecycleRoles = lifecycleRolesOf(await loadDeclaredDerived(flags.models).catch(() => null));
244
+ const lifecycleNote = await lifecycleApplyRefusal(runner, models, lifecycleRoles);
245
+ const unclassified = unclassifiedGranteesWarning(models, lifecycleRoles);
246
+ if (unclassified) warn(unclassified);
247
+ // db:plan only reads, so it mints the plan anyway; db:apply runs the same check and refuses.
248
+ if (lifecycleNote) warn(`db:apply will refuse this plan until each lifecycle finding below is fixed.\n${lifecycleNote}`);
243
249
  let snapshot: SchemaSnapshot;
244
250
  let contract: AuthzContract;
245
251
  if (dbSource.kind === 'url') {
@@ -32,7 +32,7 @@
32
32
  import type { QueryRunner } from '../authz-contract';
33
33
  import type { SourceObject } from '../derived-source';
34
34
  import { introspectDerived } from '../derived-introspect';
35
- import { catalogInputsFromModels, lifecycleReconcileRefusal } from '../lifecycle-preflight';
35
+ import { catalogInputsFromModels, lifecycleReconcileRefusal, lifecycleRolesOf } from '../lifecycle-preflight';
36
36
  import { planReconcile, objectGrantDelta, TRIGGER_ENABLED_STATE, type ReconcilePlan, type ReconcileOptions } from '../derived-plan';
37
37
  import {
38
38
  renderReconcileSql,
@@ -84,6 +84,12 @@ export interface ExecuteOptions extends ReconcileOptions {
84
84
  * the check. `unknown` because the ops Lambda passes it straight off the wire.
85
85
  */
86
86
  applicationRoles?: unknown;
87
+ /**
88
+ * The modules' declared request roles and declared owners (LifecycleRoleDeclaration), beside
89
+ * applicationRoles. Optional: absent, the catalog check runs as before. `unknown` for the wire.
90
+ */
91
+ requestRoles?: unknown;
92
+ declaredOwners?: unknown;
87
93
  /**
88
94
  * The caller already ran lifecycleApplyRefusal with the full models, on this runner, in this
89
95
  * process (executeSync does, first thing), so the reconcile does not run the catalog check again.
@@ -159,7 +165,9 @@ export async function executeReconcile(
159
165
  // --rebaseline, --only, --overwrite-drift or none) refuses on it below; a plan-only run reports
160
166
  // it as a finding. See lifecycleCatalogRefusal for what it defends and from whom.
161
167
  const lifecycleGate = (): Promise<string | null> => (options.lifecyclePreflighted ? Promise.resolve(null)
162
- : lifecycleReconcileRefusal(runner, parsed.objects, { applicationRoles: options.applicationRoles }));
168
+ : lifecycleReconcileRefusal(runner, parsed.objects, {
169
+ applicationRoles: options.applicationRoles, requestRoles: options.requestRoles, declaredOwners: options.declaredOwners,
170
+ }));
163
171
 
164
172
  if (!options.apply) {
165
173
  const lifecycleFindings = await lifecycleGate();
@@ -768,6 +776,7 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
768
776
  // The lifecycle catalog check's roles, from the barrel's models. The tables come from the
769
777
  // declared transition triggers inside the reconcile, the same derivation the ops Lambda runs.
770
778
  applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
779
+ ...lifecycleRolesOf(declared),
771
780
  renamedTables: declared?.renamedTables,
772
781
  baseline: flags.baseline === 'true',
773
782
  rebaseline: flags.rebaseline === 'true',
@@ -31,7 +31,7 @@ import fs from 'node:fs/promises';
31
31
  import path from 'node:path';
32
32
  import type { ModelDescriptor } from '@everystack/model';
33
33
  import { introspectContract, type QueryRunner } from '../authz-contract';
34
- import { catalogInputsFromModels, lifecycleApplyRefusal } from '../lifecycle-preflight';
34
+ import { catalogInputsFromModels, lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecycleRolesOf, type LifecycleRoleDeclaration } from '../lifecycle-preflight';
35
35
  import { introspectSchema, type SchemaSnapshot } from '../schema-introspect';
36
36
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog';
37
37
  import { generateMigrationSql, unmodeledTables } from '../migration-generate';
@@ -86,6 +86,8 @@ export interface SyncOptions {
86
86
  governedRoles?: string[];
87
87
  /** Pending table renames — trigger provenance migrates instead of drop+create. */
88
88
  renamedTables?: Record<string, string>;
89
+ /** The modules' role declaration (lifecycleRolesOf): what the lifecycle gates read beside the models. */
90
+ lifecycleRoles?: LifecycleRoleDeclaration;
89
91
  /** Injectable clock for tests. */
90
92
  now?: () => number;
91
93
  }
@@ -183,6 +185,8 @@ async function syncOnce(
183
185
  // The catalog check runs again here, after the state apply: a table this sync just created can
184
186
  // carry a finding (a default privilege, its owner) the check before the state apply could not see.
185
187
  applicationRoles: catalogInputsFromModels(models).applicationRoles,
188
+ requestRoles: options.lifecycleRoles?.requestRoles,
189
+ declaredOwners: options.lifecycleRoles?.declaredOwners,
186
190
  });
187
191
 
188
192
  const declaredFingerprint = fingerprintModels(models, { sequences: options.sequences }).hash;
@@ -240,8 +244,12 @@ export async function executeSync(
240
244
  hooks: SyncHooks = {},
241
245
  ): Promise<SyncRun> {
242
246
  // State runs before compute, so without this a lifecycle table would land with no transition trigger.
243
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
247
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, models, options.lifecycleRoles);
244
248
  if (lifecycleRefusal) throw new Error(lifecycleRefusal);
249
+ // Before anything is written, so before both places a pass creates derived objects (the
250
+ // functions the state batch calls, then the reconcile).
251
+ const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
252
+ if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
245
253
  const first = await syncOnce(runner, session, models, options, hooks, 1);
246
254
  // The trigger is the STATE bar, not the combined verdict. The cause above is state-layer
247
255
  // only, so a compute refusal or an unadopted object cannot be settled by looking again —
@@ -447,6 +455,7 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
447
455
  sequences: declaredDb?.sequences,
448
456
  extensions: declaredDb?.extensions,
449
457
  renamedTables: declaredDb?.renamedTables,
458
+ lifecycleRoles: lifecycleRolesOf(declaredDb),
450
459
  allowDrops: flags['allow-drops'] === 'true',
451
460
  overwriteDrift: flags['overwrite-drift'] === 'true',
452
461
  baseline: flags.baseline === 'true',
@@ -18,7 +18,7 @@ import { compileDeclaredState } from './declared-diff';
18
18
  import { createUrlRunner } from './db-source';
19
19
  import { executeSync, buildSyncReport } from './commands/db-sync';
20
20
  import { executeReconcile } from './commands/db-reconcile';
21
- import { lifecycleApplyRefusal } from './lifecycle-preflight';
21
+ import { lifecycleApplyRefusal, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal, type LifecycleRoleDeclaration } from './lifecycle-preflight';
22
22
 
23
23
  const SAFE_NAME = /^[a-z_][a-z0-9_$]*$/;
24
24
 
@@ -101,6 +101,8 @@ export interface BuildOptions {
101
101
  extensions?: string[];
102
102
  actor?: string | null;
103
103
  gitRef?: string | null;
104
+ /** The modules' role declaration (lifecycleRolesOf): what the lifecycle gates read beside the models. */
105
+ lifecycleRoles?: LifecycleRoleDeclaration;
104
106
  }
105
107
 
106
108
  /**
@@ -181,13 +183,18 @@ export async function buildIntoDatabase(
181
183
  // declared state: 20 such pairs, `type "…_row" does not exist`, from-scratch only. Their
182
184
  // explicit `dependsOn` could not have helped — this pass dropped the view from the wave, so
183
185
  // no edge could order it back in.
186
+ // Before the first derived write: the owners the reconcile will give the declared trigger
187
+ // functions on lifecycle tables. This pass runs before executeSync, which judges them again
188
+ // first thing, before its own state apply.
189
+ const ownerRefusal = await lifecycleDeclaredOwnerRefusal(runner, session, options.declared ?? [], models, { stateApplied: false, roles: options.lifecycleRoles });
190
+ if (ownerRefusal) throw new LifecycleFindingsError(ownerRefusal);
184
191
  const declaredFunctions = (options.declared ?? [])
185
192
  .filter((o) => o.kind === 'function' && !o.signatureDeps?.length);
186
193
  if (declaredFunctions.length > 0) {
187
194
  // The lifecycle catalog check, with the full models, before any lifecycle-owned function
188
195
  // lands: this pass declares functions only, so it carries no transition trigger for the
189
196
  // reconcile to read the lifecycle tables from. executeSync runs the same check again below.
190
- const lifecycleRefusal = await lifecycleApplyRefusal(runner, models);
197
+ const lifecycleRefusal = await lifecycleApplyRefusal(runner, models, options.lifecycleRoles);
191
198
  if (lifecycleRefusal) throw new Error(lifecycleRefusal);
192
199
  await runner('SET check_function_bodies = off');
193
200
  try {
@@ -211,9 +218,14 @@ export async function buildIntoDatabase(
211
218
  const run = await executeSync(runner, session, models, {
212
219
  declared: options.declared,
213
220
  sequences: options.sequences,
221
+ lifecycleRoles: options.lifecycleRoles,
214
222
  actor: options.actor ?? 'db-build',
215
223
  gitRef: options.gitRef ?? null,
216
224
  });
225
+ // Verification: the live catalog check again, over what the derived layer just made live. A
226
+ // throw, so no caller (db:check's compose ring, the dev template, a test database) can miss it.
227
+ const post = await lifecyclePostApplyRefusal(session, models, options.lifecycleRoles);
228
+ if (post) throw new LifecycleFindingsError(post);
217
229
  return {
218
230
  converged: run.converged,
219
231
  fingerprintMatch: run.fingerprintMatch,
@@ -12,7 +12,7 @@ import path from 'node:path';
12
12
  import fs from 'node:fs/promises';
13
13
  import { pathToFileURL } from 'node:url';
14
14
  import type { Module, ModelDescriptor, SequenceDescriptor, DerivedDescriptor } from '@everystack/model';
15
- import { linkModels, ModelLinkError, moduleGovernedRoles } from '@everystack/model';
15
+ import { linkModels, ModelLinkError, moduleGovernedRoles, moduleRequestRoles } from '@everystack/model';
16
16
  import { compileDerived } from './derived-compile';
17
17
  import { lifecycleDerived } from './lifecycle-derived';
18
18
  import { compileTableRenames, compileTableMoves } from './schema-compile';
@@ -35,6 +35,13 @@ export interface DeclaredDerived {
35
35
  /** Roles the modules declare as governed beyond the ones the models name. A live grantee
36
36
  * outside the governed set is exempted from reconciliation and ENUMERATED instead. */
37
37
  governedRoles: string[];
38
+ /**
39
+ * The roles a request can become (`roles: { request }`, the union across modules). Undefined
40
+ * when no module declares them: the lifecycle catalog check then runs exactly as before.
41
+ */
42
+ requestRoles?: string[];
43
+ /** Every `owner` a declared derived object names, across modules, sorted. */
44
+ declaredOwners: string[];
38
45
  /**
39
46
  * Postgres extensions the modules declare — the bootstrap the state layer needs before
40
47
  * anything that uses their types. Only the from-scratch compiler consumed these; the DIFF
@@ -194,6 +201,9 @@ export function composeDeclaredDerived(modules: Module[], modelsPath: string): D
194
201
  renamedTables: { ...compileTableRenames(models, {}), ...compileTableMoves(models, {}) },
195
202
  models,
196
203
  governedRoles: moduleGovernedRoles(modules),
204
+ requestRoles: moduleRequestRoles(modules),
205
+ declaredOwners: [...new Set(modules.flatMap((m) => m.derived)
206
+ .flatMap((d) => ('owner' in d && typeof d.owner === 'string' ? [d.owner] : [])))].sort(),
197
207
  extensions: [...new Set(modules.flatMap((m) => m.extensions ?? []))].sort(),
198
208
  };
199
209
  } catch (err) {
@@ -181,6 +181,16 @@ function objectSql(obj: SourceObject, ownerMode: OwnerApplyMode = 'set-role', re
181
181
  return [`SET ROLE ${obj.owner}`, ...statements, 'RESET ROLE'];
182
182
  }
183
183
 
184
+ /**
185
+ * The role that owns an object once the reconcile has created it: its declared owner (objectSql
186
+ * enacts it), else `builder`, the role the reconcile runs as, which creates it as itself. The
187
+ * lifecycle pre-exposure gate reads owners through this too, so the role it judges is the role the
188
+ * reconcile makes.
189
+ */
190
+ export function reconcileOwner(obj: Pick<SourceObject, 'owner'>, builder: string): string {
191
+ return obj.owner || builder;
192
+ }
193
+
184
194
  const ALTER_OWNER: Record<string, string> = {
185
195
  function: 'ALTER FUNCTION',
186
196
  view: 'ALTER VIEW',
@@ -228,7 +238,8 @@ export function triggerFunctionOf(trigger: SourceObject, source: readonly Source
228
238
  function withTransientExecute(obj: SourceObject, statements: string[], source: readonly SourceObject[], grantee?: string): string[] {
229
239
  if (!grantee || obj.kind !== 'trigger') return statements;
230
240
  const fn = triggerFunctionOf(obj, source);
231
- if (!fn?.owner) return statements;
241
+ // The builder owns a function it creates for itself, and an owner holds EXECUTE.
242
+ if (!fn || reconcileOwner(fn, grantee) === grantee) return statements;
232
243
  const declared = parseGrantAttachments(fn.attachments).grants[grantee] ?? [];
233
244
  if (declared.includes('EXECUTE') || declared.includes('ALL')) return statements;
234
245
  const target = dropTarget('function', fn.identity);
@@ -33,9 +33,9 @@ import { loadDeclaredDerived, type DeclaredDerived } from './declared-derived';
33
33
  import { modelContractPreflight } from './model-wire-lane';
34
34
  import { MODEL_WIRE_CONTRACT, type ReconcileWirePayload } from '../wire';
35
35
  import { resolveConfig, opsFunction } from './config';
36
- import { invokeAction } from './aws';
36
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from './aws';
37
37
  import { currentGitRef } from './state-apply';
38
- import { catalogInputsFromModels } from './lifecycle-preflight';
38
+ import { catalogInputsFromModels, lifecycleDeclaredOwnerRefusal, LifecycleFindingsError, lifecyclePostApplyRefusal, lifecycleRolesOf } from './lifecycle-preflight';
39
39
 
40
40
  /** Where the catch-up runs. The two lanes db:apply itself already has. */
41
41
  export type CatchUpVenue =
@@ -77,6 +77,12 @@ export interface CatchUpOptions {
77
77
  * wrongly handed an exit loses the sentence its operator was owed.
78
78
  */
79
79
  onRefusal: 'exit' | 'throw';
80
+ /**
81
+ * Whether THIS run committed table or column changes before the catch-up: a lifecycle refusal
82
+ * then says so. False for db:apply with no --plan, and for a plan that was already applied.
83
+ * Required for the same reason as `onRefusal`: only the caller knows what it wrote.
84
+ */
85
+ stateApplied: boolean;
80
86
  }
81
87
 
82
88
  export interface CatchUpOutcome {
@@ -112,15 +118,27 @@ export async function catchUpDerived(
112
118
  // The lifecycle catalog check's roles; the reconcile, here or in the ops Lambda, refuses
113
119
  // lifecycle-owned objects without them.
114
120
  applicationRoles: declared?.models ? catalogInputsFromModels(declared.models).applicationRoles : undefined,
121
+ // What the modules declare about the roles; optional on the wire (absent = the check as before).
122
+ ...lifecycleRolesOf(declared),
115
123
  actor: process.env.USER ?? null,
116
124
  gitRef: currentGitRef(),
117
125
  };
118
126
 
127
+ // Before the first derived write, judge the owners the reconcile will give the declared trigger
128
+ // functions on lifecycle tables.
129
+ const config = venue.kind === 'stage' ? await resolveConfig(venue.stage) : undefined;
130
+ const reader: QueryRunner = (q) => (venue.kind === 'direct' ? venue.runner : lambdaQueryRunner(config!.region, opsFunction(config!)))(q);
131
+ // The builder is read on the reconcile's own connection: on a stage, db:session runs on the
132
+ // operator connection, while db:query may run on a reader.
133
+ const operator: SessionRunner = (statements, opts) =>
134
+ (venue.kind === 'direct' ? venue.session : lambdaSessionRunner(config!.region, opsFunction(config!)))(statements, opts);
135
+ const gate = await lifecycleDeclaredOwnerRefusal(reader, operator, objects, declared?.models ?? [], { stateApplied: opts.stateApplied, roles: lifecycleRolesOf(declared) });
136
+ if (gate) throw new LifecycleFindingsError(gate);
137
+
119
138
  let run: ReconcileRun;
120
139
  if (venue.kind === 'direct') {
121
140
  run = await executeReconcile(venue.runner, venue.session, options);
122
141
  } else {
123
- const config = await resolveConfig(venue.stage);
124
142
  // The ops Lambda CREATES AND REPLACES database objects from these descriptors, so verify the
125
143
  // deployed stage understands their shape before sending them — the same handshake
126
144
  // db:reconcile --apply --stage runs, for the same payload.
@@ -130,14 +148,14 @@ export async function catchUpDerived(
130
148
  // before the process ends. A catch-up that is the whole command has written nothing, and
131
149
  // asks to exit: print the skew, say nothing was sent, exit.
132
150
  await modelContractPreflight({
133
- region: config.region,
134
- fn: opsFunction(config),
151
+ region: config!.region,
152
+ fn: opsFunction(config!),
135
153
  directCommand: `everystack db:apply --stage ${venue.stage} --direct`,
136
154
  onRefusal: opts.onRefusal,
137
155
  })('db:apply');
138
156
  const result: any = await invokeAction(
139
- config.region,
140
- opsFunction(config),
157
+ config!.region,
158
+ opsFunction(config!),
141
159
  'db:reconcile',
142
160
  { ...options, modelContract: MODEL_WIRE_CONTRACT } satisfies ReconcileWirePayload,
143
161
  );
@@ -154,5 +172,10 @@ export async function catchUpDerived(
154
172
  // not land, so db:apply must say so and exit the same way.
155
173
  if (run.refusal) throw new CatchUpError(run.refusal);
156
174
 
175
+ // Verification: the live catalog check again, over what the reconcile just made live. On the
176
+ // operator session, where the commit is: db:query may read a replica that has not caught up.
177
+ const post = await lifecyclePostApplyRefusal(operator, declared?.models ?? [], lifecycleRolesOf(declared));
178
+ if (post) throw new LifecycleFindingsError(post);
179
+
157
180
  return { declared: objects.length, run, statements: run.applied ? (run.statements ?? []) : [] };
158
181
  }
package/src/cli/index.ts CHANGED
@@ -44,6 +44,8 @@ import { auditLighthouseCommand } from './commands/lighthouse';
44
44
  import { auditCommand } from './commands/audit';
45
45
  import { uiAuditCommand } from './commands/ui-audit';
46
46
  import { runbookCommand } from './commands/runbook';
47
+ import { readFileSync } from 'node:fs';
48
+ import path from 'node:path';
47
49
  import { fail } from './output';
48
50
  import { parseFlags } from './parse-flags';
49
51
 
@@ -56,6 +58,13 @@ async function main() {
56
58
  process.exit(0);
57
59
  }
58
60
 
61
+ if (command === '--version' || command === '-v') {
62
+ // tsx polyfills __dirname in ESM (see runbook.ts cliVersion).
63
+ const pkg = JSON.parse(readFileSync(path.join(eval('__dirname') as string, '..', '..', 'package.json'), 'utf8'));
64
+ console.log(pkg.version);
65
+ process.exit(0);
66
+ }
67
+
59
68
  const flags = parseFlags(args.slice(1));
60
69
 
61
70
  // `everystack <command> --help` shows help instead of running the command
@@ -405,6 +414,7 @@ Auth:
405
414
 
406
415
  Options:
407
416
  --help, -h Show this help message
417
+ --version, -v Print the @everystack/cli version
408
418
  `);
409
419
  }
410
420
 
@@ -3,6 +3,8 @@ import { createHash } from 'node:crypto';
3
3
  import { DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH, LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from '@everystack/model';
4
4
  import type { QueryRunner } from './authz-contract';
5
5
  import { compileTableContract } from './authz-compile';
6
+ import { reconcileOwner, triggerFunctionOf } from './derived-apply';
7
+ import { isSessionError, type SessionRunner } from './session';
6
8
  import { parseQualified, type SourceObject } from './derived-source';
7
9
  import { lifecycleDerived, lifecycleManifestOf, lifecycleRole } from './lifecycle-derived';
8
10
 
@@ -31,13 +33,50 @@ export function lifecycleLogRowRefusal(log: { present?: unknown; current?: unkno
31
33
  * The log's shape first (its refusals are unchanged), then the live catalog facts the soft delete
32
34
  * rests on (lifecycleCatalogRefusal). Pure SELECTs: the --stage lane runs them over db:query.
33
35
  */
34
- export async function lifecycleApplyRefusal(runner: QueryRunner, models: readonly ModelDescriptor[]): Promise<string | null> {
36
+ export async function lifecycleApplyRefusal(
37
+ runner: QueryRunner,
38
+ models: readonly ModelDescriptor[],
39
+ roles: LifecycleRoleDeclaration = {},
40
+ ): Promise<string | null> {
35
41
  if (!models.some(model => model.lifecycle === true)) return null;
36
42
  const [log] = await runner(LIFECYCLE_LOG_SQL);
37
43
  // Before the plan: a modeled table's keys are the plan's to converge (the models declare each
38
44
  // one, or refuse to compile), so rule 6 reports only keys from tables the models do not have.
39
45
  const modeledTables = [...new Set(models.map(model => `${model.schema}.${model.table}`))];
40
- return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models), modeledTables });
46
+ return lifecycleLogRowRefusal(log) ?? lifecycleCatalogRefusal(runner, { ...catalogInputsFromModels(models, roles), modeledTables });
47
+ }
48
+
49
+ /**
50
+ * What the modules declare about the roles, beside the models (DeclaredDerived). The catalog check
51
+ * reads it to tell a definer role the models grant to apart from a role a request can become.
52
+ *
53
+ * requestRoles `roles: { request }`, the union across modules. Undefined = not declared, and
54
+ * the check runs exactly as before: every grantee counts as a request role.
55
+ * declaredOwners every `owner` a declared derived object names.
56
+ */
57
+ export interface LifecycleRoleDeclaration {
58
+ requestRoles?: string[];
59
+ declaredOwners?: string[];
60
+ }
61
+
62
+ /** The role declaration from a loaded declared layer; empty (not declared) when there is none. */
63
+ export function lifecycleRolesOf(declared: { requestRoles?: string[]; declaredOwners?: string[] } | null | undefined): LifecycleRoleDeclaration {
64
+ return declared?.requestRoles === undefined ? {} : { requestRoles: declared.requestRoles, declaredOwners: declared.declaredOwners ?? [] };
65
+ }
66
+
67
+ /**
68
+ * The grantees the declaration does not classify: each counts as a request role, so the lifecycle
69
+ * check treats it as application-side. A warning, never a refusal. Null when the request roles are
70
+ * not declared (nothing is classified), when no model is a lifecycle model, or when none is left.
71
+ */
72
+ export function unclassifiedGranteesWarning(models: readonly ModelDescriptor[], roles: LifecycleRoleDeclaration): string | null {
73
+ if (roles.requestRoles === undefined || !models.some(model => model.lifecycle === true)) return null;
74
+ const classified = new Set([...roles.requestRoles, ...(roles.declaredOwners ?? [])]);
75
+ const unclassified = catalogInputsFromModels(models).applicationRoles.filter(role => !classified.has(role));
76
+ if (unclassified.length === 0) return null;
77
+ return `the models grant privileges to ${unclassified.join(', ')}, which ${unclassified.length === 1 ? 'is' : 'are'} neither a declared request role `
78
+ + `(roles: { request }) nor a declared owner, so the lifecycle check counts ${unclassified.length === 1 ? 'it' : 'them'} as a role a request can become. `
79
+ + 'Add each to roles.request, or name it as the owner of the derived objects it owns.';
41
80
  }
42
81
 
43
82
  const literal = (s: string): string => `'${s.replace(/'/g, "''")}'`;
@@ -83,6 +122,13 @@ export interface LifecycleCatalogInputs {
83
122
  * rule 6 leaves to the plan. Absent after the state apply: every key is checked.
84
123
  */
85
124
  modeledTables?: readonly string[];
125
+ /**
126
+ * The declared request roles (LifecycleRoleDeclaration). Absent = not declared: the
127
+ * application roles are the grantees, exactly as before. Present: see applicationRoleSet.
128
+ */
129
+ requestRoles?: readonly string[];
130
+ /** Every declared derived-object owner. Read only when `requestRoles` is present. */
131
+ declaredOwners?: readonly string[];
86
132
  }
87
133
 
88
134
  /** A declared dependent: the child's `schema.table`, its foreign-key column (SQL name), the lifecycle parent's `schema.table`. */
@@ -125,8 +171,11 @@ export function nullifyUnrecordedFromDeclared(declared: readonly Parameters<type
125
171
 
126
172
  const notApplicationRole = (lc: string) => (role: string): boolean => role !== 'PUBLIC' && role !== 'public' && role !== lc;
127
173
 
128
- /** The catalog check's inputs, read from the models. */
129
- export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[] } {
174
+ /** The catalog check's inputs, read from the models (and the role declaration, when there is one). */
175
+ export function catalogInputsFromModels(
176
+ models: readonly ModelDescriptor[],
177
+ declaration: LifecycleRoleDeclaration = {},
178
+ ): { lifecycleTables: string[]; applicationRoles: string[]; dependentKeys: LifecycleDependentKey[]; requestRoles?: string[]; declaredOwners?: string[] } {
130
179
  const roles = new Set<string>();
131
180
  for (const model of models) {
132
181
  const contract = compileTableContract(model);
@@ -138,6 +187,7 @@ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { l
138
187
  lifecycleTables: [...new Set(models.filter(model => model.lifecycle === true).map(model => `${model.schema}.${model.table}`))].sort(),
139
188
  applicationRoles: [...roles].filter(notApplicationRole(LIFECYCLE_OWNER_ROLE)).sort(),
140
189
  dependentKeys: models.some(model => model.lifecycle === true) ? dependentKeysFromDeclared(lifecycleDerived(models)) : [],
190
+ ...(declaration.requestRoles === undefined ? {} : { requestRoles: [...declaration.requestRoles], declaredOwners: [...(declaration.declaredOwners ?? [])] }),
141
191
  };
142
192
  }
143
193
 
@@ -151,9 +201,16 @@ export function catalogInputsFromModels(models: readonly ModelDescriptor[]): { l
151
201
  const assumes = (x: string, y: string): string => `(pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') OR CASE
152
202
  WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
153
203
  ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END)`;
154
- /** `x` can act as `y`, or hold ADMIN on it: an ADMIN holder can grant `y` to itself WITH SET. */
204
+ /**
205
+ * `x` can act as `y`, or hold ADMIN on it (an ADMIN holder can grant `y` to itself WITH SET), or
206
+ * reach it through a chain of grants whose options differ link to link. MEMBER counts any chain:
207
+ * ADMIN-only on `i`, then `i` a member of `y` (grant `i` to itself WITH SET, SET ROLE `i`, then
208
+ * `y`); or SET-only on `i`, then `i` an INHERIT-only member of `y` (SET ROLE `i` holds `y`'s
209
+ * privileges). USAGE, SET and MEMBER WITH ADMIN OPTION are each false for both. Never in `assumes`:
210
+ * the side set must still leave out the RDS master's ADMIN-only membership.
211
+ */
155
212
  const reaches = (x: string, y: string): string =>
156
- `(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION'))`;
213
+ `(${assumes(x, y)} OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION') OR pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER'))`;
157
214
 
158
215
  /**
159
216
  * The application-side roles, as a CTE `side(oid, rolname)`: non-superusers that are, or can act
@@ -164,6 +221,115 @@ const sideCte = (apps: string[]): string => `side AS (SELECT r.oid, r.rolname::t
164
221
  WHERE NOT r.rolsuper AND EXISTS (SELECT 1 FROM pg_catalog.pg_roles a
165
222
  WHERE a.rolname = ANY(${textArray(apps)}) AND (a.oid = r.oid OR ${assumes('r.oid', 'a.oid')})))`;
166
223
 
224
+ /** How `x` reaches `y`, for a finding: INHERIT, SET, ADMIN or a chain of grants (the first that holds; see assumes and reaches). */
225
+ const reachHow = (x: string, y: string): string => `CASE WHEN pg_catalog.pg_has_role(${x}, ${y}, 'USAGE') THEN 'INHERIT'
226
+ WHEN (CASE WHEN pg_catalog.current_setting('server_version_num')::int >= 160000 THEN pg_catalog.pg_has_role(${x}, ${y}, 'SET')
227
+ ELSE pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER') END) THEN 'SET'
228
+ WHEN pg_catalog.pg_has_role(${x}, ${y}, 'MEMBER WITH ADMIN OPTION') THEN 'ADMIN' ELSE 'a chain of grants' END`;
229
+
230
+ /**
231
+ * The live facts the declaration rests on. Per declared request role: does it exist, is it a
232
+ * superuser, is it BYPASSRLS. Per declared owner that is a grantee or a request role: does it exist,
233
+ * can it log in, and which request roles reach it (reaches: INHERIT, SET or ADMIN, transitively).
234
+ */
235
+ const requestRoleSql = (request: string[], owners: string[]): string => `SELECT 'request' AS kind, d.name, o.oid IS NOT NULL AS present,
236
+ COALESCE(o.rolsuper, false) AS super, COALESCE(o.rolbypassrls, false) AS bypassrls, COALESCE(o.rolcanlogin, false) AS login,
237
+ ARRAY[]::text[] AS reached_by
238
+ FROM pg_catalog.unnest(${textArray(request)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name
239
+ UNION ALL
240
+ SELECT 'owner', d.name, o.oid IS NOT NULL, COALESCE(o.rolsuper, false), COALESCE(o.rolbypassrls, false), COALESCE(o.rolcanlogin, false),
241
+ ARRAY(SELECT 'request role ' || r.rolname::text || ' can act as ' || d.name || ' (' || ${reachHow('r.oid', 'o.oid')} || ')'
242
+ FROM pg_catalog.pg_roles r WHERE r.rolname = ANY(${textArray(request)}) AND r.oid <> o.oid AND ${reaches('r.oid', 'o.oid')} ORDER BY 1)
243
+ FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name
244
+ ORDER BY 1, 2`;
245
+
246
+ /** The application roles every rule reads (sideCte's input), and why each counts where the declaration can say. */
247
+ interface ApplicationRoleSet {
248
+ apps: string[];
249
+ /** A role in `apps` → one sentence saying why it is there. Empty when nothing is declared. */
250
+ why: Map<string, string>;
251
+ /** Live findings on the declared request roles. */
252
+ findings: string[];
253
+ /** The models' grantees (applicationRoles), PUBLIC and the lifecycle role left out. */
254
+ grantees: ReadonlySet<string>;
255
+ /** The request roles are declared. */
256
+ declared: boolean;
257
+ }
258
+
259
+ /**
260
+ * THE one place the application-role set is computed. Rules 1, 2 and 5, the pre-exposure gate, the
261
+ * post-check and the reconcile path all read it.
262
+ *
263
+ * Not declared (`requestRoles` absent): the grantees, exactly as before, and no query runs.
264
+ *
265
+ * Declared: the request roles and the grantees, minus each declared owner D that holds every guard,
266
+ * read live: D is not a request role, D is not the lifecycle role (never in the set: PUBLIC and it are
267
+ * filtered out), D exists and cannot log in, and no request role reaches D. A declared owner that
268
+ * fails a guard stays, and `why` names the guard. A grantee in neither list stays as well. Each
269
+ * request role must exist, and be neither a superuser nor BYPASSRLS: either is a finding.
270
+ */
271
+ async function applicationRoleSet(runner: QueryRunner, inputs: LifecycleCatalogInputs, lc: string): Promise<ApplicationRoleSet> {
272
+ const grantees = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
273
+ if (inputs.requestRoles === undefined) return { apps: grantees, why: new Map(), findings: [], grantees: new Set(grantees), declared: false };
274
+ const request = new Set([...inputs.requestRoles].filter(notApplicationRole(lc)));
275
+ const owners = new Set(inputs.declaredOwners ?? []);
276
+ const set = new Set([...request, ...grantees]);
277
+ const candidates = [...set].filter(role => owners.has(role)).sort();
278
+ const findings: string[] = [];
279
+ const why = new Map<string, string>();
280
+ for (const row of await runner(requestRoleSql([...request].sort(), candidates))) {
281
+ const name = String(row.name);
282
+ if (row.kind === 'request') {
283
+ if (!truthy(row.present)) {
284
+ findings.push(`request role ${name} does not exist. Fix: create it, or remove it from roles.request.`);
285
+ continue;
286
+ }
287
+ if (truthy(row.super)) findings.push(`request role ${name} is a superuser: a request that becomes it passes every check. Fix: ALTER ROLE ${ident(name)} NOSUPERUSER;`);
288
+ if (truthy(row.bypassrls)) findings.push(`request role ${name} has BYPASSRLS: a request that becomes it skips every row-level policy. Fix: ALTER ROLE ${ident(name)} NOBYPASSRLS;`);
289
+ continue;
290
+ }
291
+ const failed: string[] = [];
292
+ if (request.has(name)) failed.push(`declared owner ${name} is a request role`);
293
+ if (!truthy(row.present)) failed.push(`declared owner ${name} does not exist`);
294
+ else if (truthy(row.login)) failed.push(`declared owner ${name} can log in`);
295
+ failed.push(...textList(row.reached_by));
296
+ if (failed.length === 0) set.delete(name);
297
+ else why.set(name, `${name} counts as an application role: ${failed.join('; ')}.`);
298
+ }
299
+ for (const role of grantees) {
300
+ if (!request.has(role) && !owners.has(role)) {
301
+ why.set(role, `${role} counts as an application role because the models grant it privileges, and it is neither a declared request role nor a declared owner.`);
302
+ }
303
+ }
304
+ return { apps: [...set].sort(), why, findings, grantees: new Set(grantees), declared: true };
305
+ }
306
+
307
+ /**
308
+ * Why `owner`, itself an application-side role, counts: for a rule-5 finding. The declaration's
309
+ * reason when it has one. Undeclared, and the owner counts only because the models grant to it (it
310
+ * acts as no other application role, and no other application role reaches it), the finding says
311
+ * so and names the declaration that tells a definer role apart. Reached, the REVOKE fix stands alone.
312
+ */
313
+ function ownerCause(set: ApplicationRoleSet, owner: string, actsAs: readonly string[], appReach: readonly string[]): string | undefined {
314
+ const known = set.why.get(owner);
315
+ if (known) return known;
316
+ if (set.declared) return actsAs.length > 0 ? `${owner} can act as the application role${actsAs.length === 1 ? '' : 's'} ${actsAs.join(', ')}.` : undefined;
317
+ const granted = set.grantees.has(owner) && actsAs.length === 0 && appReach.length === 0;
318
+ return granted ? grantedCause(owner) : undefined;
319
+ }
320
+
321
+ const grantedCause = (owner: string): string => `${owner} counts as an application role because the models grant it privileges. `
322
+ + 'If it is a definer role that no request role reaches, declare the request roles with roles: { request: [...] } in a module.';
323
+
324
+ /**
325
+ * Two columns for the rule-5 queries: the application roles `owner` can act as (acts_as), and the
326
+ * other application roles that reach `owner` (app_reach). Neither counts `owner` itself.
327
+ */
328
+ const ownerReachColumns = (apps: string[], owner: string): string => `ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
329
+ WHERE a.rolname = ANY(${textArray(apps)}) AND a.oid <> ${owner} AND ${assumes(owner, 'a.oid')} ORDER BY 1) AS acts_as,
330
+ ARRAY(SELECT a.rolname::text FROM pg_catalog.pg_roles a
331
+ WHERE a.rolname = ANY(${textArray(apps)}) AND a.oid <> ${owner} AND ${reaches('a.oid', owner)} ORDER BY 1) AS app_reach`;
332
+
167
333
  /** Rule 1, attributes. */
168
334
  const roleSql = (lc: string): string => `SELECT lc.oid IS NOT NULL AS present,
169
335
  COALESCE(lc.rolcanlogin, false) AS login, COALESCE(lc.rolsuper, false) AS super,
@@ -182,11 +348,17 @@ const memberSql = (apps: string[], lc: string): string => `WITH ${sideCte(apps)}
182
348
  WHERE s.oid <> lc.oid AND ${reaches('s.oid', 'lc.oid')}
183
349
  ORDER BY 1`;
184
350
 
351
+ /** The deploy history: who owns one can rewrite what was applied, run and reconciled. */
352
+ const HISTORY_TABLES = ['everystack.schema_log', 'everystack.exec_log', 'everystack.derived_provenance'];
353
+
185
354
  /**
186
355
  * Rule 2. The owner of a lifecycle table can disable its triggers; the owner of destroy_log can
187
- * write the marker; the owner of a marker function can replace it. Each such object is a finding
188
- * when its owner is the lifecycle role, or when an application-side role is that owner, can act
189
- * as it, or holds ADMIN on it. The operator owning all of them is the documented setup and passes.
356
+ * write the marker; the owner of a marker function can replace it; the owner of a schema holding
357
+ * any of them can drop what is in it (DROP ... CASCADE takes the delete-path trigger along); the
358
+ * owner of schema_log, exec_log or derived_provenance can forge the deploy history. Each such
359
+ * object is a finding when its owner is the lifecycle role, or when an application-side role is
360
+ * that owner, can act as it, or holds ADMIN on it. The operator owning all of them is the
361
+ * documented setup and passes. A table that does not exist is not judged.
190
362
  */
191
363
  const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH ${sideCte(apps)},
192
364
  owned AS (
@@ -198,8 +370,19 @@ const ownerSql = (tables: string[], apps: string[], lc: string): string => `WITH
198
370
  WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log')
199
371
  UNION ALL
200
372
  SELECT 'function', m.sig, p.proowner FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
201
- JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig))
373
+ JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)
374
+ UNION ALL
375
+ SELECT 'table', n.nspname::text || '.' || c.relname::text, c.relowner
376
+ FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
377
+ WHERE c.oid IN (SELECT pg_catalog.to_regclass(t) FROM pg_catalog.unnest(${textArray(HISTORY_TABLES)}) AS t)
378
+ UNION ALL
379
+ SELECT 'schema', n.nspname::text, n.nspowner FROM pg_catalog.pg_namespace n
380
+ WHERE n.nspname = ANY(${textArray([...new Set(tables.map(table => table.slice(0, table.indexOf('.'))))])})
381
+ OR n.oid IN (SELECT c.relnamespace FROM pg_catalog.pg_class c WHERE c.oid = pg_catalog.to_regclass('everystack.destroy_log'))
382
+ OR n.oid IN (SELECT p.pronamespace FROM pg_catalog.unnest(${textArray(MARKERS)}) AS m(sig)
383
+ JOIN pg_catalog.pg_proc p ON p.oid = pg_catalog.to_regprocedure(m.sig)))
202
384
  SELECT o.kind, o.name, pg_catalog.pg_get_userbyid(o.owner)::text AS owner, o.owner = lc.oid AS lifecycle_owned,
385
+ pg_catalog.current_database()::text AS database,
203
386
  ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')} ORDER BY 1) AS via
204
387
  FROM owned o LEFT JOIN pg_catalog.pg_roles lc ON lc.rolname = ${literal(lc)}
205
388
  WHERE o.owner = lc.oid OR EXISTS (SELECT 1 FROM side s WHERE s.oid = o.owner OR ${reaches('s.oid', 'o.owner')})
@@ -248,20 +431,49 @@ const markerSql = (lc: string): string => `SELECT s.sig, p.oid IS NOT NULL AS pr
248
431
  ORDER BY s.i`;
249
432
 
250
433
  /**
251
- * Rule 5. A trigger on a lifecycle table whose function is SECURITY DEFINER and owned by neither
252
- * the table's owner nor the lifecycle role: it runs as that third role inside every delete.
434
+ * Rule 5. A trigger on a lifecycle table whose function's owner is an application-side role, or
435
+ * one an application-side role reaches. The soft delete's stamp and destroy's DELETE run as the
436
+ * lifecycle role, so an INVOKER trigger function runs as it and a DEFINER one as its owner; either
437
+ * way whoever can replace the body acts inside every soft delete and destroy. So the security
438
+ * mode does not matter, and neither the table's owner nor the lifecycle role is exempt.
253
439
  */
254
- const definerTriggerSql = (tables: string[], lc: string): string => `SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
440
+ const triggerOwnerSql = (tables: string[], apps: string[]): string => `WITH ${sideCte(apps)}
441
+ SELECT n.nspname::text || '.' || c.relname::text AS "table", g.tgname::text AS trigger,
255
442
  pg_catalog.quote_ident(fn.nspname) || '.' || pg_catalog.quote_ident(p.proname) || '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS function,
256
- pg_catalog.pg_get_userbyid(p.proowner)::text AS owner, pg_catalog.pg_get_userbyid(c.relowner)::text AS table_owner
443
+ pg_catalog.pg_get_userbyid(p.proowner)::text AS owner,
444
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')} ORDER BY 1) AS via,
445
+ ${ownerReachColumns(apps, 'p.proowner')}
257
446
  FROM pg_catalog.pg_trigger g JOIN pg_catalog.pg_class c ON c.oid = g.tgrelid
258
447
  JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_proc p ON p.oid = g.tgfoid
259
448
  JOIN pg_catalog.pg_namespace fn ON fn.oid = p.pronamespace
260
449
  WHERE NOT g.tgisinternal AND (n.nspname::text || '.' || c.relname::text) = ANY(${textArray(tables)})
261
- AND p.prosecdef AND p.proowner <> c.relowner
262
- AND p.proowner IS DISTINCT FROM (SELECT oid FROM pg_catalog.pg_roles WHERE rolname = ${literal(lc)})
450
+ AND EXISTS (SELECT 1 FROM side s WHERE s.oid = p.proowner OR ${reaches('s.oid', 'p.proowner')})
263
451
  ORDER BY 1, 2`;
264
452
 
453
+ /**
454
+ * One rule-5 finding. `via` holds the application-side roles that are, or reach, `owner`. When the
455
+ * owner is one of them the fix is a new owner; otherwise it is to revoke the path to the owner.
456
+ * `would`: the pre-exposure gate, which judges a declared trigger before the reconcile creates it.
457
+ * `cause`: why the owner counts as an application role (ownerCause), when there is more to say.
458
+ */
459
+ export function triggerOwnerFinding(
460
+ row: { trigger: string; table: string; function: string; owner: string; via: readonly string[]; cause?: string },
461
+ would = false,
462
+ ): string {
463
+ const { owner, via } = row;
464
+ const sideOwned = via.includes(owner);
465
+ const who = sideOwned ? `${owner}, an application-side role`
466
+ : `${owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
467
+ const fix = sideOwned
468
+ ? `declare ${row.function}'s owner in db/models as a role no application role can act as, and apply `
469
+ + `(the apply re-creates a declared function with its declared owner, so a live ALTER alone is undone); `
470
+ + `for a function not declared in db/models: ALTER FUNCTION ${row.function} OWNER TO <that role>;`
471
+ : `${via.map(r => `REVOKE ${ident(owner)} FROM ${ident(r)};`).join(' ')} (or from the role each reaches ${owner} through)`;
472
+ return `trigger ${row.trigger} on ${row.table} ${would ? 'would run' : 'runs'} ${row.function}, owned by ${who}; `
473
+ + `${via.join(', ')} can replace its body, and it runs inside every soft delete and destroy. Fix: ${fix}`
474
+ + (sideOwned && row.cause ? ` ${row.cause}` : '');
475
+ }
476
+
265
477
  /**
266
478
  * Rule 6. Every foreign key into a lifecycle table (a partition's clone excluded), with its columns
267
479
  * in key order and its ON DELETE action. Which ones a model declares is decided by the caller.
@@ -282,6 +494,8 @@ const OWNER_HAZARD = {
282
494
  table: 'an owner can disable its triggers',
283
495
  log: 'an owner can write the marker',
284
496
  function: 'an owner can replace the function',
497
+ schema: 'an owner can DROP ... CASCADE the delete-path trigger',
498
+ history: 'an owner can forge the deploy history',
285
499
  };
286
500
 
287
501
  /**
@@ -298,18 +512,35 @@ const OWNER_HAZARD = {
298
512
  *
299
513
  * The one implementation: db:apply, db:sync and db:plan reach it through lifecycleApplyRefusal
300
514
  * (inputs from the models); db:reconcile, local and in the ops Lambda, through
301
- * lifecycleReconcileRefusal (tables from the declared transition triggers).
515
+ * lifecycleReconcileRefusal (tables from the declared transition triggers); db:apply and db:build
516
+ * again after the derived layer lands, through lifecyclePostApplyRefusal.
302
517
  */
303
518
  export async function lifecycleCatalogRefusal(
304
519
  runner: QueryRunner,
305
520
  inputs: LifecycleCatalogInputs,
306
521
  options: { lifecycleRole?: string } = {},
307
522
  ): Promise<string | null> {
523
+ const findings = await lifecycleCatalogFindings(runner, inputs, options);
524
+ if (findings.length === 0) return null;
525
+ return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
526
+ + `fix each, then re-run:\n${findingLines(findings)}`;
527
+ }
528
+
529
+ const findingLines = (findings: readonly string[]): string => findings.map(finding => ` - ${finding}`).join('\n');
530
+ const countFindings = (n: number): string => `${n} lifecycle finding${n === 1 ? '' : 's'}`;
531
+
532
+ /** lifecycleCatalogRefusal's findings, one string each, without the heading. */
533
+ async function lifecycleCatalogFindings(
534
+ runner: QueryRunner,
535
+ inputs: LifecycleCatalogInputs,
536
+ options: { lifecycleRole?: string } = {},
537
+ ): Promise<string[]> {
308
538
  const lc = options.lifecycleRole ?? LIFECYCLE_OWNER_ROLE;
309
539
  const tables = [...new Set(inputs.lifecycleTables)].sort();
310
- if (tables.length === 0) return null;
311
- const apps = [...new Set(inputs.applicationRoles)].filter(notApplicationRole(lc)).sort();
312
- const findings: string[] = [];
540
+ if (tables.length === 0) return [];
541
+ const set = await applicationRoleSet(runner, inputs, lc);
542
+ const apps = set.apps;
543
+ const findings: string[] = [...set.findings];
313
544
 
314
545
  const [role] = await runner(roleSql(lc));
315
546
  if (!truthy(role?.present)) {
@@ -328,17 +559,23 @@ export async function lifecycleCatalogRefusal(
328
559
  }
329
560
 
330
561
  for (const row of await runner(ownerSql(tables, apps, lc))) {
331
- const kind = row.kind === 'function' ? 'function' : 'table';
332
- const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log : OWNER_HAZARD[kind];
562
+ const kind: 'function' | 'schema' | 'table' = row.kind === 'function' || row.kind === 'schema' ? row.kind : 'table';
563
+ const hazard = row.name === 'everystack.destroy_log' ? OWNER_HAZARD.log
564
+ : HISTORY_TABLES.includes(String(row.name)) ? OWNER_HAZARD.history : OWNER_HAZARD[kind];
333
565
  const via = textList(row.via);
334
566
  const who = truthy(row.lifecycle_owned) ? `${row.owner}, the lifecycle role`
335
567
  : via.includes(String(row.owner)) ? `${row.owner}, an application-side role`
336
568
  : `${row.owner}, which the application-side role${via.length === 1 ? '' : 's'} ${via.join(', ')} can assume`;
337
- const name = kind === 'function' ? String(row.name) : quoteLabel(String(row.name));
569
+ const name = kind === 'function' ? String(row.name) : kind === 'schema' ? ident(String(row.name)) : quoteLabel(String(row.name));
570
+ // pg_database_owner (public's owner on PostgreSQL 15 and later) has one implicit member, the owner
571
+ // of the database, and REVOKE cannot remove it: the database itself has to move.
338
572
  const fix = truthy(row.lifecycle_owned) || via.includes(String(row.owner))
339
573
  ? `ALTER ${kind.toUpperCase()} ${name} OWNER TO <your operator role>;`
340
- : `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
341
- findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}`);
574
+ : row.owner === 'pg_database_owner'
575
+ ? `ALTER DATABASE ${ident(String(row.database))} OWNER TO <your operator role>; (the owner of the database is pg_database_owner's only member)`
576
+ : `${via.map(r => `REVOKE ${ident(String(row.owner))} FROM ${ident(r)};`).join(' ')} (or from the role each reaches it through)`;
577
+ const cause = via.includes(String(row.owner)) ? set.why.get(String(row.owner)) : undefined;
578
+ findings.push(`${kind} ${row.name} is owned by ${who}; ${hazard}. Fix: ${fix}${cause ? ` ${cause}` : ''}`);
342
579
  }
343
580
 
344
581
  for (const row of await runner(aclSql(tables))) {
@@ -371,10 +608,11 @@ export async function lifecycleCatalogRefusal(
371
608
  }
372
609
  }
373
610
 
374
- for (const row of await runner(definerTriggerSql(tables, lc))) {
375
- findings.push(`trigger ${row.trigger} on ${row.table} runs ${row.function}, SECURITY DEFINER and owned by ${row.owner}, `
376
- + `neither the table's owner (${row.table_owner}) nor ${lc}; it runs as ${row.owner} inside every delete. `
377
- + `Fix: ALTER FUNCTION ${row.function} SECURITY INVOKER; or ALTER FUNCTION ${row.function} OWNER TO ${ident(String(row.table_owner))};`);
611
+ for (const row of await runner(triggerOwnerSql(tables, apps))) {
612
+ const owner = String(row.owner);
613
+ const via = textList(row.via);
614
+ findings.push(triggerOwnerFinding({ trigger: String(row.trigger), table: String(row.table), function: String(row.function),
615
+ owner, via, cause: ownerCause(set, owner, textList(row.acts_as), textList(row.app_reach)) }));
378
616
  }
379
617
 
380
618
  const declared = new Set(inputs.dependentKeys.map(key => JSON.stringify([key.child, key.column, key.parent])));
@@ -403,9 +641,96 @@ export async function lifecycleCatalogRefusal(
403
641
  + 'if the models declare this key nullify, upgrade the CLI (@everystack/cli) and re-run.' : ''));
404
642
  }
405
643
 
644
+ return findings;
645
+ }
646
+
647
+ /**
648
+ * A lifecycle finding raised around the derived layer: by the pre-exposure gate (nothing derived
649
+ * was written) or by the post-check (the objects are live). Its message is the whole report, one
650
+ * line of head and one line per finding, so a caller prints it as is.
651
+ */
652
+ export class LifecycleFindingsError extends Error {}
653
+
654
+ /**
655
+ * The pre-exposure gate: rule 5 over the DECLARED owners, before the reconcile creates anything.
656
+ * Each declared trigger on a lifecycle table whose function is declared is judged by the owner the
657
+ * reconcile will give that function (reconcileOwner: the declared owner, else the role the
658
+ * reconcile runs as). A declared owner that does not exist live is a finding. A trigger whose
659
+ * function is not declared is left to the post-check, which reads the live owner.
660
+ *
661
+ * `session` reads the builder: it runs on the connection the reconcile writes on. On a stage that
662
+ * is db:session, the operator connection; db:query (`runner`) may run on a reader
663
+ * (QUERY_DATABASE_URL), whose CURRENT_USER is not the role that will own the function. `runner`
664
+ * reads only pg_roles, which is the same on any connection.
665
+ *
666
+ * `stateApplied`: the table and column changes have committed, so the refusal says so.
667
+ */
668
+ export async function lifecycleDeclaredOwnerRefusal(
669
+ runner: QueryRunner,
670
+ session: SessionRunner,
671
+ declared: readonly SourceObject[],
672
+ models: readonly ModelDescriptor[],
673
+ options: { stateApplied: boolean; roles?: LifecycleRoleDeclaration },
674
+ ): Promise<string | null> {
675
+ const inputs = catalogInputsFromModels(models, options.roles);
676
+ const tables = new Set(inputs.lifecycleTables);
677
+ const triggers = declared.flatMap((object) => {
678
+ if (object.kind !== 'trigger' || !object.table) return [];
679
+ const { schema, name } = parseQualified(object.table);
680
+ const fn = tables.has(`${schema}.${name}`) ? triggerFunctionOf(object, declared) : undefined;
681
+ return fn ? [{ trigger: object.name, table: `${schema}.${name}`, fn }] : [];
682
+ });
683
+ if (triggers.length === 0) return null;
684
+ const [slot] = await session(['SELECT CURRENT_USER::text AS builder']);
685
+ if (isSessionError(slot) || slot?.[0]?.builder == null) throw new Error('could not read the role the reconcile runs as (CURRENT_USER)');
686
+ const builder = String(slot[0].builder);
687
+ const judged = triggers.map(t => ({ ...t, owner: reconcileOwner(t.fn, builder) }));
688
+ const owners = [...new Set(judged.map(t => t.owner))].sort();
689
+ const set = await applicationRoleSet(runner, inputs, LIFECYCLE_OWNER_ROLE);
690
+ const rows = await runner(`WITH ${sideCte(set.apps)}
691
+ SELECT d.name AS owner, o.oid IS NOT NULL AS present,
692
+ ARRAY(SELECT s.rolname FROM side s WHERE s.oid = o.oid OR ${reaches('s.oid', 'o.oid')} ORDER BY 1) AS via,
693
+ ${ownerReachColumns(set.apps, 'o.oid')}
694
+ FROM pg_catalog.unnest(${textArray(owners)}) AS d(name) LEFT JOIN pg_catalog.pg_roles o ON o.rolname = d.name`);
695
+ const byOwner = new Map(rows.map(row => [String(row.owner),
696
+ { present: truthy(row.present), via: textList(row.via), actsAs: textList(row.acts_as), appReach: textList(row.app_reach) }]));
697
+ const findings = [...set.findings, ...judged.flatMap((t) => {
698
+ const owner = byOwner.get(t.owner);
699
+ if (!owner?.present) {
700
+ return [`trigger ${t.trigger} on ${t.table} would run ${t.fn.identity}, owned by ${t.owner}, which does not exist. `
701
+ + `Fix: create ${t.owner} as a role no application role can act as, or declare an existing such role as ${t.fn.identity}'s owner in db/models.`];
702
+ }
703
+ if (owner.via.length === 0) return [];
704
+ return [triggerOwnerFinding({ trigger: t.trigger, table: t.table, function: t.fn.identity, owner: t.owner, via: owner.via,
705
+ cause: ownerCause(set, t.owner, owner.actsAs, owner.appReach) }, true)];
706
+ })];
707
+ if (findings.length === 0) return null;
708
+ return `${options.stateApplied ? 'tables and columns applied; ' : ''}derived layer NOT applied: ${countFindings(findings.length)}; `
709
+ + `fix each, then re-run:\n${findingLines(findings)}`;
710
+ }
711
+
712
+ /**
713
+ * The post-check: the live catalog check again, after the derived layer landed. The reconcile
714
+ * commits what it created, so the report never says refused or rolled back: the objects are live,
715
+ * and the next apply's preflight refuses until each finding is fixed.
716
+ *
717
+ * It reads on `session`, the reconcile's own connection: on a stage, db:session (the operator
718
+ * connection). db:query may read a replica the reconcile's commit has not reached yet, and pass.
719
+ */
720
+ export async function lifecyclePostApplyRefusal(
721
+ session: SessionRunner,
722
+ models: readonly ModelDescriptor[],
723
+ roles: LifecycleRoleDeclaration = {},
724
+ ): Promise<string | null> {
725
+ if (!models.some(model => model.lifecycle === true)) return null;
726
+ const read: QueryRunner = async (q) => {
727
+ const [slot] = await session([q]);
728
+ if (isSessionError(slot)) throw new Error(slot.message);
729
+ return slot ?? [];
730
+ };
731
+ const findings = await lifecycleCatalogFindings(read, catalogInputsFromModels(models, roles));
406
732
  if (findings.length === 0) return null;
407
- return `the live catalog breaks the lifecycle soft delete (${findings.length} finding${findings.length === 1 ? '' : 's'}); `
408
- + `fix each, then re-run:\n${findings.map(finding => ` - ${finding}`).join('\n')}`;
733
+ return `${countFindings(findings.length)} after reconcile; the objects are live; the next apply refuses until fixed:\n${findingLines(findings)}`;
409
734
  }
410
735
 
411
736
  /** An older CLI sends no applicationRoles, so the catalog check cannot run: fail closed. */
@@ -417,6 +742,20 @@ export const LIFECYCLE_MANIFEST_ABSENT_REFUSAL = (functions: readonly string[]):
417
742
  + `function${functions.length === 1 ? '' : 's'} ${functions.join(', ')} carr${functions.length === 1 ? 'ies' : 'y'} no manifest, so the declared dependents `
418
743
  + 'cannot be read and the lifecycle catalog check cannot run. Compile the declared layer from the models with this CLI and re-run.';
419
744
 
745
+ /**
746
+ * The role declaration is optional on the wire: absent, the check runs as before (every grantee
747
+ * counts). Present but not a list of role names, the check cannot tell what was declared: fail closed.
748
+ */
749
+ export const LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL = (field: 'request roles' | 'declared owners'): string => `this db:reconcile payload `
750
+ + `carries ${field} that are not a list of role names, so the lifecycle catalog check cannot run. `
751
+ + 'Compile the declared layer from the models with this CLI and re-run.';
752
+
753
+ /** An empty list treats no role as a request role, so no request role could reach a declared owner. */
754
+ export const LIFECYCLE_REQUEST_ROLES_EMPTY_REFUSAL = 'this db:reconcile payload declares an empty list of request roles, so every declared owner '
755
+ + 'would pass the request-role guard. defineModule refuses that list; compile the declared layer from the models with this CLI and re-run.';
756
+
757
+ const isRoleList = (value: unknown): value is string[] => Array.isArray(value) && value.every(role => typeof role === 'string');
758
+
420
759
  /** Generated by an older CLI (0.4.101's per-row restrict pair); a payload may still carry one it is replacing. */
421
760
  const RETIRED_LIFECYCLE_ROLES = ['restrict'] as const;
422
761
 
@@ -451,13 +790,15 @@ export function lifecycleTablesFromDeclared(declared: readonly SourceObject[]):
451
790
  /**
452
791
  * db:reconcile's lifecycle gate, local and in the ops Lambda alike: the destroy_log shape, the
453
792
  * declared layer's own consistency, the application roles' presence (fail closed), then the
454
- * catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles` is `unknown`
455
- * because the ops Lambda reads it off the wire.
793
+ * catalog check. Null when nothing declared is lifecycle-owned. `applicationRoles`, `requestRoles`
794
+ * and `declaredOwners` are `unknown` because the ops Lambda reads them off the wire. The last two are
795
+ * optional: an older CLI sends neither, and the check runs as before. An older server ignores them:
796
+ * it refuses more, and it skips the live checks on the request roles (see ReconcileWirePayload).
456
797
  */
457
798
  export async function lifecycleReconcileRefusal(
458
799
  runner: QueryRunner,
459
800
  declared: readonly SourceObject[],
460
- options: { applicationRoles?: unknown; lifecycleRole?: string } = {},
801
+ options: { applicationRoles?: unknown; requestRoles?: unknown; declaredOwners?: unknown; lifecycleRole?: string } = {},
461
802
  ): Promise<string | null> {
462
803
  if (!declared.some(object => object.owner === LIFECYCLE_OWNER_ROLE)) return null;
463
804
  const [log] = await runner(LIFECYCLE_LOG_SQL);
@@ -467,11 +808,16 @@ export async function lifecycleReconcileRefusal(
467
808
  if ('refusal' in derived) return derived.refusal;
468
809
  const roles = options.applicationRoles;
469
810
  if (!Array.isArray(roles) || roles.some(role => typeof role !== 'string')) return LIFECYCLE_ROLES_ABSENT_REFUSAL;
811
+ const { requestRoles, declaredOwners } = options;
812
+ if (requestRoles != null && !isRoleList(requestRoles)) return LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL('request roles');
813
+ if (declaredOwners != null && !isRoleList(declaredOwners)) return LIFECYCLE_ROLE_DECLARATION_MALFORMED_REFUSAL('declared owners');
814
+ if (Array.isArray(requestRoles) && requestRoles.length === 0) return LIFECYCLE_REQUEST_ROLES_EMPTY_REFUSAL;
470
815
  const unreadable = declared.filter(object => object.owner === LIFECYCLE_OWNER_ROLE && object.name.endsWith('_lifecycle_transition')
471
816
  && !lifecycleManifestOf(object)).map(object => object.identity).sort();
472
817
  if (unreadable.length > 0) return LIFECYCLE_MANIFEST_ABSENT_REFUSAL(unreadable);
473
818
  return lifecycleCatalogRefusal(runner, { lifecycleTables: derived.tables, applicationRoles: roles, dependentKeys: dependentKeysFromDeclared(declared),
474
- nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared) },
819
+ nullifyUnrecorded: nullifyUnrecordedFromDeclared(declared),
820
+ ...(requestRoles == null ? {} : { requestRoles, declaredOwners: isRoleList(declaredOwners) ? declaredOwners : [] }) },
475
821
  { lifecycleRole: options.lifecycleRole });
476
822
  }
477
823