@everystack/cli 0.3.22 → 0.3.23

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/README.md CHANGED
@@ -220,8 +220,9 @@ everystack db:reconcile # deploy the derived layer (functions/vi
220
220
  everystack db:fingerprint # content-address the live base schema vs the Models — MATCH/MISMATCH
221
221
  everystack db:sync # make the database match your checkout — state + compute, one verb (dev DBs)
222
222
  everystack db:diff # the state edge between two declared states — no DB, CI-pure; reverse = computed rollback
223
- everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends + the fast-forward rule (checkout must descend from the commit declaring the target's state)
223
+ everystack db:plan | db:apply # mint a reviewable plan against a target, apply it fingerprint-verified at both ends + the fast-forward rule (checkout must descend from the commit declaring the target's state); destructive plans: --confirm + snapshot + approver set when declared
224
224
  everystack db:check # the CI gate per PR: merged declared state composes + artifacts match regeneration (+ ephemeral from-scratch build w/ a scratch PG)
225
+ everystack db:approvers # declare who can DESTROY: the stage's destructive-approver set (SSM, STS-verified at apply)
225
226
 
226
227
  # Security
227
228
  everystack db:doctor # is the DB least-privilege + RLS-subject?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
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>",
@@ -78,7 +78,7 @@
78
78
  "structured-headers": "1.0.1",
79
79
  "tsx": "4.21.0",
80
80
  "typescript": "5.9.3",
81
- "@everystack/model": "0.3.5"
81
+ "@everystack/model": "0.3.6"
82
82
  },
83
83
  "peerDependencies": {
84
84
  "@everystack/server": ">=0.1.0",
@@ -93,7 +93,8 @@
93
93
  "expo-updates": "55.0.21",
94
94
  "postgres": "3.4.9",
95
95
  "react": "19.2.0",
96
- "react-native": "0.83.6"
96
+ "react-native": "0.83.6",
97
+ "@aws-sdk/client-sts": "3.1053.0"
97
98
  },
98
99
  "peerDependenciesMeta": {
99
100
  "postgres": {
@@ -131,6 +132,9 @@
131
132
  },
132
133
  "@aws-sdk/s3-request-presigner": {
133
134
  "optional": true
135
+ },
136
+ "@aws-sdk/client-sts": {
137
+ "optional": true
134
138
  }
135
139
  },
136
140
  "devDependencies": {
@@ -148,7 +152,8 @@
148
152
  "drizzle-orm": "0.41.0",
149
153
  "jest": "29.7.0",
150
154
  "react": "19.2.0",
151
- "ts-jest": "29.4.9"
155
+ "ts-jest": "29.4.9",
156
+ "@aws-sdk/client-sts": "3.1053.0"
152
157
  },
153
158
  "scripts": {
154
159
  "test": "jest",
@@ -0,0 +1,150 @@
1
+ /**
2
+ * apply-authority — destructive applies are role-gated to a declared
3
+ * approver set, enforced against the caller's REAL identity (brick 9 of the
4
+ * migration-authority design, decisions 11 + 12).
5
+ *
6
+ * "CTO drops tables; everyone else creates functions and views." The
7
+ * approver set lives STAGE-SIDE — an SSM parameter in the stage's namespace,
8
+ * admin-writable only — never in the repo, where a checkout edit would
9
+ * defeat it (a repo-declared list is a convention wearing a gate's clothes).
10
+ * The chokepoint resolves the caller's real identity via STS — the same
11
+ * pattern as the `db:psql` break-glass, where the IAM read IS the gate.
12
+ *
13
+ * The gate engages on DECLARATION: a stage that never declared approvers
14
+ * keeps the ceremony (explicit confirmation + snapshot, always) but is not
15
+ * identity-gated — the migration-friendly posture, stated honestly. A
16
+ * declared-but-EMPTY set means "no one": destructive applies are disabled
17
+ * on that stage until the set says otherwise.
18
+ *
19
+ * The refusal is the two-person flow's handoff: it names who CAN approve,
20
+ * and `plan_ref` already pins the exact reviewed artifact — a dev mints the
21
+ * destructive plan, an approver applies that same file.
22
+ *
23
+ * The decision core is pure; the AWS edge (SSM + STS) is thin and injected
24
+ * by the command shell.
25
+ */
26
+
27
+ /** The SSM parameter holding the approver set — a JSON array of entries. */
28
+ export function approverParamName(appName: string, stage: string): string {
29
+ return `/everystack/${appName}/${stage}/db-approvers`;
30
+ }
31
+
32
+ /**
33
+ * Does an approver entry match a caller ARN? Exact ARN match, or a match on
34
+ * any path segment after the first slash — so the set can hold plain names:
35
+ * `ty` matches `arn:aws:iam::123:user/ty` and the session name in
36
+ * `arn:aws:sts::123:assumed-role/Admin/ty`. Segment (not suffix) matching:
37
+ * `ty` never matches `user/notty`.
38
+ */
39
+ export function approverMatches(entry: string, identityArn: string): boolean {
40
+ if (entry === identityArn) return true;
41
+ const segments = identityArn.split('/').slice(1);
42
+ return segments.includes(entry);
43
+ }
44
+
45
+ export interface AuthorityInputs {
46
+ /** The declared approver set; null = the stage never declared one. */
47
+ approvers: string[] | null;
48
+ /** The caller's resolved identity (STS ARN); null = unresolvable. */
49
+ identity: string | null;
50
+ }
51
+
52
+ export type AuthorityVerdict =
53
+ | { ok: true; reason: 'approved' | 'undeclared' }
54
+ | { ok: false; reason: string };
55
+
56
+ /** The pure decision core for a DESTRUCTIVE apply. Non-destructive plans never reach it. */
57
+ export function checkDestructiveAuthority({ approvers, identity }: AuthorityInputs): AuthorityVerdict {
58
+ if (approvers === null) return { ok: true, reason: 'undeclared' };
59
+ if (approvers.length === 0) {
60
+ return {
61
+ ok: false,
62
+ reason:
63
+ 'the declared approver set is EMPTY — destructive applies are disabled on this stage. ' +
64
+ 'Declare approvers with `everystack db:approvers --set <arn-or-name,...>` (admin-writable parameter).',
65
+ };
66
+ }
67
+ if (identity === null) {
68
+ return {
69
+ ok: false,
70
+ reason:
71
+ 'this stage declares a destructive-approver set, so the apply must be identity-verified — ' +
72
+ 'and your AWS identity could not be resolved (credentials?). Fix the credentials or hand the plan to an approver.',
73
+ };
74
+ }
75
+ if (approvers.some((entry) => approverMatches(entry, identity))) {
76
+ return { ok: true, reason: 'approved' };
77
+ }
78
+ return {
79
+ ok: false,
80
+ reason:
81
+ `you are ${identity}, and the declared approvers are: ${approvers.join(', ')}. ` +
82
+ 'Hand the plan file to an approver — they review and apply the exact same artifact (its plan_ref pins it).',
83
+ };
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // The thin AWS edge (lazy imports, injected by the command shell).
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /** Read the approver set from SSM. Null when the parameter does not exist. */
91
+ export async function readApproverParam(region: string, appName: string, stage: string): Promise<string[] | null> {
92
+ const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
93
+ const client = new SSMClient({ region });
94
+ try {
95
+ const result = await client.send(new GetParameterCommand({ Name: approverParamName(appName, stage) }));
96
+ return parseApproverValue(result.Parameter?.Value ?? '');
97
+ } catch (err: any) {
98
+ if (err?.name === 'ParameterNotFound') return null;
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ /** The stored form is a JSON array; a bare comma-separated string is accepted on read. */
104
+ export function parseApproverValue(value: string): string[] {
105
+ const trimmed = value.trim();
106
+ if (trimmed === '') return [];
107
+ if (trimmed.startsWith('[')) {
108
+ const parsed = JSON.parse(trimmed);
109
+ if (!Array.isArray(parsed) || parsed.some((e) => typeof e !== 'string')) {
110
+ throw new Error('db-approvers parameter must be a JSON array of strings');
111
+ }
112
+ return parsed.map((e: string) => e.trim()).filter(Boolean);
113
+ }
114
+ return trimmed.split(',').map((e) => e.trim()).filter(Boolean);
115
+ }
116
+
117
+ /** Declare (or replace) the approver set. Stored as a JSON array. */
118
+ export async function writeApproverParam(region: string, appName: string, stage: string, approvers: string[]): Promise<void> {
119
+ const { SSMClient, PutParameterCommand } = await import('@aws-sdk/client-ssm');
120
+ const client = new SSMClient({ region });
121
+ await client.send(new PutParameterCommand({
122
+ Name: approverParamName(appName, stage),
123
+ Value: JSON.stringify(approvers),
124
+ Type: 'String',
125
+ Overwrite: true,
126
+ }));
127
+ }
128
+
129
+ /** Remove the declaration entirely — the stage returns to the undeclared (ceremony-only) posture. */
130
+ export async function removeApproverParam(region: string, appName: string, stage: string): Promise<void> {
131
+ const { SSMClient, DeleteParameterCommand } = await import('@aws-sdk/client-ssm');
132
+ const client = new SSMClient({ region });
133
+ try {
134
+ await client.send(new DeleteParameterCommand({ Name: approverParamName(appName, stage) }));
135
+ } catch (err: any) {
136
+ if (err?.name !== 'ParameterNotFound') throw err;
137
+ }
138
+ }
139
+
140
+ /** The caller's real identity — the STS ARN. Null when credentials can't answer. */
141
+ export async function resolveCallerIdentity(region: string): Promise<string | null> {
142
+ const { STSClient, GetCallerIdentityCommand } = await import('@aws-sdk/client-sts');
143
+ const client = new STSClient({ region });
144
+ try {
145
+ const result = await client.send(new GetCallerIdentityCommand({}));
146
+ return result.Arn ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
@@ -27,11 +27,14 @@ import { introspectSchema, type SchemaSnapshot } from '../schema-introspect.js';
27
27
  import { FUNCTIONS_SQL, contractFunctionRow } from '../security-catalog.js';
28
28
  import { fingerprintLive } from '../schema-fingerprint.js';
29
29
  import { applyGeneratedStatements, currentGitRef } from '../state-apply.js';
30
- import { renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
30
+ import { ENSURE_RECONCILER_SQL, renderSchemaLogInsert, renderSchemaLogFingerprintUpdate } from '../derived-apply.js';
31
31
  import { checkPlanPrecondition, planHash, PLAN_VERSION, type EdgePlan } from '../edge-plan.js';
32
32
  import { verifyDescent, type LiveState } from '../git-descent.js';
33
+ import { checkDestructiveAuthority, readApproverParam, resolveCallerIdentity } from '../apply-authority.js';
33
34
  import { resolveModelsPath } from '../models-path.js';
34
35
  import { resolveDbSource, createUrlRunner, type DbSource } from '../db-source.js';
36
+ import { resolveConfig, opsFunction } from '../config.js';
37
+ import { invokeAction } from '../aws.js';
35
38
  import { step, success, fail, info, warn } from '../output.js';
36
39
 
37
40
  // ---------------------------------------------------------------------------
@@ -54,17 +57,63 @@ export interface ApplyPlanOptions {
54
57
  contract: AuthzContract;
55
58
  fingerprint: string;
56
59
  }) => Promise<{ ok: true } | { ok: false; reason: string }>;
60
+ /**
61
+ * Destructive authority (brick 9, decisions 11+12): called only when the
62
+ * plan is destructive, after the lock and descent checks pass. Return
63
+ * `ok: false` to refuse — the caller is not in the stage's declared
64
+ * approver set. Omitted = no authority gate (non-staged targets keep the
65
+ * ceremony the command shell enforces).
66
+ */
67
+ verifyAuthority?: () => Promise<{ ok: true } | { ok: false; reason: string }>;
68
+ /**
69
+ * Runs after every verification passes and BEFORE the edge executes, only
70
+ * for destructive plans — the auto-snapshot seam ("losing data should be
71
+ * hard": confirmed, approved, snapshotted, in that order). A throw here
72
+ * aborts the apply with nothing executed.
73
+ */
74
+ beforeDestructive?: () => Promise<void>;
57
75
  /** Injectable clock for tests. */
58
76
  now?: () => number;
59
77
  }
60
78
 
61
79
  export interface ApplyPlanResult {
62
- status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'verify-failed';
80
+ status: 'applied' | 'already-applied' | 'refused' | 'descent-refused' | 'authority-refused' | 'verify-failed';
63
81
  liveFingerprint: string;
64
82
  reason?: string;
65
83
  logId?: number;
66
84
  }
67
85
 
86
+ /**
87
+ * Decision 7, made whole: every database records every refusal, not just
88
+ * every apply. Best-effort by construction — a memoir INSERT that cannot be
89
+ * written must never mask the refusal itself (a target that rejects the
90
+ * bookkeeping write would have failed the apply's ENSURE identically).
91
+ */
92
+ async function recordRefusal(
93
+ runner: QueryRunner,
94
+ plan: EdgePlan,
95
+ liveFingerprint: string,
96
+ gate: string,
97
+ reason: string,
98
+ options: ApplyPlanOptions,
99
+ ): Promise<void> {
100
+ try {
101
+ await runner(ENSURE_RECONCILER_SQL.join(';\n'));
102
+ await runner(renderSchemaLogInsert({
103
+ kind: 'state refusal',
104
+ outcome: 'refused',
105
+ actor: options.actor ?? null,
106
+ gitRef: options.gitRef ?? plan.gitRef,
107
+ planRef: planHash(plan),
108
+ fromFingerprint: liveFingerprint,
109
+ toFingerprint: plan.toFingerprint,
110
+ sql: `-- REFUSED (${gate}): ${reason}\n${plan.statements.join('\n;\n')}`,
111
+ }));
112
+ } catch {
113
+ // Swallowed deliberately — see the docblock.
114
+ }
115
+ }
116
+
68
117
  /** Verify → apply → verify. Pure orchestration over an injected QueryRunner. */
69
118
  export async function executeApplyPlan(
70
119
  runner: QueryRunner,
@@ -80,16 +129,30 @@ export async function executeApplyPlan(
80
129
  }
81
130
  const pre = checkPlanPrecondition(plan, live);
82
131
  if (!pre.ok) {
132
+ await recordRefusal(runner, plan, live, 'concurrency lock', pre.reason, options);
83
133
  return { status: 'refused', liveFingerprint: live, reason: pre.reason };
84
134
  }
85
135
 
86
136
  if (options.verifyDescent) {
87
137
  const descent = await options.verifyDescent({ snapshot: before, contract: beforeAuthz, fingerprint: live });
88
138
  if (!descent.ok) {
139
+ await recordRefusal(runner, plan, live, 'fast-forward rule', descent.reason, options);
89
140
  return { status: 'descent-refused', liveFingerprint: live, reason: descent.reason };
90
141
  }
91
142
  }
92
143
 
144
+ if (plan.destructive > 0 && options.verifyAuthority) {
145
+ const authority = await options.verifyAuthority();
146
+ if (!authority.ok) {
147
+ await recordRefusal(runner, plan, live, 'destructive authority', authority.reason, options);
148
+ return { status: 'authority-refused', liveFingerprint: live, reason: authority.reason };
149
+ }
150
+ }
151
+
152
+ if (plan.destructive > 0 && options.beforeDestructive) {
153
+ await options.beforeDestructive();
154
+ }
155
+
93
156
  const result = await applyGeneratedStatements(runner, plan.statements, {
94
157
  actor: options.actor,
95
158
  gitRef: options.gitRef ?? plan.gitRef,
@@ -170,6 +233,52 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
170
233
  }
171
234
  }
172
235
 
236
+ // Losing data should be hard (decision 11): a destructive plan applies
237
+ // only confirmed (--confirm, always), snapshotted (automatic with --stage,
238
+ // named over a bare connection), and — when the stage declares an approver
239
+ // set — identity-approved (decision 12).
240
+ const isDestructive = plan.destructive > 0;
241
+ let verifyAuthority: (() => Promise<{ ok: true } | { ok: false; reason: string }>) | undefined;
242
+ let beforeDestructive: (() => Promise<void>) | undefined;
243
+ if (isDestructive) {
244
+ const shape = `${plan.classification.drops} drop(s), ${plan.classification.narrowings} narrowing type change(s)`;
245
+ if (flags.confirm !== 'true') {
246
+ fail(`This plan is DESTRUCTIVE — ${plan.destructive} statement(s) lose data (${shape}). Explicit confirmation is required, always: re-run with --confirm.`);
247
+ process.exit(1);
248
+ }
249
+ if (flags.stage) {
250
+ const config = await resolveConfig(flags.stage);
251
+ const { parseAppName } = await import('../discover.js');
252
+ const appName = await parseAppName();
253
+ verifyAuthority = async () => {
254
+ step(`Destructive authority: checking the approver set for stage ${flags.stage}...`);
255
+ const approvers = await readApproverParam(config.region, appName, flags.stage);
256
+ const identity = approvers === null ? null : await resolveCallerIdentity(config.region);
257
+ const verdict = checkDestructiveAuthority({ approvers, identity });
258
+ if (!verdict.ok) return verdict;
259
+ if (verdict.reason === 'approved') {
260
+ info(`authority: ${identity} is a declared approver.`);
261
+ } else {
262
+ warn('authority: no approver set declared for this stage — proceeding on ceremony alone. Declare one: everystack db:approvers --set <arn-or-name,...> --stage ' + flags.stage);
263
+ }
264
+ return { ok: true };
265
+ };
266
+ beforeDestructive = async () => {
267
+ step('Destructive apply — taking a snapshot first (db:backup)...');
268
+ const result: any = await invokeAction(config.region, opsFunction(config), 'db:backup', { stage: flags.stage });
269
+ if (result?.error) {
270
+ throw new Error(`auto-snapshot failed, so the destructive plan was NOT applied: ${result.error}`);
271
+ }
272
+ info(`snapshot on record: ${result?.id ?? 'backup complete'} — restore with db:restore --from ${result?.id ?? '<id>'} --confirm.`);
273
+ };
274
+ } else if (!flags['snapshot-ref']) {
275
+ fail(`This plan is DESTRUCTIVE (${shape}) and the target is a bare connection — the apply cannot take the snapshot itself. Take one (db:snapshot / db:backup / pg_dump) and name it: --snapshot-ref <ref>. Or pass --stage and the apply snapshots automatically, with the stage's approver set enforced.`);
276
+ process.exit(1);
277
+ } else {
278
+ warn(`DESTRUCTIVE apply over a bare connection — approver gating unavailable (no --stage), proceeding on ceremony: --confirm + snapshot ${flags['snapshot-ref']}.`);
279
+ }
280
+ }
281
+
173
282
  step(dbSource.from === 'flag' ? 'Connecting via --database-url...' : 'Connecting via DATABASE_URL...');
174
283
  const { runner, end } = await createUrlRunner(dbSource.url);
175
284
 
@@ -182,6 +291,8 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
182
291
  const result = await executeApplyPlan(runner, plan, {
183
292
  actor: process.env.USER ?? null,
184
293
  gitRef: currentGitRef() ?? plan.gitRef,
294
+ ...(verifyAuthority ? { verifyAuthority } : {}),
295
+ ...(beforeDestructive ? { beforeDestructive } : {}),
185
296
  ...(forceDescent === undefined ? {
186
297
  verifyDescent: async (live: LiveState & { fingerprint: string }) => {
187
298
  step('Descent: searching git for the commit that declares the target\'s state...');
@@ -216,6 +327,10 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
216
327
  fail(`REFUSED (fast-forward rule): ${result.reason}`);
217
328
  process.exit(1);
218
329
  break;
330
+ case 'authority-refused':
331
+ fail(`REFUSED (destructive authority): ${result.reason}`);
332
+ process.exit(1);
333
+ break;
219
334
  case 'verify-failed':
220
335
  fail(`Verify FAILED: ${result.reason}`);
221
336
  process.exit(1);
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `everystack db:approvers` — declare who can destroy (brick 9, decision 12).
3
+ *
4
+ * db:approvers --stage <name> # show the declared set
5
+ * db:approvers --stage <name> --set "cto,arn:..." # declare / replace
6
+ * db:approvers --stage <name> --set '' # declare EMPTY: destructive applies disabled
7
+ * db:approvers --stage <name> --remove # undeclare: back to ceremony-only
8
+ *
9
+ * The set lives stage-side (an SSM parameter in the stage's namespace) so a
10
+ * checkout edit can't reach it; lock it down further with IAM by denying
11
+ * `ssm:PutParameter` on `/everystack/<app>/<stage>/db-approvers` to
12
+ * non-admins — the same posture as the rest of the stage's parameters.
13
+ * Entries are exact ARNs or bare names matched against the caller's ARN
14
+ * path segments (`ty` matches `user/ty` and an assumed-role session `ty`).
15
+ */
16
+
17
+ import { resolveConfig } from '../config.js';
18
+ import {
19
+ approverParamName,
20
+ parseApproverValue,
21
+ readApproverParam,
22
+ writeApproverParam,
23
+ removeApproverParam,
24
+ } from '../apply-authority.js';
25
+ import { step, success, fail, info, warn } from '../output.js';
26
+
27
+ export async function dbApproversCommand(flags: Record<string, string>): Promise<void> {
28
+ if (!flags.stage) {
29
+ fail('db:approvers needs --stage <name> — the approver set is per-stage configuration.');
30
+ process.exit(1);
31
+ }
32
+
33
+ step('Resolving deployed config...');
34
+ const config = await resolveConfig(flags.stage);
35
+ const { parseAppName } = await import('../discover.js');
36
+ const appName = await parseAppName();
37
+ const paramName = approverParamName(appName, flags.stage);
38
+
39
+ if (flags.remove === 'true') {
40
+ await removeApproverParam(config.region, appName, flags.stage);
41
+ success(`Removed ${paramName} — the stage is back to the undeclared posture (destructive applies gated by ceremony only).`);
42
+ return;
43
+ }
44
+
45
+ if (flags.set !== undefined) {
46
+ if (flags.set === 'true') {
47
+ fail("--set needs a value: a comma-separated list of ARNs or names (--set '' declares an EMPTY set, disabling destructive applies).");
48
+ process.exit(1);
49
+ }
50
+ const approvers = parseApproverValue(flags.set);
51
+ await writeApproverParam(config.region, appName, flags.stage, approvers);
52
+ if (approvers.length === 0) {
53
+ warn(`Declared an EMPTY approver set at ${paramName} — destructive applies are now DISABLED on ${flags.stage}.`);
54
+ } else {
55
+ success(`Declared ${approvers.length} approver(s) at ${paramName}: ${approvers.join(', ')}`);
56
+ info('Destructive db:apply runs against this stage now require one of these identities (STS-verified).');
57
+ }
58
+ return;
59
+ }
60
+
61
+ const approvers = await readApproverParam(config.region, appName, flags.stage);
62
+ if (approvers === null) {
63
+ info(`No approver set declared (${paramName} absent) — destructive applies are gated by ceremony only (--confirm + snapshot).`);
64
+ info("Declare one: everystack db:approvers --set 'cto,arn:aws:iam::…:user/…' --stage " + flags.stage);
65
+ } else if (approvers.length === 0) {
66
+ warn(`The approver set is declared EMPTY — destructive applies are DISABLED on ${flags.stage}.`);
67
+ } else {
68
+ info(`Destructive-approver set for ${flags.stage} (${approvers.length}):`);
69
+ for (const a of approvers) info(` - ${a}`);
70
+ }
71
+ }
@@ -37,6 +37,7 @@ import { generateMigrationSql, unmodeledTables } from '../migration-generate.js'
37
37
  import {
38
38
  applyStateAndVerify,
39
39
  classifyGeneratedStatements,
40
+ classifyDestructive,
40
41
  currentGitRef,
41
42
  type ClassifiedStatements,
42
43
  type StateSyncOutcome,
@@ -277,6 +278,10 @@ export async function dbSyncCommand(flags: Record<string, string>): Promise<void
277
278
  onStatePlan: (statements, classified) => {
278
279
  if (classified.executable.length > 0) {
279
280
  info(`state plan — ${classified.executable.length} statement(s):`);
281
+ const { drops, narrowings } = classifyDestructive(classified.executable);
282
+ if (drops.length + narrowings.length > 0) {
283
+ warn(`${drops.length + narrowings.length} DESTRUCTIVE — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Dev sync runs them; protected stages gate them (db:plan → db:apply).`);
284
+ }
280
285
  console.log('');
281
286
  for (const s of statements) console.log(s.endsWith(';') ? s : `${s};`);
282
287
  console.log('');
@@ -29,12 +29,22 @@ import type { ModelDescriptor } from '@everystack/model';
29
29
  import type { SchemaSnapshot } from './schema-introspect.js';
30
30
  import type { AuthzContract } from './authz-contract.js';
31
31
  import { generateMigrationSql, unmodeledTables } from './migration-generate.js';
32
- import { classifyGeneratedStatements } from './state-apply.js';
32
+ import { classifyGeneratedStatements, classifyDestructive } from './state-apply.js';
33
33
  import { fingerprintLive, fingerprintState, fingerprintModels, stableStringify } from './schema-fingerprint.js';
34
34
  import { compileDeclaredState } from './declared-diff.js';
35
35
  import { compileTableRenames } from './schema-compile.js';
36
36
 
37
- export const PLAN_VERSION = 1;
37
+ export const PLAN_VERSION = 2;
38
+
39
+ /** Classification counts (brick 9, decision 11): destructive = drops + narrowings. */
40
+ export interface PlanClassification {
41
+ /** Executable statements that lose no data. */
42
+ additive: number;
43
+ /** `DROP TABLE/COLUMN/TYPE` — the data is gone. */
44
+ drops: number;
45
+ /** Lossy/risky `ALTER COLUMN … TYPE` — data loss wearing an ALTER. */
46
+ narrowings: number;
47
+ }
38
48
 
39
49
  export interface EdgePlan {
40
50
  v: number;
@@ -47,8 +57,9 @@ export interface EdgePlan {
47
57
  /** The edge, in db:generate's statement grammar (no held drops — mint refuses them). */
48
58
  statements: string[];
49
59
  executable: number;
50
- /** Executable statements that DROP somethingshown red, explicit by construction. */
60
+ /** Executable statements that LOSE DATA (drops + narrowing type changes) — red, gated at apply. */
51
61
  destructive: number;
62
+ classification: PlanClassification;
52
63
  notices: number;
53
64
  /** Live tables no model declares — untouched by the edge, riding into `to` as-is. */
54
65
  unmodeled: string[];
@@ -135,6 +146,13 @@ export function mintEdgePlan(
135
146
  );
136
147
  }
137
148
 
149
+ // Data-destructive only: dropping a policy/grant is recoverable metadata
150
+ // (the authz phase is data-safe by construction); dropping a table,
151
+ // column, or type is not — and neither is a narrowing type change, which
152
+ // is data loss wearing an ALTER.
153
+ const breakdown = classifyDestructive(classified.executable);
154
+ const destructive = breakdown.drops.length + breakdown.narrowings.length;
155
+
138
156
  return {
139
157
  v: PLAN_VERSION,
140
158
  fromFingerprint: fingerprintLive(snapshot, contract).hash,
@@ -142,10 +160,12 @@ export function mintEdgePlan(
142
160
  declaredFingerprint: fingerprintModels(models, { schema: opts.schema }).hash,
143
161
  statements,
144
162
  executable: classified.executable.length,
145
- // Data-destructive only: dropping a policy/grant is recoverable metadata
146
- // (the authz phase is data-safe by construction); dropping a table,
147
- // column, or type is not.
148
- destructive: classified.executable.filter((s) => /\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(s)).length,
163
+ destructive,
164
+ classification: {
165
+ additive: classified.executable.length - destructive,
166
+ drops: breakdown.drops.length,
167
+ narrowings: breakdown.narrowings.length,
168
+ },
149
169
  notices: classified.notices.length,
150
170
  unmodeled: unmodeledTables(models, snapshot),
151
171
  gitRef: opts.gitRef ?? null,
@@ -183,7 +203,14 @@ export function buildPlanSummary(plan: EdgePlan): string[] {
183
203
  }
184
204
  lines.push(`${plan.executable} executable statement(s), ${plan.notices} notice(s)`);
185
205
  if (plan.destructive > 0) {
186
- lines.push(`! ${plan.destructive} DESTRUCTIVE statement(s) — drops carried explicitly, review them`);
206
+ const { drops, narrowings } = classifyDestructive(classifyGeneratedStatements(plan.statements).executable);
207
+ lines.push(
208
+ `! ${plan.destructive} DESTRUCTIVE statement(s) — ${drops.length} drop(s), ${narrowings.length} narrowing type change(s). Data loss carried explicitly; the apply is gated (--confirm + snapshot, approver set when declared):`,
209
+ );
210
+ for (const statement of [...drops, ...narrowings]) {
211
+ const ddl = statement.split('\n').find((l) => l.trim() !== '' && !l.trim().startsWith('--')) ?? statement;
212
+ lines.push(`! ${ddl.trim()}`);
213
+ }
187
214
  }
188
215
  if (plan.unmodeled.length > 0) {
189
216
  lines.push(`· ${plan.unmodeled.length} unmodeled table(s) ride through untouched: ${plan.unmodeled.join(', ')}`);
package/src/cli/index.ts CHANGED
@@ -18,6 +18,7 @@ import { dbDiffCommand } from './commands/db-diff.js';
18
18
  import { dbPlanCommand } from './commands/db-plan.js';
19
19
  import { dbApplyCommand } from './commands/db-apply.js';
20
20
  import { dbCheckCommand } from './commands/db-check.js';
21
+ import { dbApproversCommand } from './commands/db-approvers.js';
21
22
  import { dbSnapshotCommand, dbSnapshotsCommand } from './commands/db-snapshot.js';
22
23
  import { dbBackupProbeCommand, dbBackupCommand, dbBackupsCommand, dbRestoreCommand, dbBackupDownloadCommand } from './commands/db-backup.js';
23
24
  import { consoleCommand } from './commands/console.js';
@@ -200,6 +201,9 @@ async function main() {
200
201
  case 'db:check':
201
202
  await dbCheckCommand(flags);
202
203
  break;
204
+ case 'db:approvers':
205
+ await dbApproversCommand(flags);
206
+ break;
203
207
  case 'db:authz:pull':
204
208
  await dbAuthzPullCommand(flags);
205
209
  break;
@@ -327,8 +331,9 @@ Usage:
327
331
  everystack db:sync [--database-url <url>] [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against db/sql, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
328
332
  everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
329
333
  everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
330
- everystack db:apply --plan <file.plan.json> [--database-url <url>] [--models <barrel>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"; force needs the snapshot you took + --confirm), apply as one transaction (schema_log w/ plan_ref), verify it landed exactly on plan.to. Idempotent when already at the target state; direct connection required
334
+ everystack db:apply --plan <file.plan.json> [--database-url <url>] [--stage <name>] [--models <barrel>] [--confirm] [--snapshot-ref <ref>] [--force-descent <snapshot-ref> --confirm] Run a reviewed plan: verify the target is EXACTLY where the plan started (live fingerprint == plan.from, else refuse — the concurrency lock), verify the checkout DESCENDS from the commit declaring the target's state (the fast-forward rule, else refuse — "rebase first"), and for DESTRUCTIVE plans require --confirm always + a snapshot (automatic via db:backup with --stage, else --snapshot-ref) + the stage's approver set when declared (STS identity-verified). Every refusal is recorded in schema_log. Apply as one transaction (plan_ref stamped), verify it landed exactly on plan.to; idempotent when already there; direct connection required
331
335
  everystack db:check [--models <barrel>] [--sql-dir db/sql] [--schema-out <file.ts>] [--database-url <url>] [--json] The CI gate, per PR: the merged declared state must COMPOSE (models load, no duplicate tables, db/sql parses) and generated artifacts must MATCH regeneration byte-for-byte; with a scratch PostgreSQL it builds the state from scratch on an ephemeral database (created + dropped) and requires fingerprint MATCH. Exit 1 on any failure; never touches a real target
336
+ everystack db:approvers --stage <name> [--set "cto,arn:..."] [--remove] Declare who can DESTROY: the stage's destructive-approver set (SSM parameter, admin-writable). Destructive db:apply runs are then identity-verified (STS) against it; --set '' disables destructive applies; --remove returns the stage to ceremony-only
332
337
  everystack db:authz:pull [--stage <name>] [--dir authz] Introspect live authz (rls/grants/policies/secdef) → reviewable contract files
333
338
  everystack db:authz:diff [--stage <name>] [--dir authz] Validate the live DB against the committed contract (non-zero exit on drift)
334
339
  everystack db:authz:test [--stage <name>] [--dir authz] Red-team enforcement: SET ROLE + attempt per role/table/command (non-zero exit on a hole)
@@ -304,6 +304,11 @@ export function compileDrizzleSource(allModels: ModelDescriptor[], _opts: Compil
304
304
  const { call, builder: pgBuilder } = baseColumnSource(toSnakeCase(key), spec);
305
305
  pgCoreBuilders.add(pgBuilder);
306
306
  const mods = columnModifiers(spec, isComposite, modelsByDescriptor);
307
+ if (spec.isDeprecated) {
308
+ // The contract phase (decision 13): the column stays in the database and
309
+ // stays readable; the strikethrough warns new code away at author time.
310
+ colLines.push(` /** @deprecated contract phase — readable, not writable; the physical drop is a later, gated act. */`);
311
+ }
307
312
  colLines.push(` ${key}: ${call}${mods},`);
308
313
  }
309
314
 
@@ -56,6 +56,36 @@ export function classifyGeneratedStatements(statements: string[]): ClassifiedSta
56
56
  return { executable, heldDrops, notices };
57
57
  }
58
58
 
59
+ /**
60
+ * The destructive subset of an executable statement stream — destructive
61
+ * means DATA LOSS (brick 9's classification, design decision 11):
62
+ *
63
+ * drops — `DROP TABLE/COLUMN/TYPE`: the data is gone.
64
+ * narrowings — lossy/risky `ALTER COLUMN … SET DATA TYPE`: data loss
65
+ * wearing an ALTER. Detection rides the emitter's structural
66
+ * contract, not prose: a SAFE type change is a bare
67
+ * `SET DATA TYPE`; anything lossy or risky carries a `USING`
68
+ * cast (schema-diff's `classifyTypeChange`, conservative by
69
+ * default — the contract is pinned by test).
70
+ *
71
+ * Recoverable metadata is never destructive: policies, grants, constraints,
72
+ * defaults, nullability all re-declare from the models without touching a row.
73
+ */
74
+ export interface DestructiveBreakdown {
75
+ drops: string[];
76
+ narrowings: string[];
77
+ }
78
+
79
+ export function classifyDestructive(executable: string[]): DestructiveBreakdown {
80
+ const drops: string[] = [];
81
+ const narrowings: string[] = [];
82
+ for (const statement of executable) {
83
+ if (/\bDROP\s+(TABLE|COLUMN|TYPE)\b/.test(statement)) drops.push(statement);
84
+ else if (/\bSET DATA TYPE\b/.test(statement) && /\bUSING\b/.test(statement)) narrowings.push(statement);
85
+ }
86
+ return { drops, narrowings };
87
+ }
88
+
59
89
  /** The current git HEAD, for schema_log's intent pointer. Null outside a repo. */
60
90
  export function currentGitRef(): string | null {
61
91
  try {