@everystack/model 0.4.25 → 0.4.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to `@everystack/model` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [0.4.27] - 2026-11-04
8
+
9
+ ### BREAKING CHANGES
10
+
11
+ - softDelete is removed; lifecycle is the one soft delete
12
+ - createHandler refuses softDelete; lifecycle tables are the lifecycle option
13
+
14
+ ### Fixed
15
+
16
+ - a legacy descriptor with softDelete is refused wherever models are linked, derived or compiled
17
+
18
+ ### Documentation
19
+
20
+ - softDelete is removed; lifecycle is the one soft delete
21
+
22
+ ## [0.4.26] - 2026-10-31
23
+
24
+ ### Added
25
+
26
+ - declare request roles with defineModule({ roles: { request } })
27
+
28
+ ### Fixed
29
+
30
+ - a granted definer role is not a request role in the lifecycle check
31
+
32
+ ### Documentation
33
+
34
+ - say why a granted owner counts, and document roles.request
35
+ - state what an older server skips under the role declaration
36
+
7
37
  ## [0.4.25] - 2026-10-30
8
38
 
9
39
  ### BREAKING CHANGES
package/README.md CHANGED
@@ -132,48 +132,15 @@ defineModel('posts', {
132
132
 
133
133
  ## Soft delete
134
134
 
135
- A `deletedAt` column is a column. `softDelete` is the decision about what it MEANS, and it sits
136
- on the model beside `fields`:
135
+ Soft delete is `lifecycle: true`: see [Lifecycle](#lifecycle). It is the only soft delete. On a
136
+ model without it, a `deletedAt` field is a plain column and a `DELETE` removes the row.
137
+
138
+ To keep a `deleted_at` filter on a plain model's read, write it on the ability:
137
139
 
138
140
  ```typescript
139
- export const Upload = defineModel('uploads', {
140
- fields: {
141
- id: field.uuid().primaryKey().defaultRandom(),
142
- accountId: field.uuid().notNull().references(() => Account),
143
- s3Key: field.text().notNull(),
144
- deletedAt: field.timestamptz(),
145
- },
146
- softDelete: true,
147
- abilities: [can('read', { owner: 'accountId' }), can('delete', { owner: 'accountId' })],
148
- });
141
+ can('read', { role: 'anon', sql: sql`deleted_at IS NULL` })
149
142
  ```
150
143
 
151
- It changes two different things, and neither default is safe:
152
-
153
- - **Durability, on every model** — `true` makes a `DELETE` mark `deleted_at` instead of removing
154
- the row, and hides marked rows from the data API's reads and updates. `false` makes `DELETE`
155
- permanent.
156
- - **Visibility, only where there is a public read** — `true` AND-s `deleted_at IS NULL` into the
157
- anon policy. On an owner-scoped or admin-only model the compiled SQL is identical either way,
158
- so durability is the axis that matters there.
159
-
160
- **`defineModel` refuses to guess** when a model has `deletedAt` AND either a public read or a way
161
- to DELETE (`can('delete')`, `can('manage')`, or a `privileges` DELETE grant). Omit the line on
162
- such a model and it throws at import, naming which branch it tripped. Where nothing reads
163
- publicly and nothing deletes, the flag provably changes neither the SQL nor the handler, so
164
- nothing is required.
165
-
166
- Pick `true` if the row is meant to survive its own deletion — anything with a cleanup or destroy
167
- job, an audit trail, or rows other tables reference. Pick `false` if `deleted_at` here is
168
- audit-only and a `DELETE` really should remove the row. It is not cosmetic: a table whose S3
169
- cleanup job finds work by `deleted_at IS NOT NULL` orphans every file it should have removed if
170
- the row is hard-deleted instead.
171
-
172
- A column named `deleted_at` is not a statement of intent — it is carried for audit, for
173
- app-layer filtering, for half-finished migrations. This used to be inferred from the field's
174
- presence, which made the compiler author a security rule nobody wrote. `db:pull` writes the
175
- flag explicitly for the same reason.
176
-
177
144
  ## Relations
178
145
 
179
146
  ```typescript
@@ -245,7 +212,7 @@ The foreign key belongs to the child table, so the action lands there: in the pl
245
212
  - The relation must join on the primary key (the key a field `.references()` points at). A field covered only by a table-level `foreignKey(...)` declares `onDelete` there.
246
213
  - **`'cascade'` deletes the children in the database** (Rails `delete_all`). It runs no hook of the child and no code of yours. A child that must run code when it goes is not a cascade; that is `'destroy'`, which is not available yet. `'cascade'` works over a `NOT NULL` column, and it follows the child's own foreign keys in turn: deleting a thread deletes its replies and their reactions.
247
214
  - **A cascade is not checked against the child's own abilities.** PostgreSQL runs the referential action as the owner of the child table, so a role that may delete the parent row removes its children even when it holds no `DELETE` grant on the child and the child's row-level policy would refuse it. Grant `delete` on the parent only to roles that may remove everything below it. `'nullify'` has the same property for `UPDATE`.
248
- - Any other value (for example `'softDelete'`) is refused by name.
215
+ - Any other value is refused by name.
249
216
  - `belongsTo` takes no `dependent`. A `hasMany` with no `dependent` is display only and compiles exactly as before.
250
217
 
251
218
  `db:pull` renders the action on the child (`.references(() => Post, { onDelete: 'set null' })`) and no `dependent` on the parent; that agrees with a parent that declares one, so pull then plan is empty. A parent rendered `lifecycle: true` is the exception: `db:pull` renders the `dependent` of each key from a lifecycle child on the parent, from the key's `ON DELETE`, and leaves a plain child's key as it is, with a comment: only a lifecycle table may hold a key into a lifecycle table, so that model refuses to compile until you make it `lifecycle: true` or drop the `.references()`.
@@ -274,7 +241,6 @@ The compiler adds nullable `deletedAt` (timestamptz), `deletedBy` (text, no fore
274
241
  `deletedBatch` (uuid) and `deletedXid` (bigint). `LIFECYCLE_COLUMNS` names them. They enter the schema plan,
275
242
  fingerprint and generated schema. Do not declare these fields yourself, including their snake_case spellings.
276
243
  Lifecycle needs a single-column primary key and RLS. Omit `lifecycle` to disable it; `false` is refused.
277
- Do not combine it with `softDelete`. The boolean remains supported separately; its removal has not shipped.
278
244
 
279
245
  A DELETE sets these columns and reports zero removed rows. Read roles see only live rows;
280
246
  a plain manage role can see deleted rows. The API hides `deletedBy`, `deletedBatch` and `deletedXid` from other roles.
@@ -294,6 +260,11 @@ An explicit field `onDelete: 'cascade'` into a lifecycle table is refused; use t
294
260
  running app hooks. `dependent: 'destroy'` has not shipped.
295
261
  AFTER DELETE audit triggers are supported; they do not run on a soft deletion.
296
262
 
263
+ A trigger function on a lifecycle table must be owned by a role no request can become. The check counts
264
+ every role the models grant to as a request role. If a definer role owns such a function and holds grants,
265
+ declare the request roles: `defineModule({ roles: { request: [...] } })`. `moduleRequestRoles(modules)`
266
+ returns their union. See [docs/lifecycle.md](../../docs/lifecycle.md#owner-definer-and-request-roles).
267
+
297
268
  #### Refusing some deletes: `deleteWhen`
298
269
 
299
270
  `lifecycle: { deleteWhen: sql\`<predicate>\` }` is lifecycle with a guard: a row may be deleted only when the predicate
@@ -565,7 +536,7 @@ everystack db:pull --stage dev # reverse an existing database into model s
565
536
 
566
537
  ### Handler config
567
538
 
568
- `deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership, **`softDelete.tables`**) — so the HTTP handler and the database enforce the *same* declared rules, with no second source of truth.
539
+ `deriveHandlerConfig(models)` compiles the same Models into the authorization config `createHandler` consumes (exposed tables, hidden/protected columns, relations, row ownership, the **`lifecycle`** tables) — so the HTTP handler and the database enforce the *same* declared rules, with no second source of truth.
569
540
 
570
541
  ```typescript
571
542
  import { deriveHandlerConfig } from '@everystack/model';
@@ -573,8 +544,8 @@ const handler = createHandler(db, schema, deriveHandlerConfig(models));
573
544
  ```
574
545
 
575
546
  Do not hand-write the per-table keys beside it. Each one is a view of the models, and a
576
- hand-kept copy drifts silently: `softDelete.tables` losing two entries is what let a table start
577
- hard-deleting rows whose S3 objects a destroy job keyed on `deleted_at IS NOT NULL`. Handler-level
547
+ hand-kept copy drifts silently: a soft-delete table list losing two entries is what once let a
548
+ table start hard-deleting rows whose S3 objects a destroy job keyed on `deleted_at IS NOT NULL`. Handler-level
578
549
  options that are not per-table — `auth`, `pgSettings`, `rpc`, limits — stay explicit.
579
550
 
580
551
  ## Programmatic API (`@everystack/cli/model`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/model",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "description": "Declare a table's schema, validation, and authorization once — compiled to migrations, RLS, and handler config",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
package/src/can.ts CHANGED
@@ -193,50 +193,6 @@ export function privilegesGrantDelete(privileges: Record<string, readonly string
193
193
  return Object.values(privileges ?? {}).some((privs) => privs.includes('DELETE'));
194
194
  }
195
195
 
196
- /** The three facts the soft-delete rule turns on. See {@link needsSoftDeleteDeclaration}. */
197
- export interface SoftDeleteFacts {
198
- /** A `deletedAt` / `deleted_at` field is present. */
199
- hasDeletedAtField: boolean;
200
- /** Some ability is a PUBLIC read — see {@link isPublicReadAbility}. */
201
- hasPublicRead: boolean;
202
- /**
203
- * A DELETE can reach this table — via an ability ({@link isDeleteAbility}) or via a
204
- * `privileges` grant ({@link privilegesGrantDelete}). The SPELLING of the grant was never
205
- * load-bearing; what matters is that rows can be destroyed.
206
- */
207
- grantsDelete: boolean;
208
- }
209
-
210
- /**
211
- * THE rule deciding when `softDelete` must be declared rather than defaulted.
212
- *
213
- * `softDelete` changes observable behaviour on TWO axes, and the refusal has to cover both:
214
- *
215
- * - **visibility** — on a model with a public read, `true` AND-s `deleted_at IS NULL` into the
216
- * anon policy. What a caller with no session can see.
217
- * - **durability** — on ANY model that can be deleted from, `true` makes the handler mark
218
- * `deleted_at` instead of removing the row. What a DELETE destroys.
219
- *
220
- * The guard originally asked only the visibility question, so a model with an owner-scoped
221
- * read and a `delete` ability sailed past it and silently defaulted to `false`. That is how
222
- * `uploads` and `image_variants` began hard-deleting rows, orphaning their S3 objects forever
223
- * — on those models `softDelete: true` adds no policy predicate at all, so visibility was
224
- * never the axis that mattered.
225
- *
226
- * Deliberately NOT "any model with a `deletedAt` field": where nothing reads publicly and
227
- * nothing deletes, the flag provably changes neither SQL nor handler behaviour, and refusing
228
- * there would be ceremony charged to every brownfield pull for no hazard.
229
- *
230
- * Stated over FACTS, not over a descriptor, because it has two callers holding two
231
- * representations: `defineModel` has structured {@link Ability} objects, and `db:pull`'s
232
- * renderer only ever holds rendered source text. Both extract the same three facts and share
233
- * this one boolean, so the two surfaces cannot drift on the rule itself — the failure that
234
- * already bit this guard once, when the refusal and the renderer disagreed about `role: 'anon'`.
235
- */
236
- export function needsSoftDeleteDeclaration(facts: SoftDeleteFacts): boolean {
237
- return facts.hasDeletedAtField && (facts.hasPublicRead || facts.grantsDelete);
238
- }
239
-
240
196
  /** Declare one authorization rule. */
241
197
  export function can(action: Action, condition: AbilityCondition = {}): Ability {
242
198
  return { action, condition };
package/src/derive.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * the API handler's options blob.
4
4
  *
5
5
  * `exposedTables`, `hiddenColumns`, `protectedFields`, `rowOwnership`,
6
- * `softDelete`, `relations`, and `hooks` are all *views of the model*, so they
6
+ * `lifecycle`, `relations`, and `hooks` are all *views of the model*, so they
7
7
  * derive from it instead of a hand-maintained literal that drifts from the schema.
8
8
  *
9
9
  * Scope boundary: only the PER-TABLE surface derives here. App/handler-level
@@ -16,6 +16,7 @@ import type { ModelDescriptor } from './model';
16
16
  import type { ModelHooks } from './model';
17
17
  import { assertDeleteHookNames, assertDestroyHooks } from './lifecycle-hooks';
18
18
  import type { AbilityCondition } from './can';
19
+ import { assertNoLegacySoftDelete } from './legacy-soft-delete';
19
20
  import { linkModels } from './link';
20
21
 
21
22
  /** The handler's RelationConfig shape, mirrored structurally (no @everystack/api dep). */
@@ -39,9 +40,8 @@ export interface DerivedHandlerConfig {
39
40
  protectedFields: Record<string, string[]>;
40
41
  /** `exceptRoles` is present only when a `can('manage', { role })` exists for the table. */
41
42
  rowOwnership: Record<string, { column: string; userField: string; exceptRoles?: string[] }>;
42
- /** `lifecycle` (present only when a lifecycle model exists) lists the `lifecycle: true` tables;
43
- * each is also in `tables`, so the read filter applies to it. */
44
- softDelete: { tables: string[]; lifecycle?: string[] };
43
+ /** The `lifecycle: true` tables, present only when a lifecycle model exists. */
44
+ lifecycle?: string[];
45
45
  relations: Record<string, Record<string, RelationConfig>>;
46
46
  hooks: Record<string, ModelHooks>;
47
47
  }
@@ -57,13 +57,13 @@ function primaryKeyOf(model: ModelDescriptor): string {
57
57
  }
58
58
 
59
59
  export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerConfig {
60
+ for (const model of models) assertNoLegacySoftDelete(model);
60
61
  linkModels(models);
61
62
  const exposedTables: string[] = [];
62
63
  const hiddenColumns: Record<string, string[]> = {};
63
64
  const hiddenColumnsExceptRoles: Record<string, string[]> = {};
64
65
  const protectedFields: Record<string, string[]> = {};
65
66
  const rowOwnership: DerivedHandlerConfig['rowOwnership'] = {};
66
- const softDeleteTables: string[] = [];
67
67
  const lifecycleTables: string[] = [];
68
68
  const relations: Record<string, Record<string, RelationConfig>> = {};
69
69
  const hooks: Record<string, ModelHooks> = {};
@@ -114,13 +114,9 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
114
114
  if (exceptRoles.length) rowOwnership[table].exceptRoles = exceptRoles;
115
115
  }
116
116
 
117
- // Declared, not inferred — one `softDelete` decision drives both the API filter and the
118
- // RLS guard, so the two can never disagree about what a soft-deleted row is.
119
- if (model.softDelete) softDeleteTables.push(table);
120
117
  // A lifecycle table soft-deletes in the database (its delete trigger); the handler reads it as
121
118
  // a soft-delete table and drives that machinery instead of writing deleted_at itself.
122
119
  if (model.lifecycle) {
123
- softDeleteTables.push(table);
124
120
  lifecycleTables.push(table);
125
121
  hiddenColumns[table] = [...new Set([...(hiddenColumns[table] ?? []), 'deletedBy', 'deletedBatch', 'deletedXid'])];
126
122
  hiddenColumnsExceptRoles[table] = exceptRoles;
@@ -176,7 +172,7 @@ export function deriveHandlerConfig(models: ModelDescriptor[]): DerivedHandlerCo
176
172
  ...(lifecycleTables.length ? { hiddenColumnsExceptRoles } : {}),
177
173
  protectedFields,
178
174
  rowOwnership,
179
- softDelete: { tables: softDeleteTables, ...(lifecycleTables.length ? { lifecycle: lifecycleTables } : {}) },
175
+ ...(lifecycleTables.length ? { lifecycle: lifecycleTables } : {}),
180
176
  relations,
181
177
  hooks,
182
178
  };
package/src/derived.ts CHANGED
@@ -461,6 +461,9 @@ export interface FunctionDescriptor {
461
461
  * Same rule as @everystack/server's withRole.
462
462
  */
463
463
  const OWNER_ROLE_NAME = /^[a-z_][a-z0-9_]*$/;
464
+ /** The bare role-name rule, for every other place a module names a role (defineModule's roles.request). */
465
+ export const BARE_ROLE_NAME = OWNER_ROLE_NAME;
466
+ export const isBareRoleName = (role: string): boolean => OWNER_ROLE_NAME.test(role);
464
467
 
465
468
  /**
466
469
  * Refuse an owner that is not a bare lowercase role name. ONE implementation across
package/src/index.ts CHANGED
@@ -23,8 +23,7 @@ export type { TableConstraint } from './constraints';
23
23
  export { sql } from './raw-sql';
24
24
  export type { RawSql } from './raw-sql';
25
25
 
26
- export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
27
- export type { SoftDeleteFacts } from './can';
26
+ export { can, isColumnAbility, isPublicReadAbility, isDeleteAbility, privilegesGrantDelete, sessionlessAudience } from './can';
28
27
  export type { Ability, AbilityCondition, Action } from './can';
29
28
 
30
29
  export { belongsTo, hasMany, hasOne } from './relations';
@@ -36,8 +35,8 @@ export { veto, isVeto } from './lifecycle-hooks';
36
35
  export { defineModel, defineMaterializedTable, LIFECYCLE_COLUMNS, LIFECYCLE_OWNER_ROLE, EXTRA_PRIVILEGES, DML_PRIVILEGES, TABLE_PRIVILEGES } from './model';
37
36
  export type { ModelDescriptor, ModelDefinition, ModelHooks, LifecycleOptions, DestroyContext, DestroyHook, LifecycleContext, HookVeto, BeforeLifecycleHook, AfterLifecycleHook, WrittenBy, ExtraPrivilege, TablePrivilege, DmlPrivilege, MaterializedTableMarker, MaterializedTableDefinition } from './model';
38
37
 
39
- export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles } from './module';
40
- export type { Module, ModuleDefinition } from './module';
38
+ export { defineModule, moduleModels, moduleExtensions, moduleFunctionSql, moduleSequences, moduleDerived, moduleGovernedRoles, moduleRequestRoles } from './module';
39
+ export type { Module, ModuleDefinition, ModuleRoles } from './module';
41
40
 
42
41
  export {
43
42
  defineView, defineMaterializedView, defineFunction, defineSql, defineSequence,
@@ -53,6 +52,7 @@ export type {
53
52
  } from './derived';
54
53
 
55
54
  export { deriveHandlerConfig, deriveForceRlsCarveouts, deriveFunctionSchemas } from './derive';
55
+ export { assertNoLegacySoftDelete } from './legacy-soft-delete';
56
56
  export type { DerivedHandlerConfig, RelationConfig } from './derive';
57
57
 
58
58
  export { ENSURE_DESTROY_LOG_SQL, DESTROY_LOG_MARKER_BODIES, DESTROY_LOG_MARKER_LANGUAGES, DESTROY_LOG_MARKER_SEARCH_PATH } from './destroy-log';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `softDelete` was removed from the model. defineModel refuses the key, but a descriptor built
3
+ * by an older @everystack/model, or exported by an older published package, never passes
4
+ * through it and may still carry the key. Compiled silently, its public read would lose the
5
+ * `deleted_at IS NULL` guard and the handler's DELETE would destroy rows it used to mark.
6
+ *
7
+ * So every path that links, derives or compiles models refuses such a descriptor: linkModels,
8
+ * deriveHandlerConfig and compileTableContract (the security boundary itself). Present with
9
+ * any value is refused, `false` included: the descriptor is from an older build either way.
10
+ */
11
+ export function assertNoLegacySoftDelete(model: { table: string; schema?: string }): void {
12
+ if (!('softDelete' in model)) return;
13
+ const name = model.schema && model.schema !== 'public' ? `${model.schema}.${model.table}` : model.table;
14
+ throw new Error(
15
+ `model '${name}': softDelete was removed: this model was built by an older @everystack/model or comes from an older package; ` +
16
+ 'upgrade it, or use lifecycle: true. ' +
17
+ "To keep a deleted_at filter on a read, write it on the ability: can('read', { ..., sql: sql`deleted_at IS NULL` }).",
18
+ );
19
+ }
package/src/link.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ModelDescriptor } from './model';
2
+ import { assertNoLegacySoftDelete } from './legacy-soft-delete';
2
3
  import { parseQualified } from './qualified';
3
4
  import { assertExternalTargets } from './external';
4
5
  import { relationDeleteActions } from './dependents';
@@ -49,6 +50,7 @@ export function referenceLocation(target: (() => unknown) | undefined, location:
49
50
  */
50
51
  export function linkModels<T extends readonly ModelDescriptor[]>(models: T): T {
51
52
  const uniqueModels = [...new Set(models)];
53
+ for (const model of uniqueModels) assertNoLegacySoftDelete(model);
52
54
  const byName = new Map<string, ModelDescriptor[]>();
53
55
  for (const model of uniqueModels) {
54
56
  const candidates = byName.get(model.table) ?? [];
package/src/model.ts CHANGED
@@ -15,7 +15,7 @@ import { dependentWhereRefusal } from './dependents';
15
15
  import { field, type FieldBuilder } from './field';
16
16
  import { referenceLocation } from './link';
17
17
  import type { Ability } from './can';
18
- import { can, isPublicReadAbility, isDeleteAbility, needsSoftDeleteDeclaration, privilegesGrantDelete, sessionlessAudience } from './can';
18
+ import { can, sessionlessAudience } from './can';
19
19
  import { assertRelationShape, type Relation } from './relations';
20
20
  import { assertCheckNames, type TableConstraint } from './constraints';
21
21
  import { lifecycleCascadeHarm } from './dependents';
@@ -102,42 +102,18 @@ export interface ModelDefinition {
102
102
  */
103
103
  privileges?: Record<string, TablePrivilege[]>;
104
104
  /**
105
- * What a soft delete MEANS on this table. It drives two different machines, and both
106
- * consequences are real — a model declaring it should expect all of this:
107
- *
108
- * - **The generic data API** (`deriveHandlerConfig` → `createHandler`): `DELETE` becomes
109
- * `UPDATE SET deleted_at = now()` instead of removing the row; and soft-deleted rows are
110
- * then filtered out of GET (for EVERY role, unless `?deleted=include|only`), out of
111
- * embedded relation reads, and out of UPDATE. This half applies to every model, whatever
112
- * its audience.
113
- * - **RLS**: `deleted_at IS NULL` is AND-ed into the PUBLIC read policies. This half applies
114
- * only where there is a public read — on an owner-scoped or admin-only model the compiled
115
- * contract is byte-identical either way (pinned in cli's `public-read-pin.test.ts`).
116
- *
117
- * **Required — not inferred — on any model with a `deletedAt` field that either has a public
118
- * read OR grants DELETE** (`can('delete')`, or `can('manage')`, which is "all actions"). See
119
- * {@link needsSoftDeleteDeclaration}, the one rule this and `db:pull` share.
120
- *
121
- * The guard used to ask only about the public read, which is the visibility axis. That let a
122
- * model with an owner-scoped read and a `delete` ability default to `false` and begin
123
- * destroying rows — with an S3 cleanup job keyed on `deleted_at IS NOT NULL`, that orphaned
124
- * every file it should have cleaned up. On those models the flag adds no policy at all, so
125
- * visibility was never the axis that mattered.
126
- *
127
- * It used to be inferred from the field's mere presence. A column named `deleted_at` is
128
- * not a statement of visibility intent: it is carried for audit, for app-layer filtering,
129
- * for half-finished migrations. Inferring the predicate made the compiler author a
130
- * security rule nobody wrote — and because `db:pull` renders `deletedAt` straight from the
131
- * live column, a brownfield model acquired it automatically and the FIRST plan narrowed a
132
- * policy the adopter had written. `createHandler`'s own `softDelete: { column, tables }`
133
- * was always explicit; this matches it. Convention may propose it, never enforce it.
105
+ * Removed. `lifecycle: true` is the one soft delete. defineModel refuses this key, `true` or
106
+ * `false`, so an upgrade never compiles quietly into a model whose read lost its
107
+ * `deleted_at IS NULL` guard or whose DELETE starts destroying rows it used to mark. For a
108
+ * soft delete, declare `lifecycle: true`; to keep a `deleted_at` filter on a read, write it on
109
+ * the ability: `can('read', { ..., sql: sql\`deleted_at IS NULL\` })`.
134
110
  */
135
- softDelete?: boolean;
111
+ softDelete?: never;
136
112
  /**
137
113
  * `lifecycle: true` — a soft delete with a delay. defineModel generates four nullable columns
138
114
  * ({@link LIFECYCLE_COLUMNS}): `deletedAt` timestamptz, `deletedBy` text (no foreign key),
139
- * `deletedBatch` uuid, `deletedXid` bigint. The author never declares them, never combines
140
- * lifecycle with `softDelete`, and declares a single-column primary key; each is refused here.
115
+ * `deletedBatch` uuid, `deletedXid` bigint. The author never declares them and declares a
116
+ * single-column primary key; each is refused here.
141
117
  *
142
118
  * `{ deleteWhen: sql\`<predicate>\` }` is lifecycle too, with a guard: a row may be marked
143
119
  * deleted only when the predicate is TRUE for it (FALSE or NULL refuses, SQLSTATE 23514), whether
@@ -223,8 +199,6 @@ export interface ModelDescriptor {
223
199
  writtenBy: WrittenBy;
224
200
  /** Beyond-CRUD privileges per role, unioned with what the abilities grant. See {@link ModelDefinition.privileges}. */
225
201
  privileges: Record<string, TablePrivilege[]>;
226
- /** `true` excludes `deleted_at IS NULL` rows from public reads and the data API. Declared, never inferred. */
227
- softDelete: boolean;
228
202
  /** Present only when declared: `fields` carries the generated {@link LIFECYCLE_COLUMNS}. Always
229
203
  * `true` on the descriptor, whichever form the definition used. */
230
204
  lifecycle?: true;
@@ -350,12 +324,6 @@ function assertLifecycle(table: string, def: ModelDefinition): void {
350
324
  } else if (value !== true) {
351
325
  throw new Error(`model '${table}': lifecycle: ${JSON.stringify(value)} — the only values are lifecycle: true and lifecycle: { deleteWhen: sql\`…\` }.`);
352
326
  }
353
- if (def.softDelete !== undefined) {
354
- throw new Error(
355
- `model '${table}': lifecycle replaces softDelete — delete softDelete: ${String(def.softDelete)}. ` +
356
- `lifecycle: true already declares what a delete does.`,
357
- );
358
- }
359
327
  for (const name of LIFECYCLE_RESERVED) {
360
328
  if (name in def.fields) {
361
329
  throw new Error(
@@ -483,7 +451,22 @@ function assertLifecycleRestoreSafe(table: string, def: ModelDefinition): void {
483
451
  }
484
452
  }
485
453
 
454
+ /**
455
+ * `softDelete` was removed. Refuse the key, `true` or `false`, rather than ignore it: an ignored
456
+ * `true` would drop the `deleted_at IS NULL` guard from a public read and turn a marking DELETE
457
+ * into a destroying one, with nothing in any diff to review.
458
+ */
459
+ function assertNoSoftDelete(table: string, def: ModelDefinition): void {
460
+ if (!('softDelete' in def)) return;
461
+ throw new Error(
462
+ `model '${table}': softDelete was removed. For a soft delete, use lifecycle: true. ` +
463
+ "To keep a deleted_at filter on a read, write it on the ability: can('read', { ..., sql: sql`deleted_at IS NULL` }). " +
464
+ 'If it said false, delete the line.',
465
+ );
466
+ }
467
+
486
468
  export function defineModel(table: string, def: ModelDefinition): ModelDescriptor {
469
+ assertNoSoftDelete(table, def);
487
470
  assertDeleteHookNames(table, def.hooks);
488
471
  assertDestroyHooks(table, def.hooks, isLifecycle(def));
489
472
  // Refuse first, then inject: the author-declared collisions are checked against what the author wrote.
@@ -643,52 +626,6 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
643
626
  }
644
627
  }
645
628
 
646
- // The soft-delete guard used to be inferred from the field's presence, which meant a model
647
- // could gain — or, once the inference was removed, silently LOSE — a predicate that decides
648
- // what anonymous users can see, with nothing in any diff to review. Neither default is safe,
649
- // so there is no default: on the exact set of models where the answer changes behaviour, it
650
- // must be declared. `softDelete: true` is what the inference used to do.
651
- //
652
- // That set is NOT "has a public read". It is the union of two axes — see
653
- // `needsSoftDeleteDeclaration`, which is the whole rule and is shared with db:pull so the two
654
- // surfaces cannot drift on it.
655
- // `isPublicReadAbility`, not a local copy: this refusal and db:pull's renderer were asking
656
- // the same question — "can a caller with no session see these rows?" — and answering it
657
- // differently. The renderer already counted `role: 'anon'`; this refusal did not, so a table
658
- // whose authenticated read diverges (rendered as an anon-scoped read) slipped past the very
659
- // decision the refusal exists to force. It now fires on the anonymous audience however it is
660
- // spelled. Explicit `softDelete: false` remains the one-line answer, and pull writes that
661
- // line into every model it renders, so a pulled checkout keeps loading.
662
- const hasPublicRead = (def.abilities ?? []).some(isPublicReadAbility);
663
- // Either spelling of a reachable DELETE. `privileges` matters most on an `rls: false` table,
664
- // where the grant is unrestricted and no policy stands between it and every row.
665
- const grantsDelete = (def.abilities ?? []).some(isDeleteAbility) || privilegesGrantDelete(def.privileges);
666
- // Both spellings of the field key. The guard used to test `'deletedAt' in def.fields`
667
- // literally, so a model keying the column `deleted_at` — the shape a snake_case brownfield
668
- // schema pulls as — carried a soft-delete column past a guard that never looked at it.
669
- const softDeleteField = 'deletedAt' in def.fields || 'deleted_at' in def.fields;
670
- // A lifecycle model has declared what a delete does; its generated deletedAt is not an author field.
671
- if (needsSoftDeleteDeclaration({ hasDeletedAtField: softDeleteField, hasPublicRead, grantsDelete }) && def.softDelete === undefined) {
672
- // DURABILITY FIRST. The old message called this "a visibility decision", which is false
673
- // for exactly the models that broke: with no public read, `softDelete: true` adds no
674
- // policy predicate at all. Naming the wrong consequence teaches the wrong fix, so each
675
- // axis is stated only when it actually fired.
676
- const why = grantsDelete
677
- ? `grants DELETE (via can('delete'), can('manage') — which is "all actions" — or a privileges DELETE grant), `
678
- + `so it must declare softDelete. `
679
- + `This is a durability decision: softDelete: true makes a DELETE mark deleted_at instead of destroying the row; `
680
- + `softDelete: false makes DELETE permanent.`
681
- : `has a public read, so it must declare softDelete.`;
682
- const visibility = hasPublicRead
683
- ? ` It is also a visibility decision: softDelete: true excludes soft-deleted rows from the public read policies `
684
- + `(what the compiler used to infer from the field's presence); softDelete: false leaves them readable.`
685
- : '';
686
- throw new Error(
687
- `model '${table}': has a 'deletedAt' field and ${why}${visibility} `
688
- + `What a DELETE destroys is declared, not guessed — add softDelete: true or softDelete: false.`,
689
- );
690
- }
691
-
692
629
  // A privilege outside the table-privilege vocabulary is still refused — that is a typo, not a
693
630
  // state a database can hold. The DML four ARE accepted now: they used to throw here so that a
694
631
  // grant could never be declared without its policy, but that invariant was vacuous for any
@@ -767,7 +704,6 @@ export function defineModel(table: string, def: ModelDefinition): ModelDescripto
767
704
  writtenBy: def.writtenBy ?? 'app',
768
705
  privileges: def.privileges ?? {},
769
706
  // Stays false on a lifecycle model: its reads are filtered by the RLS guard (L1.2a), its deletes arrive with the triggers.
770
- softDelete: def.softDelete ?? false,
771
707
  ...(lifecycle ? { lifecycle: true as const } : {}),
772
708
  ...(deleteWhen !== undefined ? { deleteWhen } : {}),
773
709
  schema: def.schema ?? 'public',
package/src/module.ts CHANGED
@@ -21,7 +21,7 @@
21
21
 
22
22
  import { LIFECYCLE_OWNER_ROLE, type ModelDescriptor } from './model';
23
23
  import type { DerivedDescriptor, SequenceDescriptor } from './derived';
24
- import { rejectHoles, functionArgSignature } from './derived';
24
+ import { rejectHoles, functionArgSignature, isBareRoleName, BARE_ROLE_NAME } from './derived';
25
25
  import { deriveFunctionSchemas } from './derive';
26
26
  import { parseQualified } from './qualified';
27
27
  import { linkModels } from './link';
@@ -66,6 +66,20 @@ export interface ModuleDefinition {
66
66
  * baseline fails the gate. Exempt from reconciliation, never exempt from the report.
67
67
  */
68
68
  governedRoles?: string[];
69
+ /**
70
+ * What the database's roles are FOR, where the grants alone cannot tell.
71
+ *
72
+ * `request` — the roles a request can become: the roles the API sets for anon, signed-in and
73
+ * admin requests, and the login the application connects as. The lifecycle catalog check treats
74
+ * every role the models grant to as one a request can be. A least-privilege SECURITY DEFINER
75
+ * role that does not own the tables must still be granted DML, so without this list it counts
76
+ * as a request role, and every trigger function it owns on a lifecycle table is refused.
77
+ * Declared, a declared `owner:` that no request role can reach (and that cannot log in) no
78
+ * longer counts. Undeclared, the check runs as before.
79
+ *
80
+ * Refused: an empty list, `PUBLIC`, and `everystack_lifecycle`.
81
+ */
82
+ roles?: ModuleRoles;
69
83
  /**
70
84
  * Package-owned SQL emitted after the tables — SECURITY DEFINER functions, triggers, etc.
71
85
  * A thunk so it is generated lazily and can take run-specific input (the app's event-trigger
@@ -87,10 +101,39 @@ export interface Module {
87
101
  derived: DerivedDescriptor[];
88
102
  /** Roles this module's authz governs beyond the ones its models name. Widen-only. */
89
103
  governedRoles: string[];
104
+ /** What the roles are for — see {@link ModuleDefinition.roles}. Absent when not declared. */
105
+ roles?: ModuleRoles;
90
106
  /** Function-SQL thunks, normalized to an array so composing modules just concatenates. */
91
107
  functions: Array<() => string>;
92
108
  }
93
109
 
110
+ /** {@link ModuleDefinition.roles}. On a Module, `request` is deduped and sorted. */
111
+ export interface ModuleRoles {
112
+ /** The roles a request can become. */
113
+ request?: string[];
114
+ }
115
+
116
+ /** Validate and normalize `roles.request`: non-empty, real role names, never PUBLIC or the lifecycle role. */
117
+ function normalizeRoles(roles: ModuleRoles): ModuleRoles {
118
+ if (roles.request === undefined) return {};
119
+ if (roles.request.length === 0) {
120
+ throw new Error('defineModule: roles.request is empty. Name every role a request can become (the API\'s request roles and the '
121
+ + 'login the application connects as), or leave roles.request out; an empty list would treat no role as a request role.');
122
+ }
123
+ for (const role of roles.request) {
124
+ if (role === 'PUBLIC' || role === 'public') {
125
+ throw new Error(`defineModule: roles.request names '${role}'. PUBLIC is every role, not one a request becomes; name the roles themselves.`);
126
+ }
127
+ if (role === LIFECYCLE_OWNER_ROLE) {
128
+ throw new Error(`defineModule: roles.request names '${role}'. That role is reserved for the lifecycle soft delete; a request must never become it.`);
129
+ }
130
+ if (!isBareRoleName(role)) {
131
+ throw new Error(`defineModule: roles.request names '${role}', which is not a bare lowercase role name (${BARE_ROLE_NAME}).`);
132
+ }
133
+ }
134
+ return { request: [...new Set(roles.request)].sort() };
135
+ }
136
+
94
137
  /** Declare a package's database slice. Empty fields default to nothing — a models-only module is fine. */
95
138
  export function defineModule(def: ModuleDefinition): Module {
96
139
  // The loud undefined gate — a missing import composes as a hole; name it here,
@@ -124,6 +167,7 @@ export function defineModule(def: ModuleDefinition): Module {
124
167
  sequences: def.sequences ?? [],
125
168
  derived: def.derived ?? [],
126
169
  governedRoles: [...new Set(def.governedRoles ?? [])].sort(),
170
+ ...(def.roles ? { roles: normalizeRoles(def.roles) } : {}),
127
171
  functions: def.functions ? [def.functions] : [],
128
172
  };
129
173
  }
@@ -232,3 +276,13 @@ export function moduleGovernedRoles(modules: Module[]): string[] {
232
276
  const lifecycle = modules.some((m) => m.models.some((model) => model.lifecycle));
233
277
  return [...new Set([...modules.flatMap((m) => m.governedRoles), ...(lifecycle ? [LIFECYCLE_OWNER_ROLE] : [])])].sort();
234
278
  }
279
+
280
+ /**
281
+ * The union of every module's request roles, sorted. Undefined when no module declares any, so a
282
+ * caller can tell "not declared" (the lifecycle check runs as before) from a declared list.
283
+ */
284
+ export function moduleRequestRoles(modules: Module[]): string[] | undefined {
285
+ const declared = modules.filter((m) => m.roles?.request !== undefined);
286
+ if (declared.length === 0) return undefined;
287
+ return [...new Set(declared.flatMap((m) => m.roles!.request!))].sort();
288
+ }
package/src/wire.ts CHANGED
@@ -245,6 +245,19 @@ export interface ReconcileWirePayload extends ModelWireEnvelope {
245
245
  * declared object is lifecycle-owned = the reconcile refuses (fail closed).
246
246
  */
247
247
  applicationRoles?: string[];
248
+ /**
249
+ * The roles a request can become (`roles: { request }` on the modules), and every owner a
250
+ * declared derived object names. The catalog check reads them beside `applicationRoles`, so a
251
+ * definer role the models grant to is not mistaken for a request role.
252
+ *
253
+ * OPTIONAL, and added without a contract bump on purpose: absent (an older CLI), the server
254
+ * checks exactly as before. An older server ignores them and runs the check from before they
255
+ * existed: it refuses more, but it skips the live checks on the request roles (missing,
256
+ * superuser, BYPASSRLS). db:apply's CLI-side gates still run those; `db:reconcile --apply
257
+ * --stage` has none, so against an older server it does not. See wire-shape.test.ts.
258
+ */
259
+ requestRoles?: string[];
260
+ declaredOwners?: string[];
248
261
  actor?: string | null;
249
262
  gitRef?: string | null;
250
263
  }