Skip to content

Commit 9a2bbae

Browse files
project(functional-indexes): slice-3 spec + plan (rls-exact-names)
Grounded on the slice-2 branch. Notable resolutions: policy introspection drops the ?? policyname prefix fallback (it conflated managed with adopt-as-exact; the head-derivation role returns in slice-4 infer), the "not-equal unreachable" planner assumption ends (exact policies make it reachable; maps to drop+create per scenario F), and the D9 policy warning reaches the shared batch through a generic warning sink on AuthoringEntityContext (the pack lowering cannot reach the build-contract collector). Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
1 parent 9d31d18 commit 9a2bbae

2 files changed

Lines changed: 119 additions & 0 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Plan — Slice 3: `rls-exact-names`
2+
3+
**Spec:** [rls-exact-names.spec.md](../specs/rls-exact-names.spec.md). Branch `slice/rls-exact-names`, stacked on `slice/expression-index-authoring`. Same loop conventions and full standing gate per dispatch.
4+
5+
## Dispatch sequence
6+
7+
### 1 — Exact-capable policy substrate
8+
9+
**Outcome:** `prefix` optional across entity/node/validator with the wire-name constructor invariant; mode-selected `isEqualTo` (managed id-equality unchanged; exact branch compares operation/permissive/sorted-roles/bodies verbatim); introspection stamps `prefix` per the index convention (fallback removed, slice-4 comment); planner maps policy `not-equal` → drop+create (destructive-gated, conflict otherwise; "unreachable" comment replaced); `policyNodeToContractPolicy` tolerates absence. Nothing authors exact policies yet — managed behavior byte-identical, fixtures unmoved.
10+
11+
**Builds on:** slices 1–2 substrate. **Hands to:** exact policy nodes representable and comparable end-to-end.
12+
13+
### 2 — `@@map` authoring + D9 sink
14+
15+
**Outcome:** the five policy blocks accept `@@map` (native_enum mechanism; `PSL_POLICY_INVALID_MAP` via the contributed-code seam; head-keyed duplicate checks byte-unchanged; no cap on exact names); the generic warning sink lands on `AuthoringEntityContext` (family-neutral, stop-condition if it can't stay generic) and `@@map` policies push D9 hits into the shared per-build batch. Managed lowering byte-unchanged.
16+
17+
**Builds on:** dispatch 1. **Hands to:** exact policies authorable; warning batched with indexes.
18+
19+
### 3 — Content pairing + scenarios + docs
20+
21+
**Outcome:** phase-2 content pairing in the policy pass (`policyContentEqual`, verbatim bodies, deterministic, widening-only; index pass untouched); scenario C e2e (adopt → verify clean → swap to managed → renames-only plan → apply → verify clean) and scenario F integration (drift → not-equal → drop+create / conflict) green; planner-unit phase-2 cases ported from the index suite; docs + upgrade entries; DoD walk.
22+
23+
**Builds on:** dispatches 1–2. **Hands to:** slice-DoD met; PR-open (stacked on slice 2).
24+
25+
## Sizing notes
26+
27+
Three dispatches mirroring slice 2's shape: substrate first (invisible), authoring + warning second, pairing + acceptance third. Each lands with the full standing gate green; the only framework touch (warning sink) is isolated in dispatch 2 with a stop-condition.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Spec — Slice 3: `rls-exact-names`
2+
3+
**Parent:** [project spec](../spec.md) §§ D1 (policy half), D3 (policy half), D5 (policy half), D7 (policy half), D9 · [plan](../plan.md) slice 3 · builds on slices 1–2.
4+
5+
## At a glance
6+
7+
A policy adopted from a live database becomes authorable without renaming it:
8+
9+
```prisma
10+
policy_select existing_tenant_read {
11+
target User
12+
roles [app_user]
13+
using "tenant_id = current_setting('app.tenant')::uuid"
14+
@@map("Tenant members can read")
15+
}
16+
```
17+
18+
With `@@map`, the lowered policy is **exact-named**: `name` is the verbatim physical name, no `prefix`, no hash — and equivalence becomes content comparison (structured attributes strict, SQL bodies byte-for-byte), reliable precisely when the body text is a Postgres reprint. Replacing `@@map` with the plain head prefix later converges via a single `ALTER POLICY … RENAME` (content pairing), completing scenario C for policies. This is the policy half of the identity model indexes adopted in slices 1–2; slice 4's infer emits these blocks.
19+
20+
## Chosen design
21+
22+
### 1. `prefix` becomes optional (D1 policy half)
23+
24+
`PostgresRlsPolicyInput`/`PostgresRlsPolicy` (`postgres-rls-policy.ts`), `PostgresPolicySchemaNodeInput`/`PostgresPolicySchemaNode` (`postgres-policy-schema-node.ts`), and the arktype `PostgresRlsPolicySchema` (`postgres-validators.ts:10`) all make `prefix` optional — absent ⇔ exact-named. Constructors assign conditionally (the repo's `ifDefined` convention); `policyNodeToContractPolicy` (`planner.ts:721`) tolerates absence. Constructor invariant, mirroring the index entity: `prefix !== undefined ⇒ name === formatWireName(prefix, <8hex>)` shape with matching prefix (`parseWireName` check, no hash recomputation). The TS entity-handle path (`postgresLowerEntityHandles`) stays managed-only — no new TS parameter (project § Dependencies: TS policy authoring doesn't exist).
25+
26+
Managed policies keep every byte of today's behavior; contracts not using exact policies must not move (`fixtures:check` — flag ANY movement and explain it before proceeding).
27+
28+
### 2. PSL `@@map` on the five policy blocks (D3 policy half)
29+
30+
`lowerRlsPolicyFromBlock` (`authoring.ts:179`) reads `block.blockAttributes.find(a => a.name === 'map')` — the exact `native_enum` mechanism (`authoring.ts:241–263`, `unwrapQuotedString`); no grammar or descriptor change (the parser already surfaces `blockAttributes` on every extension block). With `@@map`: the block-head identifier stays the source-level logical identifier (head-keyed duplicate checking byte-unchanged, `PSL_DUPLICATE_EXTENSION_ENTITY` untouched) but the lowered entity is exact — `name` = map value verbatim, `prefix` absent, no hash computed, no prefix-length cap applied (the 54-char cap is a wire-prefix rule; exact names are verbatim physical names, same stance as index `map:`). Invalid `@@map` argument → new diagnostic `PSL_POLICY_INVALID_MAP` via slice 2's contributed-code seam, message mirroring `PSL_NATIVE_ENUM_INVALID_MAP`. Without `@@map`, behavior byte-unchanged (head = prefix, wire name computed, cap enforced).
31+
32+
### 3. Mode-selected policy equivalence (D5 policy half)
33+
34+
`PostgresPolicySchemaNode.isEqualTo` selects on `this.prefix`:
35+
36+
- **Managed** (`prefix` present): unchanged — id equality (paired ⇒ equal).
37+
- **Exact** (`prefix` absent): compare `operation`, `permissive`, sorted `roles`, and `using ?? ''` / `withCheck ?? ''` **verbatim, byte-for-byte, no normalization** (the node already carries every field; only the branch is new).
38+
39+
The planner comment "`not-equal` is unreachable" (`planner.ts:526`) becomes false: a `not-equal` policy issue maps to **drop + create** (project scenario F, "existing semantics") — drop gated `destructive`; without `destructive` the issue surfaces as a planner conflict (the policy analog of `indexIncompatible` — reuse the existing policy conflict vocabulary if one exists, else the generic node-conflict path; do not invent a new conflict kind without checking).
40+
41+
### 4. Introspection prefix stamping matches the index convention (D6 policy half)
42+
43+
`control-adapter.ts:1270` currently stamps `prefix = parseWireName(policyname)?.prefix ?? policyname` — the fallback conflates "managed" with "adopt as exact". It becomes `parseWireName(policyname)?.prefix` (undefined when unparseable), byte-matching the index convention from slice 1. The `?? policyname` derivation reappears in slice 4 as the *source head identifier* rule in infer — a comment marks that. Consequence to verify in tests: a live policy with an unparseable name introspects as an exact-shaped actual node; verify behavior for stray/tolerated extras is unchanged (equality is driven by the expected side).
44+
45+
### 5. Policy content pairing — phase 2 (D7 policy half)
46+
47+
In `planPostgresSchemaDiff`, after the existing phase-1 hash pairing (`planner.ts:560–596`): phase 2 over the *remaining* missing nodes with `prefix` defined × *remaining* extras of any name shape, pairing iff content-equal — `operation` strict, `permissive` strict, `roles` sorted-strict, `using ?? ''`/`withCheck ?? ''` **verbatim** (a `policyContentEqual` sibling of `indexContentEqual`, `planner.ts:742` — NOT the normalized hash tuple). Deterministic: missing sorted by name, candidates sorted by name, first match consumed → `RenamePostgresRlsPolicyCall`. Widening-only; leftovers create/drop exactly as today; index pass untouched.
48+
49+
### 6. D9 for policies — the warning sink wiring
50+
51+
`ExactNameBodyWarning.subject` already admits `'policy'`, but the postgres pack's policy lowering can't reach `build-contract.ts`'s collector. Wiring (grounded choice): `AuthoringEntityContext` (`framework-authoring.ts:187`) gains an optional, **generic** warning sink beside its existing `diagnostics` sink (family-neutral framework field — a callback accepting `{ code, message }`-shaped entries; no policy/index vocabulary). `buildSqlContractFromDefinition` passes a sink that feeds the same `exactNameBodyWarnings` batch it already flushes once per build; `lowerRlsPolicyFromBlock` pushes a D9 hit (subject `'policy'`, exact name) whenever `@@map` is present — every policy has a body, so every `@@map` policy warns, message per project § D9. If the framework field can't stay generic or spreads beyond `framework-authoring.ts`, stop and surface.
52+
53+
### 7. Scenario tests (C and F, policy edition)
54+
55+
- **C — exact → managed transition e2e** (cli-journey): PSL fixture with an `@@map` policy adopted against a live database created with that exact policy (raw SQL); verify clean (zero issues); swap to the managed head (drop `@@map`, keep body text verbatim) → the widening plan is **exactly one** `ALTER POLICY … RENAME` (byte-asserted) → apply → verify clean. Plus the planner-unit phase-2 cases ported from the index suite (deterministic multi-candidate, byte-different body does not pair, exact-named missing never pairs, additive-only degradation).
56+
- **F — out-of-band drift on an exact-named policy** (adapter integration): live `ALTER POLICY` changing the body under the same name → verify reports `not-equal`; plan under destructive = drop + create; without destructive = conflict. Also the managed-mode contrast (same drift on a managed policy stays invisible to `isEqualTo` — covered by hash identity; state it in the test name).
57+
- D9 warning asserts (listener) for `@@map` policy lowering, batched semantics shared with indexes.
58+
59+
## Coherence rationale
60+
61+
The policy half of one already-shipped identity model, delivered as one PR: entity/mode change, authoring surface, equivalence, pairing, and warning are a single coherent adoption story ("a live policy can be signed and later renamed into management"). Slice 4 consumes it immediately.
62+
63+
## Scope
64+
65+
**In:** the above; docs touched by the policy authoring reference (policy block docs / RLS subsystem page if they enumerate block attributes); upgrade-skill entries per the coverage check.
66+
67+
**Deliberately out:** TS policy authoring (`map` descriptor — future work with that surface); infer emission (slice 4); RESTRICTIVE authoring (authoring stays `permissive: true`; the exact branch still compares `permissive` since inferred/live policies vary); any index-side change; renaming the five block kinds.
68+
69+
## Pre-investigated edge cases
70+
71+
| Case | Obligation |
72+
| --- | --- |
73+
| Introspection `?? policyname` fallback | Removed per § 4; its head-derivation role is slice-4 infer's, comment-marked. Check for any other consumer of actual-side policy `prefix` (rename pass groups by `parseWireName(name)`, not `prefix` — verify). |
74+
| `permissive` hardcoded `true` at authoring | Exact comparison and phase-2 still compare it (live/inferred can be RESTRICTIVE); managed authoring unchanged. |
75+
| Existing managed contracts (incl. Supabase policies) | Zero movement expected; the optional-prefix schema widening must not change canonical bytes of prefix-carrying policies. |
76+
| Planner "not-equal unreachable" comment | Now false; comment replaced, mapping added, tested (scenario F). |
77+
| Exact policy under additive-only with a same-name live policy | CREATE POLICY precheck collides — confirm the plan surfaces this sanely (existing precheck failure semantics; no new mechanism). |
78+
79+
## Slice-specific done conditions
80+
81+
1. Scenario C e2e (renames-only transition) and scenario F integration (drift → not-equal → drop+create/conflict) green, byte-asserted where stated.
82+
2. PSL/managed byte-stability proven (fixtures unmoved; head-keyed duplicate checks untouched).
83+
3. The D9 policy warning fires through the shared batch (one flush per build across indexes + policies).
84+
4. Full standing gate (incl. `lint:throws`, `lint:framework-vocabulary`, `lint:skills`, `check:upgrade-coverage --mode pr`, `test:examples`).
85+
86+
## Open questions
87+
88+
None.
89+
90+
## References
91+
92+
Grounding (paths current on the slice-2 branch): `authoring.ts:87–93,146–263,393–511,792–834`, `psl-extension-block.ts:277–293`, `interpreter.ts:528,2200`, `postgres-rls-policy.ts`, `postgres-policy-schema-node.ts:44–100`, `postgres-validators.ts:10`, `entity-kinds.ts:13`, `postgres-contract-serializer.ts:79,136`, `control-adapter.ts:1247–1288,1435,1979`, `planner.ts:388–485,511–622,721–754`, `operations/rls.ts:142`, `op-factory-call.ts:1784`, `index-naming.ts:33–72,122–126`, `build-contract.ts:665,689,931,1351`, `framework-authoring.ts:187–195`, `rls-rename-planner.test.ts`, the adapter RLS integration suites.

0 commit comments

Comments
 (0)