@everystack/cli 0.4.100 → 0.4.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/package.json +3 -3
  3. package/src/cli/authz-baseline.ts +8 -4
  4. package/src/cli/authz-compile.ts +91 -14
  5. package/src/cli/authz-contract.ts +29 -14
  6. package/src/cli/authz-derive.ts +284 -30
  7. package/src/cli/authz-reconcile.ts +6 -4
  8. package/src/cli/commands/db-check.ts +40 -0
  9. package/src/cli/commands/db-generate.ts +10 -9
  10. package/src/cli/commands/db-plan.ts +32 -13
  11. package/src/cli/commands/db-pull.ts +155 -63
  12. package/src/cli/commands/db-reconcile.ts +22 -3
  13. package/src/cli/commands/db-sync.ts +6 -5
  14. package/src/cli/commands/db.ts +91 -8
  15. package/src/cli/commands/update.ts +3 -2
  16. package/src/cli/db-build.ts +1 -0
  17. package/src/cli/declared-derived.ts +3 -1
  18. package/src/cli/declared-diff.ts +5 -1
  19. package/src/cli/derived-apply.ts +116 -10
  20. package/src/cli/derived-grants.ts +4 -2
  21. package/src/cli/derived-introspect.ts +23 -3
  22. package/src/cli/derived-plan.ts +37 -1
  23. package/src/cli/derived-render.ts +33 -34
  24. package/src/cli/edge-plan.ts +92 -9
  25. package/src/cli/generated-artifacts.ts +22 -0
  26. package/src/cli/generated-names.ts +108 -0
  27. package/src/cli/host-url.ts +48 -27
  28. package/src/cli/index-lock.ts +65 -0
  29. package/src/cli/index.ts +5 -5
  30. package/src/cli/lifecycle-canon.ts +37 -0
  31. package/src/cli/lifecycle-derived.ts +787 -0
  32. package/src/cli/lifecycle-pull.ts +252 -0
  33. package/src/cli/lifecycle-verify.ts +238 -0
  34. package/src/cli/migration-compile.ts +11 -5
  35. package/src/cli/migration-generate.ts +24 -3
  36. package/src/cli/model-api.ts +9 -0
  37. package/src/cli/model-map.ts +255 -0
  38. package/src/cli/model-render.ts +232 -76
  39. package/src/cli/plain-output.ts +211 -10
  40. package/src/cli/runbook/model-section.ts +8 -5
  41. package/src/cli/schema-compile.ts +64 -18
  42. package/src/cli/schema-diff.ts +166 -31
  43. package/src/cli/schema-fingerprint.ts +40 -6
  44. package/src/cli/schema-introspect.ts +97 -4
  45. package/src/cli/schema-source.ts +40 -9
  46. package/src/cli/schema-swap.ts +3 -2
  47. package/src/cli/state-apply.ts +10 -4
  48. package/src/cli/ts-template.ts +52 -0
package/CHANGELOG.md CHANGED
@@ -4,6 +4,118 @@ All notable changes to `@everystack/cli` are documented here.
4
4
 
5
5
  Format based on [Keep a Changelog](https://keepachangelog.com/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ### Fixed
10
+
11
+ - export compileDeclaredState from @everystack/cli/model
12
+
13
+ ## [0.4.103] - 2026-10-23
14
+
15
+ ### BREAKING CHANGES
16
+
17
+ - lifecycle delete and restore hand the batch id to the caller
18
+ - lifecycle: { deleteWhen } refuses deleting a row its predicate does not allow
19
+ - refuse dependent + where on a lifecycle parent's hasMany
20
+ - can('read', { role, deleted: 'include' | 'only' }) on a lifecycle model
21
+ - deleteWhen spliced on its own lines; a deleted reader is not granted deleted_by
22
+
23
+ ### Added
24
+
25
+ - index the FK column of every lifecycle dependent
26
+ - db:plan warns when a CREATE INDEX will block writes to a live table
27
+
28
+ ## [0.4.102] - 2026-10-23
29
+
30
+ ### Added
31
+
32
+ - db:doctor reports lifecycle rows past the purge window
33
+ - generated lifecycle functions carry manifests
34
+ - db:pull verifies generated lifecycle objects
35
+
36
+ ### Fixed
37
+
38
+ - restore uses set-based updates, not a subtransaction per row
39
+ - drop a replaced primary key before altering its columns
40
+ - match quoted trigger functions by parsed identifiers
41
+ - db:pull renders the owner read pair as owner
42
+ - the verifier leaves an author's own restore overload alone
43
+ - allow author after-delete lifecycle triggers
44
+ - detect constraint lifecycle trigger replacements
45
+ - the verifier compares the declared delete rules with the live policies
46
+ - an invalid --purge-after fails cleanly
47
+ - check lifecycle restrict edges once, after the whole cascade
48
+ - reconcile drops a removed trigger before the function it executes
49
+ - a DELETE issued from inside a trigger on a lifecycle table is a soft delete
50
+ - pull-verify names the older CLI's restrict pair as stale and points to db:apply
51
+ - pull-verify drops only a whole pg_catalog qualifier from a trigger WHEN
52
+
53
+ ### Documentation
54
+
55
+ - state the lifecycle restrict contract, multi-row order and the lifecycle-column rule
56
+ - consumer triggers under a stamp or purge: qualified names, soft deletes, and the db:apply remedy for an older CLI's restrict pair
57
+ - an older CLI's restrict function may report a missing manifest instead of stale; both say db:apply
58
+
59
+ ## [0.4.101] - 2026-10-22
60
+
61
+ ### Added
62
+
63
+ - db:plan lists the functions, views and triggers db:apply will change
64
+ - update names the .sst files it read when it finds no HOST_URL
65
+ - external FK target, .references({ schema, table, column })
66
+ - F7a relation options; hasMany dependent sets the child FK ON DELETE
67
+ - relation display options and hasOne (F7b)
68
+ - db:generate writes the model map; db:check fails when it is stale (F8)
69
+ - the model map carries softDelete; the admin reads it instead of the preset's hand-written flag (A5)
70
+ - hasMany dependent 'cascade' is ON DELETE CASCADE on the child's foreign key (D1)
71
+ - lifecycle: true declares four generated columns (L1.1)
72
+ - lifecycle gets a reserved owner role and hides deleted rows from read roles (L1.2a)
73
+ - lifecycle triggers turn DELETE into a soft delete (L1.2b)
74
+ - lifecycle restore, for whoever may delete the row (L1.3)
75
+ - db:pull folds a lifecycle table back to lifecycle: true (L1.4)
76
+ - a lifecycle parent's delete soft-deletes its cascade children in the same batch (L2 + L6, P1.2)
77
+ - <t>_restore brings back the row's whole batch subtree on the root's authority (L3, P1.3)
78
+ - Restore on a lifecycle resource restores its batch and previews it first (P1.5)
79
+ - db:provision creates the reserved lifecycle role (P2.7)
80
+ - a NOT NULL column has no clear control (N6)
81
+
82
+ ### Fixed
83
+
84
+ - a disabled derived trigger is drift
85
+ - import a non-public FK target by its qualified name
86
+ - a plan that removes a function, view or trigger is not "Safe to apply"
87
+ - key the pulled set on qualified names
88
+ - db:pull --govern-roles round-trips custom-role policies and column grants (assoc F4)
89
+ - db:pull refuses a no-USING column policy and escapes live predicates (assoc F4 review)
90
+ - escape live text in db:pull's sql templates
91
+ - escape the defineSql fallback's DROP FUNCTION template
92
+ - db:pull refuses live names with no safe generated identifier or file name
93
+ - db:pull escapes live text in // comments and walls every write inside its directory
94
+ - a grant to a role named __proto__ is visible to db:plan, db:pull and the fingerprint
95
+ - db:pull never renders a policy that admits no row as an ability
96
+ - F3e review minors: catalog FK target, one target slot, derived refusal
97
+ - F3e re-review: fail closed on a changed FK target; unambiguous comment
98
+ - F7a review: derive dependent ON DELETE per set, never write a field spec
99
+ - F7b review: mark display relations in drizzle, withhold only where/limit
100
+ - F8 review: private SQL spellings, key collisions, T2 mock proof
101
+ - run state DDL under the canonical search_path; read non-public enums as qualified
102
+ - retype a column onto an enum through text, dropping the old default first
103
+ - the map publishes the soft-delete field key, not a boolean (A5 review)
104
+ - owned trigger functions deploy as a non-superuser; the lifecycle transition runs only on its own table (L1.2c)
105
+ - the lifecycle pull fold keeps an author's restore overload and governs the reserved role at adoption (L1.4 review)
106
+ - the lifecycle pull fold checks the reserved role's effective privileges and always names its unfolded grants (P1.0 review)
107
+ - db:plan changes a primary key, and refuses to mint what it cannot express (P2.8)
108
+
109
+ ### Changed
110
+
111
+ - fake timers for the two polling suites; generate the default cert once
112
+
113
+ ### Documentation
114
+
115
+ - name the one runtime-vs-generated Drizzle difference a parent dependent creates
116
+ - say which fingerprint inputs moved; mark the guard-only enum diff test
117
+ - state precisely which policies admit no row and that a column grant under one is revoked
118
+
7
119
  ## [0.4.100] - 2026-10-16
8
120
 
9
121
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.100",
3
+ "version": "0.4.103",
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>",
@@ -145,7 +145,7 @@
145
145
  "structured-headers": "1.0.1",
146
146
  "tsx": "4.21.0",
147
147
  "typescript": "5.9.3",
148
- "@everystack/model": "0.4.22"
148
+ "@everystack/model": "0.4.24"
149
149
  },
150
150
  "peerDependencies": {
151
151
  "@everystack/server": ">=0.4.0",
@@ -192,7 +192,7 @@
192
192
  "react": "19.2.0",
193
193
  "ts-jest": "29.4.9",
194
194
  "zod": "3.25.67",
195
- "@everystack/server": "0.4.35"
195
+ "@everystack/server": "0.4.39"
196
196
  },
197
197
  "scripts": {
198
198
  "test": "jest",
@@ -83,7 +83,9 @@ export function buildStageBaseline(
83
83
  exemptions: readonly GrantExemption[],
84
84
  meta: { observedAt: string; fingerprint: string; fpVersion?: number },
85
85
  ): BaselineStage {
86
- const grantees: Record<string, BaselineGrantee> = {};
86
+ // Null-prototype: a grantee is a live role name. On a plain object `grantees['__proto__'] ??=`
87
+ // reads Object.prototype, and the writes below would land ON Object.prototype.
88
+ const grantees: Record<string, BaselineGrantee> = Object.create(null);
87
89
  for (const e of exemptions) {
88
90
  const g = (grantees[e.grantee] ??= { privileges: [], tables: [], write: false });
89
91
  g.privileges = [...new Set([...g.privileges, ...e.privileges])].sort();
@@ -112,10 +114,10 @@ export function mergeBaseline(existing: AuthzBaseline | null, stage: string, ent
112
114
  * itself would make the review signal worthless.
113
115
  */
114
116
  export function renderBaseline(baseline: AuthzBaseline): string {
115
- const stages: Record<string, BaselineStage> = {};
117
+ const stages: Record<string, BaselineStage> = Object.create(null);
116
118
  for (const stage of Object.keys(baseline.stages).sort()) {
117
119
  const s = baseline.stages[stage];
118
- const grantees: Record<string, BaselineGrantee> = {};
120
+ const grantees: Record<string, BaselineGrantee> = Object.create(null);
119
121
  for (const g of Object.keys(s.grantees).sort()) {
120
122
  const { privileges, tables, write } = s.grantees[g];
121
123
  grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
@@ -182,7 +184,9 @@ export function checkAgainstBaseline(
182
184
 
183
185
  for (const grantee of Object.keys(observed).sort()) {
184
186
  const now = observed[grantee];
185
- const then = recorded[grantee];
187
+ // Own keys only: the recorded baseline is JSON.parse'd, so a missing `__proto__` entry would
188
+ // read Object.prototype — truthy — and wave an unbaselined role through.
189
+ const then = Object.hasOwn(recorded, grantee) ? recorded[grantee] : undefined;
186
190
  if (!then) {
187
191
  violations.push({
188
192
  grantee,
@@ -20,7 +20,7 @@
20
20
  * Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
21
21
  */
22
22
 
23
- import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
23
+ import { isColumnAbility, LIFECYCLE_OWNER_ROLE, type ModelDescriptor, type Ability } from '@everystack/model';
24
24
  import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract';
25
25
  import { parenthesizeOnce, isBareCall } from './authz-contract';
26
26
  import { normalizeDeparsedExpr } from './deparse-normal';
@@ -278,23 +278,42 @@ const granteeKey = (role: string): string => (isPseudoPublic(role) ? 'PUBLIC' :
278
278
  /** The policy-side spelling of a declared role — what `pg_policies` would report. */
279
279
  const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' : role);
280
280
 
281
+ /**
282
+ * Who deleted a row, in which batch and which transaction: the lifecycle columns a deleted reader
283
+ * never selects. Only a `manage` role sees them; `deletedAt` stays visible to every reader.
284
+ */
285
+ const DELETED_READ_HIDDEN = new Set(['deletedBy', 'deletedBatch', 'deletedXid']);
286
+
287
+ /**
288
+ * `can('read', { role, deleted })` with no `columns`: it reaches deleted rows, whose deleted_by,
289
+ * deleted_batch and deleted_xid are set, so it grants SELECT on every other column instead of the
290
+ * whole table. Grants are a union: defineModel refuses a `manage` for the same role, and the policy
291
+ * compile refuses a second read for it, so nothing re-grants the table.
292
+ */
293
+ const isDeletedReadOfEveryColumn = (a: Ability): boolean =>
294
+ a.action === 'read' && a.condition.deleted !== undefined && !isColumnAbility(a);
295
+
281
296
  /**
282
297
  * Column grants from column abilities. The action determines the PostgreSQL verb; the named role
283
298
  * (or authenticated for an owner-only ability) is the grantee. Field keys are snake-cased and
284
299
  * deduplicated, matching the introspected contract.
285
300
  */
286
- function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
287
- const out: Record<string, Record<string, Set<string>>> = {};
301
+ function compileColumnGrants(abilities: readonly Ability[], fields: readonly string[] = []): Record<string, Record<string, string[]>> | undefined {
302
+ // Null-prototype maps, as on the introspected side (assembleContract): a role is a key, and
303
+ // '__proto__' passes the role-name guard, so a plain object would lose it.
304
+ const out: Record<string, Record<string, Set<string>>> = Object.create(null);
305
+ const add = (role: string, verb: string, columns: readonly string[]): void => {
306
+ const cols = ((out[role] ??= Object.create(null) as Record<string, Set<string>>)[verb] ??= new Set<string>());
307
+ for (const column of columns) cols.add(toSnakeCase(column));
308
+ };
288
309
  for (const a of abilities) {
310
+ if (isDeletedReadOfEveryColumn(a)) add(granteeKey(a.condition.role!), 'SELECT', fields.filter((f) => !DELETED_READ_HIDDEN.has(f)));
289
311
  if (!isColumnAbility(a)) continue;
290
- const role = granteeKey(a.condition.role ?? 'authenticated');
291
- const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
292
- const cols = ((out[role] ??= {})[verb] ??= new Set<string>());
293
- for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
312
+ add(granteeKey(a.condition.role ?? 'authenticated'), a.action === 'read' ? 'SELECT' : 'UPDATE', a.condition.columns!);
294
313
  }
295
- const grants: Record<string, Record<string, string[]>> = {};
314
+ const grants: Record<string, Record<string, string[]>> = Object.create(null);
296
315
  for (const [role, verbs] of Object.entries(out)) {
297
- grants[role] = {};
316
+ grants[role] = Object.create(null);
298
317
  for (const [verb, columns] of Object.entries(verbs)) grants[role][verb] = [...columns].sort();
299
318
  }
300
319
  return Object.keys(grants).length ? grants : undefined;
@@ -438,6 +457,12 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
438
457
  ? viaPredicate(via)
439
458
  : null;
440
459
  const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
460
+ // A lifecycle model hides deleted rows from every read policy but the lifecycle role's own.
461
+ // Unlike softDelete there is no owner escape: the guard is AND-ed into each SELECT policy as
462
+ // compiled. A `manage` role's ALL policy is NOT guarded, on purpose: it sees deleted rows (to
463
+ // restore or purge them), exactly as it does under softDelete.
464
+ const lifecycleCol = model.lifecycle ? 'deleted_at' : null;
465
+ const lifecycleGuard = lifecycleCol ? softDeleteGuard(lifecycleCol) : null;
441
466
 
442
467
  // A "row-scoped" condition is either a direct owner or a transitive via.
443
468
  const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
@@ -533,7 +558,14 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
533
558
  const policy = (
534
559
  name: string, command: PolicyCommand, roles: string[],
535
560
  using: string | null, check: string | null,
561
+ deleted?: 'include' | 'only',
536
562
  ): void => {
563
+ // can('read', { deleted }): 'include' leaves the guard off this role's policy (its other
564
+ // restrictions stay); 'only' puts `(deleted_at IS NOT NULL)` in its place. Omitted: the guard.
565
+ if (lifecycleGuard && lifecycleCol && command === 'SELECT' && !roles.includes(LIFECYCLE_OWNER_ROLE) && deleted !== 'include') {
566
+ const state = deleted === 'only' ? `(${lifecycleCol} IS NOT NULL)` : lifecycleGuard;
567
+ using = andPredicates(state, using) ?? state;
568
+ }
537
569
  // Same normalization the live producer applies (authz-contract's `predicate`): a model
538
570
  // pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
539
571
  // and it must compare equal against a stage on another. One rule table, two producers.
@@ -650,6 +682,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
650
682
  rawPredicate(a.condition.sql ?? a.condition.where, where),
651
683
  ) ?? 'true',
652
684
  null,
685
+ a.condition.deleted,
653
686
  );
654
687
  }
655
688
 
@@ -823,6 +856,24 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
823
856
 
824
857
  policies.sort((a, b) => a.name.localeCompare(b.name));
825
858
 
859
+ // The lifecycle restore function inlines these DELETE rules and runs them as the lifecycle
860
+ // owner. Check the text as compiled, whatever produced it (via, ownerExpr, where, sql): a
861
+ // subquery would read with that role's privileges, and current_user/session_user would not
862
+ // name the caller. defineModel refuses the common spellings first with friendlier messages.
863
+ if (model.lifecycle) {
864
+ for (const p of policies) {
865
+ if (p.command !== 'DELETE' && p.command !== 'ALL') continue;
866
+ const offender = /\bselect\b|\bcurrent_user\b|\bsession_user\b/i.exec(p.using ?? '')?.[0];
867
+ if (!offender) continue;
868
+ throw new Error(
869
+ `${table}: policy ${p.name} (${p.command}) uses ${offender} on a lifecycle: true model. Restore runs this delete rule `
870
+ + `inside a SECURITY DEFINER function as the lifecycle owner, where a subquery reads with that role's privileges and `
871
+ + `current_user/session_user do not name the caller. Use a predicate on the row's own columns or the request claims `
872
+ + `(owner without ownerExpr, or a plain where).`,
873
+ );
874
+ }
875
+ }
876
+
826
877
  return {
827
878
  table,
828
879
  // The FORCE axiom: FORCE iff the write principal is subject to its own RLS
@@ -836,11 +887,35 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
836
887
  // security downgrade via version skew.
837
888
  rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
838
889
  grants: compileGrants(model.abilities, model.privileges),
839
- ...(compileColumnGrants(model.abilities) ? { columnGrants: compileColumnGrants(model.abilities) } : {}),
890
+ ...(compileColumnGrants(model.abilities, Object.keys(model.fields)) ? { columnGrants: compileColumnGrants(model.abilities, Object.keys(model.fields)) } : {}),
840
891
  policies,
841
892
  };
842
893
  }
843
894
 
895
+ /**
896
+ * The table's DELETE rule per role: role -> the USING of every compiled DELETE or ALL policy
897
+ * naming that role, OR-ed (each parenthesized) when there are several. Read back from
898
+ * {@link compileTableContract}, so it is the policy rule by construction, never a second
899
+ * derivation. The lifecycle role is left out: it is the machinery, not an author's role.
900
+ * A policy with no USING admits nothing here, so it is skipped (fail closed).
901
+ */
902
+ export function deleteRulePredicates(model: ModelDescriptor, opts: CompileOptions = {}): Record<string, string> {
903
+ const byRole = new Map<string, string[]>();
904
+ for (const p of compileTableContract(model, opts).policies) {
905
+ if ((p.command !== 'DELETE' && p.command !== 'ALL') || p.using == null) continue;
906
+ for (const role of p.roles) {
907
+ if (role === LIFECYCLE_OWNER_ROLE) continue;
908
+ byRole.set(role, [...(byRole.get(role) ?? []), p.using]);
909
+ }
910
+ }
911
+ const out: Record<string, string> = {};
912
+ for (const role of [...byRole.keys()].sort()) {
913
+ const preds = byRole.get(role)!;
914
+ out[role] = preds.length === 1 ? preds[0] : preds.map((p) => `(${p})`).join(' OR ');
915
+ }
916
+ return out;
917
+ }
918
+
844
919
  /** The four DML privileges, sorted — the expansion of a `manage` ability. */
845
920
  const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
846
921
 
@@ -885,14 +960,16 @@ function compileGrants(
885
960
  abilities: readonly Ability[],
886
961
  privileges: Record<string, readonly string[]> = {},
887
962
  ): Record<string, string[]> {
888
- const grants: Record<string, Set<string>> = {};
963
+ // Null-prototype, for the same reason as compileColumnGrants.
964
+ const grants: Record<string, Set<string>> = Object.create(null);
889
965
  const add = (role: string, verbs: readonly string[]): void => {
890
966
  const set = (grants[role] ??= new Set<string>());
891
967
  for (const v of verbs) set.add(v);
892
968
  };
893
969
  for (const a of abilities) {
894
- // A column ability grants only its listed columns, never a table-level privilege.
895
- if (isColumnAbility(a)) continue;
970
+ // A column ability grants only its listed columns, never a table-level privilege; nor does
971
+ // a deleted read (compileColumnGrants grants it every column but the three it may not see).
972
+ if (isColumnAbility(a) || isDeletedReadOfEveryColumn(a)) continue;
896
973
  const verbs = verbsFor(a.action);
897
974
  if (a.condition.role) {
898
975
  add(granteeKey(a.condition.role), verbs);
@@ -907,7 +984,7 @@ function compileGrants(
907
984
  for (const [role, privs] of Object.entries(privileges)) {
908
985
  if (privs.length) add(granteeKey(role), privs);
909
986
  }
910
- const out: Record<string, string[]> = {};
987
+ const out: Record<string, string[]> = Object.create(null);
911
988
  for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
912
989
  return out;
913
990
  }
@@ -102,6 +102,12 @@ export interface PolicyContract {
102
102
  using: string | null;
103
103
  /** `WITH CHECK` predicate (deparsed), or null when absent. */
104
104
  check: string | null;
105
+ /**
106
+ * Set only by db:pull's lifecycle fold, never by the catalog read or the compiler: this role's
107
+ * read policy on a lifecycle table carries no `(deleted_at IS NULL)` guard ('include') or carries
108
+ * `(deleted_at IS NOT NULL)` instead ('only'), so it renders as can('read', { role, deleted }).
109
+ */
110
+ deleted?: 'include' | 'only';
105
111
  }
106
112
 
107
113
  /**
@@ -713,7 +719,7 @@ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): Sequenc
713
719
  for (const row of rows) {
714
720
  const entry = bySequence.get(row.sequence)
715
721
  ?? {
716
- sequence: row.sequence, table: row.table, grants: {} as Record<string, string[]>,
722
+ sequence: row.sequence, table: row.table, grants: Object.create(null) as Record<string, string[]>,
717
723
  // Omitted rather than `false` on a serial sequence, so a contract read before this
718
724
  // field existed and one read after compare structurally equal.
719
725
  ...(truthy(row.is_identity) ? { identity: true } : {}),
@@ -766,15 +772,15 @@ export type DefaultAclScope = Record<string, Record<string, string[]>>;
766
772
 
767
773
  /** Fold the per-privilege rows into { tables, sequences }, each schema → grantee → privileges. */
768
774
  export function assembleDefaultAcls(rows: readonly DefaultAclRow[]): { tables: DefaultAclScope; sequences: DefaultAclScope } {
769
- const tables: DefaultAclScope = {};
770
- const sequences: DefaultAclScope = {};
775
+ const tables: DefaultAclScope = Object.create(null);
776
+ const sequences: DefaultAclScope = Object.create(null);
771
777
  for (const row of rows) {
772
778
  if (row.grantee == null || row.privilege == null) continue;
773
779
  const scope = String(row.objtype) === 'S' ? sequences : String(row.objtype) === 'r' ? tables : null;
774
780
  if (!scope) continue;
775
781
  const schema = String(row.schema ?? '');
776
782
  const grantee = String(row.grantee);
777
- const byGrantee = scope[schema] ?? (scope[schema] = {});
783
+ const byGrantee = scope[schema] ?? (scope[schema] = Object.create(null));
778
784
  byGrantee[grantee] = [...new Set([...(byGrantee[grantee] ?? []), String(row.privilege)])].sort();
779
785
  }
780
786
  return { tables, sequences };
@@ -796,8 +802,8 @@ export function parseSchemaAcl(text: string | null | undefined): Record<string,
796
802
  const s = String(text).trim();
797
803
  if (!s.startsWith('{') || !s.endsWith('}')) return null;
798
804
  const body = s.slice(1, -1);
799
- if (body.trim() === '') return {};
800
- const out: Record<string, string[]> = {};
805
+ if (body.trim() === '') return Object.create(null);
806
+ const out: Record<string, string[]> = Object.create(null);
801
807
  // Split items at top-level commas — a quoted region may contain commas.
802
808
  const items: string[] = [];
803
809
  let start = 0;
@@ -986,13 +992,19 @@ export interface ContractRows {
986
992
  * empty allowlist a diff would never flag. Only SECURITY DEFINER functions are
987
993
  * recorded: an INVOKER function runs as the caller under RLS and is not an
988
994
  * authorization decision point; a SECDEF function runs above RLS and is.
995
+ *
996
+ * Every map keyed by a live name (role, schema) is `Object.create(null)`. On a plain object
997
+ * `grants['__proto__'] = x` sets the prototype and adds no key, and PostgreSQL allows a quoted
998
+ * role named "__proto__": its grants vanished from this read, so the plan, the fingerprint and
999
+ * the pull could not see them. A null-prototype object serializes and iterates exactly like a
1000
+ * plain one, so an ordinary database reads byte-identically.
989
1001
  */
990
1002
  export function assembleContract(rows: ContractRows): AuthzContract {
991
1003
  const tables = new Map<string, TableContract>();
992
1004
  for (const row of rows.rls) {
993
1005
  if (IGNORED_SCHEMAS.has(row.schema)) continue;
994
1006
  const { table, rls } = rlsRowToDescriptor(row);
995
- tables.set(table, { table, rls, grants: {}, policies: [] });
1007
+ tables.set(table, { table, rls, grants: Object.create(null), policies: [] });
996
1008
  }
997
1009
 
998
1010
  // A grant or policy on a table the RLS enumerator didn't list (e.g. a view, excluded
@@ -1011,8 +1023,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
1011
1023
  const { table, grantee, privilege, column } = columnGrantRowToDescriptor(row);
1012
1024
  const t = tables.get(table);
1013
1025
  if (!t) continue;
1014
- const byGrantee = (t.columnGrants ??= {});
1015
- const byPriv = (byGrantee[grantee] ??= {});
1026
+ const byGrantee = (t.columnGrants ??= Object.create(null) as Record<string, Record<string, string[]>>);
1027
+ const byPriv = (byGrantee[grantee] ??= Object.create(null) as Record<string, string[]>);
1016
1028
  (byPriv[privilege] ??= []).push(column);
1017
1029
  }
1018
1030
 
@@ -1025,7 +1037,7 @@ export function assembleContract(rows: ContractRows): AuthzContract {
1025
1037
  const { table, role, privileges } = effectivePrivilegeRowToDescriptor(row);
1026
1038
  const t = tables.get(table);
1027
1039
  if (!t || privileges.length === 0) continue;
1028
- (t.effectivePrivileges ??= {})[role] = [...privileges].sort();
1040
+ (t.effectivePrivileges ??= Object.create(null) as Record<string, string[]>)[role] = [...privileges].sort();
1029
1041
  }
1030
1042
  }
1031
1043
 
@@ -1053,10 +1065,10 @@ export function assembleContract(rows: ContractRows): AuthzContract {
1053
1065
  // is distinguishable from "we never looked".
1054
1066
  let schemaAcls: Record<string, Record<string, string[]>> | undefined;
1055
1067
  if (rows.schemaAcls) {
1056
- schemaAcls = {};
1068
+ schemaAcls = Object.create(null) as Record<string, Record<string, string[]>>;
1057
1069
  for (const row of rows.schemaAcls) {
1058
1070
  if (IGNORED_SCHEMAS.has(row.schema)) continue;
1059
- schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? {};
1071
+ schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? Object.create(null);
1060
1072
  }
1061
1073
  }
1062
1074
 
@@ -1212,9 +1224,12 @@ export function compareContracts(declared: AuthzContract, live: AuthzContract):
1212
1224
 
1213
1225
  function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
1214
1226
  const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l.grants)]);
1227
+ // Own keys only: a declared contract loaded with JSON.parse is a plain object, where a
1228
+ // grantee the file lacks (`__proto__`, `constructor`) would read an Object.prototype member.
1229
+ const held = (grants: Record<string, string[]>, g: string): string[] => (Object.hasOwn(grants, g) ? grants[g] : []);
1215
1230
  for (const g of grantees) {
1216
- const dp = (d.grants[g] ?? []).join(',');
1217
- const lp = (l.grants[g] ?? []).join(',');
1231
+ const dp = held(d.grants, g).join(',');
1232
+ const lp = held(l.grants, g).join(',');
1218
1233
  if (dp !== lp) {
1219
1234
  out.push({ subject: table, kind: 'grant', detail: `grant to ${g}: declared [${dp}], live [${lp}]` });
1220
1235
  }