proof-harness 0.1.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/COMPATIBILITY.md +26 -0
  2. package/LICENSE +201 -0
  3. package/PROOF_SDK_CONTRACT.md +1255 -0
  4. package/README.md +60 -0
  5. package/cli/config.mjs +116 -0
  6. package/cli/engines/generate_proof_action_registry.mjs +271 -0
  7. package/cli/engines/parse_proof_schema.mjs +457 -0
  8. package/cli/engines/proof_coverage.mjs +871 -0
  9. package/cli/engines/proof_drift.mjs +980 -0
  10. package/cli/engines/proof_module_check.mjs +498 -0
  11. package/cli/engines/proof_mutation_check.mjs +1084 -0
  12. package/cli/engines/proof_mutation_inventory.mjs +508 -0
  13. package/cli/engines/proof_source_mutation.mjs +35 -0
  14. package/cli/engines/proof_verify.mjs +620 -0
  15. package/cli/engines/scan_module_meta.mjs +352 -0
  16. package/cli/engines/scan_proof_capabilities.mjs +359 -0
  17. package/cli/proof-harness.mjs +107 -0
  18. package/dist/action-types-C3OmHRyH.d.ts +26 -0
  19. package/dist/fixture-B-dmnG5G.d.ts +105 -0
  20. package/dist/mission-types-Dr5rBHYO.d.ts +299 -0
  21. package/dist/node.d.ts +79 -0
  22. package/dist/node.js +553 -0
  23. package/dist/node.js.map +1 -0
  24. package/dist/playwright.d.ts +433 -0
  25. package/dist/playwright.js +1824 -0
  26. package/dist/playwright.js.map +1 -0
  27. package/dist/portable-vocabulary.d.ts +86 -0
  28. package/dist/portable-vocabulary.js +43 -0
  29. package/dist/portable-vocabulary.js.map +1 -0
  30. package/dist/server.d.ts +55 -0
  31. package/dist/server.js +376 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/shared.d.ts +169 -0
  34. package/dist/shared.js +121 -0
  35. package/dist/shared.js.map +1 -0
  36. package/dist/trace-types-DkIzq-l9.d.ts +333 -0
  37. package/package.json +109 -0
  38. package/proof-consumer-fixtures/README.md +60 -0
  39. package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-FAIL.json +112 -0
  40. package/proof-consumer-fixtures/aggregates/M-CONFORMANCE-PASS.json +101 -0
  41. package/proof-consumer-fixtures/baseline/failing/widgets-tenant-isolation.json +46 -0
  42. package/proof-consumer-fixtures/baseline/passing/widgets-tenant-isolation.json +45 -0
  43. package/proof-consumer-fixtures/corpus.json +102 -0
  44. package/proof-consumer-fixtures/duplicates/first/widgets-tenant-isolation.json +12 -0
  45. package/proof-consumer-fixtures/duplicates/second/widgets-tenant-isolation.json +12 -0
  46. package/proof-consumer-fixtures/inputs/capabilities.json +4 -0
  47. package/proof-consumer-fixtures/inputs/mission.json +20 -0
  48. package/proof-consumer-fixtures/inputs/schema.json +4 -0
  49. package/proof-consumer-fixtures/malformed/invalid-trace.json.invalid +4 -0
  50. package/proof-consumer-fixtures/malformed/wrong-manifest-version.json +11 -0
  51. package/proof-consumer-fixtures/malformed/wrong-trace-shape.json +8 -0
  52. package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/mutation.json +17 -0
  53. package/proof-consumer-fixtures/mutations/MUT-WIDGETS-LEAK/widgets-tenant-isolation.json +50 -0
@@ -0,0 +1,871 @@
1
+ #!/usr/bin/env node
2
+ // ---------------------------------------------------------------------------
3
+ // proof_coverage.mjs
4
+ //
5
+ // What is protected, and what do we merely believe?
6
+ //
7
+ // Every other check in this system is scoped to one mission: the manifest says
8
+ // what this PR must prove, and the validator grades the PR against its own
9
+ // homework. That leaves a blind spot with a specific shape — a mission adds a
10
+ // new scoped table, never asks for tenant isolation on it, CI goes green, and
11
+ // nothing in the pipeline is capable of noticing that a new route can leak
12
+ // across tenants.
13
+ //
14
+ // This script closes that by grading the whole schema instead of one mission.
15
+ // It joins three artifacts that are regenerated on every run:
16
+ //
17
+ // .proof/schema.json every table + its RLS classification
18
+ // .proof/capabilities.json every action + the invariants it declares
19
+ // .proof/traces/*.json every assertion a proof actually emitted
20
+ //
21
+ // and reports, per table, whether a proof exists for the invariant its
22
+ // classification implies — plus, per action, whether every invariant it declares
23
+ // via `withProof` is backed by an assertion. It also derives a mandatory
24
+ // action-layer requirement when a workspace-id-driven action mutates data
25
+ // through the service role: both a refusal and a working
26
+ // allowed-path control must be present, regardless of `withProof` metadata.
27
+ //
28
+ // IMPORTANT — what a gap does and does not mean:
29
+ // "No proof" is NOT "unprotected". These tables have RLS policies; the
30
+ // classification in schema.json was derived FROM those policies. A gap means
31
+ // the policy is asserted by a migration and never exercised by a test. The
32
+ // honest word is *unproven*, not unprotected.
33
+ //
34
+ // The verdict is computed evidence measured against authored intent:
35
+ // - evidence comes from the traces, and is never committed;
36
+ // - intent lives in .proof/coverage-policy.json, which IS committed, because
37
+ // an accepted gap is a decision a human made, not a fact about a run.
38
+ //
39
+ // With --strict, an unaccepted gap fails the build. That makes the policy file
40
+ // a ratchet: today's gaps are listed and pass, but the next scoped table added
41
+ // without a proof fails CI until someone either proves it or writes down, in a
42
+ // reviewable diff, why they chose not to.
43
+ //
44
+ // Usage:
45
+ // node scripts/proof_coverage.mjs # report
46
+ // node scripts/proof_coverage.mjs --strict # exit 1 on any unaccepted gap
47
+ // node scripts/proof_coverage.mjs --json # machine-readable report
48
+ // ---------------------------------------------------------------------------
49
+
50
+ import fs from "node:fs";
51
+ import path from "node:path";
52
+ import { fileURLToPath } from "node:url";
53
+
54
+ // Single source of truth for the helper-identity lists, shared with the
55
+ // mission validators; see portable-vocabulary.mjs for the rationale.
56
+ import {
57
+ TENANT_ISOLATION_ACTION_HELPERS,
58
+ TENANT_ISOLATION_TABLE_HELPERS,
59
+ } from "../../dist/portable-vocabulary.js";
60
+ import { loadProofConfig } from "../config.mjs";
61
+
62
+ const CONFIG = await loadProofConfig();
63
+ process.chdir(CONFIG.rootDir);
64
+
65
+ const SCHEMA_PATH = CONFIG.artifacts.schema;
66
+ const CAPABILITIES_PATH = CONFIG.artifacts.capabilities;
67
+ const TRACES_DIR = CONFIG.artifacts.traces;
68
+ const POLICY_PATH = CONFIG.policies.coverage;
69
+
70
+ /**
71
+ * The invariant each classification implies, and why.
72
+ *
73
+ * Read as: "if the parser concluded a table is reachable this way, then this is
74
+ * the claim a proof has to make about it."
75
+ */
76
+ const REQUIRED_BY_CLASSIFICATION = {
77
+ workspace_scoped: {
78
+ kind: "tenant_isolation",
79
+ because:
80
+ "reachable by members of a workspace, so another workspace must not read it",
81
+ },
82
+ user_scoped: {
83
+ kind: "tenant_isolation",
84
+ because: "reachable by its owning user, so another user must not read it",
85
+ },
86
+ admin_only: {
87
+ kind: "authorization",
88
+ because:
89
+ "no authenticated policy grants access, so a normal user must be refused",
90
+ },
91
+ service_only: {
92
+ kind: "authorization",
93
+ because:
94
+ "only the service role may touch it, so an authenticated client must be refused",
95
+ },
96
+ // Deliberately absent: `public_read` (readable by design) and `unclassified`
97
+ // (the parser could not determine reachability — reported separately, since a
98
+ // table we cannot classify is a blind spot rather than a gap).
99
+ };
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Inputs
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function readJson(file, { required = true } = {}) {
106
+ if (!fs.existsSync(file)) {
107
+ if (!required) return null;
108
+ console.error(
109
+ `[proof:coverage] ${file} not found.\n` +
110
+ ` suggestion: run \`pnpm proof:build\` (and \`pnpm proof:verify\` for traces).`,
111
+ );
112
+ process.exit(2);
113
+ }
114
+ try {
115
+ return JSON.parse(fs.readFileSync(file, "utf8"));
116
+ } catch (err) {
117
+ console.error(`[proof:coverage] ${file} is not valid JSON: ${err.message}`);
118
+ process.exit(2);
119
+ }
120
+ }
121
+
122
+ /** Every assertion from every trace, flattened. Steps may nest assertions. */
123
+ function readAssertions() {
124
+ if (!fs.existsSync(TRACES_DIR)) return [];
125
+ const out = [];
126
+ for (const file of fs.readdirSync(TRACES_DIR)) {
127
+ if (!file.endsWith(".json")) continue;
128
+ const parsed = readJson(path.join(TRACES_DIR, file));
129
+ for (const artifact of Array.isArray(parsed) ? parsed : [parsed]) {
130
+ // The aggregate <missionId>.json embeds every per-spec trace; skip it so
131
+ // assertions are not counted twice.
132
+ if (Array.isArray(artifact?.traces)) continue;
133
+ const nested = (artifact?.steps ?? []).flatMap((s) => s.assertions ?? []);
134
+ for (const assertion of [...(artifact?.assertions ?? []), ...nested]) {
135
+ out.push({ ...assertion, proofId: artifact?.proofId ?? null });
136
+ }
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Target resolution
144
+ //
145
+ // Assertion targets are free-form by design, and in practice they take several
146
+ // shapes: `workspace_members`, `workspace_members.insert`,
147
+ // `users.is_super_admin`, `/secret-admin`, `deduct_workspace_credits`,
148
+ // `workspace:removeMember`.
149
+ //
150
+ // Rather than impose a naming convention on every proof (and break targets that
151
+ // missions already reference), resolution is decided by the schema itself: the
152
+ // segment before the first `.` is a table only if schema.json says such a table
153
+ // exists. That makes the join unambiguous without a convention, and it fails in
154
+ // the safe direction — a typo resolves to nothing, so the table it was meant to
155
+ // cover still shows up as a gap rather than being silently marked proven.
156
+ //
157
+ // Anything that does not resolve is reported, so the join is never silent.
158
+ // ---------------------------------------------------------------------------
159
+
160
+ function resolveTable(target, tableNames) {
161
+ const head = String(target).split(".")[0];
162
+ return tableNames.has(head) ? head : null;
163
+ }
164
+
165
+ function normalizedRole(assertion) {
166
+ if (assertion.role === "control" || assertion.role === "positive_control") {
167
+ return "control";
168
+ }
169
+ return "primary";
170
+ }
171
+
172
+ /**
173
+ * Whether an assertion's origin is trustworthy enough to count as coverage
174
+ * evidence for its kind.
175
+ *
176
+ * `tenant_isolation` claims must carry helper provenance (`emittedBy`), and
177
+ * the stamp is identity-checked, not merely present-checked:
178
+ * `assert.httpResponse` records whatever `kind` its caller passes, so its
179
+ * stamp on an isolation assertion would prove only that an HTTP probe ran.
180
+ * Isolation OF A TABLE is exactly what `assert.tenantIsolation` proves, so
181
+ * table targets accept only it; action-ref targets also accept
182
+ * `assert.authorization`, whose action probes record the cross-tenant
183
+ * invariant they establish. Other kinds accept spec-recorded assertions —
184
+ * bespoke probes (RPC grants, column guards) are legitimate there and remain
185
+ * backstopped by the mutation inventory.
186
+ */
187
+ function verifiedOrigin(assertion, { isTable }) {
188
+ if (assertion.kind !== "tenant_isolation") return true;
189
+ const allowed = isTable
190
+ ? TENANT_ISOLATION_TABLE_HELPERS
191
+ : TENANT_ISOLATION_ACTION_HELPERS;
192
+ return allowed.includes(assertion.emittedBy);
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Report
197
+ // ---------------------------------------------------------------------------
198
+
199
+ function buildReport() {
200
+ const schema = readJson(SCHEMA_PATH);
201
+ const capabilities = readJson(CAPABILITIES_PATH);
202
+ const policy = readJson(POLICY_PATH, { required: false }) ?? {
203
+ acceptedGaps: [],
204
+ };
205
+ const assertions = readAssertions();
206
+
207
+ const tableNames = new Set((schema.tables ?? []).map((t) => t.name));
208
+
209
+ // table -> Set(kind) of PASSING PRIMARY assertions. Controls and non-passing
210
+ // assertions are excluded on purpose: a control proves the fixture works, not
211
+ // the claim, and only a primary assertion is evidence of the invariant.
212
+ const provenKinds = new Map();
213
+ const unresolved = new Map();
214
+ // Passing primaries whose origin disqualifies them (see verifiedOrigin).
215
+ // Reported rather than dropped, so the gap they leave behind is explicable.
216
+ const unverifiedOrigin = [];
217
+
218
+ for (const a of assertions) {
219
+ if (normalizedRole(a) !== "primary") continue;
220
+ if (a.passed !== true) continue;
221
+ const resolved = resolveTable(a.target, tableNames);
222
+ if (!verifiedOrigin(a, { isTable: resolved !== null })) {
223
+ unverifiedOrigin.push({
224
+ kind: a.kind,
225
+ target: a.target,
226
+ emittedBy: a.emittedBy ?? null,
227
+ proofId: a.proofId ?? null,
228
+ });
229
+ continue;
230
+ }
231
+ const table = resolved;
232
+ if (!table) {
233
+ const key = `${a.kind}:${a.target}`;
234
+ unresolved.set(key, (unresolved.get(key) ?? 0) + 1);
235
+ continue;
236
+ }
237
+ if (!provenKinds.has(table)) provenKinds.set(table, new Set());
238
+ provenKinds.get(table).add(a.kind);
239
+ }
240
+
241
+ const accepted = new Map(
242
+ (policy.acceptedGaps ?? []).map((g) => [`${g.table}:${g.kind}`, g]),
243
+ );
244
+ const usedAcceptances = new Set();
245
+ const acceptedActionGaps = new Map(
246
+ (policy.acceptedActionGaps ?? []).map((gap) => [gap.action, gap]),
247
+ );
248
+ const usedActionAcceptances = new Set();
249
+
250
+ const reviewed = new Map(
251
+ (policy.reviewedUnclassified ?? []).map((r) => [r.table, r]),
252
+ );
253
+ const usedReviews = new Set();
254
+
255
+ const rows = [];
256
+ const unassessable = [];
257
+
258
+ for (const table of [...(schema.tables ?? [])].sort((a, b) =>
259
+ a.name.localeCompare(b.name),
260
+ )) {
261
+ const requirement = REQUIRED_BY_CLASSIFICATION[table.rls_classification];
262
+ if (!requirement) {
263
+ // A table the parser cannot classify is the one case where no requirement
264
+ // can be derived, which means it can never be reported as a gap. Left at
265
+ // that, an unparseable policy would be the cheapest way to disappear from
266
+ // this report entirely — so each one has to be acknowledged by hand.
267
+ if (table.rls_classification === "unclassified") {
268
+ const review = reviewed.get(table.name);
269
+ if (review) usedReviews.add(table.name);
270
+ unassessable.push({
271
+ table: table.name,
272
+ note: review?.note ?? null,
273
+ acknowledged: Boolean(review),
274
+ proof: [...(provenKinds.get(table.name) ?? [])].sort(),
275
+ });
276
+ }
277
+ continue;
278
+ }
279
+
280
+ const proven = provenKinds.get(table.name)?.has(requirement.kind) ?? false;
281
+ const acceptance = accepted.get(`${table.name}:${requirement.kind}`);
282
+ if (!proven && acceptance)
283
+ usedAcceptances.add(`${table.name}:${requirement.kind}`);
284
+
285
+ rows.push({
286
+ table: table.name,
287
+ classification: table.rls_classification,
288
+ requires: requirement.kind,
289
+ because: requirement.because,
290
+ status: proven ? "proven" : acceptance ? "accepted_gap" : "gap",
291
+ acceptedReason: acceptance?.reason ?? null,
292
+ otherProof: [...(provenKinds.get(table.name) ?? [])].sort(),
293
+ });
294
+ }
295
+
296
+ // An acceptance for a table that is now proven, or that no longer exists, is
297
+ // stale. Left unreported it would quietly license a future regression.
298
+ const staleAcceptances = (policy.acceptedGaps ?? [])
299
+ .filter((g) => !usedAcceptances.has(`${g.table}:${g.kind}`))
300
+ .map((g) => ({
301
+ ...g,
302
+ why: tableNames.has(g.table)
303
+ ? "the invariant is now proven — delete the acceptance"
304
+ : "no such table in schema.json — delete or fix the acceptance",
305
+ }));
306
+
307
+ const staleReviews = (policy.reviewedUnclassified ?? [])
308
+ .filter((r) => !usedReviews.has(r.table))
309
+ .map((r) => ({
310
+ ...r,
311
+ why: tableNames.has(r.table)
312
+ ? "the table is now classified, so a real requirement applies — delete the acknowledgement"
313
+ : "no such table in schema.json — delete or fix the acknowledgement",
314
+ }));
315
+
316
+ const declaredInvariants = (capabilities.capabilities ?? []).filter(
317
+ (c) => (c.invariants ?? []).length > 0,
318
+ );
319
+
320
+ // Action-level claims.
321
+ //
322
+ // `withProof` is voluntary, so it cannot be used to derive what an action
323
+ // OUGHT to prove — omitting it stays free. What it can do is make a
324
+ // declaration cost something: an action that claims an invariant must have a
325
+ // passing primary assertion against `<module>:<name>`, the same ref the
326
+ // scanner writes into capabilities.json.
327
+ //
328
+ // Deliberately no acceptance path here, unlike table gaps. A table's
329
+ // requirement is derived from its policies, so it exists whether anyone likes
330
+ // it or not and sometimes has to be waived. A declared claim is authored, so
331
+ // the honest escape hatch is deleting the claim rather than excusing it.
332
+ const actionRows = [];
333
+ for (const capability of [...declaredInvariants].sort((a, b) =>
334
+ `${a.module}:${a.name}`.localeCompare(`${b.module}:${b.name}`),
335
+ )) {
336
+ const ref = `${capability.module}:${capability.name}`;
337
+ for (const kind of capability.invariants ?? []) {
338
+ const key = `${kind}:${ref}`;
339
+ const proven = unresolved.has(key);
340
+ // Matched refs are removed so they stop being reported as targets that
341
+ // resolved to nothing — they resolved to an action.
342
+ if (proven) unresolved.delete(key);
343
+ actionRows.push({
344
+ action: ref,
345
+ requires: kind,
346
+ status: proven ? "proven" : "gap",
347
+ file: capability.file ?? null,
348
+ });
349
+ }
350
+ }
351
+
352
+ // Mandatory service-role action boundaries.
353
+ //
354
+ // This requirement is derived from code + schema, not optional withProof
355
+ // metadata. The scanner reports concrete service-role mutations and whether
356
+ // the user-facing action takes a workspaceId. The workspaceId is the scope
357
+ // signal here: tenant-root tables such as `workspaces` are classified as
358
+ // user_scoped by their owner policy, but a service-role action accepting an
359
+ // arbitrary workspaceId still needs an action-layer cross-tenant probe.
360
+ //
361
+ // A denial alone is not enough. A broken/unregistered action also refuses
362
+ // everyone, so the same action target needs a passing allowed-path control.
363
+ const denialKinds = new Set(["authorization", "tenant_isolation"]);
364
+ const controlKinds = new Set([
365
+ "authorization",
366
+ "tenant_isolation",
367
+ "happy_path",
368
+ ]);
369
+ const serviceRoleActionRows = [];
370
+
371
+ for (const capability of [...(capabilities.capabilities ?? [])].sort((a, b) =>
372
+ `${a.module}:${a.name}`.localeCompare(`${b.module}:${b.name}`),
373
+ )) {
374
+ if (
375
+ capability.acceptsWorkspaceId !== true ||
376
+ capability.internalOnly === true
377
+ ) {
378
+ continue;
379
+ }
380
+
381
+ const workspaceMutations = capability.serviceRoleMutations ?? [];
382
+ if (workspaceMutations.length === 0) continue;
383
+
384
+ const action = `${capability.module}:${capability.name}`;
385
+ const denialEvidence = assertions.filter(
386
+ (assertion) =>
387
+ assertion.passed === true &&
388
+ assertion.target === action &&
389
+ normalizedRole(assertion) === "primary" &&
390
+ denialKinds.has(assertion.kind) &&
391
+ verifiedOrigin(assertion, { isTable: false }),
392
+ );
393
+ const controlEvidence = assertions.filter(
394
+ (assertion) =>
395
+ assertion.passed === true &&
396
+ assertion.target === action &&
397
+ normalizedRole(assertion) === "control" &&
398
+ controlKinds.has(assertion.kind),
399
+ );
400
+ const denialProven = denialEvidence.length > 0;
401
+ const controlProven = controlEvidence.length > 0;
402
+ const proven = denialProven && controlProven;
403
+ const acceptance = acceptedActionGaps.get(action);
404
+ if (!proven && acceptance) usedActionAcceptances.add(action);
405
+
406
+ // These targets are now resolved by a derived action requirement.
407
+ for (const kind of denialKinds) unresolved.delete(`${kind}:${action}`);
408
+
409
+ serviceRoleActionRows.push({
410
+ action,
411
+ tables: [...new Set(workspaceMutations.map((m) => m.table))].sort(),
412
+ mutations: workspaceMutations,
413
+ denial: denialProven ? "proven" : "gap",
414
+ control: controlProven ? "proven" : "gap",
415
+ status: proven ? "proven" : acceptance ? "accepted_gap" : "gap",
416
+ acceptedReason: acceptance?.reason ?? null,
417
+ denialProofs: [
418
+ ...new Set(denialEvidence.map((a) => a.proofId).filter(Boolean)),
419
+ ].sort(),
420
+ controlProofs: [
421
+ ...new Set(controlEvidence.map((a) => a.proofId).filter(Boolean)),
422
+ ].sort(),
423
+ file: capability.file ?? null,
424
+ });
425
+ }
426
+
427
+ const staleActionAcceptances = (policy.acceptedActionGaps ?? [])
428
+ .filter((gap) => !usedActionAcceptances.has(gap.action))
429
+ .map((gap) => ({
430
+ ...gap,
431
+ why: serviceRoleActionRows.some((row) => row.action === gap.action)
432
+ ? "the action boundary is now proven — delete the acceptance"
433
+ : "no derived service-role action requirement has this ref — delete or fix the acceptance",
434
+ }));
435
+
436
+ return {
437
+ rows,
438
+ actionRows,
439
+ serviceRoleActionRows,
440
+ unassessable,
441
+ unverifiedOrigin,
442
+ staleReviews,
443
+ unresolved: [...unresolved.entries()].map(([key, count]) => ({
444
+ key,
445
+ count,
446
+ })),
447
+ staleAcceptances,
448
+ staleActionAcceptances,
449
+ capabilityCount: (capabilities.capabilities ?? []).length,
450
+ declaredInvariants,
451
+ assertionCount: assertions.length,
452
+ };
453
+ }
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // Output
457
+ // ---------------------------------------------------------------------------
458
+
459
+ // The report is read by a human deciding whether to trust a build, so it is laid
460
+ // out in sections that can be skipped: a heading says what the section grades, a
461
+ // table carries the verdicts, and prose is wrapped and indented under the row it
462
+ // belongs to instead of running off the right edge.
463
+ //
464
+ // `[PROOF_FAIL]` lines are the exception. Those are the machine-readable part of
465
+ // the contract, so each stays on one line with any guidance on an indented
466
+ // `suggestion:` line, matching the format the rest of the SDK emits.
467
+
468
+ const WIDTH = 76;
469
+
470
+ function pad(value, width) {
471
+ return String(value).padEnd(width);
472
+ }
473
+
474
+ /** Greedy word wrap. Long unbreakable tokens (paths, refs) are left intact. */
475
+ function wrap(text, indent = "") {
476
+ const width = Math.max(WIDTH - indent.length, 24);
477
+ const lines = [];
478
+ let current = "";
479
+ for (const word of String(text).split(/\s+/).filter(Boolean)) {
480
+ if (current === "") current = word;
481
+ else if (`${current} ${word}`.length <= width) current += ` ${word}`;
482
+ else {
483
+ lines.push(current);
484
+ current = word;
485
+ }
486
+ }
487
+ if (current !== "") lines.push(current);
488
+ return lines.map((line) => `${indent}${line}`);
489
+ }
490
+
491
+ function heading(title, subtitle) {
492
+ console.log(`\n${title}`);
493
+ console.log("─".repeat(WIDTH));
494
+ if (subtitle) for (const line of wrap(subtitle)) console.log(line);
495
+ }
496
+
497
+ /** columns: [{ header, get }] — widths are derived from the widest cell. */
498
+ function printTable(columns, rows) {
499
+ const widths = columns.map((column) =>
500
+ Math.max(
501
+ column.header.length,
502
+ ...rows.map((row) => String(column.get(row)).length),
503
+ ),
504
+ );
505
+ const line = (cells) =>
506
+ console.log(
507
+ ` ${cells.map((cell, i) => pad(cell, widths[i])).join(" ")}`.trimEnd(),
508
+ );
509
+ line(columns.map((c) => c.header));
510
+ line(widths.map((w) => "-".repeat(w)));
511
+ for (const row of rows) line(columns.map((c) => String(c.get(row))));
512
+ }
513
+
514
+ function printReport(report) {
515
+ const { rows } = report;
516
+ const proven = rows.filter((r) => r.status === "proven");
517
+ const acceptedGaps = rows.filter((r) => r.status === "accepted_gap");
518
+ const gaps = rows.filter((r) => r.status === "gap");
519
+
520
+ if (report.assertionCount === 0) {
521
+ console.log(
522
+ "[proof:coverage] no assertions found in .proof/traces — every table will read as a gap.\n" +
523
+ " suggestion: run `pnpm proof:verify` first so there is evidence to measure.",
524
+ );
525
+ }
526
+
527
+ heading(
528
+ "TABLE INVARIANTS",
529
+ "One requirement per table, derived from its RLS classification. A gap means unproven, not unprotected.",
530
+ );
531
+ console.log("");
532
+ printTable(
533
+ [
534
+ { header: "table", get: (r) => r.table },
535
+ { header: "classification", get: (r) => r.classification },
536
+ { header: "requires", get: (r) => r.requires },
537
+ { header: "status", get: (r) => r.status },
538
+ ],
539
+ rows,
540
+ );
541
+ console.log(
542
+ `\n ${proven.length} proven · ${acceptedGaps.length} accepted gap(s) · ${gaps.length} unaccepted gap(s)`,
543
+ );
544
+
545
+ if (gaps.length > 0) {
546
+ console.log("");
547
+ for (const r of gaps) {
548
+ console.log(` ${r.table} needs ${r.requires}`);
549
+ for (const line of wrap(r.because, " ")) console.log(line);
550
+ }
551
+ }
552
+
553
+ if (acceptedGaps.length > 0) {
554
+ console.log(`\n Accepted in ${POLICY_PATH}:`);
555
+ for (const r of acceptedGaps) {
556
+ console.log(` ${r.table} · ${r.requires}`);
557
+ for (const line of wrap(
558
+ r.acceptedReason ?? "no reason given",
559
+ " ",
560
+ )) {
561
+ console.log(line);
562
+ }
563
+ }
564
+ }
565
+
566
+ if (report.actionRows.length > 0) {
567
+ const provenActions = report.actionRows.filter(
568
+ (r) => r.status === "proven",
569
+ );
570
+ heading(
571
+ "ACTION CLAIMS",
572
+ `Invariants declared via withProof, which must be backed by an assertion. ` +
573
+ `${report.declaredInvariants.length} of ${report.capabilityCount} capabilities declare one; ` +
574
+ `${provenActions.length} of ${report.actionRows.length} claim(s) proven.`,
575
+ );
576
+ console.log("");
577
+ printTable(
578
+ [
579
+ { header: "action", get: (r) => r.action },
580
+ { header: "requires", get: (r) => r.requires },
581
+ { header: "status", get: (r) => r.status },
582
+ ],
583
+ report.actionRows,
584
+ );
585
+ } else if (report.capabilityCount > 0) {
586
+ heading(
587
+ "ACTION CLAIMS",
588
+ `None: 0 of ${report.capabilityCount} capabilities declare an invariant via withProof, ` +
589
+ `so there is no action-level claim to check.`,
590
+ );
591
+ }
592
+
593
+ if (report.serviceRoleActionRows.length > 0) {
594
+ const provenBoundaries = report.serviceRoleActionRows.filter(
595
+ (r) => r.status === "proven",
596
+ );
597
+ const acceptedBoundaries = report.serviceRoleActionRows.filter(
598
+ (r) => r.status === "accepted_gap",
599
+ );
600
+ heading(
601
+ "SERVICE-ROLE ACTION BOUNDARIES",
602
+ "Derived from caller-controlled workspaceId inputs plus service-role mutations. Each action needs both an unauthorized/cross-tenant refusal and an allowed-path control; withProof metadata is not consulted.",
603
+ );
604
+ console.log("");
605
+ printTable(
606
+ [
607
+ { header: "action", get: (r) => r.action },
608
+ { header: "tables", get: (r) => r.tables.join(",") },
609
+ { header: "denial", get: (r) => r.denial },
610
+ { header: "control", get: (r) => r.control },
611
+ { header: "status", get: (r) => r.status },
612
+ ],
613
+ report.serviceRoleActionRows,
614
+ );
615
+ console.log(
616
+ `\n ${provenBoundaries.length} proven · ${acceptedBoundaries.length} accepted gap(s) · ` +
617
+ `${report.serviceRoleActionRows.length - provenBoundaries.length - acceptedBoundaries.length} unaccepted gap(s)`,
618
+ );
619
+ if (acceptedBoundaries.length > 0) {
620
+ console.log(`\n Accepted in ${POLICY_PATH}:`);
621
+ for (const row of acceptedBoundaries) {
622
+ console.log(` ${row.action}`);
623
+ for (const line of wrap(
624
+ row.acceptedReason ?? "no reason given",
625
+ " ",
626
+ )) {
627
+ console.log(line);
628
+ }
629
+ }
630
+ }
631
+ }
632
+
633
+ if (report.unassessable.length > 0) {
634
+ const unacknowledged = report.unassessable.filter((u) => !u.acknowledged);
635
+ heading(
636
+ "NOT ASSESSABLE",
637
+ `${report.unassessable.length} table(s) whose policies the parser could not classify, so no ` +
638
+ `requirement is derived. Each needs an entry in ${POLICY_PATH} — otherwise an unparseable ` +
639
+ `policy is the cheapest way off this report.`,
640
+ );
641
+ console.log("");
642
+ for (const u of report.unassessable) {
643
+ const evidence =
644
+ u.proof.length > 0
645
+ ? `${u.proof.join(", ")} proven`
646
+ : "no primary proof";
647
+ console.log(
648
+ ` ${u.table} · ${u.acknowledged ? "reviewed" : "NOT ACKNOWLEDGED"} · ${evidence}`,
649
+ );
650
+ const note =
651
+ u.note ??
652
+ "Add a reviewedUnclassified entry stating what the table is and how it is protected.";
653
+ for (const line of wrap(note, " ")) console.log(line);
654
+ }
655
+ if (unacknowledged.length > 0) {
656
+ console.log(`\n ${unacknowledged.length} unacknowledged`);
657
+ }
658
+ }
659
+
660
+ const staleEntries = [
661
+ ...report.staleAcceptances.map((s) => ({
662
+ label: `accepted gap ${s.table}:${s.kind}`,
663
+ why: s.why,
664
+ })),
665
+ ...report.staleReviews.map((s) => ({
666
+ label: `acknowledgement ${s.table}`,
667
+ why: s.why,
668
+ })),
669
+ ...report.staleActionAcceptances.map((s) => ({
670
+ label: `accepted action gap ${s.action}`,
671
+ why: s.why,
672
+ })),
673
+ ];
674
+ if (staleEntries.length > 0) {
675
+ heading(
676
+ "STALE POLICY ENTRIES",
677
+ `${staleEntries.length} entr(y/ies) in ${POLICY_PATH} that no longer apply. Left in place they ` +
678
+ `license a regression nobody decided to allow.`,
679
+ );
680
+ console.log("");
681
+ for (const stale of staleEntries) {
682
+ console.log(` ${stale.label}`);
683
+ for (const line of wrap(stale.why, " ")) console.log(line);
684
+ }
685
+ }
686
+
687
+ if (report.unverifiedOrigin.length > 0) {
688
+ heading(
689
+ "UNVERIFIED ORIGIN",
690
+ `${report.unverifiedOrigin.length} passing primary assertion(s) whose origin cannot vouch for the ` +
691
+ `claimed kind — recorded directly by spec code, or stamped by a helper that takes its kind from the ` +
692
+ `caller. They do NOT count as coverage evidence — emit the claim through the owning helper ` +
693
+ `(assert.tenantIsolation for table isolation) instead.`,
694
+ );
695
+ console.log("");
696
+ printTable(
697
+ [
698
+ { header: "kind", get: (u) => u.kind },
699
+ { header: "target", get: (u) => u.target },
700
+ { header: "origin", get: (u) => u.emittedBy ?? "(spec-recorded)" },
701
+ { header: "proof", get: (u) => u.proofId ?? "(unknown)" },
702
+ ],
703
+ report.unverifiedOrigin,
704
+ );
705
+ }
706
+
707
+ if (report.unresolved.length > 0) {
708
+ heading(
709
+ "UNRESOLVED TARGETS",
710
+ `${report.unresolved.length} assertion target(s) that are neither a table nor a declared claim. ` +
711
+ `Routes and RPCs belong here; a typo would too, which is why they are listed.`,
712
+ );
713
+ console.log("");
714
+ printTable(
715
+ [
716
+ { header: "target", get: (u) => u.key },
717
+ { header: "assertions", get: (u) => u.count },
718
+ ],
719
+ report.unresolved,
720
+ );
721
+ }
722
+ }
723
+
724
+ export function main() {
725
+ const args = process.argv.slice(2);
726
+ const strict = args.includes("--strict");
727
+ const asJson = args.includes("--json");
728
+
729
+ if (args.includes("--help") || args.includes("-h")) {
730
+ console.log(`Usage: node scripts/proof_coverage.mjs [--strict] [--json]
731
+
732
+ Reports which table-level invariants are proven by the trace evidence, which are
733
+ accepted gaps, and which are neither, plus whether every invariant an action
734
+ declares via withProof is backed by an assertion. Workspace-id-driven actions
735
+ that mutate through the service role additionally need
736
+ both a refusal and an allowed-path control. --strict exits 1 on an unaccepted
737
+ gap or an unproven action requirement.`);
738
+ return;
739
+ }
740
+
741
+ const unknown = args.filter((a) => !["--strict", "--json"].includes(a));
742
+ if (unknown.length > 0) {
743
+ console.error(`[proof:coverage] unknown flag: ${unknown.join(", ")}`);
744
+ process.exit(2);
745
+ }
746
+
747
+ const report = buildReport();
748
+
749
+ const gaps = report.rows.filter((r) => r.status === "gap");
750
+ const problems = [];
751
+ if (gaps.length > 0) {
752
+ problems.push({
753
+ line:
754
+ `[PROOF_FAIL] coverage_gap: ${gaps.length} table(s) require an invariant that no proof asserts: ` +
755
+ gaps.map((g) => `${g.table} (${g.requires})`).join(", "),
756
+ suggestion: `add a proof, or record the gap in ${POLICY_PATH} with a reason`,
757
+ });
758
+ }
759
+ const actionGaps = report.actionRows.filter((r) => r.status === "gap");
760
+ if (actionGaps.length > 0) {
761
+ problems.push({
762
+ line:
763
+ `[PROOF_FAIL] coverage_gap: ${actionGaps.length} action(s) declare an invariant via withProof that no proof asserts: ` +
764
+ actionGaps.map((g) => `${g.action} (${g.requires})`).join(", "),
765
+ suggestion:
766
+ `add a proof whose assertion kind is the invariant and whose target is the "<module>:<action>" ref, ` +
767
+ `or drop the declaration — a claim with no evidence is worse than no claim`,
768
+ });
769
+ }
770
+ const serviceRoleActionGaps = report.serviceRoleActionRows.filter(
771
+ (r) => r.status === "gap",
772
+ );
773
+ if (serviceRoleActionGaps.length > 0) {
774
+ problems.push({
775
+ line:
776
+ `[PROOF_FAIL] service_role_action_gap: ${serviceRoleActionGaps.length} workspace-targeted service-role action(s) lack mandatory action evidence: ` +
777
+ serviceRoleActionGaps
778
+ .map((g) => {
779
+ const missing = [
780
+ g.denial === "gap" ? "denial" : null,
781
+ g.control === "gap" ? "allowed control" : null,
782
+ ].filter(Boolean);
783
+ return `${g.action} (${missing.join(" + ")})`;
784
+ })
785
+ .join(", "),
786
+ suggestion:
787
+ `add a passing primary authorization/tenant_isolation assertion for the unauthorized or cross-tenant call and ` +
788
+ `a passing control assertion for an allowed call, both targeting the exact "<module>:<action>" ref; ` +
789
+ `if the action cannot be exercised yet, record the debt in ${POLICY_PATH}.acceptedActionGaps with a durable reason`,
790
+ });
791
+ }
792
+ if (report.staleAcceptances.length > 0) {
793
+ problems.push({
794
+ line:
795
+ `[PROOF_FAIL] coverage_policy_stale: ${report.staleAcceptances.length} accepted gap(s) no longer apply: ` +
796
+ report.staleAcceptances.map((s) => `${s.table}:${s.kind}`).join(", "),
797
+ suggestion: `remove them from ${POLICY_PATH} so the policy keeps meaning what it says`,
798
+ });
799
+ }
800
+ const unacknowledged = report.unassessable.filter((u) => !u.acknowledged);
801
+ if (unacknowledged.length > 0) {
802
+ problems.push({
803
+ line:
804
+ `[PROOF_FAIL] coverage_blind_spot: ${unacknowledged.length} table(s) have policies the parser cannot classify and have not been reviewed: ` +
805
+ unacknowledged.map((u) => u.table).join(", "),
806
+ suggestion:
807
+ `no requirement can be derived for them, so add a reviewedUnclassified entry in ${POLICY_PATH} ` +
808
+ `stating what the table is and how it is protected`,
809
+ });
810
+ }
811
+ if (report.staleReviews.length > 0) {
812
+ problems.push({
813
+ line:
814
+ `[PROOF_FAIL] coverage_policy_stale: ${report.staleReviews.length} acknowledgement(s) no longer apply: ` +
815
+ report.staleReviews.map((s) => s.table).join(", "),
816
+ suggestion: `remove them from ${POLICY_PATH}`,
817
+ });
818
+ }
819
+ if (report.staleActionAcceptances.length > 0) {
820
+ problems.push({
821
+ line:
822
+ `[PROOF_FAIL] coverage_policy_stale: ${report.staleActionAcceptances.length} accepted action gap(s) no longer apply: ` +
823
+ report.staleActionAcceptances.map((gap) => gap.action).join(", "),
824
+ suggestion: `remove them from ${POLICY_PATH} so new regressions cannot inherit obsolete exceptions`,
825
+ });
826
+ }
827
+
828
+ if (asJson) {
829
+ console.log(JSON.stringify(report, null, 2));
830
+ if (problems.length > 0 && strict) process.exit(1);
831
+ return;
832
+ }
833
+
834
+ printReport(report);
835
+
836
+ if (problems.length === 0) {
837
+ console.log(
838
+ `\n[proof:coverage] PASS · ${report.rows.length - gaps.length} table invariant(s) and ` +
839
+ `${report.actionRows.length - actionGaps.length} declared claim(s), ` +
840
+ `${report.serviceRoleActionRows.length - serviceRoleActionGaps.length} service-role action boundary(ies) proven or accepted`,
841
+ );
842
+ return;
843
+ }
844
+
845
+ // Not strict: the sections above already show what is wrong, so summarise
846
+ // rather than emitting a failure line, which the consumer reads as a failed
847
+ // run. That is also why the summary below avoids the literal token — anything
848
+ // grepping this output should find it only when the build actually failed.
849
+ if (!strict) {
850
+ console.log(
851
+ `\n[proof:coverage] ${problems.length} problem(s) — re-run with --strict to fail the build on them`,
852
+ );
853
+ return;
854
+ }
855
+
856
+ console.error("");
857
+ for (const problem of problems) {
858
+ console.error(problem.line);
859
+ const [first, ...rest] = wrap(problem.suggestion, " ");
860
+ console.error(` suggestion: ${first.trim()}`);
861
+ for (const line of rest) console.error(line);
862
+ console.error("");
863
+ }
864
+ process.exit(1);
865
+ }
866
+
867
+ const invokedDirectly =
868
+ process.argv[1] !== undefined &&
869
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
870
+
871
+ if (invokedDirectly) main();