@everystack/cli 0.4.80 → 0.4.81

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,16 @@ 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
+ ## [0.4.81] - 2026-09-08
8
+
9
+ ### Added
10
+
11
+ - every declared write compiles a policy; check narrows, never replaces
12
+
13
+ ### Fixed
14
+
15
+ - db:pull recovers owner: from the normalized predicate and round-trips
16
+
7
17
  ## [0.4.80] - 2026-09-07
8
18
 
9
19
  ### BREAKING CHANGES
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/cli",
3
- "version": "0.4.80",
3
+ "version": "0.4.81",
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>",
@@ -322,6 +322,22 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
322
322
  // A "row-scoped" condition is either a direct owner or a transitive via.
323
323
  const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
324
324
 
325
+ // The `authenticated` role — named `role: 'authenticated'` OR the role-less default. On a
326
+ // row-scoped model this role is ALWAYS row-scoped; every other role gets its own policy.
327
+ const isAuthClass = (a: Ability): boolean => !a.condition.role || a.condition.role === 'authenticated';
328
+
329
+ // The model's row scope as a bare condition — owner/via keys ONLY, with the READ's guards
330
+ // stripped. It is what an authenticated/role-less `manage` desugars to: the four verbs are
331
+ // scoped to the model's owner, never to whatever a `can('read', { owner, sql })` narrowed the
332
+ // read with. Non-null exactly when `rowPred` is.
333
+ const ownerConditionAbility = model.abilities.find((a) => a.condition.owner);
334
+ const viaConditionAbility = model.abilities.find((a) => a.condition.via);
335
+ const rowScopeCondition: Ability['condition'] | null = owner && ownerConditionAbility
336
+ ? { owner: ownerConditionAbility.condition.owner, userField: ownerConditionAbility.condition.userField, ownerExpr: ownerConditionAbility.condition.ownerExpr }
337
+ : via && viaConditionAbility
338
+ ? { via: viaConditionAbility.condition.via }
339
+ : null;
340
+
325
341
  /**
326
342
  * A read scoped to a named role — `can('read', { role })`. It compiles to its own
327
343
  * `<table>_select_<role>` policy, so its predicate must NOT also be folded into the
@@ -331,19 +347,29 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
331
347
  const isRoleRead = (a: Ability): boolean =>
332
348
  a.action === 'read' && Boolean(a.condition.role) && !isColumnAbility(a);
333
349
 
334
- // `manage` + owner/via desugars into four verb abilities for policy emission.
335
- // Role-scoped `manage` (the admin bypass) passes through — it matches `hasAdminManage`
336
- // directly. Grants handle `manage` natively via `verbsFor`, so this expansion is
337
- // policy-compiler-local: `compileGrants` still reads `model.abilities`.
350
+ // `manage` desugars into four verb abilities for policy emission. Two cases become
351
+ // manage-OWN (four row-scoped verbs):
352
+ // - `manage` carrying its OWN owner/via (`can('manage', { owner })`); or
353
+ // - authenticated/role-less `manage` on a row-scoped MODEL — the role is dropped and the
354
+ // model's owner/via added, because the authenticated role on a row-scoped model is
355
+ // always row-scoped (the W1 ruling). The literal reading (an ALL policy USING true for
356
+ // authenticated beside `_select_own`) would be the round-1 widening: permissive policies
357
+ // UNION, so an unconditional policy beside an owner-checked one opens the whole table.
358
+ // Everything else — another role's `manage` (the admin bypass, an editor's ALL), or an
359
+ // authenticated `manage` with no row scope — passes through to the ALL-policy path below.
360
+ // Grants handle `manage` natively via `verbsFor`, so this expansion is policy-compiler-local:
361
+ // `compileGrants` still reads `model.abilities`.
362
+ const desugarManage = (c: Ability['condition']): Ability[] => [
363
+ { action: 'read', condition: c },
364
+ { action: 'create', condition: c },
365
+ { action: 'update', condition: c },
366
+ { action: 'delete', condition: c },
367
+ ];
338
368
  const abilities: readonly Ability[] = model.abilities.flatMap((a) => {
339
- if (a.action !== 'manage' || !rowScoped(a)) return [a];
340
- const c = a.condition;
341
- return [
342
- { action: 'read' as const, condition: c },
343
- { action: 'create' as const, condition: c },
344
- { action: 'update' as const, condition: c },
345
- { action: 'delete' as const, condition: c },
346
- ];
369
+ if (a.action !== 'manage') return [a];
370
+ if (rowScoped(a)) return desugarManage(a.condition);
371
+ if (isAuthClass(a) && rowScopeCondition) return desugarManage(rowScopeCondition);
372
+ return [a];
347
373
  });
348
374
 
349
375
  const predFor = (action: Ability['action']): string | null => {
@@ -370,15 +396,6 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
370
396
  }
371
397
  return null;
372
398
  };
373
- const checkFor = (action: Ability['action']): string | null => {
374
- for (const a of abilities) {
375
- if (a.action !== action) continue;
376
- const p = rawPredicate(a.condition.check, `${table}: can('${action}', { check })`);
377
- if (p) return p;
378
- }
379
- return null;
380
- };
381
- const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
382
399
  const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
383
400
  // The audience is open when the public read says so. Read off the SAME abilities that make
384
401
  // `hasPublicRead` true, so the two can never disagree about which read is being compiled.
@@ -389,10 +406,7 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
389
406
  // full-row owner read — so it is excluded here and handled by its own branch.
390
407
  const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnAbility(a));
391
408
  const hasColumnOwnerRead = abilities.some((a) => a.action === 'read' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
392
- const canCreate = abilities.some((a) => a.action === 'create');
393
- const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a) && !isColumnAbility(a));
394
409
  const hasColumnUpdateOwner = abilities.some((a) => a.action === 'update' && isColumnAbility(a) && !a.condition.role && rowScoped(a));
395
- const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
396
410
 
397
411
  const t = model.table;
398
412
  const policies: PolicyContract[] = [];
@@ -410,8 +424,15 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
410
424
  });
411
425
  };
412
426
 
413
- // admin-bypass — one ALL policy, true/true.
414
- if (hasAdminManage) policy(`${t}_admin`, 'ALL', ['admin'], 'true', 'true');
427
+ // The ALL-policy `manage` roles — every `manage` ability that did NOT desugar to manage-own:
428
+ // another role's bypass (admin, editor, moderator) and an authenticated `manage` with no row
429
+ // scope. Each is one ALL policy `<t>_<role>` USING true WITH CHECK true. `admin` is simply the
430
+ // first instance of this rule, not a special case; the fold to `authenticated` covers the
431
+ // role-less no-scope `can('manage')`. Deduplicated by policy name.
432
+ const manageAllRoles = [...new Set(
433
+ abilities.filter((a) => a.action === 'manage').map((a) => policyRole(a.condition.role ?? 'authenticated')),
434
+ )];
435
+ for (const role of manageAllRoles) policy(`${t}_${role}`, 'ALL', [role], 'true', 'true');
415
436
 
416
437
  if (hasPublicRead) {
417
438
  // public read on a soft-delete table — split per role; authenticated owners
@@ -512,26 +533,136 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
512
533
  );
513
534
  }
514
535
 
515
- // owner-gated writes. INSERT checks ownership (you can only create rows you own);
516
- // UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
536
+ // Writes. Every declared write compiles a policy — a grant with no policy behind it is dead
537
+ // under RLS, which is the whole bug this milestone closes. The rule per verb:
517
538
  //
518
- // A declared `check` is the WITH CHECK half; without one the read half governs both
519
- // (Postgres' own default). The two differ when a row may be written into a shape its
520
- // author could not have selected by — the direction that matters is a check WEAKER than
521
- // the using, which lets an owner move their own row past whatever the using guarded.
522
- const createPred = andPredicates(rowPred, predFor('create'));
523
- const updatePred = andPredicates(rowPred, predFor('update'));
524
- const deletePred = andPredicates(rowPred, predFor('delete'));
525
-
526
- if (canCreate && createPred) {
527
- policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, checkFor('create') ?? createPred);
528
- }
529
- if ((hasUpdateOwner || predFor('update')) && updatePred) {
530
- policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, checkFor('update') ?? updatePred);
539
+ // - authenticated (named or role-less), on a ROW-SCOPED model → `_<verb>_own`.
540
+ // USING = rowScope AND where; WITH CHECK = rowScope AND (check ?? where). The row-scope
541
+ // term is NEVER removable: a `where`/`check` narrows WITHIN own rows. INSERT has no USING;
542
+ // DELETE has no WITH CHECK.
543
+ // - authenticated, on a model with NO row scope → `_<verb>_authenticated`.
544
+ // USING = where ?? true; WITH CHECK = check ?? where ?? true.
545
+ // - any OTHER named role → `_<verb>_<role>`, predicate ?? true. NEVER folded into `_own`,
546
+ // so one role's `where` can never narrow (or, unscoped, widen) another's rows.
547
+ //
548
+ // Permissive policies UNION, so at most ONE authenticated policy is emitted per verb — the
549
+ // where/check are taken from the first authenticated ability that declares one.
550
+ // A column-scoped write with no role cannot carry a WITH CHECK. Postgres' WITH CHECK is
551
+ // ROW-level, never column-level, so the predicate has nowhere honest to go: AND-ing it into
552
+ // the full-row `_own` policy over-applies it to every update (not just the scoped columns),
553
+ // and dropping it silently is an evaporating security rule — the exact hole this compiler
554
+ // throws over for a malformed `sql:`. So it throws here too, rather than swallow it. (A
555
+ // NAMED-role column write keeps its own policy and is unaffected; a row-level `check:` on a
556
+ // non-column ability is the supported spelling.)
557
+ for (const a of model.abilities) {
558
+ // A role name is emitted UNQUOTED into GRANT/CREATE POLICY, so Postgres folds or splits
559
+ // anything that is not a bare lowercase identifier: `AUTHENTICATED` folds onto
560
+ // `authenticated`, `anon, authenticated` becomes two grantees — and a policy meant for a
561
+ // distinct role silently lands on a real vocabulary role beside its owner-checked one, which
562
+ // permissive UNION turns into the whole table. Refuse it. (PUBLIC is folded deliberately by
563
+ // isPseudoPublic and is exempt.)
564
+ const role = a.condition.role;
565
+ if (role && !isPseudoPublic(role) && !/^[a-z_][a-z0-9_]*$/.test(role)) {
566
+ throw new Error(
567
+ `${table}: can('${a.action}', { role: '${role}' }) — a role name is emitted unquoted, so it must be a bare `
568
+ + `lowercase identifier ([a-z_][a-z0-9_]*). '${role}' would be case-folded or split by Postgres and the policy `
569
+ + `would land on a different role. Use the exact lowercase role name.`,
570
+ );
571
+ }
572
+ // A role-less column ability cannot carry a ROW predicate (where/sql/check). Postgres USING
573
+ // and WITH CHECK are row-level, never column-level, so the predicate has nowhere honest to
574
+ // go: applied to the whole-row policy it over-applies to every column, dropped it evaporates
575
+ // — the exact hole this compiler throws over for a malformed `sql:`. (A NAMED-role column
576
+ // ability keeps its own policy and is unaffected; a row-level predicate on a non-column
577
+ // ability is the supported spelling.)
578
+ if (isColumnAbility(a) && !a.condition.role
579
+ && (a.condition.check != null || a.condition.sql != null || a.condition.where != null)) {
580
+ throw new Error(
581
+ `${table}: can('${a.action}', { owner, columns, … }) — a column-scoped ability cannot carry a row predicate `
582
+ + `(where/sql/check). Postgres USING/WITH CHECK are row-level, not column-level, so the predicate could only be `
583
+ + `applied to the whole-row policy (over-applying it to every column) or dropped (an evaporating security rule). `
584
+ + `Move the predicate to a row-level ability — can('${a.action}', { owner, where }) — or enforce it with a trigger.`,
585
+ );
586
+ }
531
587
  }
532
- else if (hasColumnUpdateOwner && updatePred) {
533
- policy(`${t}_update_own`, 'UPDATE', ['authenticated'], updatePred, updatePred);
588
+
589
+ const WRITE_VERBS = [
590
+ { action: 'create' as const, word: 'insert', command: 'INSERT' as PolicyCommand },
591
+ { action: 'update' as const, word: 'update', command: 'UPDATE' as PolicyCommand },
592
+ { action: 'delete' as const, word: 'delete', command: 'DELETE' as PolicyCommand },
593
+ ];
594
+ const firstPred = (abs: readonly Ability[], half: 'where' | 'check'): string | null => {
595
+ for (const a of abs) {
596
+ const raw = half === 'check' ? a.condition.check : (a.condition.sql ?? a.condition.where);
597
+ const label = `${table}: can('${a.action}'${half === 'check' ? ', { check }' : ''})`;
598
+ const p = rawPredicate(raw, label);
599
+ if (p) return p;
600
+ }
601
+ return null;
602
+ };
603
+ for (const { action, word, command } of WRITE_VERBS) {
604
+ const verbAbilities = abilities.filter((a) => a.action === action && !isColumnAbility(a));
605
+ const authAbilities = verbAbilities.filter(isAuthClass);
606
+
607
+ if (authAbilities.length) {
608
+ const where = firstPred(authAbilities, 'where');
609
+ const check = firstPred(authAbilities, 'check');
610
+ if (rowPred) {
611
+ policy(
612
+ `${t}_${word}_own`, command, ['authenticated'],
613
+ command === 'INSERT' ? null : andPredicates(rowPred, where),
614
+ command === 'DELETE' ? null : andPredicates(rowPred, check ?? where),
615
+ );
616
+ } else {
617
+ // No row scope: USING = where ?? true, WITH CHECK = check ?? where ?? true. Route through
618
+ // andPredicates so a vacuous `where: sql`true`` collapses to plain `true` rather than
619
+ // leaking a `(true)` term that would churn the reconciler against a live `USING (true)`.
620
+ policy(
621
+ `${t}_${word}_authenticated`, command, ['authenticated'],
622
+ command === 'INSERT' ? null : (andPredicates(where) ?? 'true'),
623
+ command === 'DELETE' ? null : (andPredicates(check ?? where) ?? 'true'),
624
+ );
625
+ }
626
+ } else if (action === 'update' && hasColumnUpdateOwner && rowPred) {
627
+ // A column-scoped owner update with no full-row authenticated update: the row gate is the
628
+ // owner (this policy), the GRANT scopes the columns. The full-row branch above would have
629
+ // covered it otherwise, so this only fires when there is no non-column owner update.
630
+ policy(`${t}_update_own`, 'UPDATE', ['authenticated'], rowPred, rowPred);
631
+ }
632
+
633
+ // Other named roles — each its own policy, never folded into `_own`.
634
+ const seenRoles = new Set<string>();
635
+ for (const a of verbAbilities) {
636
+ if (isAuthClass(a)) continue;
637
+ const role = policyRole(a.condition.role!);
638
+ if (seenRoles.has(role)) continue;
639
+ seenRoles.add(role);
640
+ const name = `${t}_${word}_${role}`;
641
+ const label = `${table}: can('${action}', { role: '${role}' })`;
642
+ if (policies.some((p) => p.name === name)) {
643
+ throw new Error(
644
+ `${label} collides with the ${name} policy already compiled from this model. `
645
+ + `Declare one ${action} ability for that role: permissive policies OR together, so separate rules could only widen.`,
646
+ );
647
+ }
648
+ const where = rawPredicate(a.condition.sql ?? a.condition.where, label);
649
+ const check = rawPredicate(a.condition.check, `${label}, { check }`);
650
+ // A role that ALSO declares owner:/via: (`can('update', { role: 'editor', owner })`) is
651
+ // scoped to its own rows exactly as an authenticated owner write is — the row scope AND-s
652
+ // in, and without it the role would get USING (true): the whole table, not its own rows.
653
+ // Same rule the role-read (`isRoleRead`) and column-role-update loops already apply.
654
+ const roleScope = rowScoped(a) ? rowPred : null;
655
+ policy(
656
+ name, command, [role],
657
+ command === 'INSERT' ? null : (andPredicates(roleScope, where) ?? 'true'),
658
+ command === 'DELETE' ? null : (andPredicates(roleScope, check ?? where) ?? 'true'),
659
+ );
660
+ }
534
661
  }
662
+
663
+ // Column-scoped role UPDATE — a `can('update', { role, columns })` grants those columns and
664
+ // needs its own policy TO that role. (The owner/column-owner cases are handled above; this is
665
+ // the named-role column path, kept distinct because `verbAbilities` excludes column abilities.)
535
666
  for (const a of abilities.filter((ability) =>
536
667
  isColumnAbility(ability) && ability.action === 'update' && Boolean(ability.condition.role),
537
668
  )) {
@@ -544,14 +675,16 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
544
675
  + `Declare one update ability for that role: permissive policies OR together, so separate rules could only widen.`,
545
676
  );
546
677
  }
547
- const using = andPredicates(
548
- rowScoped(a) ? rowPred : null,
549
- rawPredicate(a.condition.sql ?? a.condition.where, where),
550
- ) ?? 'true';
551
- policy(name, 'UPDATE', [role], using, rawPredicate(a.condition.check, `${where}, { check }`) ?? using);
552
- }
553
- if ((hasDeleteOwner || predFor('delete')) && deletePred) {
554
- policy(`${t}_delete_own`, 'DELETE', ['authenticated'], deletePred, null);
678
+ const rowScope = rowScoped(a) ? rowPred : null;
679
+ const wherePred = rawPredicate(a.condition.sql ?? a.condition.where, where);
680
+ const checkPred = rawPredicate(a.condition.check, `${where}, { check }`);
681
+ const using = andPredicates(rowScope, wherePred) ?? 'true';
682
+ // WITH CHECK NARROWS, symmetric with USING: the row scope is ANDed in, never replaced, and
683
+ // the write guard defaults to the read guard. The old `rawPredicate(check) ?? using` emitted
684
+ // the check verbatim, so on a row-scoped column update a `check:` dropped the owner term —
685
+ // the one path the milestone's headline claim did not yet hold.
686
+ const check = andPredicates(rowScope, checkPred ?? wherePred) ?? using;
687
+ policy(name, 'UPDATE', [role], using, check);
555
688
  }
556
689
 
557
690
  policies.sort((a, b) => a.name.localeCompare(b.name));
@@ -92,14 +92,20 @@ function deriveExtraPrivileges(contract: TableContract): Record<string, string[]
92
92
  }
93
93
 
94
94
  /**
95
- * The deparsed owner predicate the compiler produces, in both its casts:
96
- * (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
95
+ * The deparsed owner predicate, in BOTH wire spellings — the `::text` casts are optional:
96
+ * normalized (what both producers emit since a616a1f3, 2026-08-24, and what a live stage
97
+ * returns through `predicate()`'s cross-version normalizer):
98
+ * (col = (((current_setting('request.jwt.claims', true))::jsonb ->> 'sub'))::uuid)
99
+ * raw old-major deparse (a dump captured before the normalizer, or an older stage):
100
+ * (col = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text)))
97
101
  * Recognising it turns a predicate back into `{ owner: 'col' }` — the structured shape —
98
- * instead of an opaque `sql:` fragment. Anything unrecognised stays verbatim rather than
99
- * being guessed at.
102
+ * instead of an opaque `sql:` fragment. Before the casts were made optional the matcher
103
+ * expected only the raw spelling, so a normalized live predicate — every one, today — fell
104
+ * through to `sql:` and lost the `owner:` on round-trip. Anything unrecognised still stays
105
+ * verbatim rather than being guessed at.
100
106
  */
101
107
  const OWNER_RE =
102
- /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'::text,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'::text\)*(?:::[a-z]+)?\)?$/i;
108
+ /^\(?([a-z_][a-z0-9_]*)\s*=\s*\(*\(current_setting\('request\.jwt\.claims'(?:::text)?,\s*true\)\)::jsonb\s*->>\s*'([a-z_]+)'(?:::text)?\)*(?:::[a-z]+)?\)?$/i;
103
109
 
104
110
  /**
105
111
  * The OTHER owner shape: the database's own accessor, `(<col> = auth.user_id())`.
@@ -132,6 +138,33 @@ export function parseOwnerPredicate(
132
138
  return f ? { column: f[1], claim: 'sub', expr: f[2] } : null;
133
139
  }
134
140
 
141
+ /**
142
+ * Decompose a WRITE predicate into its owner conjunct and the remaining guard.
143
+ *
144
+ * The compiler builds an owner write as `rowScope AND where` (USING) and `rowScope AND (check ??
145
+ * where)` (WITH CHECK). To round-trip, the deriver reverses that: the owner term may be the whole
146
+ * predicate (`owner = claim`) or one top-level AND conjunct (`owner = claim AND state = 'draft'`),
147
+ * and everything else is the guard the compiler will AND back in beside the owner. Returns the
148
+ * owner parse (null when there is none) and the guard text (null when the owner is the whole
149
+ * predicate). Nothing is guessed: a predicate with no recognisable owner conjunct returns
150
+ * `{ owner: null, guard: <the whole predicate> }`, which the caller carries verbatim as `sql:`.
151
+ */
152
+ function splitOwnerPredicate(
153
+ pred: string | null,
154
+ ): { owner: ReturnType<typeof parseOwnerPredicate>; guard: string | null } {
155
+ if (!pred) return { owner: null, guard: null };
156
+ const direct = parseOwnerPredicate(pred);
157
+ if (direct) return { owner: direct, guard: null };
158
+ const conjuncts = splitTopLevel(pred, 'AND');
159
+ if (conjuncts.length < 2) return { owner: null, guard: pred };
160
+ const idx = conjuncts.findIndex((c) => parseOwnerPredicate(c));
161
+ if (idx === -1) return { owner: null, guard: pred };
162
+ const owner = parseOwnerPredicate(conjuncts[idx]);
163
+ const rest = conjuncts.filter((_, i) => i !== idx);
164
+ const guard = rest.length === 0 ? null : rest.length === 1 ? rest[0] : `(${rest.join(' AND ')})`;
165
+ return { owner, guard };
166
+ }
167
+
135
168
  /**
136
169
  * Split a predicate on a top-level operator, respecting parens and string literals.
137
170
  *
@@ -562,7 +595,7 @@ export function deriveAbilities(
562
595
  }
563
596
 
564
597
  if (diverges && own) {
565
- const parts = [`owner: '${own.owner.column}'`];
598
+ const parts = [`owner: '${modelFieldKey(own.owner.column)}'`];
566
599
  if (own.owner.claim !== 'sub') parts.push(`userField: '${own.owner.claim}'`);
567
600
  if (own.owner.expr) parts.push(`ownerExpr: sql\`${own.owner.expr}\``);
568
601
  const guard = sqlFragment(own.ownerSql);
@@ -597,7 +630,7 @@ export function deriveAbilities(
597
630
  const pol = policiesFor(contract, 'authenticated', 'SELECT')[0];
598
631
  const owner = parseOwnerPredicate(pol?.using ?? null);
599
632
  if (owner) {
600
- const parts = [`owner: '${owner.column}'`];
633
+ const parts = [`owner: '${modelFieldKey(owner.column)}'`];
601
634
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
602
635
  if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
603
636
  abilities.push(`can('read', { ${parts.join(', ')} })`);
@@ -616,22 +649,58 @@ export function deriveAbilities(
616
649
  ?? policiesFor(contract, 'authenticated', command)[0];
617
650
  const using = pol?.using ?? null;
618
651
  const check = pol?.check ?? null;
619
- const owner = parseOwnerPredicate(using) ?? parseOwnerPredicate(check);
652
+ // Decompose both halves into (owner, guard). INSERT carries the owner in WITH CHECK (its
653
+ // USING is null); UPDATE/DELETE carry it in USING. The owner is recovered from whichever
654
+ // half has it.
655
+ const u = splitOwnerPredicate(using);
656
+ const c = splitOwnerPredicate(check);
657
+ const owner = u.owner ?? c.owner;
620
658
 
621
659
  const parts: string[] = [];
622
660
  if (owner) {
623
- parts.push(`owner: '${owner.column}'`);
661
+ parts.push(`owner: '${modelFieldKey(owner.column)}'`);
624
662
  if (owner.claim !== 'sub') parts.push(`userField: '${owner.claim}'`);
625
663
  if (owner.expr) parts.push(`ownerExpr: sql\`${owner.expr}\``);
626
- }
627
- // A predicate beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
628
- const usingFrag = owner && parseOwnerPredicate(using) ? null : sqlFragment(using);
629
- if (usingFrag) parts.push(`sql: ${usingFrag}`);
630
- // The WRITE half, only when it genuinely differs. Postgres defaults it to USING, and
631
- // emitting it unconditionally would be noise that also defeats the round-trip diff.
632
- if (check && check !== using) {
633
- const checkFrag = sqlFragment(check);
634
- if (checkFrag) parts.push(`check: ${checkFrag}`);
664
+ // The USING guard beyond the owner gate (a state guard, a tenant filter) rides as `sql`.
665
+ const usingGuard = sqlFragment(u.guard);
666
+ if (usingGuard) parts.push(`sql: ${usingGuard}`);
667
+ // The WRITE half rides as `check` ONLY when its guard differs from the USING guard. The
668
+ // compiler defaults WITH CHECK to (rowScope AND where), so emitting an equal guard would
669
+ // double the owner term on recompile — the round-trip regression this closes.
670
+ // WITH CHECK handling. `check == null` means the live policy has NO explicit WITH CHECK
671
+ // (Postgres defaults it to USING at runtime) — emit no `check:` and let the compiler
672
+ // default it the same way. Only an EXPLICIT check that differs from the USING guard needs a
673
+ // `check:`, and only in the shapes below.
674
+ if (command !== 'DELETE' && check != null && c.owner == null) {
675
+ // The live WITH CHECK does not even restate the owner gate that USING carries — it is
676
+ // WIDER on the owner axis (the extreme is `WITH CHECK (true)`: write a row you do not
677
+ // own). The model has no spelling for that, and every `check:` the compiler emits ANDs
678
+ // the owner back in, so the derived ability necessarily TIGHTENS live. Disclose it rather
679
+ // than round-trip the loss silently; the residual db:plan statement is the real diff.
680
+ notes.push(`policy "${pol?.name}" (${command}) has a WITH CHECK that does not restate the owner gate in its USING —`);
681
+ notes.push(` it is wider than any owner ability can express, so the rendered model TIGHTENS it to the owner. Live WITH CHECK:`);
682
+ notes.push(` ${check}`);
683
+ } else if (command !== 'DELETE' && check != null && c.owner != null && c.guard == null && u.guard != null) {
684
+ // Explicit owner-only WITH CHECK while USING carries a guard — the write half is
685
+ // deliberately WIDER than the read half (the moderation shape this file's header names:
686
+ // "your own draft rows" to target, "your own row" to write into). Left implicit, the
687
+ // compiler would default WITH CHECK to (owner AND where) and TIGHTEN it, so the pull would
688
+ // misreport live. `check: sql`true`` forces WITH CHECK back to the owner gate alone.
689
+ parts.push('check: sql`true`');
690
+ } else if (c.guard != null && (c.guard ?? '') !== (u.guard ?? '')) {
691
+ // An explicit WITH CHECK carrying a DIFFERENT guard than USING (owner ∧ a distinct write
692
+ // guard). Rides as `check:`; the compiler ANDs the owner back in.
693
+ const checkFrag = sqlFragment(c.guard);
694
+ if (checkFrag) parts.push(`check: ${checkFrag}`);
695
+ }
696
+ } else {
697
+ // No owner recovered — carry the predicates verbatim, USING and (differing) WITH CHECK.
698
+ const usingFrag = sqlFragment(using);
699
+ if (usingFrag) parts.push(`sql: ${usingFrag}`);
700
+ if (check && check !== using) {
701
+ const checkFrag = sqlFragment(check);
702
+ if (checkFrag) parts.push(`check: ${checkFrag}`);
703
+ }
635
704
  }
636
705
 
637
706
  if (!parts.length && !pol) {