@everystack/cli 0.4.44 → 0.4.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/cli/alter-type-dependents.ts +96 -0
  3. package/src/cli/apply-execute.ts +22 -8
  4. package/src/cli/authz-adoption-class.ts +314 -0
  5. package/src/cli/authz-baseline.ts +25 -3
  6. package/src/cli/authz-canonical.ts +178 -0
  7. package/src/cli/authz-compile.ts +130 -40
  8. package/src/cli/authz-contract.ts +212 -44
  9. package/src/cli/authz-derive.ts +244 -34
  10. package/src/cli/authz-identity.ts +222 -0
  11. package/src/cli/authz-ownership.ts +193 -0
  12. package/src/cli/authz-reconcile.ts +61 -27
  13. package/src/cli/aws.ts +32 -0
  14. package/src/cli/commands/db-apply.ts +60 -14
  15. package/src/cli/commands/db-authz.ts +9 -14
  16. package/src/cli/commands/db-fingerprint.ts +54 -18
  17. package/src/cli/commands/db-generate.ts +59 -15
  18. package/src/cli/commands/db-plan.ts +89 -9
  19. package/src/cli/commands/db-pull.ts +36 -19
  20. package/src/cli/commands/db-reconcile.ts +18 -20
  21. package/src/cli/commands/db-swap.ts +5 -4
  22. package/src/cli/commands/db-sync.ts +8 -5
  23. package/src/cli/db-build.ts +2 -2
  24. package/src/cli/db-source.ts +56 -0
  25. package/src/cli/derived-introspect.ts +27 -26
  26. package/src/cli/derived-lint.ts +7 -8
  27. package/src/cli/edge-plan.ts +125 -17
  28. package/src/cli/git-descent.ts +16 -9
  29. package/src/cli/index.ts +2 -18
  30. package/src/cli/model-render.ts +75 -52
  31. package/src/cli/output.ts +25 -3
  32. package/src/cli/parse-flags.ts +39 -0
  33. package/src/cli/schema-compile.ts +6 -1
  34. package/src/cli/schema-diff.ts +1 -1
  35. package/src/cli/schema-fingerprint.ts +154 -42
  36. package/src/cli/schema-introspect.ts +44 -17
  37. package/src/cli/schema-source.ts +9 -0
  38. package/src/cli/session.ts +184 -0
  39. package/src/cli/stage-read-consistency.ts +128 -0
  40. package/src/cli/state-apply.ts +4 -2
  41. package/src/cli/swap-execute.ts +4 -3
  42. package/src/cli/search-path.ts +0 -51
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Table OWNERSHIP — reported, never compared.
3
+ *
4
+ * A table's owner bypasses its own RLS policies unless the table is FORCEd. So the
5
+ * pair (owner, FORCE) decides whether the policies this repo argues about are actually
6
+ * in force for the principal that writes. Neither half is visible in the contract:
7
+ * `GRANTS_SQL` deliberately EXCLUDES the owner's self-grant because the owner is
8
+ * environment-specific (the dev user locally, the master/operator role when deployed),
9
+ * and committing it would make the contract non-portable and drift on every stage.
10
+ *
11
+ * That exclusion is right, and it is exactly why this module exists. The owner is a
12
+ * fact about the DEPLOYED database that no committed artifact can carry, so the only
13
+ * honest treatment is to NAME it on every surface that can see a live database, and
14
+ * let the human read it. Locally it is the dev user and nothing fires; on the stage it
15
+ * is somebody else, and that difference is the whole point.
16
+ *
17
+ * **The owner is NOT part of the contract.** It does not enter `TableContract`, it does
18
+ * not enter `canonicalAuthz`, and it therefore does not touch the fingerprint. Putting
19
+ * an environment-specific field into the content address would make the same declared
20
+ * state hash differently per stage — and adding any field to the canonical form is a
21
+ * FORMAT BUMP, which this does not need: it needs to be reported, not equated.
22
+ *
23
+ * Search_path: this query deparses nothing (`pg_get_expr` is not involved) — it reads
24
+ * catalog names directly — so it needs no canonical-path pinning to be stable.
25
+ */
26
+
27
+ import type { WrittenBy, ModelDescriptor } from '@everystack/model';
28
+ import { IGNORED_SCHEMAS, type QueryRunner, type AuthzContract } from './authz-contract.js';
29
+
30
+ /**
31
+ * Every base table with its owner. The exclusions are RLS_SQL's, verbatim — base
32
+ * relations only, no `pg_catalog` / `information_schema` / `pg_%`, no extension-owned
33
+ * objects — so the two queries agree about which tables exist and the report can be
34
+ * joined onto the contract without either side inventing a table the other cannot see.
35
+ */
36
+ export const TABLE_OWNERS_SQL = `
37
+ SELECT
38
+ n.nspname AS schema,
39
+ c.relname AS "table",
40
+ r.rolname AS owner
41
+ FROM pg_class c
42
+ JOIN pg_namespace n ON n.oid = c.relnamespace
43
+ JOIN pg_roles r ON r.oid = c.relowner
44
+ WHERE c.relkind = 'r'
45
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
46
+ AND n.nspname NOT LIKE 'pg_%'
47
+ AND NOT EXISTS (
48
+ SELECT 1 FROM pg_depend d
49
+ WHERE d.objid = c.oid AND d.deptype = 'e'
50
+ )
51
+ ORDER BY n.nspname, c.relname;
52
+ `.trim();
53
+
54
+ export interface TableOwnerRow {
55
+ schema: string;
56
+ table: string;
57
+ owner: unknown;
58
+ }
59
+
60
+ /** One live table's owner, keyed by the same schema-qualified identity the contract uses. */
61
+ export interface TableOwner {
62
+ /** Schema-qualified table, e.g. `public.posts`. */
63
+ table: string;
64
+ /** The role that owns it. */
65
+ owner: string;
66
+ }
67
+
68
+ /**
69
+ * Fold owner rows into the reportable set. Tooling schemas are dropped here rather than
70
+ * in SQL, matching `assembleContract` — one definition of "not the app's", applied the
71
+ * same way on both sides.
72
+ */
73
+ export function assembleOwners(rows: TableOwnerRow[]): TableOwner[] {
74
+ return rows
75
+ .filter((row) => !IGNORED_SCHEMAS.has(row.schema))
76
+ .map((row) => ({ table: `${row.schema}.${row.table}`, owner: String(row.owner ?? '') }))
77
+ .sort((a, b) => a.table.localeCompare(b.table));
78
+ }
79
+
80
+ /** Read the live table owners. Read-only, one query, no canonical-path pinning needed. */
81
+ export async function introspectTableOwners(run: QueryRunner): Promise<TableOwner[]> {
82
+ return assembleOwners((await run(TABLE_OWNERS_SQL)) as TableOwnerRow[]);
83
+ }
84
+
85
+ /** One governed table's ownership posture, with the model's own intent beside it. */
86
+ export interface OwnershipRow {
87
+ /** Schema-qualified table. */
88
+ table: string;
89
+ /** The live owner, or null when the ownership query did not return this table. */
90
+ owner: string | null;
91
+ /** Live `relforcerowsecurity` — whether the owner is subject to its own policies. */
92
+ forced: boolean;
93
+ /** The model's declared write principal, or null when there is no model (db:pull). */
94
+ writtenBy: WrittenBy | null;
95
+ /** The owner writes past every policy on this table, and the model did not ask for that. */
96
+ flagged: boolean;
97
+ }
98
+
99
+ /**
100
+ * Build the ownership report for the tables the models govern.
101
+ *
102
+ * The flag rule follows the FORCE axiom the compiler already encodes: FORCE iff the
103
+ * write principal is subject to its own policies.
104
+ *
105
+ * - `writtenBy: 'app'` + FORCE off -> FLAGGED. The app writes through the owner
106
+ * connection past policies the model believes are enforcing. A silent bypass.
107
+ * - `writtenBy: 'worker' | 'functions'` + FORCE off -> reported, NOT flagged. These
108
+ * write on the owner connection on purpose; a FORCEd table would block them (on RDS
109
+ * the owner is not a superuser). Correct, and still named.
110
+ * - FORCE on -> reported, not flagged, whatever the principal.
111
+ *
112
+ * Without models (`db:pull`, where the models do not exist yet) `writtenBy` is null and
113
+ * NOTHING is flagged: the intent that would make a bypass wrong has not been declared.
114
+ * The owner is still named — that half is a live fact the pull genuinely saw.
115
+ */
116
+ export function buildOwnershipReport(
117
+ owners: readonly TableOwner[],
118
+ live: AuthzContract,
119
+ opts: { models?: readonly ModelDescriptor[]; tables?: readonly string[] } = {},
120
+ ): OwnershipRow[] {
121
+ const ownerByTable = new Map(owners.map((o) => [o.table, o.owner]));
122
+ const liveByTable = new Map(live.tables.map((t) => [t.table, t]));
123
+
124
+ // Governed = the tables the models declare when there are models; else the caller's
125
+ // explicit subject list (the pull's schema-scoped tables); else every live table.
126
+ const subjects: { table: string; writtenBy: WrittenBy | null }[] = opts.models
127
+ ? opts.models.map((m) => ({ table: `${m.schema}.${m.table}`, writtenBy: m.writtenBy }))
128
+ : (opts.tables ?? live.tables.map((t) => t.table)).map((table) => ({ table, writtenBy: null }));
129
+
130
+ const rows: OwnershipRow[] = [];
131
+ for (const s of subjects) {
132
+ const l = liveByTable.get(s.table);
133
+ // A model with no live table is a CREATE — it has no owner yet, and nothing to report.
134
+ if (!l) continue;
135
+ const forced = l.rls.forced;
136
+ rows.push({
137
+ table: s.table,
138
+ owner: ownerByTable.get(s.table) ?? null,
139
+ forced,
140
+ writtenBy: s.writtenBy,
141
+ flagged: s.writtenBy === 'app' && !forced,
142
+ });
143
+ }
144
+ return rows.sort((a, b) => a.table.localeCompare(b.table));
145
+ }
146
+
147
+ /**
148
+ * Render the report. Grouped by owner, because one operator role usually owns the whole
149
+ * schema and thirty-three identical lines would bury the findings — the same reason
150
+ * `db:pull` says "grants exist for X" once instead of repeating it in every model.
151
+ *
152
+ * When ONE owner owns everything, that is a single line naming it. When there is more
153
+ * than one, every owner is named WITH its tables: a split ownership is itself the
154
+ * anomaly, and a count alone would not say which table changed hands.
155
+ */
156
+ export function renderOwnershipReport(rows: readonly OwnershipRow[]): string[] {
157
+ if (rows.length === 0) return [];
158
+
159
+ const byOwner = new Map<string, OwnershipRow[]>();
160
+ for (const r of rows) {
161
+ const key = r.owner ?? '(not returned by the ownership query)';
162
+ let group = byOwner.get(key);
163
+ if (!group) byOwner.set(key, (group = []));
164
+ group.push(r);
165
+ }
166
+ const owners = [...byOwner.keys()].sort();
167
+
168
+ const lines = [
169
+ `Ownership of ${rows.length} governed table(s) — the owner bypasses RLS unless the table is FORCEd:`,
170
+ ];
171
+ for (const owner of owners) {
172
+ const group = byOwner.get(owner)!;
173
+ const forced = group.filter((r) => r.forced).length;
174
+ const notForced = group.length - forced;
175
+ const tail = notForced > 0 ? `, ${notForced} NOT forced` : '';
176
+ lines.push(` ${owner} — ${group.length} table(s): ${forced} FORCE on${tail}`);
177
+ if (owners.length > 1) {
178
+ for (const r of group) lines.push(` ${r.table}${r.forced ? '' : ' (not forced)'}`);
179
+ }
180
+ }
181
+
182
+ const flagged = rows.filter((r) => r.flagged);
183
+ for (const r of flagged) {
184
+ lines.push(
185
+ ` ! ${r.table} — written by the app but NOT FORCEd: owner ${r.owner ?? '(unknown)'} writes past every policy on this table.`,
186
+ );
187
+ }
188
+ const unknown = rows.filter((r) => r.owner === null);
189
+ for (const r of unknown) {
190
+ lines.push(` ? ${r.table} — the ownership query did not return this table; its owner is unknown.`);
191
+ }
192
+ return lines;
193
+ }
@@ -16,7 +16,8 @@
16
16
  */
17
17
 
18
18
  import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
19
- import { effectivePolicyCheck, parenthesizeOnce } from './authz-contract.js';
19
+ import { effectivePolicyCheck, parenthesizeOnce, isPolicyDead, isPolicySubsumed } from './authz-contract.js';
20
+ import { matchPolicies } from './authz-identity.js';
20
21
  import { quoteQualified } from './pg-ident.js';
21
22
 
22
23
  /**
@@ -135,20 +136,9 @@ function policyDropSql(table: string, name: string): string {
135
136
  return `DROP POLICY IF EXISTS ${name} ON ${table};`;
136
137
  }
137
138
 
138
- /**
139
- * Two policies are the same authorization when every diffed field matches. The check is
140
- * compared through {@link effectivePolicyCheck}, i.e. the server's own defaulting rule
141
- * a live `FOR ALL USING (true)` and a compiled `FOR ALL USING (true) WITH CHECK (true)`
142
- * authorize identically, and reconciling them would emit a DROP + CREATE that changes
143
- * nothing.
144
- */
145
- function policiesEqual(a: PolicyContract, b: PolicyContract): boolean {
146
- return a.command === b.command
147
- && a.permissive === b.permissive
148
- && a.roles.join(',') === b.roles.join(',')
149
- && (a.using ?? '') === (b.using ?? '')
150
- && (effectivePolicyCheck(a) ?? '') === (effectivePolicyCheck(b) ?? '');
151
- }
139
+ // Policy equality lives in authz-identity.ts and nowhere else. The copy that used to sit here
140
+ // compared roles as `roles.join(',')`, which made `['a,b']` equal `['a','b']` a false match
141
+ // on a legal role name. Two implementations of one predicate is how these surfaces drift.
152
142
 
153
143
  function tableMap(c: AuthzContract): Map<string, TableContract> {
154
144
  return new Map(c.tables.map((t) => [t.table, t]));
@@ -179,7 +169,7 @@ export function emitReconcileSql(
179
169
  // the EMITTED SQL must quote it, or a reserved table name (`public.user`) is a syntax error.
180
170
  const table = quoteQualified(d.table);
181
171
  reconcileRls(table, d, l, sql);
182
- reconcilePolicies(table, d, l, sql);
172
+ reconcilePolicies(table, d, l, sql, governed);
183
173
  reconcileGrants(table, d, l, sql, governed);
184
174
  reconcileColumnGrants(table, d, l, sql, governed);
185
175
  }
@@ -229,19 +219,63 @@ function reconcileRls(table: string, d: TableContract, l: TableContract | undefi
229
219
  if (!d.rls.forced && l?.rls.forced) out.push(`ALTER TABLE ${table} NO FORCE ROW LEVEL SECURITY;`);
230
220
  }
231
221
 
232
- function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[]): void {
233
- const dMap = new Map(d.policies.map((p) => [p.name, p]));
234
- const lMap = new Map((l?.policies ?? []).map((p) => [p.name, p]));
222
+ /**
223
+ * Reconcile policies through the SHARED matcher — the same equivalence the differ and the
224
+ * fingerprint use, so the three can never contradict each other about one database.
225
+ *
226
+ * A live policy carrying the declared authorization under a different name, or covering in one
227
+ * policy the roles the compiler splits apart, is ADOPTED: it stays exactly as it is and
228
+ * nothing is emitted. Renaming it would be DDL that buys spelling.
229
+ *
230
+ * The matcher requires full field equality, so this can only turn a DROP + CREATE pair into a
231
+ * no-op — never emit DDL the name-keyed version would not have emitted.
232
+ */
233
+ function reconcilePolicies(table: string, d: TableContract, l: TableContract | undefined, out: string[], governed: ReadonlySet<string>): void {
234
+ const live = l?.policies ?? [];
235
+ const m = matchPolicies(d.policies, live);
236
+ const byName = new Map(live.map((p) => [p.name, p]));
235
237
 
236
- // Drops first: policies removed, or changed (dropped then recreated below).
237
- for (const [name, lp] of lMap) {
238
- const dp = dMap.get(name);
239
- if (!dp || !policiesEqual(dp, lp)) out.push(policyDropSql(table, name));
238
+ // Drops first, so a replaced policy never briefly co-exists with its old form.
239
+ for (const name of m.toDrop) {
240
+ // A live policy scoped ENTIRELY to roles the models do not govern is left alone, exactly
241
+ // as reconcileGrants leaves that role's privileges alone. Without this the two lanes
242
+ // disagree about the same role: it keeps its GRANT and loses its POLICY, and on an
243
+ // RLS-enabled table that combination reads zero rows. A bounded read-only role added for
244
+ // one indexable query is the shape this bites — the model has no word for it, so "no
245
+ // declaration" and "no access" collapsed into the same thing. Same failure as the
246
+ // ungoverned-grantee bug, one lane over.
247
+ //
248
+ // Only when EVERY role is ungoverned. A policy naming anon alongside such a role still
249
+ // governs anon, and leaving it would be the reconciler declining to do its job.
250
+ const p = byName.get(name);
251
+ if (p && p.roles.length > 0 && p.roles.every((r) => !isGoverned(governed, r))) continue;
252
+ // A DEAD policy authorizes nothing — no role it names holds the privilege it polices, so
253
+ // Postgres refuses at the GRANT before ever consulting it. Dropping it changes no access,
254
+ // which makes it churn: a statement an adopter must read, approve and apply to reach zero,
255
+ // whose only effect is to tidy a catalog. Leave it, exactly as the canonical form leaves
256
+ // it out of the state.
257
+ //
258
+ // DEAD BEFORE **AND AFTER**, and the second half is the whole safety of this. Deadness is
259
+ // a property of the contract, not the policy: if THIS plan grants the privilege the policy
260
+ // polices, the policy wakes up the moment the plan lands — undeclared, ungoverned, and
261
+ // granting access the model never asked for. Leaving it would be a widening the plan
262
+ // performs on itself. So it may only be left alone when the DECLARED state keeps it just
263
+ // as inert as the live one does.
264
+ if (p && l && isPolicyDead(l, p) && isPolicyDead(d, p)) continue;
265
+ // Subsumed, before AND after, for exactly the reason deadness needs both: if this plan
266
+ // drops the PUBLIC policy that was covering it, the role-scoped one stops being redundant
267
+ // the moment the plan lands — and leaving it would keep access the model never declared.
268
+ if (p && l && isPolicySubsumed(l, p) && isPolicySubsumed(d, p)) continue;
269
+ out.push(policyDropSql(table, name));
240
270
  }
241
- // Creates: policies added, or changed (recreated to the declared form).
242
- for (const [name, dp] of dMap) {
243
- const lp = lMap.get(name);
244
- if (!lp || !policiesEqual(dp, lp)) out.push(policyCreateSql(table, dp));
271
+ // Symmetric with the drop rule above, and required by the same identity: a DECLARED policy
272
+ // the declared grants do not back is inert — it would authorize nothing the moment it
273
+ // landed. The canonical form already leaves it out of the declared state, so creating it
274
+ // would emit a statement for a difference the hash says does not exist. It appears the
275
+ // moment the model grants the privilege it polices.
276
+ for (const p of m.toCreate) {
277
+ if (isPolicyDead(d, p) || isPolicySubsumed(d, p)) continue;
278
+ out.push(policyCreateSql(table, p));
245
279
  }
246
280
  }
247
281
 
package/src/cli/aws.ts CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { isOpsHandlerCrash, formatOpsCrashReport, fetchDiagnosisWithRetry } from './ops-diagnostics.js';
9
9
  import { tagInvokeTransport } from './ops-advice.js';
10
+ import type { SessionRunner } from './session.js';
10
11
 
11
12
  let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
12
13
  let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
@@ -302,6 +303,37 @@ export function lambdaQueryRunner(
302
303
  };
303
304
  }
304
305
 
306
+ /**
307
+ * A `SessionRunner` backed by the ops Lambda's `db:session` action — the stage lane's
308
+ * only consistent read.
309
+ *
310
+ * `lambdaQueryRunner` above sends one invoke per statement, so a multi-statement read is
311
+ * answered by however many containers happen to be warm, each on its own connection.
312
+ * This sends the statements TOGETHER: one invoke, one container, one connection, one
313
+ * transaction. The response arrives gzipped (catalog JSON compresses 10-20x, which is
314
+ * what keeps a real schema inside the 6 MB invoke-response limit).
315
+ */
316
+ export function lambdaSessionRunner(
317
+ region: string,
318
+ functionName: string,
319
+ invoke: typeof invokeAction = invokeAction,
320
+ ): SessionRunner {
321
+ return async (statements, opts) => {
322
+ const result: any = await invoke(region, functionName, 'db:session', {
323
+ statements,
324
+ ...(opts ?? {}),
325
+ });
326
+ if (result?.error) throw new Error(`Session failed: ${result.error}`);
327
+ if (typeof result?.gzip !== 'string') {
328
+ throw new Error(
329
+ 'the ops Lambda did not answer db:session. Deploy a server build that ships the db:session action — the stage lane cannot read consistently without it.',
330
+ );
331
+ }
332
+ const { gunzipSync } = await import('node:zlib');
333
+ return JSON.parse(gunzipSync(Buffer.from(result.gzip, 'base64')).toString('utf8'));
334
+ };
335
+ }
336
+
305
337
  export async function invokeAction(
306
338
  region: string,
307
339
  functionName: string,
@@ -18,14 +18,19 @@
18
18
  * is exact even on brownfield targets (mint predicted it with the
19
19
  * unmodeled tables merged in). A mismatch here is a real fault, loud.
20
20
  *
21
- * Needs a direct connection (--database-url / DATABASE_URL): apply writes.
21
+ * Two lanes. DIRECT (--database-url / --stage --direct) executes CLI-side over
22
+ * one session. STAGE (--stage) executes credential-free in the ops Lambda —
23
+ * and reads over `lambdaQueryRunner`, one Lambda invoke per catalog query, so a
24
+ * single introspection can be assembled from several containers holding
25
+ * connections to different databases. That lane therefore reads the stage TWICE
26
+ * and refuses a disagreement, reports agreement as a non-detection (it cannot
27
+ * verify read consistency), and refuses a DESTRUCTIVE plan outright — losing
28
+ * data on a read that cannot be verified is not a trade the ceremony can buy.
29
+ * See stage-read-consistency.ts.
22
30
  */
23
31
 
24
32
  import fs from 'node:fs/promises';
25
33
  import { type QueryRunner } from '../authz-contract.js';
26
- import { introspectSchema } from '../schema-introspect.js';
27
- import { introspectContract } from '../authz-contract.js';
28
- import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
29
34
  import { currentGitRef } from '../state-apply.js';
30
35
  import { planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
31
36
  import { verifyDescent, type DescentVerdict, type LiveState } from '../git-descent.js';
@@ -37,12 +42,14 @@ import { compileTableContract } from '../authz-compile.js';
37
42
  import { governedRoleSet, ungovernedGrants } from '../authz-reconcile.js';
38
43
  import { readBaselineFile, checkAgainstBaseline, baselineRefusal } from '../authz-baseline.js';
39
44
  import { resolveDbSource, createUrlRunner, connectingVia, type DbSource } from '../db-source.js';
45
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
40
46
  import { resolveConfig, opsFunction } from '../config.js';
41
- import { invokeAction, lambdaQueryRunner } from '../aws.js';
47
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
42
48
  import { executeApplyPlan, type ApplyPlanResult } from '../apply-execute.js';
43
49
  import { estimateOpsRuntimeFit } from '../ops-fit.js';
44
50
  import { resolveOperatorUrlViaStage } from '../direct-venue.js';
45
51
  import { withMutationLease, MutationLeaseError } from '../mutation-lease.js';
52
+ import { readStageStateTwice, stageDestructiveRefusal } from '../stage-read-consistency.js';
46
53
  import { step, success, fail, info, warn } from '../output.js';
47
54
 
48
55
  // The apply core lives in apply-execute.ts (shared with the ops Lambda's
@@ -128,20 +135,34 @@ async function applyPlanViaStage(
128
135
 
129
136
  info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
130
137
 
138
+ // THE STAGE LANE'S READ GUARD, before anything is dispatched. Each introspection is
139
+ // one session, but a full read is two of them (state, then authz) and they can be
140
+ // answered by two containers at two moments. Read the stage twice and refuse a
141
+ // disagreement — the apply never starts. Deliberately OUTSIDE the try below: this
142
+ // refusal must exit as itself, not as "Apply failed".
143
+ step('Reading the stage TWICE (state and authz are separate ops-Lambda invokes)...');
144
+ const readSession = lambdaSessionRunner(region, fn);
145
+ const readPair = await readStageStateTwice(readSession).catch((err: any) => {
146
+ fail(`Apply failed: could not read the stage — ${err.message}`);
147
+ process.exit(1);
148
+ });
149
+ if (!readPair.ok) {
150
+ fail(readPair.reason);
151
+ process.exit(1);
152
+ }
153
+ warn(readPair.warning);
154
+
131
155
  try {
132
156
  // Descent — the fast-forward rule needs the git checkout, so the CLI
133
- // decides it (the Lambda has no checkout). Read the stage's state read-only
134
- // via the ops Lambda's db:query action, run the rule locally, and hand the
135
- // verdict to the write action, which enforces + records it in order.
157
+ // decides it (the Lambda has no checkout). It runs against the state the
158
+ // double read already produced (no third trip), and the verdict goes to the
159
+ // write action, which enforces + records it in order.
136
160
  let descentVerdict: { ok: true } | { ok: false; reason: string };
137
161
  if (forceDescent !== undefined) {
138
162
  warn(`DESCENT FORCED — the fast-forward rule is bypassed for this apply. Snapshot on record: ${forceDescent}.`);
139
163
  descentVerdict = { ok: true };
140
164
  } else {
141
- step('Asking the stage its state (read-only via the ops Lambda)...');
142
- const readRunner = lambdaQueryRunner(region, fn);
143
- const snapshot = await introspectSchema(readRunner);
144
- const contract = await introspectContract(readRunner, contractFunctionRow, FUNCTIONS_SQL);
165
+ const { snapshot, contract } = readPair.state;
145
166
  step("Descent: searching git for the commit that declares the stage's state...");
146
167
  const verdict = await verifyDescent(resolveModelsPath(flags.models), { snapshot, contract }, {});
147
168
  switch (verdict.status) {
@@ -166,6 +187,11 @@ async function applyPlanViaStage(
166
187
  // and checks it against the stage's declared approver set (SSM). The safety
167
188
  // net is a VERIFIED backup: the Lambda auto-resolves the stage's newest (or
168
189
  // the id in --snapshot-ref) and refuses unless it covers this plan. --confirm always.
190
+ //
191
+ // CURRENTLY UNREACHABLE: dbApplyCommand refuses every destructive plan on the
192
+ // stage lane before it gets here (read consistency cannot be guaranteed on this
193
+ // lane). Kept intact and second in line, so lifting that refusal restores the
194
+ // full ceremony rather than silently shipping without one.
169
195
  let authorityVerdict: AuthorityVerdict | undefined;
170
196
  if (plan.destructive > 0) {
171
197
  const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
@@ -272,6 +298,16 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
272
298
  // Deployed stage, no direct URL: the write runs in the ops Lambda — the
273
299
  // operator never holds a database URL. --database-url stays local-only.
274
300
  if (dbSource.kind === 'stage') {
301
+ // A DESTRUCTIVE plan does not run on this lane at all — no ceremony lifts it.
302
+ // The stage lane's fingerprint (the concurrency lock) can be assembled from
303
+ // several ops-Lambda containers, and the double read below only DETECTS that;
304
+ // it cannot rule it out. Dropping data on a read that may be a collage is the
305
+ // one trade never worth making, so the destructive path is the direct lane's.
306
+ if (plan.destructive > 0) {
307
+ const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
308
+ fail(stageDestructiveRefusal(shape, planPath, flags.stage));
309
+ process.exit(1);
310
+ }
275
311
  // Will this edge fit the ops-Lambda's 900-second clock? If not, refuse up front and
276
312
  // name --direct (credential-free, unbounded) instead of burning 15 minutes to learn it.
277
313
  const fit = estimateOpsRuntimeFit(plan);
@@ -328,7 +364,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
328
364
  }
329
365
 
330
366
  step(connectingVia(dbSource));
331
- const { runner, end } = await createUrlRunner(dbSource.url);
367
+ const { runner, session, end } = await createUrlRunner(dbSource.url);
332
368
 
333
369
  try {
334
370
  info(`plan ${planHash(plan).slice(0, 12)}: ${plan.fromFingerprint.slice(0, 12)} → ${plan.toFingerprint.slice(0, 12)} (${plan.executable} statement(s)${plan.destructive ? `, ${plan.destructive} destructive` : ''})`);
@@ -342,7 +378,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
342
378
  const result = await withMutationLease(
343
379
  runner,
344
380
  { verb: 'db:apply', actor: process.env.USER ?? 'unknown' },
345
- () => executeApplyPlan(runner, plan, {
381
+ () => executeApplyPlan(runner, session, plan, {
346
382
  actor: process.env.USER ?? null,
347
383
  gitRef: currentGitRef() ?? plan.gitRef,
348
384
  ...(verifyAuthority ? { verifyAuthority } : {}),
@@ -371,6 +407,16 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
371
407
  info('descent: fresh target (no tables) — nothing to protect, bootstrapping.');
372
408
  return { ok: true };
373
409
  case 'no-git':
410
+ // A DESTRUCTIVE plan may not ride an accidental waiver: the fast-forward
411
+ // rule's verdict must never depend on which directory the operator ran
412
+ // from. Unverifiable + destructive ⇒ the waiver must be the deliberate
413
+ // ceremony (--force-descent <snapshot-ref> --confirm), not a warning.
414
+ if (plan.destructive > 0) {
415
+ return {
416
+ ok: false,
417
+ reason: 'descent: not a git checkout, and this plan is DESTRUCTIVE — the fast-forward rule cannot be verified here, so waive it deliberately: re-run with --force-descent <snapshot-ref> --confirm.',
418
+ };
419
+ }
374
420
  warn('descent: not a git checkout — the fast-forward rule cannot be verified here.');
375
421
  return { ok: true };
376
422
  case 'diverged':
@@ -57,22 +57,14 @@ import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
57
57
  import { resolveConfig, opsFunction } from '../config.js';
58
58
  import { resolveModelsPath } from '../models-path.js';
59
59
  import { resolveDbSource, connectingVia, createUrlProbeRunner } from '../db-source.js';
60
- import { invokeAction } from '../aws.js';
60
+ import { borrowedSessionRunner, type SessionRunner } from '../session.js';
61
+ import { invokeAction, lambdaQueryRunner, lambdaSessionRunner } from '../aws.js';
61
62
  import { step, success, fail, info, warn } from '../output.js';
62
63
  import { opsAdviceLines, IAM_ADVICE } from '../ops-advice.js';
63
64
  import { loadModels } from './db-generate.js';
64
65
 
65
66
  const DEFAULT_DIR = 'authz';
66
67
 
67
- /** A QueryRunner backed by the ops Lambda `db:query` action (read-only). */
68
- function lambdaRunner(region: string, fn: string): QueryRunner {
69
- return async (sql: string) => {
70
- const result: any = await invokeAction(region, fn, 'db:query', { sql });
71
- if (result?.error) throw new Error(`Introspection query failed: ${result.error}`);
72
- return result?.rows ?? [];
73
- };
74
- }
75
-
76
68
  /**
77
69
  * Where an authz command runs. Both venues expose the same two capabilities, so the
78
70
  * commands never branch on venue after this point — that is what keeps the local
@@ -81,6 +73,8 @@ function lambdaRunner(region: string, fn: string): QueryRunner {
81
73
  interface AuthzVenue {
82
74
  /** Read-only introspection. */
83
75
  runner: QueryRunner;
76
+ /** The consistent multi-statement read — what every introspection takes. */
77
+ session: SessionRunner;
84
78
  /** Self-reverting red-team probe (writes, always rolled back). */
85
79
  probe: (setup: string, read: string) => Promise<any[]>;
86
80
  /** Human-readable venue, printed with every verdict. */
@@ -97,15 +91,16 @@ async function resolveVenue(flags: Record<string, string>): Promise<AuthzVenue>
97
91
  const source = resolveDbSource(flags);
98
92
  if (source.kind === 'url') {
99
93
  step(connectingVia(source));
100
- const { runner, probe, end } = await createUrlProbeRunner(source.url);
101
- return { runner, probe, end, label: 'direct connection' };
94
+ const { runner, session, probe, end } = await createUrlProbeRunner(source.url);
95
+ return { runner, session, probe, end, label: 'direct connection' };
102
96
  }
103
97
  step('Resolving deployed config...');
104
98
  const config = await resolveConfig(flags.stage);
105
99
  const fn = opsFunction(config);
106
100
  info(`Region: ${config.region}, Function: ${fn}`);
107
101
  return {
108
- runner: lambdaRunner(config.region, fn),
102
+ runner: lambdaQueryRunner(config.region, fn),
103
+ session: lambdaSessionRunner(config.region, fn),
109
104
  probe: async (setup: string, read: string) => {
110
105
  const result: any = await invokeAction(config.region, fn, 'db:authz:probe', { setup, read });
111
106
  if (result?.error) throw new Error(result.error);
@@ -120,7 +115,7 @@ async function introspectVenue(flags: Record<string, string>): Promise<{ contrac
120
115
  const venue = await resolveVenue(flags);
121
116
  try {
122
117
  step('Introspecting authorization (rls + grants + policies + secdef)...');
123
- return { contract: await introspectContract(venue.runner, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
118
+ return { contract: await introspectContract(venue.session, contractFunctionRow, FUNCTIONS_SQL), label: venue.label };
124
119
  } finally {
125
120
  await venue.end();
126
121
  }