proof-harness 0.1.0-next.6 → 0.1.0-next.8

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/COMPATIBILITY.md CHANGED
@@ -11,7 +11,7 @@ Consumers must pin an exact prerelease version, for example:
11
11
  ```json
12
12
  {
13
13
  "devDependencies": {
14
- "proof-harness": "0.1.0-next.6"
14
+ "proof-harness": "0.1.0-next.7"
15
15
  }
16
16
  }
17
17
  ```
@@ -27,6 +27,213 @@ versions rather than guessing how to interpret them.
27
27
 
28
28
  ## Prerelease migration notes
29
29
 
30
+ ### 0.1.0-next.8
31
+
32
+ Release candidate prepared for publication. Publish from `main` with workflow
33
+ input `0.1.0-next.8` after this version change is merged. Consumers can pin
34
+ `proof-harness@0.1.0-next.8` after the publish workflow succeeds.
35
+
36
+ #### Direct RLS clients and Auth administration (#18)
37
+
38
+ The scanner recognizes immutable local bindings returned by
39
+ `createSupabaseRLSClient`, including named-import aliases and awaited calls.
40
+ Their table writes are assessed as RLS writes, never recorded as service-role
41
+ writes. Binding identity is local to its scope: shadowed identifiers,
42
+ reassignment, escaped clients/builders, and unresolved privilege still fail.
43
+ This remains convention-based discovery of the named consumer factories, not
44
+ verification of their implementation or arbitrary wrapper/dataflow analysis.
45
+
46
+ Direct `serviceClient.auth.admin.deleteUser(...)` is recorded in the additive
47
+ capability field `serviceRoleAuthOperations: ["deleteUser"]`, separate from
48
+ `serviceRoleMutations`. This does not pretend the Auth API is a SQL DELETE
49
+ (and does not infer whether its optional soft-delete argument is true).
50
+ Other administrative methods, dynamic method names, detached calls, and
51
+ unresolved administrative privilege remain unassessed.
52
+
53
+ Regenerate capabilities. Changes to Auth operations participate in drift under
54
+ `service_role_mutations_changed`. Coverage requires a package-origin denial
55
+ assertion and an allowed-path control for a user-facing Auth-admin action,
56
+ even when it has no workspace input or voluntary invariant declaration.
57
+ Existing explicit action-gap policy handling remains unchanged; a recognized
58
+ operation alone is not passing authorization evidence. Trace, mission and
59
+ health protocol versions are unchanged; capability schema remains additive v1.
60
+
61
+ #### Catalog providers and rollback INSERT controls (#19, #20)
62
+
63
+ These changes are not in the published `0.1.0-next.7`. They are opt-in; the
64
+ default SQL parser and REST INSERT/DELETE control remain available. No trace,
65
+ mission, or health protocol version changes. Pin exactly `0.1.0-next.8` only
66
+ after it is released.
67
+
68
+ #### Catalog assessment
69
+
70
+ Set `schemaProvider: "scripts/proof/catalog-provider.mjs"` in the protected
71
+ `proof.config.mjs`. `parse`, the parse step of `build`, and both sides of
72
+ `drift` use it instead of the conservative SQL parser. ALTER and DROP are then
73
+ evaluated by PostgreSQL, not inferred from historical CREATE statements.
74
+
75
+ The module must export a default async function receiving:
76
+
77
+ - `protocolVersion: 1`, `rootDir` for the tree being assessed;
78
+ - `migrations`: ordered `{ path, sql, sha256 }` entries from the configured
79
+ migration directory (top-level `.sql` files), and their combined `inputHash`;
80
+ - `withLocalPostgres(connectionUrl, callback)` and
81
+ `readPostgresCatalog(client, schemas = ["public"])` from `proof-harness/node`.
82
+ These are injected so the archived base needs no installed dependencies to
83
+ import these helpers. Keep the provider's own imports dependency-free.
84
+
85
+ The consumer provider must create an empty disposable database, apply its
86
+ versioned platform bootstrap and every supplied migration in order, read the
87
+ catalog, and drop the database in `finally`. Fail on migration or cleanup
88
+ errors. Return `{ protocolVersion: 1, assessed: true, issues: [], tables }` only
89
+ after successful evaluation and cleanup; `tables` is the helper's result.
90
+ The connection helper restricts connections to loopback hosts. The consumer
91
+ owns the local database URL, creation privileges, platform bootstrap, migration
92
+ ordering, schema selection, and any required role setup. Never use the live
93
+ head database as the base catalog, clone an application-populated database,
94
+ or catch a failed migration and return partial facts.
95
+
96
+ Catalog table facts contain `schema`, `name`, `columns` (names), `rls_enabled`,
97
+ `rls_forced`, and `policies`. Each policy contains `name`, `command`, `roles`,
98
+ `mode`, `using`, and `check`. Classification is package-owned. Drift compares
99
+ column names, RLS state including FORCE, and full policy facts; this does not
100
+ add drift coverage for column types, defaults, functions, triggers, or grants.
101
+ Malformed envelopes/facts and changed migration inputs fail assessment and
102
+ replace stale successful output with an unassessed artifact.
103
+
104
+ The schema artifact adds `assessment` with provider protocol/path/hash,
105
+ migration paths/hashes, combined input hash, and repository source hash. This
106
+ is source identity, not a signature. The provider, its platform bootstrap,
107
+ configuration, and installed harness remain protected trust inputs.
108
+
109
+ For generated migrations, retain `driftPrepare` to rebuild them in each tree.
110
+ **Adoption requires a preparatory base commit:** register the provider, its
111
+ platform bootstrap, and config in the consumer before the comparison that
112
+ upgrades the package. Both base and head must declare a provider. Drift rejects
113
+ switching assessment modes across the comparison and never substitutes head
114
+ configuration for a base without the provider. Existing next.7 ignores the new
115
+ keys; registering them does not repair its conservative parser. A consumer
116
+ already blocked on next.7 needs to land this trusted setup separately before
117
+ the candidate's first successful drift run.
118
+
119
+ #### Append-only INSERT controls
120
+
121
+ Configure the protected consumer config explicitly, using your local ports:
122
+
123
+ ```js
124
+ insertControl: {
125
+ mode: "rollback",
126
+ databaseUrlEnv: "PROOF_DATABASE_URL",
127
+ role: "service_role",
128
+ supabaseUrl: "http://127.0.0.1:54321",
129
+ }
130
+ ```
131
+
132
+ Supply the corresponding local PostgreSQL URL through that environment
133
+ variable; never commit its password. The configured REST origin must match
134
+ `NEXT_PUBLIC_SUPABASE_URL`. The database URL must refer to the same stack,
135
+ schema, and migrations as REST; the harness validates the configured origin
136
+ but cannot discover which database a REST server actually serves.
137
+
138
+ The denied INSERT still executes through the authenticated Supabase client.
139
+ Only after an explicit `42501` denial and a service read confirming no write
140
+ does the harness validate the same payload on one PostgreSQL connection:
141
+ BEGIN, SET LOCAL ROLE, INSERT, force deferred constraints, ROLLBACK. The role
142
+ must have the intended INSERT permission. Missing columns keep their database
143
+ defaults; the harness supplies no tenancy values. An invalid payload, failed
144
+ role change, database error, or unacknowledged rollback fails as incomplete
145
+ evidence. The harness records the control assertion only after success and a
146
+ REST reread confirming no matching row remains. There is no consumer callback
147
+ that can stamp a passing control.
148
+
149
+ No DELETE privilege is needed for this control. Transactional trigger writes
150
+ also roll back, but sequence advancement and external side effects do not;
151
+ use only a disposable local stack. This control checks database validity under
152
+ the configured role, not REST middleware or an authenticated user's JWT
153
+ context. Templates whose privileged insert depends on that context must supply
154
+ a suitable database design/control strategy rather than treat this mode as
155
+ equivalent. Product authorization errors must still use SQLSTATE `42501`;
156
+ generic exceptions such as `P0001` are not accepted as authorization evidence.
157
+
158
+ For maintainers, `PROOF_DATABASE_URL=... node tests/postgres-integration.mjs`
159
+ after `pnpm build` exercises a separate temporary database against PostgreSQL,
160
+ including immediate and deferred failures and catalog ALTER/DROP behavior.
161
+
162
+ ### 0.1.0-next.7 — audit hardening
163
+
164
+ Published release `0.1.0-next.7` tightens acceptance behavior. Trace
165
+ <a id="unreleased-audit-hardening"></a>
166
+
167
+ ### 0.1.0-next.7
168
+
169
+ Published September 7, 2026. This release tightens acceptance behavior. Trace
170
+ schema remains 2, with additive source/execution identity fields. Version 1
171
+ and unversioned trace shapes remain explicitly readable, but CLI acceptance
172
+ requires current provenance. Required consumer changes:
173
+
174
+ - Regenerate traces. `verify`, `coverage`, and mutation inventory share a
175
+ decoder: corrupt JSON, duplicate proof IDs, unknown vocabulary, and
176
+ contradictory verdicts fail. Only passing baseline evidence can satisfy
177
+ claims; mutation evidence is separate. Current spec contents must match
178
+ `specHash`; in Git worktrees both `commit` and `sourceHash` must match.
179
+ `sourceHash` includes tracked and untracked nonignored inputs and excludes
180
+ configured generated artifacts. Run generators before proofs. Non-Git
181
+ exports cannot establish whole-source freshness and only check spec hashes.
182
+ - Use a dedicated trace directory. The writer honors `artifacts.traces` and
183
+ the runner's `--traces-dir` override. IDs must use letters, digits, dots,
184
+ underscores, or hyphens, starting with a letter or digit. Duplicate IDs
185
+ cannot overwrite another proof; a later Playwright retry of the same test
186
+ may replace its earlier attempt. Standalone repeated runs need fresh output.
187
+ Mission aggregates now live at `<traces>/missions/<missionId>.json` to
188
+ prevent collisions with proof IDs. Cleanup refuses unknown JSON before
189
+ deleting any trace and rejects repository/ancestor directories, including
190
+ symlink aliases. Move legacy or corrupt files aside before rerunning.
191
+ - UPDATE denial probes require stable `identityColumns` (default `["id"]`),
192
+ present and non-null in every target row, excluded from the update payload.
193
+ The attempted update must change a value. Rereads use original identities,
194
+ even when a write changes a filter column. SQL errors other than `42501`
195
+ are incomplete evidence. INSERT probes require a payload absent before the
196
+ probe and valid for a service-role control insertion; that control is
197
+ cleaned up. HTTP expectations must contain a status or a nonempty matcher
198
+ list. Write requests are never automatically retried after transport errors.
199
+ - Capability discovery uses TypeScript syntax analysis for import aliases and
200
+ multiple actions. Unknown factory/client indirection, escaped service query
201
+ builders, dynamic table names, and ambiguous metadata block assessment.
202
+ Discovery still assumes the configured template action layout.
203
+ - The SQL parser is deliberately conservative, not a final PostgreSQL catalog
204
+ evaluator. Unsupported ALTER/DROP or dynamic SQL makes schema assessment
205
+ fail. RLS must be explicitly enabled for classification; public ALL/write
206
+ policies are never exempted as public-read. Policy expressions and mode
207
+ participate in drift. Resolve unsupported migrations with a catalog-backed
208
+ integration before using this parser as a gate; do not edit shipped SQL or
209
+ mark an unassessed artifact assessed to obtain a pass.
210
+ - Drift always regenerates both trees using the installed harness and each
211
+ tree's consumer configuration. It includes untracked actions, SQL, and
212
+ lockfiles. For ignored/generated migrations, configure `driftPrepare` as a
213
+ command argv array (for example `["node", "scripts/aggregate_migrations.mjs",
214
+ "--fresh"]`). That consumer-owned command runs in each tree before parsing;
215
+ failure prevents assessment. It must work in the archived tree without an
216
+ implicit dependency install. The old `driftSources` optimization is unused.
217
+ - Mutations require fresh mapped baseline claims and write isolated traces.
218
+ SQL applies run as one transaction; recovery is attempted even after a
219
+ failed apply. Policy snapshots preserve expressions, roles, command, and
220
+ permissive/restrictive mode. Generic privilege mutations support direct,
221
+ owner-issued grants without grant options; unsupported inherited/PUBLIC or
222
+ ownership cases fail before planting. Move old mutation outputs aside on
223
+ first use: nonempty output directories require a harness ownership marker.
224
+ An interrupted run leaves `mutation-recovery.json` beside the mutation output
225
+ directory. Run `proof-harness mutate --recover` against the same local
226
+ database to restore and verify the journaled subject. A timeout, signal, or
227
+ incomplete failed claim never counts as detection. Recovery covers the
228
+ catalog's declared subject and cleanup, not arbitrary undeclared side effects.
229
+ - Partial fixture setup is cleaned up, and cleanup errors now fail the proof.
230
+
231
+ These checks validate observed evidence. Protect the installed harness,
232
+ configuration, mission, policies, mutation catalog, and CI acceptance job from
233
+ executor edits. Trace metadata is not a signature or a sandbox: it cannot
234
+ establish honesty against an executor allowed to replace the gate or fabricate
235
+ its inputs. Independent product acceptance and a trusted runner remain needed.
236
+
30
237
  ### 0.1.0-next.6
31
238
 
32
239
  **Removed: the module-descriptor system.** It served the consumer's planner
@@ -10,7 +10,7 @@ instead; this document is for anyone _consuming_ the template.
10
10
  > **"Manifest" in this document always means the _mission_ manifest**
11
11
  > (`.proof/current-mission.json`) — the file that pins what one PR must prove.
12
12
 
13
- - **Version:** 1 (`schemaVersion` in every manifest and artifact)
13
+ - **Versions:** mission manifests 1; proof traces 2; proof health protocol 1.
14
14
  - **Stability:** The shapes in this document are the contract. Additive
15
15
  changes (new optional fields, new vocabulary entries) do not bump the
16
16
  version; breaking changes bump `schemaVersion` and are documented here.
@@ -22,6 +22,17 @@ instead; this document is for anyone _consuming_ the template.
22
22
 
23
23
  ---
24
24
 
25
+ ## Current acceptance semantics
26
+
27
+ The `0.1.0-next.7` audit hardening in [COMPATIBILITY.md](COMPATIBILITY.md#010-next7)
28
+ supersedes older template-specific command examples below. Trace consumers
29
+ must use `decodeTrace`/`decodeTraceBundle` for structural validation and
30
+ `readTraceDirectory` for CLI-equivalent freshness checks. Evidence counts only
31
+ when the proof and step passed, the assertion has a passing consistent verdict,
32
+ and the trace is a baseline rather than a planted mutation. Helper-origin
33
+ metadata is a convention enforced by the writer, not cryptographic attestation.
34
+ The consumer controls and protects the runner and its trust inputs.
35
+
25
36
  ## 1. The loop
26
37
 
27
38
  ```
@@ -47,7 +58,7 @@ instead; this document is for anyone _consuming_ the template.
47
58
  │ │ fails cheaply BEFORE the database proofs run │
48
59
  │ ├─ proof:verify │
49
60
  │ ├─ runs every *.proof.ts spec │
50
- │ ├─ aggregates .proof/traces/<missionId>.json │
61
+ │ ├─ aggregates .proof/traces/missions/<missionId>.json │
51
62
  │ └─ validates manifest requirements against artifacts │
52
63
  │ ├─ proof:coverage --strict │
53
64
  │ └─ proof:inventory → requires mutation or acceptance │
@@ -1210,3 +1221,28 @@ a trace version bump.
1210
1221
  `.proof/coverage-policy.json` carries its own `schemaVersion` (currently `1`).
1211
1222
  It is authored, not derived, and is read only by `pnpm proof:coverage`; consumers
1212
1223
  do not need to parse it.
1224
+
1225
+
1226
+ ## Catalog provider and rollback controls (0.1.0-next.8)
1227
+
1228
+ The protected consumer config may opt into `schemaProvider` protocol 1 and
1229
+ `insertControl.mode: "rollback"`. See [the setup and migration contract](COMPATIBILITY.md)
1230
+ for the exact provider envelope, base-commit requirements, and control configuration.
1231
+ `proof-harness/node` exports `withLocalPostgres`, `readPostgresCatalog`,
1232
+ `rollbackInsertControl`, and the `PostgresCatalogTable`/`PostgresCatalogPolicy` types.
1233
+ Providers own database materialization; the harness validates catalog facts,
1234
+ classifies policies, binds source identity, and compares both trees. A rollback
1235
+ INSERT control is package-executed and can only record passing control evidence
1236
+ after its insert, deferred checks, rollback, and absence reread succeed. The
1237
+ original authenticated request remains the primary authorization assertion.
1238
+
1239
+
1240
+ ## Direct Auth administration (0.1.0-next.8)
1241
+
1242
+ Capability schema v1 adds optional `serviceRoleAuthOperations`, currently a
1243
+ closed list containing only `"deleteUser"`. It describes direct service-client
1244
+ Auth API calls separately from `serviceRoleMutations` (SQL table writes).
1245
+ Such user-facing actions require denial and allowed-control evidence in
1246
+ coverage even without a workspace input. Changes use the existing
1247
+ `service_role_mutations_changed` drift facet. See `COMPATIBILITY.md` for
1248
+ supported factory/binding shapes and required consumer regeneration.
package/README.md CHANGED
@@ -17,13 +17,13 @@ Public prereleases are published under the npm `next` tag. Pin an exact
17
17
  prerelease version while the API is stabilizing:
18
18
 
19
19
  ```sh
20
- pnpm add proof-harness@0.1.0-next.6
20
+ pnpm add proof-harness@0.1.0-next.7
21
21
  ```
22
22
 
23
23
  The package exposes environment-specific entry points:
24
24
 
25
25
  - `proof-harness/shared` — portable types and vocabularies
26
- - `proof-harness/node` — Node mission validation helpers
26
+ - `proof-harness/node` — Node mission validation and local PostgreSQL helpers
27
27
  - `proof-harness/playwright` — Playwright proof assertions and traces
28
28
  - `proof-harness/server` — server-side fixtures, guards, and service clients
29
29
  - `proof-harness/portable-vocabulary` — dependency-free runtime constants
@@ -91,3 +91,23 @@ Licensed under the [Apache License 2.0](./LICENSE).
91
91
  The package is released from this public repository with npm provenance.
92
92
  Prerelease consumers must pin an exact version rather than using `next`, `^`, or
93
93
  `~`; see [COMPATIBILITY.md](./COMPATIBILITY.md) for the compatibility policy.
94
+
95
+ ## Audit hardening (0.1.0-next.7)
96
+
97
+ This release adds shared fail-closed trace ingestion, source freshness,
98
+ stronger authorization probes, isolated mutation evidence and interruption
99
+ recovery, conservative discovery, and package-owned drift regeneration.
100
+ See [required migration changes](COMPATIBILITY.md#010-next7--audit-hardening)
101
+ before upgrading a consumer. In particular, existing traces must be rerun and
102
+ unsupported SQL now blocks assessment.
103
+
104
+ Release candidate `0.1.0-next.8` adds direct RLS client and Auth-admin discovery,
105
+ a consumer-owned catalog provider for full SQL histories, and transactional
106
+ INSERT controls for append-only tables. After publication, pin exactly
107
+ `proof-harness@0.1.0-next.8`. See [compatibility and setup](COMPATIBILITY.md#010-next8)
108
+ for the required consumer changes.
109
+
110
+ The harness establishes specific tested claims. Acceptance still requires
111
+ consumer-owned requirements and a trusted CI job that controls the harness,
112
+ policies, and evidence. A passing trace is not a general proof of code quality
113
+ or a defense against an executor that can rewrite the acceptance gate.
package/cli/config.mjs CHANGED
@@ -43,6 +43,11 @@ const DEFAULT_CONFIG = Object.freeze({
43
43
  envExample: ".env.local.example",
44
44
  supabaseConfig: "supabase/config.toml",
45
45
  },
46
+ // Optional consumer-owned argv used to prepare generated migration inputs
47
+ // in both the working tree and the archived base before parsing.
48
+ driftPrepare: null,
49
+ schemaProvider: null,
50
+ insertControl: null,
46
51
  driftSources: ["app", "package.json"],
47
52
  mutationCatalog: null,
48
53
  });
@@ -105,3 +110,29 @@ export async function loadMutationCatalog(config) {
105
110
  }
106
111
 
107
112
  export { DEFAULT_CONFIG };
113
+
114
+ /** Derived outputs are excluded from the source identity, never trust inputs. */
115
+ export function evidenceExclusions(config) {
116
+ return [
117
+ ...Object.values(config.artifacts),
118
+ `${path.dirname(config.artifacts.mutations)}/mutation-recovery.json`,
119
+ ].map((p) => path.resolve(config.rootDir, p));
120
+ }
121
+
122
+ /** Reject a trace/output directory that resolves to the repository or its parent. */
123
+ export function assertArtifactDirectory(directory, rootDir) {
124
+ function canonical(file) {
125
+ const absolute = path.resolve(file);
126
+ if (fs.existsSync(absolute)) return fs.realpathSync(absolute);
127
+ const parent = path.dirname(absolute);
128
+ return path.join(canonical(parent), path.basename(absolute));
129
+ }
130
+ const output = canonical(directory);
131
+ const root = canonical(rootDir);
132
+ const relative = path.relative(output, root);
133
+ if (!relative || (!relative.startsWith("..") && !path.isAbsolute(relative)))
134
+ throw new Error(
135
+ "[PROOF_FAIL] unsafe_artifact_directory: output must not be the repository or an ancestor",
136
+ );
137
+ return output;
138
+ }
@@ -40,6 +40,8 @@ import fs from "node:fs";
40
40
  import path from "node:path";
41
41
  import { pathToFileURL } from "node:url";
42
42
 
43
+ import { assessWithProvider } from "./proof_schema_provider.mjs";
44
+ import { classifyTable } from "./proof_schema_classification.mjs";
43
45
  import { loadProofConfig } from "../config.mjs";
44
46
 
45
47
  const CONFIG = await loadProofConfig();
@@ -269,6 +271,7 @@ function parsePolicies(sql, sourceFile) {
269
271
  name: policyName,
270
272
  table: tableName,
271
273
  command,
274
+ mode: /\bAS\s+RESTRICTIVE\b/i.test(rest) ? "RESTRICTIVE" : "PERMISSIVE",
272
275
  roles,
273
276
  using: usingBody.trim(),
274
277
  check: checkBody.trim(),
@@ -282,90 +285,33 @@ function parsePolicies(sql, sourceFile) {
282
285
  // Classification
283
286
  // ---------------------------------------------------------------------------
284
287
 
285
- function policyIndicatesWorkspace(policy) {
286
- const body = `${policy.using} ${policy.check}`.toLowerCase();
287
- if (body.includes("get_user_workspace_ids(")) return true;
288
- if (
289
- /workspace_id\s+in\s*\(\s*select\s+workspace_id\s+from\s+workspace_members/i.test(
290
- `${policy.using} ${policy.check}`,
291
- )
292
- ) {
293
- return true;
294
- }
295
- return false;
296
- }
297
-
298
- function policyIndicatesUser(policy) {
299
- const body = `${policy.using} ${policy.check}`;
300
- // user_id = auth.uid() OR auth.uid() = user_id OR owner/created_by variants
301
- const patterns = [
302
- /\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(?\s*(select\s+)?auth\.uid\(\)/i,
303
- /\bauth\.uid\(\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
304
- /\(\s*select\s+auth\.uid\(\)\s*\)\s*=\s*(user_id|owner_id|created_by|invited_by)\b/i,
305
- /\b(user_id|owner_id|created_by|invited_by)\s*=\s*\(\s*select\s+auth\.uid\(\)\s*\)/i,
306
- ];
307
- return patterns.some((re) => re.test(body));
308
- }
309
-
310
- function policyIsPublicRead(policy) {
311
- if (policy.command !== "SELECT" && policy.command !== "ALL") return false;
312
- const body = policy.using.trim().toLowerCase();
313
- return body === "true";
314
- }
315
-
316
- function classifyTable(policies) {
317
- // No policies at all → admin_only (assumes RLS-enabled Supabase table
318
- // without authenticated access).
319
- if (policies.length === 0) return "admin_only";
320
-
321
- // A policy with no TO clause defaults to `TO public` in Postgres, so
322
- // treat an empty `roles` list as implicit public (i.e. authenticated
323
- // users are subject to it). This matches the common
324
- // `CREATE POLICY foo ON t FOR SELECT USING (...)` pattern.
325
- const isAuthFacing = (p) =>
326
- p.roles.length === 0 ||
327
- p.roles.some((r) => r === "authenticated" || r === "public");
328
- const authPolicies = policies.filter(isAuthFacing);
329
- const serviceOnly =
330
- authPolicies.length === 0 &&
331
- policies.length > 0 &&
332
- policies.every(
333
- (p) => p.roles.length === 1 && p.roles[0] === "service_role",
334
- );
335
-
336
- if (serviceOnly) return "service_only";
337
-
338
- // Check workspace first (most specific).
339
- if (authPolicies.some(policyIndicatesWorkspace)) return "workspace_scoped";
340
- if (authPolicies.some(policyIndicatesUser)) return "user_scoped";
341
-
342
- // public_read: every authenticated-accessible policy is SELECT with
343
- // USING (true). (Don't classify as public_read if any write policy
344
- // targets authenticated, that's a different pattern.)
345
- const readPolicies = authPolicies.filter(
346
- (p) => p.command === "SELECT" || p.command === "ALL",
347
- );
348
- if (
349
- readPolicies.length > 0 &&
350
- readPolicies.every(policyIsPublicRead) &&
351
- authPolicies.every(
352
- (p) => p.command === "SELECT" || p.command === "ALL" || p.check === "",
353
- )
354
- ) {
355
- return "public_read";
356
- }
357
-
358
- if (authPolicies.length === 0) return "admin_only";
359
-
360
- return "unclassified";
361
- }
362
-
363
288
  // ---------------------------------------------------------------------------
364
289
  // Main
365
290
  // ---------------------------------------------------------------------------
366
291
 
367
- export function main() {
292
+ export async function main() {
293
+ if (CONFIG.schemaProvider) return assessWithProvider(CONFIG);
368
294
  const sources = readAllMigrations();
295
+ const unsupported = [];
296
+ const enabledTables = new Set();
297
+ for (const { file, sql } of sources) {
298
+ // This parser is intentionally conservative. Never preserve historical CREATE
299
+ // facts as current evidence after an operation we cannot interpret.
300
+ const enable =
301
+ /ALTER\s+TABLE\s+(?:ONLY\s+)?(?:"?public"?\.)?"?(\w+)"?\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY\s*;/gi;
302
+ for (const match of sql.matchAll(enable)) enabledTables.add(match[1]);
303
+ const remainder = sql.replace(enable, "");
304
+ if (
305
+ /\b(?:ALTER\s+(?:TABLE|POLICY)|DROP\s+(?:TABLE|POLICY)|DO\s+\$|EXECUTE\s+)/i.test(
306
+ remainder,
307
+ )
308
+ )
309
+ unsupported.push({
310
+ file,
311
+ reason:
312
+ "migration changes schema or policy state using unsupported SQL; final schema is unassessable",
313
+ });
314
+ }
369
315
  const tablesByName = new Map();
370
316
  const policiesByTable = new Map();
371
317
 
@@ -373,7 +319,10 @@ export function main() {
373
319
  for (const t of extractTables(sql, file)) {
374
320
  const existing = tablesByName.get(t.name);
375
321
  if (existing) {
376
- // Merge columns from ALTER TABLE or re-declarations (future-proof).
322
+ unsupported.push({
323
+ file,
324
+ reason: `repeated CREATE TABLE ${t.name} cannot establish final columns`,
325
+ });
377
326
  existing.columns = [...new Set([...existing.columns, ...t.columns])];
378
327
  if (!existing.files.includes(file)) existing.files.push(file);
379
328
  } else {
@@ -395,17 +344,23 @@ export function main() {
395
344
 
396
345
  for (const [name, entry] of tablesByName) {
397
346
  const policies = policiesByTable.get(name) ?? [];
398
- const classification = classifyTable(policies);
347
+ const classification = enabledTables.has(name)
348
+ ? classifyTable(policies)
349
+ : "unclassified";
399
350
 
400
351
  const normalized = {
401
352
  name,
402
353
  columns: [...entry.columns].sort(),
403
354
  rls_classification: classification,
355
+ rls_enabled: enabledTables.has(name),
404
356
  policies: policies
405
357
  .map((p) => ({
406
358
  name: p.name,
407
359
  command: p.command,
408
360
  roles: [...p.roles].sort(),
361
+ using: p.using,
362
+ check: p.check,
363
+ mode: p.mode,
409
364
  }))
410
365
  .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
411
366
  sourceFiles: [...entry.files]
@@ -429,6 +384,8 @@ export function main() {
429
384
 
430
385
  const output = {
431
386
  schemaVersion: 1,
387
+ assessed: unsupported.length === 0,
388
+ issues: unsupported,
432
389
  tables,
433
390
  unclassified: unclassifiedTables,
434
391
  };
@@ -439,6 +396,10 @@ export function main() {
439
396
  console.log(
440
397
  `[proof:parse] parsed ${sources.length} migration file(s); wrote ${tables.length} table(s), ${unclassifiedTables.length} unclassified to ${OUTPUT_PATH}`,
441
398
  );
399
+ if (unsupported.length)
400
+ throw new Error(
401
+ `[PROOF_FAIL] schema_unassessed: ${unsupported.map((p) => `${p.file}: ${p.reason}`).join("; ")}`,
402
+ );
442
403
  if (unclassifiedTables.length > 0) {
443
404
  console.log(`[proof:parse] unclassified tables:`);
444
405
  for (const u of unclassifiedTables)
@@ -453,5 +414,5 @@ const invokedDirectly =
453
414
  import.meta.url === pathToFileURL(process.argv[1]).href;
454
415
 
455
416
  if (invokedDirectly) {
456
- main();
417
+ await main();
457
418
  }