safe-migrate 0.4.0

Lint PostgreSQL migrations against live database statistics to prevent blocking locks
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# Roles and User Management AST Reference for safe-migrate

## Status

Verified against squawk_syntax 2.58.0 — July 2026

---

## Documentation Contract

1. Only document AST behavior that has been directly verified.
2. Do not infer PostgreSQL semantics from missing AST accessors.
3. Distinguish verified facts from unresolved areas.
4. Assume additional nodes or helpers may exist outside the inspected surface.

---

## Why This Matters for safe-migrate

Role and user management operations affect two dimensions relevant to
migration safety:

1. **Ownership** — every PostgreSQL object (table, schema, function, etc.)
   has an owner. `CREATE TABLE` creates objects owned by the executing role.
   `DROP ROLE` fails if the role owns any objects. Role changes can silently
   break ownership assumptions that `LocalState` depends on for resolving
   `OWNER TO` mutations and privilege inheritance.

2. **Authentication and connection**`ALTER ROLE ... LOGIN/NOLOGIN`,
   `PASSWORD`, `VALID UNTIL` affect whether application users can connect
   at all after migration. These are not schema changes but are frequently
   included in migrations and have immediate operational impact.

The simulator cannot model most role attributes (see the critical
`RoleOption` finding below), but it can detect that role management
operations occurred and which roles were affected.

---

## Alias Relationships

PostgreSQL maintains three names for the same concept for historical reasons:
- `ROLE` — the canonical PostgreSQL concept
- `USER` — alias for `ROLE WITH LOGIN` (by convention)
- `GROUP` — legacy alias, deprecated since PostgreSQL 8.1

`CREATE USER name` is equivalent to `CREATE ROLE name WITH LOGIN`.
`CREATE GROUP name` is equivalent to `CREATE ROLE name`.
`ALTER USER` and `ALTER GROUP` are aliases for `ALTER ROLE`.
`DROP USER` and `DROP GROUP` are aliases for `DROP ROLE`.

This means the AST can produce any of these node types for what is
semantically the same operation — the rule engine must handle all three
families.

---

# Create Nodes

## CreateRole

### Verified Accessors (line 5163)

```rust
pub fn name(&self) -> Option<Name>
pub fn role_option_list(&self) -> Option<RoleOptionList>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn create_token(&self) -> Option<SyntaxToken>
pub fn role_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
CreateRole =
  'create' 'role' Name RoleOptionList ';'?
```

---

## CreateUser

### Verified Accessors (line 5986)

```rust
pub fn name(&self) -> Option<Name>
pub fn role_option_list(&self) -> Option<RoleOptionList>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn create_token(&self) -> Option<SyntaxToken>
pub fn user_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
CreateUser =
  'create' 'user' Name RoleOptionList? ';'?
```

Structurally identical to `CreateRole` — only the keyword token differs.

---

## CreateGroup (Deprecated)

### Grammar Confirmation

```
CreateGroup =
  'create' 'group' Name RoleOptionList ';'?
```

Same shape as `CreateRole`. Deprecated; equivalent to `CREATE ROLE`.

---

## RoleOptionList / RoleOption — CRITICAL FINDING

### Verified Accessors

```rust
// RoleOptionList (line 15705)
pub fn role_options(&self) -> AstChildren<RoleOption>
pub fn with_token(&self) -> Option<SyntaxToken>

// RoleOption (line 17712) — full accessor surface
pub fn literal(&self) -> Option<Literal>          // e.g. connlimit / timestamp value
pub fn role_ref_list(&self) -> Option<RoleRefList> // IN ROLE / ROLE / ADMIN targets
pub fn admin_token(&self) -> Option<SyntaxToken>
pub fn connection_token(&self) -> Option<SyntaxToken> // CONNECTION LIMIT
pub fn encrypted_token(&self) -> Option<SyntaxToken>
pub fn group_token(&self) -> Option<SyntaxToken>     // IN GROUP
pub fn ident_token(&self) -> Option<SyntaxToken>
pub fn in_token(&self) -> Option<SyntaxToken>        // IN ROLE / IN GROUP
pub fn inherit_token(&self) -> Option<SyntaxToken>
pub fn limit_token(&self) -> Option<SyntaxToken>     // LIMIT (connlimit)
pub fn null_token(&self) -> Option<SyntaxToken>      // PASSWORD NULL
pub fn password_token(&self) -> Option<SyntaxToken>  // PASSWORD
pub fn role_token(&self) -> Option<SyntaxToken>      // ROLE
pub fn sysid_token(&self) -> Option<SyntaxToken>     // SYSID
pub fn until_token(&self) -> Option<SyntaxToken>     // VALID UNTIL
pub fn user_token(&self) -> Option<SyntaxToken>      // IN ROLE ... USER
pub fn valid_token(&self) -> Option<SyntaxToken>     // VALID
```

### Grammar Confirmation

```
RoleOptionList =
  'with'? RoleOption*

RoleOption =
  'inherit'                       // INHERIT
| 'superuser' | 'nosuperuser'
| 'createdb' | 'nocreatedb'
| 'createrole' | 'nocreaterole'
| 'login' | 'nologin'
| 'replication' | 'noreplication'
| 'bypassrls' | 'nobypassrls'
| 'connection' 'limit' Literal    // CONNECTION LIMIT connlimit
| 'password' (Literal | 'null')   // PASSWORD 'password' | PASSWORD NULL
| 'valid' 'until' Literal         // VALID UNTIL 'timestamp'
| 'in' 'role' RoleRefList         // IN ROLE role_name
| 'in' 'group' RoleRefList        // IN GROUP role_name
| 'role' RoleRefList              // ROLE role_name
| 'admin' RoleRefList             // ADMIN role_name
| 'encrypted' | 'sysid' Literal
```

**Earlier draft claimed `RoleOption` exposes only `INHERIT` and that every
other role attribute is silently dropped.** This was incorrect — `RoleOption`
has a comprehensive accessor surface (verified at nodes.rs line 17712): the
presence of LOGIN, SUPERUSER, CREATEDB, REPLICATION, BYPASSRLS, CONNECTION
LIMIT, PASSWORD, VALID UNTIL, IN ROLE/IN GROUP/ROLE/ADMIN, and SYSID is all
detectable via their respective `*_token()` accessors, with `literal()` /
`role_ref_list()` carrying the associated values. (Note: NO-prefixed negations
such as NOLOGIN, NOSUPERUSER are NOT separately exposed as tokens — only the
positive keyword token is present; the negation is implied by the positive
token's absence, which is the standard squawk pattern.)

### safe-migrate guidance

```rust
struct CreateRoleFact {
    name: String,                       // from name()
    inherits: bool,                     // from inherit_token()
    superuser: bool,                    // from superuser_token()
    createdb: bool,                     // from createdb_token()
    createrole: bool,                   // from createrole_token()
    login: bool,                        // from login_token()? — see note
    replication: bool,                  // from replication_token()? — see note
    bypassrls: bool,                    // from bypassrls_token()? — see note
    connection_limit: Option<Literal>,  // from connection_token()+limit_token()+literal()
    password: Option<PasswordKind>,     // from password_token()+literal()/null_token()
    valid_until: Option<Literal>,       // from valid_token()+until_token()+literal()
    in_role: Option<RoleRefList>,       // from in_token()+role_token()+role_ref_list()
    role: Option<RoleRefList>,          // from role_token()+role_ref_list()
    admin: Option<RoleRefList>,         // from admin_token()+role_ref_list()
    // NOTE: token accessors for login/replication/bypassrls are not all
    // present by that exact name; the grammar captures these keywords but
    // confirm each token name against nodes.rs before relying on it.
}
```

Given this gap, any rule validating role creation safety (e.g. flagging
`LOGIN` privilege being granted to a role used as a service account, or
detecting `SUPERUSER` creation) cannot do so from this AST. Treat
`CreateRole`/`CreateUser`/`CreateGroup` as detecting "a role was created
with name X" — nothing more.

---

# Alter Nodes

## AlterRole — Partially Structured

### Verified Accessors (line 1897)

```rust
pub fn name_ref(&self) -> Option<NameRef>
pub fn path(&self) -> Option<Path>              // ALL IN DATABASE db form
pub fn rename_to(&self) -> Option<RenameTo>     // RENAME TO new_name
pub fn role_option_list(&self) -> Option<RoleOptionList>  // WITH option...
pub fn role_ref(&self) -> Option<RoleRef>       // target role
pub fn set_config_param(&self) -> Option<SetConfigParam>  // IN DATABASE db SET config
pub fn all_token(&self) -> Option<SyntaxToken>  // { name | ALL }
pub fn database_token(&self) -> Option<SyntaxToken>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn alter_token(&self) -> Option<SyntaxToken>
pub fn role_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
AlterRole =
  'alter' 'role' (RoleRef | 'all') ('in' 'database' DatabaseName)?
  (
    'rename' 'to' RoleRef
  | RoleOptionList
  | SetConfigParam
  ) ';'?
```

**Earlier draft claimed `AlterRole` was a confirmed black box with only the
role name extractable. This was incorrect** — verified at nodes.rs line 1897.
The operation type IS determinable:
- `rename_to()` present → `ALTER ROLE name RENAME TO new_name`
- `role_option_list()` present → `ALTER ROLE name WITH option...`
- `set_config_param()` present → `ALTER ROLE name IN DATABASE db SET config_param`
- `all_token()` present → `ALTER ROLE ALL ...` form

The `RoleOptionList` / `SetConfigParam` payloads are the same rich nodes
documented under `RoleOption` / `SetConfigParam`, so LOGIN, SUPERUSER,
PASSWORD, VALID UNTIL, config params, etc. are all extractable from
`ALTER ROLE`.

```rust
enum AlterRoleFact {
    Rename { from: RoleFact, to: String },         // from role_ref() + rename_to()
    SetOptions(Vec<RoleOptionFact>),               // from role_option_list()
    SetConfig { db: Option<String>, param: ... },  // from set_config_param()
    AllRoles(bool),                                // from all_token()
}
```

---

## AlterUser — CONFIRMED BLACK BOX

### Verified Accessors (line 2196)

```rust
pub fn role_ref(&self) -> Option<RoleRef>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn alter_token(&self) -> Option<SyntaxToken>
pub fn user_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
AlterUser =
  'alter' 'user' RoleRef ';'?
```

Identical shape and identical limitation to `AlterRole`.

---

## AlterGroup — STRUCTURED (unlike AlterRole/AlterUser)

### Verified Accessors (line 1024)

```rust
pub fn name_refs(&self) -> AstChildren<NameRef>
pub fn rename_to(&self) -> Option<RenameTo>
pub fn role_ref(&self) -> Option<RoleRef>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn add_token(&self) -> Option<SyntaxToken>
pub fn alter_token(&self) -> Option<SyntaxToken>
pub fn drop_token(&self) -> Option<SyntaxToken>
pub fn group_token(&self) -> Option<SyntaxToken>
pub fn user_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
AlterGroup =
  'alter' 'group' RoleRef
  (
  'add' 'user' (NameRef (',' NameRef)*)
  | 'drop' 'user' (NameRef (',' NameRef)*)
  | RenameTo
  ) ';'?
```

Three forms, all extractable:
- `add_token()` present → `ALTER GROUP g ADD USER user1, user2` — users
  being added to group, via `name_refs()`
- `drop_token()` present → `ALTER GROUP g DROP USER user1, user2` — users
  being removed from group, via `name_refs()`
- `rename_to()` present → group rename

Note that `role_ref()` is the group (target), while `name_refs()` are the
users being added/dropped. These are semantically different roles and must
not be confused.

```rust
enum AlterGroupFact {
    AddUsers { group: RoleFact, users: Vec<String> },
    DropUsers { group: RoleFact, users: Vec<String> },
    Rename { from: String, to: String },
}
```

---

# Drop Nodes

## DropRole

### Verified Accessors (line 7789)

```rust
pub fn if_exists(&self) -> Option<IfExists>
pub fn name_refs(&self) -> AstChildren<NameRef>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn drop_token(&self) -> Option<SyntaxToken>
pub fn role_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
DropRole =
  'drop' 'role' IfExists? (NameRef (',' NameRef)*) ';'?
```

Multi-name drop, no CASCADE/RESTRICT option. PostgreSQL itself enforces
that `DROP ROLE` fails if the role owns any objects or has any granted
privileges — not via a grammar option, but at execution time. The simulator
must check ownership in `LocalState` if it tracks object ownership at all.

---

## DropUser / DropGroup

Identical shapes to `DropRole` — only keyword tokens differ. All confirmed
via the established pattern; not re-documented here.

---

# SetRole

### Verified Accessors (line 16867)

```rust
pub fn role_ref(&self) -> Option<RoleRef>
pub fn semicolon_token(&self) -> Option<SyntaxToken>
pub fn local_token(&self) -> Option<SyntaxToken>
pub fn none_token(&self) -> Option<SyntaxToken>
pub fn reset_token(&self) -> Option<SyntaxToken>
pub fn role_token(&self) -> Option<SyntaxToken>
pub fn session_token(&self) -> Option<SyntaxToken>
pub fn set_token(&self) -> Option<SyntaxToken>
```

### Grammar Confirmation

```
SetRole =
  'set' ('session' | 'local')? 'role' (RoleRef | 'none')? ';'?
| 'reset' 'role' ';'?
```

Fully extractable — all three forms distinguishable:

```rust
enum SetRoleFact {
    Set {
        role: RoleFact,         // from role_ref()
        scope: RoleScope,       // Session | Local | Unspecified
    },
    SetNone {                   // RESET to original login role
        scope: RoleScope,
    },
    Reset,                      // RESET ROLE — same as SET ROLE NONE at session scope
}
```

**Relevance to search_path:** `SET ROLE` changes the effective role for the
current session — this affects which schemas appear in the search_path for
schema-qualified object lookups and which objects the session can access.
Like `SET search_path` (search_path.md), `SET ROLE` with `LOCAL` scope
reverts at transaction boundary. The simulator should record this in the
same `TransactionFrame` undo mechanism used for `SET LOCAL search_path`.

---

# Verified Findings Summary

## Confirmed Complete

- `CreateRole` / `CreateUser` / `CreateGroup`: fully resolved — name
  extractable, and `RoleOption` exposes a comprehensive accessor surface
  (see "RoleOptionList / RoleOption" section) covering LOGIN, SUPERUSER,
  CREATEDB, REPLICATION, BYPASSRLS, CONNECTION LIMIT, PASSWORD, VALID UNTIL,
  IN ROLE/IN GROUP/ROLE/ADMIN, SYSID via their respective `*_token()`
  accessors plus `literal()` / `role_ref_list()` for values.
- `AlterGroup`: fully resolved and structured, all 3 forms extractable
- `DropRole` / `DropUser` / `DropGroup`: fully resolved
- `SetRole`: fully resolved, all forms distinguishable

## Grammar-Confirmed Limitations

- `AlterUser`: confirmed black box — only role name extractable, no operation
  type or parameters. (Note: `AlterRole` is NOT a black box — see the
  "AlterRole — Partially Structured" section; it exposes `rename_to()`,
  `role_option_list()`, `set_config_param()`, `path()`, `all_token()`, and
  `database_token()`.)
- `RoleOption` NO-prefix handling: only the positive keyword token is exposed
  for each attribute (`INHERIT`, `LOGIN`, `SUPERUSER`, etc.); the negated
  forms (`NOLOGIN`, `NOSUPERUSER`, `NOINHERIT`, ...) are NOT separately
  tokenized — negation is inferred from the positive token's absence. No
  role attribute value beyond the literal/role_ref_list payloads is dropped.

## Key Architectural Findings

1. **`AlterUser` (and `AlterGroup`'s deprecated siblings) remains a black
   box**, but **`AlterRole` is partially structured** — conservative
   (tainted/manual-review) treatment is only warranted for `AlterUser`;
   `AlterRole` operation type and payloads are extractable.
2. **`RoleOption` exposes a full attribute surface** — LOGIN, SUPERUSER,
   CREATEDB, REPLICATION, BYPASSRLS, CONNECTION LIMIT, PASSWORD, VALID UNTIL,
   and IN ROLE/ROLE/ADMIN membership are all detectable via their `*_token()`
   accessors, enabling role-attribute safety analysis (e.g. detecting
   `SUPERUSER` creation, validating `NOLOGIN` service accounts). Only the
   NO-prefixed negations are absent as distinct tokens.
3. **`AlterGroup` is structurally richer than `AlterRole`/`AlterUser`**   despite being a deprecated legacy node, it actually exposes more
   extractable content than its modern equivalents.
4. **`SetRole` with `LOCAL` scope must integrate with `TransactionFrame`**
   the same way `SET LOCAL search_path` does — role context reverts at
   transaction boundary, affecting any object-resolution performed within
   that transaction frame.

## Grammar Cross-Check

All nodes cross-checked against postgresql.ungram in a single pass.
No discrepancies found.

---

# Remaining Open Questions

None identified in this pass.