@everystack/cli 0.4.56 → 0.4.57

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.56",
3
+ "version": "0.4.57",
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>",
@@ -109,7 +109,7 @@
109
109
  "structured-headers": "1.0.1",
110
110
  "tsx": "4.21.0",
111
111
  "typescript": "5.9.3",
112
- "@everystack/model": "0.4.15"
112
+ "@everystack/model": "0.4.16"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "@everystack/server": ">=0.4.0",
@@ -159,7 +159,8 @@
159
159
  "drizzle-orm": "0.41.0",
160
160
  "jest": "29.7.0",
161
161
  "react": "19.2.0",
162
- "ts-jest": "29.4.9"
162
+ "ts-jest": "29.4.9",
163
+ "@everystack/server": "0.4.20"
163
164
  },
164
165
  "scripts": {
165
166
  "test": "jest",
@@ -260,6 +260,44 @@ function effective(contract: TableContract, role: string, priv: string): boolean
260
260
  return policiesFor(contract, role, priv).some((p) => p.permissive);
261
261
  }
262
262
 
263
+ /** The four CRUD privileges, in the order the deriver reports them. */
264
+ const CRUD_PRIVS = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const;
265
+
266
+ /**
267
+ * Classify every (role, privilege) surface ONCE: may pull render it as an ABILITY, or must it
268
+ * be transcribed as a grant?
269
+ *
270
+ * A DIFFERENT question from {@link effective}, and conflating them is what broke the RLS-off
271
+ * shape. `effective` answers "does this grant reach rows?" — with RLS off the honest answer is
272
+ * YES, unrestricted. But the RENDERER is asking whether it may say `can(...)`, and an ability
273
+ * compiles to a policy. On a table with row security disabled, rendering one makes the model
274
+ * propose `ALTER TABLE … ENABLE ROW LEVEL SECURITY` plus `CREATE POLICY …` against a database
275
+ * that has neither, and REVOKE whatever privilege the ability has no room for — three changes
276
+ * emitted by a command that only READ the database. That is the G1 class (a manufactured
277
+ * policy) reached from the adoption side, and the round-trip gate refuses it.
278
+ *
279
+ * So with RLS OFF nothing is an ability; the grants are still declared, through `privileges`,
280
+ * which is precisely what the governed-role path already did. That asymmetry — foreign roles
281
+ * transcribed, vocabulary roles turned into abilities — WAS the bug; there is one rule now.
282
+ *
283
+ * Computed as one pass returning a tag per surface, rather than a predicate each site calls,
284
+ * so a future ability-emitting site cannot quietly skip the question (Fable, 2026-08-11).
285
+ *
286
+ * Lives here, in the pull/derive direction only. The COMPILER must never consult it: compile
287
+ * turns declarations into SQL and has no business knowing how they were rendered.
288
+ */
289
+ function classifyAbilitySurfaces(contract: TableContract): ReadonlySet<string> {
290
+ const surfaces = new Set<string>();
291
+ // Grants-only table: every surface is a grant, none is an ability.
292
+ if (!contract.rls.enabled) return surfaces;
293
+ for (const role of Object.keys(contract.grants)) {
294
+ for (const priv of CRUD_PRIVS) {
295
+ if (effective(contract, role, priv)) surfaces.add(`${role}:${priv}`);
296
+ }
297
+ }
298
+ return surfaces;
299
+ }
300
+
263
301
  /** A sql`…` fragment literal, or null when the predicate is vacuous. */
264
302
  function sqlFragment(pred: string | null): string | null {
265
303
  if (!pred) return null;
@@ -339,6 +377,9 @@ export function deriveAbilities(
339
377
  opts: { governRoles?: ReadonlySet<string> } = {},
340
378
  ): DerivedAbilities {
341
379
  const govern = opts.governRoles ?? new Set<string>();
380
+ /** One classification pass; every ability-vs-grant decision below reads this tag. */
381
+ const abilitySurfaces = classifyAbilitySurfaces(contract);
382
+ const isAbilitySurface = (role: string, priv: string): boolean => abilitySurfaces.has(`${role}:${priv}`);
342
383
  const abilities: string[] = [];
343
384
  const notes: string[] = [];
344
385
  const table = contract.table;
@@ -365,7 +406,7 @@ export function deriveAbilities(
365
406
  // non-owner, and RLS binds non-owners whenever `enabled` is true. `forced` is irrelevant here.
366
407
  const CRUD = ['SELECT', 'INSERT', 'UPDATE', 'DELETE'];
367
408
  const adminGrants = CRUD.filter((p) => granted(contract, 'admin', p));
368
- const adminLive = adminGrants.filter((p) => effective(contract, 'admin', p));
409
+ const adminLive = adminGrants.filter((p) => isAbilitySurface('admin', p));
369
410
  const adminDead = adminGrants.filter((p) => !adminLive.includes(p));
370
411
 
371
412
  if (adminLive.length === CRUD.length) {
@@ -402,7 +443,7 @@ export function deriveAbilities(
402
443
  // declared (so the plan does not REVOKE a live privilege) and never rendered as an ability
403
444
  // (so the plan does not CREATE the policy the database is missing).
404
445
  for (const role of ['authenticated', 'anon']) {
405
- const dead = CRUD.filter((p) => granted(contract, role, p) && !effective(contract, role, p));
446
+ const dead = CRUD.filter((p) => granted(contract, role, p) && !isAbilitySurface(role, p));
406
447
  if (!dead.length) continue;
407
448
  deadGrants[role] = dead;
408
449
  notes.push(`${role} holds ${dead.join(', ')} but no policy covers ${role} —`);
@@ -444,8 +485,8 @@ export function deriveAbilities(
444
485
  // `effective`, not `granted`: a SELECT grant with no policy behind it renders can('read'),
445
486
  // which compiles to CREATE POLICY … USING (true) — manufacturing a policy the database does
446
487
  // not have. Same defect as the admin branch above; the round-trip gate found it here.
447
- const anonRead = effective(contract, 'anon', 'SELECT');
448
- const authedRead = effective(contract, 'authenticated', 'SELECT');
488
+ const anonRead = isAbilitySurface('anon', 'SELECT');
489
+ const authedRead = isAbilitySurface('authenticated', 'SELECT');
449
490
 
450
491
  if (anonRead) {
451
492
  // Public read. If the live policy narrows it (a soft-delete guard, a published flag),
@@ -569,7 +610,7 @@ export function deriveAbilities(
569
610
  // --- writes --------------------------------------------------------------------------
570
611
  for (const [command, action] of Object.entries(DML)) {
571
612
  if (action === 'read') continue;
572
- if (!effective(contract, 'authenticated', command)) continue;
613
+ if (!isAbilitySurface('authenticated', command)) continue;
573
614
 
574
615
  const pol = policiesFor(contract, 'authenticated', command).find((p) => p.command !== 'ALL')
575
616
  ?? policiesFor(contract, 'authenticated', command)[0];
@@ -75,6 +75,10 @@ export function descentVerdictForApply(verdict: DescentVerdict): { ok: true } |
75
75
  return { ok: true };
76
76
  case 'diverged':
77
77
  case 'drift':
78
+ // `indeterminate` refuses like the others, and must: the search was not exhaustive, so
79
+ // the fast-forward rule is unproven. Its REASON is the difference — it points at this
80
+ // run, not at the database. Never fold it into `drift`.
81
+ case 'indeterminate':
78
82
  return { ok: false, reason: verdict.reason };
79
83
  }
80
84
  }
@@ -178,6 +182,12 @@ async function applyPlanViaStage(
178
182
  case 'diverged':
179
183
  case 'drift':
180
184
  break; // the reason travels to the action's refusal + the memoir
185
+ case 'indeterminate':
186
+ // Said out loud here because the operator's next move is "run it again", not
187
+ // "reconcile your models" — and a refusal that reads like drift sends them the
188
+ // wrong way. The reason still travels to the refusal.
189
+ warn(`descent: ${verdict.unevaluated.length} historical tree(s) could not be extracted — the search was not exhaustive.`);
190
+ break;
181
191
  }
182
192
  descentVerdict = descentVerdictForApply(verdict);
183
193
  }
@@ -437,6 +447,7 @@ export async function dbApplyCommand(flags: Record<string, string>): Promise<voi
437
447
  return { ok: true };
438
448
  case 'diverged':
439
449
  case 'drift':
450
+ case 'indeterminate':
440
451
  return { ok: false, reason: verdict.reason };
441
452
  }
442
453
  },
@@ -279,9 +279,26 @@ export async function dbPlanCommand(flags: Record<string, string>): Promise<void
279
279
  // work — say so on the review surface, where a human still reads it.
280
280
  if (plan.statements.length > 0) {
281
281
  const verdict = await verifyDescent(modelsPath, { snapshot, contract }, {});
282
- if (verdict.status === 'diverged' || verdict.status === 'drift') {
283
- warn(`descent: ${verdict.reason}`);
284
- warn('db:apply from this checkout will refuse this plan rebase first, then re-mint.');
282
+ // A switch, not an `if (diverged || drift)`. That form silently ignored every verdict
283
+ // it did not name, so `indeterminate` would have minted a plan with no warning at all
284
+ // and then been refused at apply time with no hint of why. Exhaustive here means a new
285
+ // verdict cannot be forgotten.
286
+ switch (verdict.status) {
287
+ case 'ok':
288
+ case 'fresh-target':
289
+ case 'no-git':
290
+ break;
291
+ case 'diverged':
292
+ case 'drift':
293
+ warn(`descent: ${verdict.reason}`);
294
+ warn('db:apply from this checkout will refuse this plan — rebase first, then re-mint.');
295
+ break;
296
+ case 'indeterminate':
297
+ // NOT "rebase first" — nothing is wrong with the checkout or the database. The
298
+ // search failed, so the rule is unproven either way.
299
+ warn(`descent: ${verdict.reason}`);
300
+ warn('db:apply from this checkout will refuse this plan until that search completes — re-mint and try again.');
301
+ break;
285
302
  }
286
303
  }
287
304
 
@@ -49,6 +49,14 @@ export interface DescentOptions {
49
49
  /** The Postgres schema the Models default to. Default: `public`. */
50
50
  schema?: string;
51
51
  loader?: ModelsLoader;
52
+ /**
53
+ * How a tree is extracted. Default: {@link materializeTree}.
54
+ *
55
+ * Injected for the same reason `loader` is: an in-module call goes through the local
56
+ * binding, so it cannot be spied on, and extraction failure is a condition that MUST be
57
+ * covered by a test — it is the one that used to be misreported as database drift.
58
+ */
59
+ materialize?: (tree: string, dest: string, cwd: string) => void;
52
60
  /**
53
61
  * Where historical trees materialize. Default: a fresh temp dir under the
54
62
  * repo's node_modules — bare imports (`@everystack/model`) must resolve
@@ -69,7 +77,15 @@ export type DescentVerdict =
69
77
  /** The declaring commits exist but none is an ancestor of HEAD — rebase first. */
70
78
  | { status: 'diverged'; commits: string[]; tree: string; reason: string }
71
79
  /** No committed models state declares the live fingerprint — operator decision. */
72
- | { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string };
80
+ | { status: 'drift'; scannedTrees: number; compileFailures: string[]; reason: string }
81
+ /**
82
+ * One or more trees could not be EVALUATED at all — extraction or IO failed, so "no tree
83
+ * declares this state" is not a conclusion we are entitled to draw. Distinct from `drift`
84
+ * because the operator's next move is different: retry, not `db:pull`.
85
+ *
86
+ * `unevaluated` names the trees and why each failed.
87
+ */
88
+ | { status: 'indeterminate'; scannedTrees: number; unevaluated: string[]; reason: string };
73
89
 
74
90
  export interface ModelTreeCandidate {
75
91
  /** The models directory's tree oid — the identity of a declared state. */
@@ -134,14 +150,33 @@ export function enumerateModelTrees(modelsDir: string, cwd: string): ModelTreeCa
134
150
  return order.map((tree) => ({ tree, commits: byTree.get(tree)! }));
135
151
  }
136
152
 
137
- /** Extract a tree into `dest` — `git archive` piped to tar, no shell. */
153
+ /**
154
+ * Extract a tree into `dest`, via a temporary archive FILE — never a pipe.
155
+ *
156
+ * This used to buffer `git archive` into memory (512MB `maxBuffer`) and hand that buffer to
157
+ * `tar` as `input`. `execFileSync` writes the whole buffer to the child's stdin, so if the
158
+ * child is not draining it the write fails with **EPIPE** — and under a loaded machine it
159
+ * does. Measured 2026-08-11 on a full-repo run: `spawnSync tar EPIPE`, recorded against a
160
+ * historical tree, twice in four runs.
161
+ *
162
+ * It failed badly, not loudly: `verifyDescent` recorded it as a COMPILE failure and returned
163
+ * `drift`, telling the operator their database had been hand-edited. See the `indeterminate`
164
+ * verdict for that half of the fix; this is the half that stops the failure happening.
165
+ *
166
+ * `git archive -o` writes the archive itself and `tar -xf <file>` reads it — two independent
167
+ * processes, no shared pipe, no backpressure to lose, and no multi-hundred-MB buffer through
168
+ * the parent. Retrying the pipe harder would only have made it rarer.
169
+ */
138
170
  export function materializeTree(tree: string, dest: string, cwd: string): void {
139
171
  fs.mkdirSync(dest, { recursive: true });
140
- const archive = execFileSync('git', ['archive', '--format=tar', tree], {
141
- cwd,
142
- maxBuffer: 512 * 1024 * 1024,
143
- });
144
- execFileSync('tar', ['-xf', '-', '-C', dest], { input: archive });
172
+ // Sibling of `dest`, not inside it — a file under `dest` would be extracted over.
173
+ const archivePath = `${dest}.tar`;
174
+ try {
175
+ execFileSync('git', ['archive', '--format=tar', '-o', archivePath, tree], { cwd });
176
+ execFileSync('tar', ['-xf', archivePath, '-C', dest]);
177
+ } finally {
178
+ fs.rmSync(archivePath, { force: true });
179
+ }
145
180
  }
146
181
 
147
182
  /**
@@ -181,39 +216,60 @@ export async function verifyDescent(
181
216
  ?? fs.mkdtempSync(path.join(fs.existsSync(nodeModules) ? nodeModules : os.tmpdir(), '.everystack-descent-'));
182
217
  const ownsRoot = !opts.materializeRoot;
183
218
  const loader = opts.loader ?? defaultLoader;
219
+ const materialize = opts.materialize ?? materializeTree;
184
220
 
185
221
  const compileFailures: string[] = [];
222
+ const unevaluated: string[] = [];
186
223
  const declaringButDiverged: ModelTreeCandidate[] = [];
187
224
  try {
188
225
  for (const candidate of candidates) {
189
226
  const dest = path.join(materializeRoot, candidate.tree);
190
- let declares: boolean;
191
- // A swallowed error here skips the candidate — and if it was the DECLARING tree, the
192
- // verdict silently falls through to 'drift'. Real compile failures are deterministic;
193
- // a transient materialize/import failure (fd pressure, module-loader contention under
194
- // a parallel test run or a busy CI box) is not — so a failed candidate gets exactly
195
- // one retry, from a clean materialization, before it is recorded as a compile failure.
227
+ const where = `${short(candidate.tree)} (at ${short(candidate.commits[0])})`;
228
+
229
+ // TWO KINDS OF FAILURE, and conflating them is what made this path lie.
196
230
  //
231
+ // EXTRACTION failing says nothing about the tree — we never saw it. It was recorded as a
232
+ // "compile failure" and the verdict fell through to `drift`, which tells an operator
233
+ // their database was hand-edited. Observed for real: `spawnSync tar EPIPE` under a loaded
234
+ // machine. Those trees now go to `unevaluated` and produce `indeterminate`.
235
+ //
236
+ // COMPILING failing IS a property of the tree — a models file that no longer builds
237
+ // against today's @everystack/model is genuinely unusable, deterministically, and
238
+ // skipping it is correct. Those keep `compileFailures` and still allow `drift`.
239
+ //
240
+ // Each gets ONE retry from a clean materialization first, because either can also be
241
+ // transient under load.
242
+ try {
243
+ if (!fs.existsSync(dest)) materialize(candidate.tree, dest, repoRoot);
244
+ } catch {
245
+ try {
246
+ fs.rmSync(dest, { recursive: true, force: true });
247
+ materialize(candidate.tree, dest, repoRoot);
248
+ } catch (err: any) {
249
+ unevaluated.push(`${where}: could not extract — ${err.message}`);
250
+ continue;
251
+ }
252
+ }
253
+
197
254
  // The declares-test is GOVERNED on both sides, per candidate: the commit's
198
255
  // models define the governed set, and the live hash is filtered through the
199
256
  // SAME set as the prediction — comparing a governed prediction against the
200
257
  // raw live hash can never match on a brownfield target with foreign roles.
201
- const attempt = async (): Promise<boolean> => {
202
- if (!fs.existsSync(dest)) materializeTree(candidate.tree, dest, repoRoot);
258
+ const evaluate = async (): Promise<boolean> => {
203
259
  const models = await loader(path.join(dest, barrel));
204
260
  const governedRoles = governedRolesForModels(models);
205
261
  const predicted = predictLiveFingerprint(models, live.snapshot, live.contract, { schema: opts.schema, governedRoles });
206
262
  return predicted === governedLiveFingerprint(live.snapshot, live.contract, governedRoles);
207
263
  };
264
+ let declares: boolean;
208
265
  try {
209
266
  try {
210
- declares = await attempt();
267
+ declares = await evaluate();
211
268
  } catch {
212
- fs.rmSync(dest, { recursive: true, force: true });
213
- declares = await attempt();
269
+ declares = await evaluate();
214
270
  }
215
271
  } catch (err: any) {
216
- compileFailures.push(`${short(candidate.tree)} (at ${short(candidate.commits[0])}): ${err.message}`);
272
+ compileFailures.push(`${where}: ${err.message}`);
217
273
  continue;
218
274
  }
219
275
  if (!declares) continue;
@@ -254,6 +310,22 @@ export async function verifyDescent(
254
310
  };
255
311
  }
256
312
 
313
+ // Nothing matched — but if a tree was never evaluated, "nothing matched" is a claim we
314
+ // cannot make. The unevaluated one could be the declaring tree. Reported BEFORE drift
315
+ // because drift accuses the database, and this condition is about our own run.
316
+ if (unevaluated.length > 0) {
317
+ return {
318
+ status: 'indeterminate',
319
+ scannedTrees: candidates.length,
320
+ unevaluated,
321
+ reason:
322
+ `${unevaluated.length} of ${candidates.length} historical tree(s) could not be extracted, ` +
323
+ 'so the search was not exhaustive and one of them may be the commit that declares this state. ' +
324
+ 'This is a fault in THIS run, not evidence about the database — retry, and if it persists ' +
325
+ `check disk space and open-file limits. Details: ${unevaluated.join('; ')}`,
326
+ };
327
+ }
328
+
257
329
  const failureNote = compileFailures.length > 0
258
330
  ? ` (${compileFailures.length} historical tree(s) no longer compile and were skipped)`
259
331
  : '';
@@ -35,6 +35,30 @@ export * from './authz-owner-probe.js';
35
35
  // --- the SECDEF function catalog the contract introspection needs -----------
36
36
  export { FUNCTIONS_SQL, catalogFunctionToDescriptor } from './security-catalog.js';
37
37
 
38
+ // --- how you HAND a database to the VERIFY calls ---------------------------
39
+ // `introspectSchema` and `introspectContract` take a `SessionRunner`, not a `QueryRunner`:
40
+ // their queries must describe ONE moment, so they run as one transaction on one connection.
41
+ // This barrel exported neither the type nor a way to build one, which left a consumer able
42
+ // to CALL those functions with nothing legitimate to pass them. That gap is not academic —
43
+ // the reference app's own dogfood suite kept passing a `QueryRunner` long after the
44
+ // signature moved, and Postgres reported it as `syntax error at or near ","` (the array of
45
+ // statements stringified) rather than as the type error it was.
46
+ //
47
+ // `sessionRunnerOver(sql)` wraps a postgres.js client whose pool is a SINGLE connection.
48
+ // `INTROSPECTION_SESSION` is the options both introspections expect — read only, repeatable
49
+ // read, canonical `search_path`. Pass it through; do not re-derive it.
50
+ export { sessionRunnerOver } from './db-source.js';
51
+ export {
52
+ INTROSPECTION_SESSION,
53
+ borrowedSessionRunner,
54
+ isSessionError,
55
+ rowsOrEmpty,
56
+ type SessionRunner,
57
+ type SessionStatement,
58
+ type SessionOptions,
59
+ type SessionResult,
60
+ } from './session.js';
61
+
38
62
  // --- databases FROM the declared state: ephemeral test DBs + the builder ----
39
63
  // `createEphemeralDatabase(adminUrl, models, { sources })` is the packaged
40
64
  // create-sync-drop shape for consumer test suites: a fresh database at the
@@ -21,6 +21,10 @@ import type { TableContract } from './authz-contract.js';
21
21
  import { deriveAbilities, renderDerivedAbilities } from './authz-derive.js';
22
22
  import { normalizeDefault, normalizeCheck } from './schema-diff.js';
23
23
  import { generatedIndexName, generatedForeignKeyName } from './schema-compile.js';
24
+ // The soft-delete RULE itself — one boolean combination, shared with `defineModel`'s refusal.
25
+ // Only the FACT EXTRACTION is duplicated here (this renderer holds text, not descriptors);
26
+ // the rule is not, so the two surfaces cannot drift on when a declaration is required.
27
+ import { needsSoftDeleteDeclaration, privilegesGrantDelete } from '@everystack/model';
24
28
 
25
29
  /**
26
30
  * Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
@@ -127,13 +131,30 @@ export function isPublicReadAbility(expr: string): boolean {
127
131
  return !role || role[1] === 'anon';
128
132
  }
129
133
 
134
+ /**
135
+ * A rendered ability that hands out DELETE — the TEXT form of `isDeleteAbility` from
136
+ * `@everystack/model`.
137
+ *
138
+ * The second fact the soft-delete rule turns on, and the one the old guard missed entirely.
139
+ * `manage` counts: it is "all actions" and compiles to the admin-bypass ALL policy, so it
140
+ * destroys rows exactly as `can('delete')` does.
141
+ *
142
+ * Same two-representations situation as its sibling above, for the same reason — this renderer
143
+ * only ever holds rendered source. The BOOLEAN RULE both facts feed is not duplicated here: it
144
+ * lives once in `needsSoftDeleteDeclaration`. Only the fact extraction is representation-
145
+ * specific, and the pin test drives both extractors over one ability matrix.
146
+ */
147
+ export function isDeleteAbility(expr: string): boolean {
148
+ return /^can\('(delete|manage)'/.test(expr.trim());
149
+ }
150
+
130
151
  /**
131
152
  * The scaffold stanza for one model, plus whether it declares a public read — the renderer
132
153
  * needs the second fact to decide the `softDelete` line, and it must come from the STRUCTURED
133
154
  * abilities, not a regex over the joined text (a live predicate can span lines and carry its
134
155
  * own braces). An unknown preset throws — grants are authored, never guessed.
135
156
  */
136
- function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean } {
157
+ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<string, TableContract>, governRoles?: ReadonlySet<string>): { text: string; publicRead: boolean; grantsDelete: boolean } {
137
158
  if (mode === 'live') {
138
159
  const contract = table && liveAuthz?.get(table.table);
139
160
  if (!contract) {
@@ -144,6 +165,7 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
144
165
  ' // private: true,',
145
166
  ].join('\n'),
146
167
  publicRead: false,
168
+ grantsDelete: false,
147
169
  };
148
170
  }
149
171
  const derived = deriveAbilities(contract, { governRoles });
@@ -159,7 +181,13 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
159
181
  if (!derived.abilities.length && !contract.rls.enabled) {
160
182
  lines.push(` rls: false, // live reality: row security is OFF — authorization here is grants-only.`);
161
183
  }
162
- return { text: lines.join('\n'), publicRead: derived.abilities.some(isPublicReadAbility) };
184
+ return {
185
+ text: lines.join('\n'),
186
+ publicRead: derived.abilities.some(isPublicReadAbility),
187
+ // Both spellings. `privileges` is where pull puts a live DELETE grant no policy covers,
188
+ // which is precisely the shape that can hard-delete on an rls: false table.
189
+ grantsDelete: derived.abilities.some(isDeleteAbility) || privilegesGrantDelete(derived.privileges),
190
+ };
163
191
  }
164
192
  if (mode === 'commented') {
165
193
  return {
@@ -171,13 +199,18 @@ function abilitiesStanza(mode: string, table?: TableSchema, liveAuthz?: Map<stri
171
199
  ].join('\n'),
172
200
  // Nothing is stamped uncommented, so the model declares no read at all yet.
173
201
  publicRead: false,
202
+ grantsDelete: false,
174
203
  };
175
204
  }
176
205
  const preset = ABILITY_PRESETS[mode];
177
206
  if (!preset) {
178
207
  throw new Error(`Unknown --abilities preset '${mode}' — known: ${Object.keys(ABILITY_PRESETS).join(', ')} (or omit the flag for the commented scaffold).`);
179
208
  }
180
- return { text: ` abilities: [${preset.join(', ')}],`, publicRead: preset.some(isPublicReadAbility) };
209
+ return {
210
+ text: ` abilities: [${preset.join(', ')}],`,
211
+ publicRead: preset.some(isPublicReadAbility),
212
+ grantsDelete: preset.some(isDeleteAbility),
213
+ };
181
214
  }
182
215
 
183
216
  /**
@@ -210,10 +243,41 @@ function writtenByStanza(table: TableSchema, liveAuthz?: Map<string, TableContra
210
243
  + `'app' (the default) would FORCE it — a change to who bypasses row security, not an adoption.\n`;
211
244
  }
212
245
 
213
- function softDeleteStanza(table: TableSchema, publicRead: boolean): string {
214
- const hasColumn = table.columns.some((c) => c.name === 'deleted_at');
215
- if (!hasColumn || !publicRead) return '';
216
- return ` softDelete: false, // live reality: no policy filters deleted_at. true excludes soft-deleted rows from public reads.\n`;
246
+ function softDeleteStanza(table: TableSchema, publicRead: boolean, grantsDelete: boolean): string {
247
+ const hasDeletedAtField = table.columns.some((c) => c.name === 'deleted_at');
248
+ if (!needsSoftDeleteDeclaration({ hasDeletedAtField, hasPublicRead: publicRead, grantsDelete })) return '';
249
+
250
+ // A PUBLIC read forces `false`, and the constraint is the fingerprint, not a preference:
251
+ // `true` AND-s `deleted_at IS NULL` into the anon policy, so rendering it would transcribe a
252
+ // predicate no live policy carries and the pulled checkout would MISMATCH the database it
253
+ // was pulled from. Live reality is what a pull is for.
254
+ if (publicRead) {
255
+ return ` softDelete: false, // live reality: no policy filters deleted_at, so false is what this database does.\n`
256
+ + ` // true would AND deleted_at IS NULL into the public read policy — a predicate no live\n`
257
+ + ` // policy carries, so it would MISMATCH the database this was pulled from. It would also\n`
258
+ + ` // soften DELETE and hide marked rows from the data API. Change it deliberately, with a plan.\n`;
259
+ }
260
+
261
+ // No public read: the flag adds no POLICY (the compiler reads its guard only on the
262
+ // public-read branch), so either value fingerprints identically and introspection cannot
263
+ // recover which the app intended — a hard DELETE and a soft one leave the same schema.
264
+ //
265
+ // Fingerprint-neutral is NOT behaviour-neutral, and the comment must not imply it is: the
266
+ // generic data API also FILTERS soft-deleted rows out of reads. On a table where `deleted_at`
267
+ // is carried for audit rather than for deletion, adopting with `true` quietly drops rows from
268
+ // every listing — which is the same class of harm as the bug this rule exists to prevent,
269
+ // pointed the other way. So the comment states the full blast radius and asks the one
270
+ // question introspection cannot answer.
271
+ //
272
+ // `true` and not `false` because the worst cases are not symmetric: `true` hides rows
273
+ // recoverably (flip the flag; they were never destroyed, and they are still there over raw
274
+ // SQL), while `false` destroys data on the next DELETE and re-exposes rows the old
275
+ // application already treated as deleted. Irreversibility loses.
276
+ return ` softDelete: true, // ADOPTION DECISION — does deleted_at here mean DELETED, or merely audited?\n`
277
+ + ` // true: DELETE marks deleted_at instead of destroying the row, AND the data API\n`
278
+ + ` // hides marked rows from reads (every role, unless ?deleted=include|only)\n`
279
+ + ` // and from updates. No RLS policy changes either way — there is no public read.\n`
280
+ + ` // false: DELETE is permanent, marked rows stay visible. Set it if deleted_at is audit-only.\n`;
217
281
  }
218
282
 
219
283
  // The inverse type map lives with the vocabulary it inverts (`@everystack/model`),
@@ -594,7 +658,7 @@ export function renderModelBlock(table: TableSchema, known: Set<string>, enums:
594
658
  // put it, proving the position is mechanical-edit-friendly).
595
659
  const stanza = abilitiesStanza(abilities, table, liveAuthz, governRoles);
596
660
  const writtenBy = writtenByStanza(table, liveAuthz);
597
- const softDelete = softDeleteStanza(table, stanza.publicRead);
661
+ const softDelete = softDeleteStanza(table, stanza.publicRead, stanza.grantsDelete);
598
662
 
599
663
  // A non-public table carries `schema:` — defineModel stores the name VERBATIM, so the
600
664
  // qualification cannot ride in the first argument (that would make the table literally