@everystack/cli 0.4.55 → 0.4.56

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.
@@ -37,6 +37,33 @@ export interface LiveObject {
37
37
  comment?: string;
38
38
  /** sha256 over normalized definition + indexes + comment. */
39
39
  defHash: string;
40
+ /**
41
+ * Functions, views and matviews: the owning role.
42
+ *
43
+ * Read because the grant differ CANNOT be correct without it. Owner-implicit privileges are
44
+ * excluded from the ACL read (`a.grantee <> p.proowner` / `<> c.relowner`), which is right —
45
+ * but it means the SET of non-owner grants shifts when the owner changes, so the same object
46
+ * under two owners yields two different privilege sets and the differ mints statements to
47
+ * reconcile a difference that is purely ownership.
48
+ *
49
+ * A15 — and for views it is a privilege boundary in its own right. A `security_invoker =
50
+ * false` view executes with its OWNER's rights, exactly as a SECURITY DEFINER function does.
51
+ * Undeclared, the owner is whoever ran the build: an `admin`-owned sealed-slice view rebuilds
52
+ * as builder-owned, `admin` can no longer read through it, and every gate stays green.
53
+ */
54
+ owner?: string;
55
+ /**
56
+ * Is `owner` a SUPERUSER (or an rds_superuser member — RDS has no true superuser)? Half of
57
+ * the discriminator db:pull uses to decide whether the observed owner is a CHOSEN principal
58
+ * or the ambient build credential. Absent means not measured.
59
+ */
60
+ ownerIsSuperuser?: boolean;
61
+ /**
62
+ * Is `owner` the role this introspection is CONNECTED AS? The other half of the
63
+ * discriminator. A normalized non-superuser migrator owns everything it creates by default,
64
+ * so "superuser" alone would miss it. Absent means not measured.
65
+ */
66
+ ownerIsConnectedRole?: boolean;
40
67
  /** Matview only: whether it holds data. */
41
68
  populated?: boolean;
42
69
  /** Matview only: pg_total_relation_size — the rebuild-cost signal. */
@@ -128,7 +155,22 @@ SELECT
128
155
  EXISTS (
129
156
  SELECT 1 FROM unnest(COALESCE(c.reloptions, '{}'::text[])) o
130
157
  WHERE o IN ('security_invoker=true', 'security_invoker=on')
131
- ) AS security_invoker
158
+ ) AS security_invoker,
159
+ -- A15. The same three fields the function read carries, for the same reason: a
160
+ -- security_invoker = false view executes with its OWNER's rights, so its owner is a
161
+ -- privilege boundary exactly as a SECURITY DEFINER function's is. The two discriminators
162
+ -- let db:pull tell a CHOSEN owner from the ambient build credential.
163
+ pg_get_userbyid(c.relowner) AS owner,
164
+ COALESCE(
165
+ (SELECT r.rolsuper
166
+ OR EXISTS (
167
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
168
+ WHERE m.member = c.relowner AND g.rolname = 'rds_superuser'
169
+ )
170
+ FROM pg_roles r WHERE r.oid = c.relowner),
171
+ false
172
+ ) AS owner_is_superuser,
173
+ (pg_get_userbyid(c.relowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role
132
174
  FROM pg_class c
133
175
  JOIN pg_namespace n ON n.oid = c.relnamespace
134
176
  WHERE c.relkind IN ('v', 'm')
@@ -171,6 +213,25 @@ SELECT
171
213
  l.lanname AS language,
172
214
  p.prosecdef AS secdef,
173
215
  p.provolatile AS volatility,
216
+ pg_get_userbyid(p.proowner) AS owner,
217
+ -- The AMBIENT-BUILD-CREDENTIAL discriminator, two clauses and no more. db:pull renders
218
+ -- the owner only when the observed owner is neither of these — rendering the ambient
219
+ -- credential would BLESS an accident, turning "whoever happened to run the migration" into
220
+ -- fingerprinted, reproducible design. When it IS ambient the renderer emits a FIXME instead:
221
+ -- the defect stays visible without being ratified.
222
+ --
223
+ -- Superuser, matching security-catalog's rule (RDS has no true superuser, so rds_superuser
224
+ -- membership counts).
225
+ (
226
+ SELECT r.rolsuper OR EXISTS (
227
+ SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
228
+ WHERE m.member = p.proowner AND g.rolname = 'rds_superuser'
229
+ )
230
+ FROM pg_roles r WHERE r.oid = p.proowner
231
+ ) AS owner_is_superuser,
232
+ -- ...or the role we are connected as. A normalized, deliberately non-superuser migrator owns
233
+ -- every object it creates, so the superuser clause alone would miss the commonest case.
234
+ (pg_get_userbyid(p.proowner) IN (CURRENT_USER, SESSION_USER)) AS owner_is_connected_role,
174
235
  (SELECT split_part(cfg, '=', 2) FROM unnest(COALESCE(p.proconfig, '{}'::text[])) cfg
175
236
  WHERE cfg LIKE 'search_path=%' LIMIT 1) AS search_path,
176
237
  p.prosrc AS src
@@ -458,6 +519,12 @@ export interface RelationRow {
458
519
  rows: unknown;
459
520
  /** security_invoker reloption (views). Optional — pre-B5 fixtures omit it. */
460
521
  security_invoker?: unknown;
522
+ /** `pg_get_userbyid(relowner)` — see LiveObject.owner. Optional: pre-A15 fixtures omit it. */
523
+ owner?: unknown;
524
+ /** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
525
+ owner_is_superuser?: unknown;
526
+ /** See LiveObject.ownerIsConnectedRole. Optional: absent means not measured. */
527
+ owner_is_connected_role?: unknown;
461
528
  }
462
529
 
463
530
  export interface FunctionRow {
@@ -475,6 +542,12 @@ export interface FunctionRow {
475
542
  secdef?: unknown;
476
543
  volatility?: unknown;
477
544
  search_path?: unknown;
545
+ /** `pg_get_userbyid(proowner)` — see LiveObject.owner. Optional: pre-owner fixtures omit it. */
546
+ owner?: unknown;
547
+ /** See LiveObject.ownerIsSuperuser. Optional: absent means not measured. */
548
+ owner_is_superuser?: unknown;
549
+ /** See LiveObject.ownerIsConnectedRole. Optional: absent means not measured. */
550
+ owner_is_connected_role?: unknown;
478
551
  src?: unknown;
479
552
  }
480
553
 
@@ -544,6 +617,19 @@ export interface DerivedRows {
544
617
  grants?: GrantAclRow[];
545
618
  }
546
619
 
620
+ /** Coerce a Postgres boolean as any driver spells it. `undefined` means NOT MEASURED —
621
+ * never conflated with a measured `false`. NULL is not-measured too: the only way these
622
+ * sub-selects go NULL is the role vanishing between reads, and guessing there would be
623
+ * exactly the fabrication this feature exists to stop. */
624
+ function pgBool(v: unknown): boolean | undefined {
625
+ if (v === undefined || v === null) return undefined;
626
+ if (typeof v === 'boolean') return v;
627
+ const s = String(v).toLowerCase();
628
+ if (s === 't' || s === 'true') return true;
629
+ if (s === 'f' || s === 'false') return false;
630
+ return undefined;
631
+ }
632
+
547
633
  /** Coerce a Postgres text[] (a JS array, or the `{a,b}` text form some drivers return) to string[]. */
548
634
  function pgTextArray(v: unknown): string[] {
549
635
  if (Array.isArray(v)) return v.map(String);
@@ -597,6 +683,12 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
597
683
  definition, indexes,
598
684
  ...(comment !== undefined ? { comment } : {}),
599
685
  defHash: computeDefHash(definition, indexes, comment),
686
+ // A15 — same three fields, same meaning, as the function branch below. Absent (not
687
+ // measured) and false (measured, not ambient) stay DIFFERENT, so a pre-A15 fixture
688
+ // keeps behaving exactly as it did.
689
+ ...(row.owner != null ? { owner: String(row.owner) } : {}),
690
+ ...(pgBool(row.owner_is_superuser) !== undefined ? { ownerIsSuperuser: pgBool(row.owner_is_superuser) } : {}),
691
+ ...(pgBool(row.owner_is_connected_role) !== undefined ? { ownerIsConnectedRole: pgBool(row.owner_is_connected_role) } : {}),
600
692
  ...grantsFor('r', identity),
601
693
  };
602
694
  if (kind === 'materialized view') {
@@ -625,6 +717,11 @@ export function assembleDerivedCatalog(rows: DerivedRows): DerivedCatalog {
625
717
  definition, indexes: [],
626
718
  ...(comment !== undefined ? { comment } : {}),
627
719
  defHash: computeDefHash(definition, [], comment),
720
+ ...(row.owner != null ? { owner: String(row.owner) } : {}),
721
+ // Absent (not measured) and false (measured, not ambient) are DIFFERENT — a pre-owner
722
+ // fixture must keep behaving exactly as it did, so undefined never becomes false here.
723
+ ...(pgBool(row.owner_is_superuser) !== undefined ? { ownerIsSuperuser: pgBool(row.owner_is_superuser) } : {}),
724
+ ...(pgBool(row.owner_is_connected_role) !== undefined ? { ownerIsConnectedRole: pgBool(row.owner_is_connected_role) } : {}),
628
725
  ...grantsFor('f', identity),
629
726
  ...(row.src !== undefined ? {
630
727
  fn: {
@@ -233,6 +233,62 @@ export function findPublicExecutableSecdef(derived: readonly DerivedDescriptor[]
233
233
  return warnings;
234
234
  }
235
235
 
236
+ // ---------------------------------------------------------------------------
237
+ // Advisory: SECURITY DEFINER with no pin (db:check warn) — A3's linter half
238
+ // ---------------------------------------------------------------------------
239
+
240
+ export interface UnpinnedDefinerFinding extends DerivedAuthzGap {
241
+ /** The schemas the DECLARATION can justify, `pg_catalog` first. A floor to review — never applied. */
242
+ suggested: string[];
243
+ }
244
+
245
+ /**
246
+ * Every SECURITY DEFINER function that declares `searchPath: 'unpinned'`, with a SUGGESTED pin.
247
+ *
248
+ * This is the half of A3 that keeps the honesty from becoming permission. `db:pull` no longer
249
+ * invents `['pg_catalog']` for a function whose live `proconfig` is empty — inventing it was an
250
+ * outage that passed every gate, and fabrication corrodes fingerprint, plan and apply, all of
251
+ * which rest on the model telling the truth. So the parser records what is there and the
252
+ * OPINION lives here: an unpinned definer function is a real latent vulnerability, and it gets
253
+ * named on every run until someone decides about it.
254
+ *
255
+ * SUGGEST, never apply. The suggestion is derived from the DECLARED structure — the function's
256
+ * own schema plus the schemas of its `dependsOn` — and not from reading the body. Body-derived
257
+ * pins are inventing with extra steps: dynamic SQL, operators and casts defeat the parse, and a
258
+ * pin that is wrong in the tool's favour is the same failure mode with better intentions. The
259
+ * suggestion is a floor. The author reviews it and lands it themselves.
260
+ *
261
+ * WARN, not fail. On an adopted database this is a faithful report of what is already true, and
262
+ * failing CI on a faithful pull would make `db:pull` unusable on exactly the schemas it exists
263
+ * for. Same call, and the same reasoning, as findPublicExecutableSecdef above.
264
+ */
265
+ export function findUnpinnedDefiners(derived: readonly DerivedDescriptor[]): UnpinnedDefinerFinding[] {
266
+ const findings: UnpinnedDefinerFinding[] = [];
267
+ for (const d of derived) {
268
+ if (d.kind !== 'function' || d.security !== 'definer' || d.searchPath !== 'unpinned') continue;
269
+ const identity = identityOf(d);
270
+ const schemas = new Set<string>([parseQualified(d.name).schema]);
271
+ for (const ref of d.dependsOn ?? []) {
272
+ schemas.add(isModelRef(ref) ? (ref.schema ?? 'public') : parseQualified(ref.name).schema);
273
+ }
274
+ // pg_catalog FIRST and always: it is what stops a caller shadowing a built-in the body
275
+ // calls unqualified, which is the whole attack this pin defends against.
276
+ const suggested = ['pg_catalog', ...[...schemas].filter((s) => s !== 'pg_catalog').sort()];
277
+ findings.push({
278
+ identity,
279
+ suggested,
280
+ message:
281
+ `${identity} is SECURITY DEFINER with NO search_path — declared 'unpinned', which is the truth about the `
282
+ + `database, not an approval. It runs with its owner's rights, and a caller controls the search_path, so an `
283
+ + `unqualified name in the body can be resolved to an object the caller planted. SUGGESTED pin from what the `
284
+ + `declaration references: searchPath: [${suggested.map((s) => `'${s}'`).join(', ')}]. That is a FLOOR derived `
285
+ + `from dependsOn, not from reading the body — dynamic SQL, operators and casts are invisible to it, so review `
286
+ + `it, extend it, and land it yourself. Nothing here is ever applied for you.`,
287
+ });
288
+ }
289
+ return findings;
290
+ }
291
+
236
292
  // ---------------------------------------------------------------------------
237
293
  // Gate: matview snapshot over row-scoped sources (db:check warn)
238
294
  // ---------------------------------------------------------------------------
@@ -233,6 +233,30 @@ export function planReconcile(
233
233
  const sqlDrops = new Map<string, { dropSql: string; reason: string }>();
234
234
  const extraWarnings: string[] = [];
235
235
 
236
+ /**
237
+ * B2 — the declared `SET search_path` compared against the LIVE one, structurally.
238
+ *
239
+ * Every other comparison in this table is a hash against a RECORDED hash, which is why the
240
+ * pin could differ in silence: `--baseline` records the declared source hash beside the live
241
+ * def hash without ever comparing the two, so a model claiming `search_path=pg_catalog` about
242
+ * a database with no pin reads as up to date forever. Measured — `db:reconcile --check`
243
+ * returned zero actions and zero drift on exactly that state.
244
+ *
245
+ * This is the one comparison that reads both sides at once. Null when they agree, or when
246
+ * either side was not measured (a parser-era object with no declared pin, a pre-B5 catalog
247
+ * read with no structured fields) — absent evidence is not evidence.
248
+ */
249
+ const pinMismatch = (src: SourceObject, liveObj: LiveObject): string | null => {
250
+ if (src.kind !== 'function' || src.searchPath === undefined || !liveObj.fn) return null;
251
+ const tokens = (v: string | undefined): string[] =>
252
+ (v ?? '').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
253
+ const declared = src.searchPath === 'unpinned' ? [] : src.searchPath.map((s) => s.trim());
254
+ const livePin = tokens(liveObj.fn.searchPath);
255
+ if (declared.length === livePin.length && declared.every((s, i) => s === livePin[i])) return null;
256
+ const say = (p: string[]): string => (p.length ? `search_path = ${p.join(', ')}` : 'no search_path');
257
+ return `declared ${say(declared)}, live has ${say(livePin)} — replaced from source`;
258
+ };
259
+
236
260
  for (const src of source.objects) {
237
261
  // --only restricts the primary decisions to the named identities; the rest are left
238
262
  // untouched (they are not skips — they were never in scope, so they get no plan entry).
@@ -271,9 +295,16 @@ export function planReconcile(
271
295
  // (live deparse), so the reconciler can't verify live == source. --baseline trusts it,
272
296
  // --rebuild guarantees it by rebuilding from source (relations through the dependency graph
273
297
  // below; functions replace in place), and the default flags it for a decision.
298
+ const pin = pinMismatch(src, liveObj);
274
299
  if (options.rebuild) {
275
300
  if (isRelation(src.kind)) rebuild.set(src.identity, 'rebuilt from source (first contact, --rebuild)');
276
301
  else fnReplace.set(src.identity, 'replaced from source (first contact, --rebuild)');
302
+ } else if (pin) {
303
+ // --baseline asserts "live IS the source, trust me". Here we can PROVE it is not, so
304
+ // recording the assertion would bless the difference permanently — nothing downstream
305
+ // ever compares these two again. Converge instead, on every path: an object whose pin
306
+ // is measurably wrong is not unverifiable, it is verifiably different.
307
+ fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
277
308
  } else if (options.baseline) {
278
309
  baseline.push(src.identity);
279
310
  } else {
@@ -341,6 +372,15 @@ export function planReconcile(
341
372
  else fnReplace.set(src.identity, 'source changed');
342
373
  continue;
343
374
  }
375
+ // Self-consistent provenance is not proof. A --baseline taken before B2, or a mistaken
376
+ // --rebaseline, records the declared source hash beside the live def hash without ever
377
+ // comparing them — so a declared pin the database does not have reads as up to date on
378
+ // every run. The structural read is what breaks that loop; it self-heals in one apply.
379
+ const pin = pinMismatch(src, liveObj);
380
+ if (pin) {
381
+ fnReplace.set(src.identity, `search_path differs from live: ${pin}`);
382
+ continue;
383
+ }
344
384
  // Up-to-date (src + live both match provenance). If provenance predates body_hash, record it
345
385
  // once — a record-only backfill that ARMS the authz-only fast path for a future grant change.
346
386
  // Converges: after this run the row has body_hash, so it skips cleanly next time.
@@ -430,8 +470,15 @@ export function planReconcile(
430
470
  );
431
471
  } else {
432
472
  const target = parsed.target ?? (src.kind === 'function' ? `FUNCTION ${src.identity}` : src.identity);
433
- const statements = diffObjectGrants(target, parsed.grants, liveObj.grants);
473
+ // Functions carry ownership context; relations do not (relacl's NULL means owner-only
474
+ // and aclexplode(NULL) is empty, so a relation's grant set does not shift with its owner).
475
+ const owners = src.kind === 'function'
476
+ ? { ...(src.owner !== undefined ? { declared: src.owner } : {}), ...(liveObj.owner !== undefined ? { live: liveObj.owner } : {}) }
477
+ : undefined;
478
+ const { statements, suppressed } = diffObjectGrants(target, parsed.grants, liveObj.grants, owners);
434
479
  if (statements.length > 0) regrants.push({ identity: src.identity, statements });
480
+ // Never silent: a withheld revoke is reported every run until the owner is declared.
481
+ for (const note of suppressed) extraWarnings.push(note);
435
482
  }
436
483
  }
437
484
 
@@ -208,6 +208,110 @@ function parseArgs(args: string): ParsedArg[] | null {
208
208
  return out;
209
209
  }
210
210
 
211
+ /**
212
+ * The role-name shape `defineFunction` accepts (packages/model derived.ts OWNER_ROLE_NAME).
213
+ * The owner is interpolated into `SET ROLE` / `ALTER … OWNER TO`, so anything else is refused
214
+ * at declaration — which means rendering it here would emit source that throws on import.
215
+ */
216
+ const RENDERABLE_ROLE = /^[a-z_][a-z0-9_]*$/;
217
+
218
+ /**
219
+ * An object that RUNS AS ITS OWNER, and the sentence that says so.
220
+ *
221
+ * Two shapes qualify, and they are the same hazard one object apart: a `SECURITY DEFINER`
222
+ * function, and a view with `security_invoker = false`. For both, an owner nobody chose is a
223
+ * live privilege surface, so the FIXME is loud and the warning reaches stderr.
224
+ */
225
+ export interface ElevatedRights {
226
+ /** How the object declares it — `'SECURITY DEFINER'`, `'not securityInvoker'`. */
227
+ posture: string;
228
+ /** What runs as the owner — `'Every call'`, `'Every read through it'`. */
229
+ what: string;
230
+ }
231
+
232
+ export const SECURITY_DEFINER_RIGHTS: ElevatedRights = { posture: 'SECURITY DEFINER', what: 'Every call' };
233
+ export const DEFINER_VIEW_RIGHTS: ElevatedRights = { posture: 'not securityInvoker', what: 'Every read through it' };
234
+ export const MATVIEW_RIGHTS: ElevatedRights = { posture: 'a materialized view', what: 'Every REFRESH' };
235
+
236
+ /**
237
+ * What db:pull says about an object's OBSERVED owner: render it, or flag it.
238
+ *
239
+ * Ownership is a privilege boundary, so a pulled model that stays silent about it reproduces
240
+ * the wrong database. But rendering EVERY observed owner is worse than saying nothing: on a
241
+ * brownfield database most objects are owned by whoever ran the migration, and writing
242
+ * `owner: 'postgres'` into the models would BLESS that accident — converting an unreviewed
243
+ * superuser-owned SECURITY DEFINER function into fingerprinted, reproducible design.
244
+ *
245
+ * So the discriminator is the AMBIENT BUILD CREDENTIAL, two clauses and no more: the owner is
246
+ * a superuser, or it is the role this pull connected as. Neither → a deliberately chosen
247
+ * principal → render it. Either → a FIXME naming the observed owner, loud when the object runs
248
+ * as its owner. The defect stays visible without being ratified.
249
+ *
250
+ * This also settles the portability objection: the roles whose names legitimately differ
251
+ * between a dev machine, a deployed master and a normalized operator ARE the operator tier —
252
+ * exactly the tier this declines to render.
253
+ *
254
+ * A15 — ONE discriminator for functions AND relations. The rule is identical and the reason is
255
+ * identical; two copies of it is how the view half ends up subtly different from the function
256
+ * half, which is the defect this whole plan keeps finding.
257
+ */
258
+ export function renderedOwner(
259
+ o: Pick<LiveObject, 'identity' | 'owner' | 'ownerIsSuperuser' | 'ownerIsConnectedRole'>,
260
+ elevated: ElevatedRights | null,
261
+ ): { prop?: string; fixme?: string; warning?: string } {
262
+ const owner = o.owner;
263
+ // Not measured. A pre-owner catalog read carries no owner at all, and absent evidence is
264
+ // not evidence of a problem — say nothing, exactly as before.
265
+ if (owner === undefined) return {};
266
+
267
+ const superuser = o.ownerIsSuperuser;
268
+ const connected = o.ownerIsConnectedRole;
269
+ const ambient: string[] = [];
270
+ if (superuser === true) ambient.push('a superuser');
271
+ if (connected === true) ambient.push('the role db:pull connected as');
272
+
273
+ if (!ambient.length && superuser !== undefined && connected !== undefined) {
274
+ // A chosen principal. Render it — unless PostgreSQL's name is one defineFunction refuses,
275
+ // in which case rendering would emit source that throws on import.
276
+ if (!RENDERABLE_ROLE.test(owner)) {
277
+ const why = `${o.identity}: owner '${owner}' is not a bare lowercase role name, which is all the model's owner property accepts (it is interpolated into SET ROLE) — ownership NOT declared. Rename the role, or declare the owner by hand.`;
278
+ return { fixme: `// FIXME: ${why}`, warning: why };
279
+ }
280
+ return { prop: `owner: ${tsString(owner)},` };
281
+ }
282
+
283
+ if (!ambient.length) {
284
+ // Owner known, discriminator not measured. Refuse to classify rather than guess.
285
+ return {
286
+ fixme: `// FIXME: ${o.identity} is owned by '${owner}', but this pull could not tell whether that is a chosen principal or the build credential — owner is NOT declared. Add owner: '${owner}' if that role is the intended owner.`,
287
+ };
288
+ }
289
+
290
+ const because = ambient.join(' and ');
291
+ // The runs-as-owner case is the one that reaches stderr as well as the file: it is a live
292
+ // privilege-escalation surface, not a style note. The plain case stays an inline FIXME —
293
+ // on a brownfield database it is the common shape, and echoing all of it would bury the rest.
294
+ return elevated
295
+ ? {
296
+ fixme: `// FIXME SECURITY: ${o.identity} is ${elevated.posture} and owned by '${owner}' — ${because}. ${elevated.what} runs with that role's privileges. The observed owner is NOT declared, because declaring the build credential would make the accident reproducible; declare the intended owner with owner: '<role>'.`,
297
+ warning: `${o.identity}: ${elevated.posture}, owned by '${owner}' (${because}) — ${elevated.what.toLowerCase()} runs with that role's privileges. Ownership was NOT declared; declare the intended owner with owner: '<role>'.`,
298
+ }
299
+ : {
300
+ fixme: `// FIXME: ${o.identity} is owned by '${owner}' — ${because}, not a chosen principal, so ownership is NOT declared. Add owner: '<role>' if a specific role should own it.`,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * The A4 spelling, kept so existing callers and tests read unchanged. `renderedOwner` is the
306
+ * implementation; this is the function-shaped door onto it.
307
+ */
308
+ export function renderedFunctionOwner(
309
+ o: Pick<LiveObject, 'identity' | 'owner' | 'ownerIsSuperuser' | 'ownerIsConnectedRole'>,
310
+ secdef: boolean,
311
+ ): { prop?: string; fixme?: string; warning?: string } {
312
+ return renderedOwner(o, secdef ? SECURITY_DEFINER_RIGHTS : null);
313
+ }
314
+
211
315
  /** One matview index attachment → the Brick E builder chain, or null (FIXME). */
212
316
  function renderIndexBuilder(indexdef: string): string | null {
213
317
  const parsed = parseIndexDefinition(indexdef);
@@ -469,6 +573,24 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
469
573
  const props: string[] = [];
470
574
  const hasPrivileges = Object.keys(privileges).length > 0;
471
575
  if (o.kind === 'view') props.push(`securityInvoker: ${o.securityInvoker === true},`);
576
+ // A15 — the owner, through the SAME discriminator functions use. Elevated exactly when
577
+ // the relation runs as its owner: a view that is not securityInvoker, and a matview
578
+ // always (its rows are computed at REFRESH time with the owner's rights, no exceptions).
579
+ const relationOwnership = renderedOwner(
580
+ o,
581
+ o.kind === 'materialized view' ? MATVIEW_RIGHTS
582
+ : o.securityInvoker === false ? DEFINER_VIEW_RIGHTS
583
+ : null,
584
+ );
585
+ if (relationOwnership.prop) props.push(relationOwnership.prop);
586
+ if (relationOwnership.fixme) fixmes.push(relationOwnership.fixme);
587
+ // The FIXME lands in the file, at the declaration, which is where the operator acts on
588
+ // it. The stderr WARNING is deliberately NOT raised for relations: `SECURITY DEFINER` is
589
+ // opt-in and rare, so echoing those is signal, but `security_invoker = false` is
590
+ // PostgreSQL's DEFAULT for views — on any real database nearly every view and every
591
+ // matview qualifies, and promoting all of them would bury the function findings the
592
+ // channel exists for. Same judgement the function branch already makes for its plain
593
+ // case, applied where the base rate is inverted.
472
594
  // `private: true` means "declared dark, no grants" — it contradicts a recorded
473
595
  // grant, and the model refuses the pair. A relation whose only reach is recorded
474
596
  // (PUBLIC SELECT, or a write grant) is not dark; it just has no CHOSEN audience.
@@ -516,6 +638,7 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
516
638
  const roleGrants = Object.keys(grants).filter((r) => r.toUpperCase() !== 'PUBLIC').sort();
517
639
  const publicExec = Object.keys(grants).some((r) => r.toUpperCase() === 'PUBLIC');
518
640
  const args = f && (f.language === 'sql' || f.language === 'plpgsql') ? parseArgs(f.args) : null;
641
+ const ownership = renderedFunctionOwner(o, f?.secdef === true);
519
642
 
520
643
  if (!f || args === null || (f.language !== 'sql' && f.language !== 'plpgsql')) {
521
644
  warnings.push(`${o.identity}: signature beyond the v1 vocabulary (${f ? `language ${f.language}, args '${f.args}'` : 'no structured fields'}) — rendered as defineSql.`);
@@ -523,6 +646,14 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
523
646
  // collapse to one provenance row here too — the declared name carries the signature
524
647
  // (the drop is explicit, so the name is a label, and a unique one is what it needs).
525
648
  const sqlName = declaredName(o) + (f?.identityArgs !== undefined ? `(${f.identityArgs})` : '');
649
+ // A defineSql object cannot carry an owner. Where the pull observed a CHOSEN owner that
650
+ // is real fidelity loss, so it is said out loud on both surfaces — never dropped. An
651
+ // ambient owner needs no note here: there is nothing to declare and nothing was lost.
652
+ if (ownership.prop) {
653
+ const why = `${o.identity}: owned by '${o.owner}', but defineSql cannot declare an owner — the build will create it as the connecting role. Move it into defineFunction's vocabulary, or set the owner by hand after the build.`;
654
+ warnings.push(why);
655
+ lines.push(`// FIXME: ${why}`);
656
+ }
526
657
  lines.push(
527
658
  `// FIXME: ${o.identity} — signature beyond defineFunction's v1 vocabulary; kept verbatim as defineSql.`,
528
659
  `export const ${varName} = defineSql(${tsString(sqlName)}, {`,
@@ -548,11 +679,30 @@ export function renderDerivedSource(catalog: DerivedCatalog, opts: DerivedRender
548
679
  props.push(`returns: ${tsString(f.returns)},`);
549
680
  props.push(`language: ${tsString(f.language)},`);
550
681
  if (VOLATILITY[f.volatility]) props.push(`volatility: ${tsString(VOLATILITY[f.volatility])},`);
551
- if (f.secdef) {
552
- props.push(`security: 'definer',`);
553
- const path = (f.searchPath ?? 'pg_catalog').split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
682
+ if (f.secdef) props.push(`security: 'definer',`);
683
+ // THE PIN IS READ, NEVER INVENTED.
684
+ //
685
+ // This used to render `searchPath: ['pg_catalog']` for any definer function whose live
686
+ // proconfig was empty. A build from those models then applied the invented pin for real, so
687
+ // functions that had always resolved `public` stopped resolving it — sign-up and password
688
+ // change died on a database built from the consumer's own models, with every gate green.
689
+ // 34 of their 34 pinned-in-the-model functions were unpinned in the database.
690
+ //
691
+ // A live pin renders as the array. No live pin on a definer function renders the explicit
692
+ // `'unpinned'` sentinel — an ANSWER, not silence, so `defineFunction` still throws for the
693
+ // author who simply forgot. No live pin on an invoker function renders nothing, which
694
+ // already means exactly that.
695
+ if (f.searchPath) {
696
+ const path = f.searchPath.split(',').map((s) => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
554
697
  props.push(`searchPath: [${path.map(tsString).join(', ')}],`);
698
+ } else if (f.secdef) {
699
+ props.push(`searchPath: 'unpinned',`);
555
700
  }
701
+ // Ownership sits beside security because it IS the other half of it: `security: 'definer'`
702
+ // says the function runs as its owner, and this says who that is.
703
+ if (ownership.prop) props.push(ownership.prop);
704
+ if (ownership.fixme) fixmes.push(ownership.fixme);
705
+ if (ownership.warning) warnings.push(ownership.warning);
556
706
  if (roleGrants.length) {
557
707
  props.push(`abilities: [${roleGrants.map((r) => `can('execute', { role: ${tsString(r)} })`).join(', ')}],`);
558
708
  need('can');
@@ -60,6 +60,30 @@ export interface SourceObject {
60
60
  file: string;
61
61
  /** Position in the concatenated source — a valid dependency order by convention. */
62
62
  seq: number;
63
+ /**
64
+ * kind 'function' only: the declared owning role.
65
+ *
66
+ * A SECURITY DEFINER function executes with its OWNER's rights, so this is a privilege
67
+ * boundary. The applier enacts it by running the CREATE under `SET ROLE <owner>` rather
68
+ * than following it with `ALTER … OWNER TO`: measured on PG16, once ownership has moved the
69
+ * builder gets "must be owner of function" on the next `CREATE OR REPLACE`, so the
70
+ * alter-after form works exactly once and breaks every re-run.
71
+ */
72
+ owner?: string;
73
+ /**
74
+ * kind 'function' only: the declared `SET search_path`, or the sentinel `'unpinned'`.
75
+ *
76
+ * It is already inside `sql`, so this is NOT in the hash and changes no fingerprint. It rides
77
+ * structurally so the differ can compare the declared pin against the LIVE catalog directly
78
+ * rather than only against a recorded hash. Without that, `--baseline` records a declared pin
79
+ * the database does not have and every later check reports zero actions, forever: measured,
80
+ * and it is how a model came to claim `search_path=pg_catalog` about a database with none
81
+ * while every gate stayed green.
82
+ *
83
+ * `undefined` means NOT DECLARED — a parser-era db/sql object, or an invoker function with no
84
+ * pin. The comparison is skipped there; absent evidence is not evidence.
85
+ */
86
+ searchPath?: string[] | 'unpinned';
63
87
  /** kind 'sql' only: the declared PostgreSQL object kind (`'aggregate'`, …). */
64
88
  objectKind?: string;
65
89
  /** kind 'sql' only: the explicit DROP for kinds whose drop isn't derivable from the name. */
@@ -82,9 +106,15 @@ export const DECLARED_SOURCE_FILE = 'db/models (declared)';
82
106
  /** The one content-hash formula. Unchanged since the db/sql era on purpose: provenance
83
107
  * recorded from parser-era objects still matches the descriptor-compiled hash of the
84
108
  * same SQL, so the db/sql → descriptor migration reconciles as a no-op. */
85
- export function hashSourceContent(createSql: string, attachments: Attachment[]): string {
109
+ export function hashSourceContent(createSql: string, attachments: Attachment[], owner?: string): string {
86
110
  const content = [normalizeSql(createSql), ...attachments.map((a) => normalizeSql(a.sql))].join('\n');
87
- return createHash('sha256').update(content).digest('hex');
111
+ // Ownership is part of the declared state — a SECURITY DEFINER function that changes owner
112
+ // changes who it executes as, which is a rebuild, not an authz delta. It is folded in as a
113
+ // PREFIX only when declared, so every object without an owner hashes byte-identically to
114
+ // before this existed. That is deliberate: a consumer mid-adoption must not be forced into a
115
+ // second fingerprint re-mint in a week by a field they do not use.
116
+ const body = owner ? `-- owner: ${owner}\n${content}` : content;
117
+ return createHash('sha256').update(body).digest('hex');
88
118
  }
89
119
 
90
120
  /** A raw file the generic dir readers return (db/backfills, seed lanes). */
@@ -76,6 +76,9 @@ export const HELD_DROP_PREFIX = '-- DROP held back';
76
76
  */
77
77
  function isRemoval(change: SchemaChange, replacedConstraints: Set<string>): boolean {
78
78
  if (change.kind === 'dropColumn' || change.kind === 'dropTable' || change.kind === 'dropType' || change.kind === 'dropIndex') return true;
79
+ // DROP IDENTITY destroys the backing sequence and its counter — a removal, not a change.
80
+ // ADD/SET GENERATED are not: they create or retune the auto-value and lose nothing.
81
+ if (change.kind === 'setIdentity' && change.identity == null) return true;
79
82
  if (change.kind === 'dropConstraint') return !replacedConstraints.has(`${change.table}::${change.name}`);
80
83
  return false;
81
84
  }
@@ -20,6 +20,7 @@ import type { DerivedRenderResult } from './derived-render.js';
20
20
  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
+ import { generatedIndexName, generatedForeignKeyName } from './schema-compile.js';
23
24
 
24
25
  /**
25
26
  * Reverse a CHECK predicate back into the `.validate(z…)` that produced it — the inverse of
@@ -459,10 +460,17 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
459
460
  let expr = call ?? verbatimFieldCall(col.type);
460
461
  let comment = call ? '' : ` // verbatim: no first-class field for '${col.type}' — carried as-is`;
461
462
 
463
+ // The auto-value the database actually holds. Rendered BEFORE .primaryKey()/.notNull() so a
464
+ // pulled surrogate key reads the way one is authored, and rendered at all because without it
465
+ // the rebuilt column has no auto-value and every INSERT that omits it fails NOT NULL.
466
+ if (col.identity) {
467
+ expr += col.identity === 'always' ? '.generatedAlwaysAsIdentity()' : '.generatedByDefaultAsIdentity()';
468
+ }
469
+
462
470
  const isPk = table.primaryKey.includes(col.name);
463
471
  if (isPk) expr += '.primaryKey()';
464
- // A serial field is NOT NULL by construction, so the modifier would be redundant.
465
- else if (col.notNull && !serial) expr += '.notNull()';
472
+ // A serial or identity field is NOT NULL by construction, so the modifier would be redundant.
473
+ else if (col.notNull && !serial && !col.identity) expr += '.notNull()';
466
474
 
467
475
  if (table.uniques.some((u) => u.columns.length === 1 && u.columns[0] === col.name)) expr += '.unique()';
468
476
 
@@ -473,7 +481,11 @@ function renderField(table: TableSchema, col: ColumnSchema, known: Set<string>,
473
481
  fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
474
482
  fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
475
483
  ].filter(Boolean);
476
- const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
484
+ // A8 the FK's own name, said only when it is not the one the compiler would generate.
485
+ if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
486
+ actions.push(`name: ${tsLiteral(fk.name)}`);
487
+ }
488
+ const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
477
489
  expr += `.references(() => ${modelVarName(fk.refTable)}${opts})`;
478
490
  } else comment += ` // FK → ${fk.refTable} (not in the pulled set)`;
479
491
  }
@@ -528,6 +540,15 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
528
540
  if (ix.unique) s += '.unique()';
529
541
  if (ix.include?.length) s += `.include(${ix.include.map((c) => tsLiteral(toCamelCase(c))).join(', ')})`;
530
542
  if (ix.where) s += `.where(sql\`${normalizeCheck(ix.where)}\`)`;
543
+ // A7 — keep the name the database actually has, but only say it when it is not the one the
544
+ // compiler would generate anyway. A brownfield schema arrives with its previous tooling's
545
+ // names (`index_contents_on_destination_id`), and rebuilding under ours renamed ~200 of one
546
+ // consumer's indexes: counts and definitions matched, so every gate stayed green while a
547
+ // planner regression test, runbooks and pg_stat_user_indexes history all broke. Declaring
548
+ // only the divergent ones keeps a greenfield pull clean and an adopted one faithful.
549
+ if (ix.name && ix.name !== generatedIndexName(bareName(table.table), ix.columns)) {
550
+ s += `.name(${tsLiteral(ix.name)})`;
551
+ }
531
552
  items.push(s);
532
553
  }
533
554
  for (const fk of table.foreignKeys) {
@@ -542,6 +563,10 @@ function renderConstraintsBlock(table: TableSchema, checks: CheckConstraint[], k
542
563
  fk.onDelete ? `onDelete: ${tsLiteral(fk.onDelete)}` : null,
543
564
  fk.onUpdate ? `onUpdate: ${tsLiteral(fk.onUpdate)}` : null,
544
565
  ].filter(Boolean);
566
+ // A8 — the FK's own name, said only when it is not the one the compiler would generate.
567
+ if (fk.name && fk.name !== generatedForeignKeyName(bareName(table.table), fk.columns, bareName(fk.refTable), fk.refColumns)) {
568
+ actions.push(`name: ${tsLiteral(fk.name)}`);
569
+ }
545
570
  const opts = actions.length ? `, { ${actions.join(', ')} }` : '';
546
571
  items.push(`foreignKey(${cols}, () => ${modelVarName(fk.refTable)}, ${refCols}${opts})`);
547
572
  }