proof-harness 0.1.0-next.11 → 0.1.0-next.13

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
@@ -27,9 +27,89 @@ versions rather than guessing how to interpret them.
27
27
 
28
28
  ## Prerelease migration notes
29
29
 
30
- ### 0.1.0-next.11 (release candidate) — conservative service RPC assessment
30
+ ### 0.1.0-next.13 — column-restricted tenant isolation (release candidate)
31
31
 
32
- Prepared for release. After publication, pin exactly `proof-harness@0.1.0-next.11`.
32
+ Prepared for release. After publication, pin exactly `proof-harness@0.1.0-next.13`
33
+ and update the lockfile. Add `columns` only where column grants require it, then
34
+ rerun the baseline, coverage, mutation and control checks against the real database.
35
+ Existing consumers that omit this option need no API changes.
36
+
37
+ `assert.tenantIsolation` accepts optional `columns: ["id", "account_id"]` for
38
+ private tables that grant customers only selected columns. The SDK uses the
39
+ same projection for service ground truth, owner controls, and outsider probes.
40
+ Only distinct plain column names are allowed; empty lists, wildcards, aliases,
41
+ aggregates, casts and joins are rejected before fixture setup. Omitting the
42
+ option retains the existing all-column query.
43
+
44
+ This proves row isolation for the selected columns. Consumers must separately
45
+ prove that private columns remain unreadable; do not grant extra columns to
46
+ make a fixture pass. Existing positive-control, bidirectional, non-vacuity and
47
+ provenance requirements are unchanged. No trace, mission, or health protocol migration is
48
+ required. Adopt only an exact reviewed published package version.
49
+
50
+ ### 0.1.0-next.12 — independent isolation fixtures and column ACL recovery
51
+
52
+ Published release. Pin exactly `proof-harness@0.1.0-next.12` when adopting these changes.
53
+
54
+ `assert.tenantIsolation` now accepts a caller-owned `isolationFixture` instead of
55
+ workspace seeding. Supply a stable fixture `id`, the matching `table`, an explicit
56
+ `scopeColumn`, `setup()` returning two distinct actors and scope values, and
57
+ `cleanup()` that can handle partial setup. Scope values can be user IDs or
58
+ independent account IDs. `scope: "account"` requires this fixture path. Existing
59
+ workspace fixtures and their reverse-direction behavior remain unchanged.
60
+
61
+ ```ts
62
+ await assert.tenantIsolation({
63
+ table: "notifications",
64
+ scope: "account",
65
+ scopeColumn: "account_id",
66
+ isolationFixture: {
67
+ id: "notifications-two-accounts-v1",
68
+ table: "notifications",
69
+ async setup() {
70
+ // Consumer creates real Auth customers, resolves account IDs, and seeds rows.
71
+ return {
72
+ a: { actor: { email: ownerA.email, password: ownerA.password }, scopeValue: accountA.id },
73
+ b: { actor: { email: ownerB.email, password: ownerB.password }, scopeValue: accountB.id },
74
+ };
75
+ },
76
+ async cleanup() { await cleanupOwnedResources(); },
77
+ },
78
+ });
79
+ ```
80
+
81
+ The SDK creates the service client and authenticated probe clients itself. Both
82
+ sides must contain rows before probing; each owner must read all scoped rows,
83
+ and each outsider must see none. Both directions are mandatory. Scope/criterion
84
+ filters apply identically to ground truth and probes. Setup and cleanup run
85
+ without helper provenance; callers cannot stamp SDK assertions. Cleanup errors
86
+ fail the proof. Fixture IDs appear in probe detail; existing trace source hashes
87
+ bind tracked and nonignored fixture source to evidence. Keep fixtures under the
88
+ consumer's source identity, not an excluded artifact directory. This path is
89
+ database-only: `page` is unsupported. It does not extend account-policy SQL
90
+ classification; existing explicit classifier reviews remain separate.
91
+
92
+ `tablePrivilege` mutations now snapshot the relevant table and column ACL entries
93
+ (including grant options) and compare that complete state after restoration.
94
+ Column UPDATE grants removed by PostgreSQL's table REVOKE are re-established.
95
+ Owner-issued direct grants, including grant options, are supported; delegated
96
+ grant graphs, inherited/PUBLIC effective privileges, and owner-target mutations
97
+ remain blocked before planting. No CASCADE recovery is used. Recovery fails
98
+ closed on an old Boolean snapshot: it cannot reconstruct already-lost column
99
+ grants. Keep that journal and reconcile with the protected baseline rather than
100
+ claiming automatic recovery succeeded.
101
+
102
+ After upgrading: port fixtures to the public API, regenerate baseline evidence,
103
+ restore formal table-isolation coverage, and rerun frozen install, static/unit/
104
+ browser checks, the full proof/mutation/control gate, package parity, production
105
+ build, and smoke. Verify actual profile editing and denied privileged-column
106
+ writes after mutations. Package tests are not a substitute for that consumer run.
107
+ Trace, mission, and health protocol versions remain unchanged; the internal
108
+ ACL recovery snapshot has its own version 1.
109
+
110
+ ### 0.1.0-next.11 — conservative service RPC assessment
111
+
112
+ Published release. Pin exactly `proof-harness@0.1.0-next.11`.
33
113
  Regenerate
34
114
  capabilities and rerun the complete baseline, coverage, inventory, and mutation
35
115
  validation after adoption. Trace, mission, and health versions are unchanged.
@@ -1290,3 +1290,13 @@ action-targeted primary denial and allowed control with operation `invoke`,
1290
1290
  including internal actions and actions without workspace inputs. Caller evidence
1291
1291
  is not transitively inherited. See COMPATIBILITY.md for supported syntax and
1292
1292
  remaining unassessable cases.
1293
+
1294
+ ## Independent scope fixtures (0.1.0-next.12 candidate)
1295
+
1296
+ The Playwright entry point exports `IsolationFixture`, `IsolationParticipant`,
1297
+ and `TenantIsolationOptions`. `isolationFixture` is an alternative to the legacy
1298
+ workspace `setup`/`fixture` options. Its setup and cleanup are consumer-owned;
1299
+ clients, ground-truth checks, both owner controls, and both outsider probes stay
1300
+ SDK-owned. Missing either side, malformed actors/scopes, failed probes or cleanup
1301
+ prevent a passing proof. The SDK does not emit provenance on callback assertions.
1302
+ See COMPATIBILITY.md for the public call shape and source-identity requirements.
package/README.md CHANGED
@@ -152,7 +152,15 @@ Release `0.1.0-next.10` restores explicit consumer primary assertions
152
152
  for mutation detection while retaining strict control provenance. See the
153
153
  [next.10 migration notes](COMPATIBILITY.md) before adopting it.
154
154
 
155
- Release candidate `0.1.0-next.11` discovers direct service RPC calls as potentially
155
+ Release `0.1.0-next.11` discovers direct service RPC calls as potentially
156
156
  privileged writes and requires action-boundary evidence even without workspace
157
157
  inputs or for internal actions. See [required migration steps](COMPATIBILITY.md)
158
- before adopting the exact version after publication.
158
+ before adopting the exact version.
159
+
160
+ Release `0.1.0-next.12` adds caller-owned account/user isolation fixtures
161
+ and restores column grants and grant options after table-privilege mutations.
162
+ See [migration requirements](COMPATIBILITY.md).
163
+
164
+ Release candidate `0.1.0-next.13` adds optional readable-column selection to
165
+ `assert.tenantIsolation` for tables with column grants. Private-column denial
166
+ remains a separate consumer proof; see [migration requirements](COMPATIBILITY.md).
@@ -1,4 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ tableAclSnapshotSql,
4
+ validateTableAcl,
5
+ restoreTableAclSql,
6
+ } from "./proof_table_acl.mjs";
2
7
  import {
3
8
  validateControlBaseline,
4
9
  assertionSelectorsTurnedRed,
@@ -234,13 +239,13 @@ function assertDirectPrivilege(container, subject, functionSubject = false) {
234
239
  `SELECT NOT EXISTS (
235
240
  SELECT 1 FROM ${catalog} c, LATERAL aclexplode(coalesce(c.${acl},acldefault('${defaultKind}',c.${owner}))) a
236
241
  WHERE c.oid = ${object} AND a.privilege_type = ${quoteLiteral(subject.privilege.toUpperCase())}
237
- AND ((a.grantee = ${quoteLiteral(subject.role)}::regrole AND (a.is_grantable OR a.grantor <> c.${owner}))
242
+ AND ((a.grantee = ${quoteLiteral(subject.role)}::regrole AND (${functionSubject ? "a.is_grantable OR " : ""}a.grantor <> c.${owner}))
238
243
  OR (a.grantee <> ${quoteLiteral(subject.role)}::regrole AND (a.grantee = 0 OR pg_has_role(${quoteLiteral(subject.role)},a.grantee,'USAGE'))))
239
244
  ) AND NOT EXISTS (SELECT 1 FROM ${catalog} WHERE oid = ${object} AND ${owner} = ${quoteLiteral(subject.role)}::regrole)`,
240
245
  );
241
246
  if (supported !== "t")
242
247
  throw new Error(
243
- "[PROOF_FAIL] mutation_unassessed: privilege restoration supports owner-issued direct grants without grant options; inherited, PUBLIC, and owner privileges require a dedicated catalog mutation",
248
+ `[PROOF_FAIL] mutation_unassessed: privilege restoration requires owner-issued direct grants${functionSubject ? " without grant options" : ""}; inherited, PUBLIC, and owner privileges require a dedicated catalog mutation`,
244
249
  );
245
250
  }
246
251
 
@@ -314,17 +319,13 @@ const SUBJECTS = {
314
319
  },
315
320
 
316
321
  tablePrivilege: {
317
- preflight: (container, s) => assertDirectPrivilege(container, s),
322
+ preflight: (container, s) => {
323
+ assertDirectPrivilege(container, s);
324
+ validateTableAcl(queryScalar(container, tableAclSnapshotSql(s)));
325
+ },
318
326
  describe: (s) => `${s.privilege} on ${s.table} for ${s.role}`,
319
- snapshot: (container, s) =>
320
- queryScalar(
321
- container,
322
- `SELECT has_table_privilege(${quoteLiteral(s.role)}, ${quoteLiteral(s.table)}, ${quoteLiteral(s.privilege)})`,
323
- ),
324
- restore: (s, snapshot) =>
325
- snapshot === "t"
326
- ? `GRANT ${s.privilege} ON ${s.table} TO ${s.role};`
327
- : `REVOKE ${s.privilege} ON ${s.table} FROM ${s.role};`,
327
+ snapshot: (container, s) => queryScalar(container, tableAclSnapshotSql(s)),
328
+ restore: restoreTableAclSql,
328
329
  },
329
330
 
330
331
  functionPrivilege: {
@@ -0,0 +1,62 @@
1
+ // Canonical relevant ACL state, not effective has_table_privilege booleans.
2
+ const lit = (value) => "'" + String(value).replaceAll("'", "''") + "'";
3
+ const ident = (value) => '"' + String(value).replaceAll('"', '""') + '"';
4
+ export function tableAclSnapshotSql(subject) {
5
+ return `WITH target AS (SELECT oid, relowner, relacl FROM pg_class WHERE oid = ${lit(subject.table)}::regclass), entries AS (
6
+ SELECT NULL::text AS column_name, a.* FROM target t, LATERAL aclexplode(coalesce(t.relacl,acldefault('r',t.relowner))) a
7
+ UNION ALL
8
+ SELECT c.attname::text, a.* FROM target t JOIN pg_attribute c ON c.attrelid=t.oid AND c.attnum>0 AND NOT c.attisdropped,
9
+ LATERAL aclexplode(c.attacl) a
10
+ ) SELECT json_build_object('version',1,'owner',(SELECT pg_get_userbyid(relowner) FROM target),
11
+ 'entries',coalesce((SELECT json_agg(json_build_object('column',column_name,'grantor',pg_get_userbyid(grantor),
12
+ 'grantee',CASE WHEN grantee=0 THEN 'PUBLIC' ELSE pg_get_userbyid(grantee) END,'grantable',is_grantable)
13
+ ORDER BY column_name NULLS FIRST,grantor,grantee,is_grantable)
14
+ FROM entries WHERE privilege_type=${lit(subject.privilege.toUpperCase())}), '[]'::json))::text`;
15
+ }
16
+ export function validateTableAcl(snapshot) {
17
+ const value = JSON.parse(snapshot);
18
+ if (
19
+ value?.version !== 1 ||
20
+ typeof value.owner !== "string" ||
21
+ !Array.isArray(value.entries) ||
22
+ value.entries.some(
23
+ (e) =>
24
+ !(e.column === null || typeof e.column === "string") ||
25
+ typeof e.grantee !== "string" ||
26
+ typeof e.grantable !== "boolean" ||
27
+ e.grantor !== value.owner,
28
+ )
29
+ )
30
+ throw new Error(
31
+ "[PROOF_FAIL] mutation_unassessed: table ACL requires versioned owner-issued grants; delegated grant graphs need a dedicated mutation",
32
+ );
33
+ return value;
34
+ }
35
+ export function restoreTableAclSql(subject, snapshot) {
36
+ const value = validateTableAcl(snapshot);
37
+ const privilege = subject.privilege.toUpperCase();
38
+ if (
39
+ ![
40
+ "SELECT",
41
+ "INSERT",
42
+ "UPDATE",
43
+ "DELETE",
44
+ "TRUNCATE",
45
+ "REFERENCES",
46
+ "TRIGGER",
47
+ "MAINTAIN",
48
+ ].includes(privilege)
49
+ )
50
+ throw new Error("unsupported table privilege");
51
+ const role = subject.role === "PUBLIC" ? "PUBLIC" : ident(subject.role);
52
+ // Revoking a table privilege also removes this role's column grants. Restore
53
+ // those explicitly, preserving grant options. No CASCADE: unexpected dependent
54
+ // grants abort instead of destroying state not represented by the snapshot.
55
+ const sql = [`REVOKE ${privilege} ON ${subject.table} FROM ${role};`];
56
+ for (const entry of value.entries.filter((e) => e.grantee === subject.role)) {
57
+ sql.push(
58
+ `GRANT ${privilege}${entry.column === null ? "" : ` (${ident(entry.column)})`} ON ${subject.table} TO ${role}${entry.grantable ? " WITH GRANT OPTION" : ""};`,
59
+ );
60
+ }
61
+ return sql.join("\n");
62
+ }
@@ -97,6 +97,10 @@ declare const actAsUser: {
97
97
  };
98
98
 
99
99
  interface TenantIsolationBaseOptions {
100
+ /** Plain readable column names for column-restricted tables. Defaults to all
101
+ * columns. The same projection is used for ground truth and both actors.
102
+ * This proves row isolation only; test secret-column denial separately. */
103
+ columns?: readonly string[];
100
104
  /**
101
105
  * Table to query as userB. RLS on the table is what is being tested.
102
106
  * Must be a table governed by a tenant-scoped RLS policy — either
@@ -116,7 +120,7 @@ interface TenantIsolationBaseOptions {
116
120
  *
117
121
  * Tables classified `user_scoped` in `.proof/schema.json` need `"user"`.
118
122
  */
119
- scope?: "workspace" | "user";
123
+ scope?: "workspace" | "user" | "account";
120
124
  /**
121
125
  * Column carrying the scope value. Defaults to `workspace_id` for workspace
122
126
  * scope and `user_id` for user scope.
@@ -170,7 +174,31 @@ interface TenantIsolationBaseOptions {
170
174
  * until the builder knows the final constraints. Supplying both is rejected by
171
175
  * the type system so there is one source of fixture truth.
172
176
  */
177
+ interface IsolationParticipant {
178
+ actor: {
179
+ email: string;
180
+ password: string;
181
+ };
182
+ scopeValue: string;
183
+ }
184
+ /** Caller owns all resources, including cleanup after partially failed setup. */
185
+ interface IsolationFixture {
186
+ id: string;
187
+ table: string;
188
+ setup(): Promise<{
189
+ a: IsolationParticipant;
190
+ b: IsolationParticipant;
191
+ }>;
192
+ cleanup(): Promise<void>;
193
+ }
173
194
  type TenantIsolationOptions = TenantIsolationBaseOptions & ({
195
+ isolationFixture: IsolationFixture;
196
+ scopeColumn: string;
197
+ page?: never;
198
+ setup?: never;
199
+ fixture?: never;
200
+ } | {
201
+ isolationFixture?: never;
174
202
  /**
175
203
  * Inline setup for an already-known schema. It must create at least one
176
204
  * row for side A; side B is optional but required for a bidirectional
@@ -181,6 +209,7 @@ type TenantIsolationOptions = TenantIsolationBaseOptions & ({
181
209
  } | {
182
210
  /** Explicit table-owned factory from `e2e/fixtures/<table>.ts`. */
183
211
  fixture: ProofFixtureFactory;
212
+ isolationFixture?: never;
184
213
  setup?: never;
185
214
  });
186
215
  type AuthorizationOp = "select" | "insert" | "update" | "delete";
@@ -461,4 +490,4 @@ declare const trace: {
461
490
  proof(arg: string | ProofOptions, fn: (t: TraceRecorderImpl) => Promise<void>): Promise<void>;
462
491
  };
463
492
 
464
- export { actAsUser, assert, recordAssertion, trace };
493
+ export { type IsolationFixture, type IsolationParticipant, type TenantIsolationOptions, actAsUser, assert, recordAssertion, trace };
@@ -1765,9 +1765,22 @@ async function runRlsProbe(probe, actorCreds) {
1765
1765
  detail: `${actorCreds.label} UPDATE on ${probe.table} left ${columns.join(", ")} unchanged across ${before.length} targeted row(s)${error ? ` (${error.message})` : ""}; verified by service-role re-read (RLS honored)`
1766
1766
  });
1767
1767
  }
1768
- async function countRowsAsService(sb, table, filter) {
1768
+ function tenantColumnSelection(columns) {
1769
+ if (columns === void 0) return "*";
1770
+ if (!Array.isArray(columns) || columns.length === 0 || columns.some(
1771
+ (column) => typeof column !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(column)
1772
+ ) || new Set(columns).size !== columns.length) {
1773
+ throw proofFail(
1774
+ "tenant_isolation_setup",
1775
+ "a non-empty list of distinct plain column names",
1776
+ "invalid tenant column projection"
1777
+ );
1778
+ }
1779
+ return columns.join(",");
1780
+ }
1781
+ async function countRowsAsService(sb, table, filter, selection) {
1769
1782
  const { count, error } = await applyEq(
1770
- sb.from(table).select("*", { count: "exact", head: true }),
1783
+ sb.from(table).select(selection, { count: "exact", head: true }),
1771
1784
  filter
1772
1785
  );
1773
1786
  if (error) {
@@ -1800,8 +1813,13 @@ async function probeTenantIsolation(args) {
1800
1813
  [scopeColumn]: scopeValue,
1801
1814
  ...criterion?.where ?? {}
1802
1815
  };
1803
- const criterionDetail = criterion ? ` matching planner criterion "${criterion.description}" (${JSON.stringify(criterion.where)})` : "";
1804
- const actualRowCount = await countRowsAsService(sb, table, filter);
1816
+ const criterionDetail = (criterion ? ` matching planner criterion "${criterion.description}" (${JSON.stringify(criterion.where)})` : "") + (args.selection === "*" ? "" : ` selecting columns ${args.selection}`);
1817
+ const actualRowCount = await countRowsAsService(
1818
+ sb,
1819
+ table,
1820
+ filter,
1821
+ args.selection
1822
+ );
1805
1823
  if (actualRowCount === 0) {
1806
1824
  return "skipped_no_data";
1807
1825
  }
@@ -1813,7 +1831,7 @@ async function probeTenantIsolation(args) {
1813
1831
  data: ownRows,
1814
1832
  error: ownError,
1815
1833
  status: ownStatus
1816
- } = await applyEq(ownerClient.from(table).select("*"), filter);
1834
+ } = await applyEq(ownerClient.from(table).select(args.selection), filter);
1817
1835
  failIfInfrastructure(
1818
1836
  ownError,
1819
1837
  ownStatus,
@@ -1826,7 +1844,7 @@ async function probeTenantIsolation(args) {
1826
1844
  "a valid owner control query",
1827
1845
  ownError.message
1828
1846
  );
1829
- if (ownError || (ownRows?.length ?? 0) === 0) {
1847
+ if (ownError || (ownRows?.length ?? 0) === 0 || args.strictOwner && ownRows?.length !== actualRowCount) {
1830
1848
  recordAssertion({
1831
1849
  kind: "tenant_isolation",
1832
1850
  target: table,
@@ -1860,13 +1878,29 @@ async function probeTenantIsolation(args) {
1860
1878
  data: leakedRows,
1861
1879
  error: queryError,
1862
1880
  status: queryStatus
1863
- } = await applyEq(client.from(table).select("*"), filter);
1881
+ } = await applyEq(client.from(table).select(args.selection), filter);
1864
1882
  failIfInfrastructure(
1865
1883
  queryError,
1866
1884
  queryStatus,
1867
1885
  "tenant_isolation_setup",
1868
1886
  `the outsider's SELECT on ${table}`
1869
1887
  );
1888
+ if (queryError && args.strictOwner) {
1889
+ recordAssertion({
1890
+ kind: "tenant_isolation",
1891
+ target: table,
1892
+ operation: "select",
1893
+ passed: false,
1894
+ status: "incomplete",
1895
+ role: "primary",
1896
+ detail: `outsider query failed before isolation could be assessed: ${queryError.message}`
1897
+ });
1898
+ throw proofFail(
1899
+ "tenant_isolation_setup",
1900
+ "a successful outsider query returning no rows",
1901
+ queryError.message
1902
+ );
1903
+ }
1870
1904
  if (queryError) {
1871
1905
  recordAssertion({
1872
1906
  kind: "tenant_isolation",
@@ -1886,7 +1920,9 @@ async function probeTenantIsolation(args) {
1886
1920
  );
1887
1921
  }
1888
1922
  if (leakedRows && leakedRows.length > 0) {
1889
- const sampleIds = leakedRows.slice(0, 3).map((r) => r.id ?? "?").join(", ");
1923
+ const sampleIds = leakedRows.slice(0, 3).map(
1924
+ (r) => r !== null && typeof r === "object" && "id" in r ? String(r.id) : "?"
1925
+ ).join(", ");
1890
1926
  recordAssertion({
1891
1927
  kind: "tenant_isolation",
1892
1928
  target: table,
@@ -2021,6 +2057,131 @@ var assertMethods = {
2021
2057
  */
2022
2058
  async tenantIsolation(opts) {
2023
2059
  const { table, page } = opts;
2060
+ const selection = tenantColumnSelection(opts.columns);
2061
+ if (opts.isolationFixture) {
2062
+ const fixture = await withoutAssertionProvenance(async () => {
2063
+ const supplied = opts.isolationFixture;
2064
+ return {
2065
+ id: supplied.id,
2066
+ table: supplied.table,
2067
+ setup: typeof supplied.setup === "function" ? supplied.setup.bind(supplied) : supplied.setup,
2068
+ cleanup: typeof supplied.cleanup === "function" ? supplied.cleanup.bind(supplied) : supplied.cleanup
2069
+ };
2070
+ });
2071
+ if (page)
2072
+ throw proofFail(
2073
+ "tenant_isolation_setup",
2074
+ "a database-only caller-owned fixture",
2075
+ "page is unsupported for isolationFixture"
2076
+ );
2077
+ if (!fixture.id?.trim() || fixture.table !== table || !opts.scopeColumn?.trim() || typeof fixture.setup !== "function" || typeof fixture.cleanup !== "function")
2078
+ throw proofFail(
2079
+ "tenant_isolation_setup",
2080
+ "a named, table-matched isolation fixture and scope column",
2081
+ "malformed fixture"
2082
+ );
2083
+ validateTenantIsolationCriterion(table, opts.scopeColumn, opts.criterion);
2084
+ const failures = [];
2085
+ try {
2086
+ const participants = await withoutAssertionProvenance(async () => {
2087
+ const sides = await fixture.setup();
2088
+ const valid = (side) => side && typeof side.scopeValue === "string" && side.scopeValue.trim() && typeof side.actor?.email === "string" && side.actor.email.trim() && typeof side.actor?.password === "string" && side.actor.password.length;
2089
+ if (!sides || !valid(sides.a) || !valid(sides.b) || sides.a.scopeValue === sides.b.scopeValue || sides.a.actor.email.toLowerCase() === sides.b.actor.email.toLowerCase())
2090
+ throw proofFail(
2091
+ "tenant_isolation_setup",
2092
+ "two distinct actors and scope values",
2093
+ "malformed isolation participants"
2094
+ );
2095
+ return [sides.a, sides.b].map((side) => ({
2096
+ scopeValue: side.scopeValue,
2097
+ actor: { email: side.actor.email, password: side.actor.password }
2098
+ }));
2099
+ });
2100
+ const sb = createProofServiceClient();
2101
+ for (const side of participants) {
2102
+ const count = await countRowsAsService(
2103
+ sb,
2104
+ table,
2105
+ {
2106
+ [opts.scopeColumn]: side.scopeValue,
2107
+ ...opts.criterion?.where ?? {}
2108
+ },
2109
+ selection
2110
+ );
2111
+ if (!count)
2112
+ throw proofFail(
2113
+ "tenant_isolation_vacuous",
2114
+ "existing rows for both isolation participants",
2115
+ `empty side in fixture ${fixture.id}`
2116
+ );
2117
+ }
2118
+ for (const [owner, viewer] of [
2119
+ [participants[0], participants[1]],
2120
+ [participants[1], participants[0]]
2121
+ ]) {
2122
+ const outcome = await probeTenantIsolation({
2123
+ selection,
2124
+ table,
2125
+ owner: owner.actor,
2126
+ viewer: viewer.actor,
2127
+ ownerLabel: `fixture ${fixture.id} owner`,
2128
+ viewerLabel: `fixture ${fixture.id} outsider`,
2129
+ scopeColumn: opts.scopeColumn,
2130
+ scopeValue: owner.scopeValue,
2131
+ scopeLabel: owner.scopeValue,
2132
+ criterion: opts.criterion,
2133
+ scopePattern: "the consumer-owned owner/account policy",
2134
+ sb,
2135
+ strictOwner: true
2136
+ });
2137
+ if (outcome !== "proved")
2138
+ throw proofFail(
2139
+ "tenant_isolation_vacuous",
2140
+ "existing target rows throughout both probes",
2141
+ "rows disappeared"
2142
+ );
2143
+ }
2144
+ } catch (error) {
2145
+ recordAssertion({
2146
+ kind: "tenant_isolation",
2147
+ target: table,
2148
+ operation: "select",
2149
+ passed: false,
2150
+ status: "incomplete",
2151
+ role: "primary",
2152
+ detail: `fixture ${fixture.id} did not complete both isolation directions`
2153
+ });
2154
+ failures.push(error);
2155
+ } finally {
2156
+ try {
2157
+ await withoutAssertionProvenance(() => fixture.cleanup());
2158
+ } catch (error) {
2159
+ recordAssertion({
2160
+ kind: "tenant_isolation",
2161
+ target: table,
2162
+ operation: "select",
2163
+ passed: false,
2164
+ status: "incomplete",
2165
+ role: "primary",
2166
+ detail: `fixture ${fixture.id} cleanup failed`
2167
+ });
2168
+ failures.push(error);
2169
+ }
2170
+ }
2171
+ if (failures.length === 1) throw failures[0];
2172
+ if (failures.length > 1)
2173
+ throw new AggregateError(
2174
+ failures,
2175
+ "Isolation probe and cleanup failed"
2176
+ );
2177
+ return;
2178
+ }
2179
+ if (opts.scope === "account")
2180
+ throw proofFail(
2181
+ "tenant_isolation_setup",
2182
+ "isolationFixture for account scope",
2183
+ "workspace fixture selected"
2184
+ );
2024
2185
  const tag = opts.tag ?? randomSuffix();
2025
2186
  const scope = opts.scope ?? "workspace";
2026
2187
  const scopeColumn = opts.scopeColumn ?? (scope === "user" ? "user_id" : "workspace_id");
@@ -2122,6 +2283,7 @@ var assertMethods = {
2122
2283
  );
2123
2284
  }
2124
2285
  const forward = await probeTenantIsolation({
2286
+ selection,
2125
2287
  table,
2126
2288
  viewer: userB,
2127
2289
  viewerLabel: "user B",
@@ -2157,6 +2319,7 @@ var assertMethods = {
2157
2319
  );
2158
2320
  }
2159
2321
  const reverse = await probeTenantIsolation({
2322
+ selection,
2160
2323
  table,
2161
2324
  viewer: userA,
2162
2325
  viewerLabel: "user A",