@ultimat3/testing 30.6.0 → 30.7.0

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.
@@ -3,6 +3,8 @@
3
3
  // lookup that pins a unique key reads one row whatever the planner walked to find it.
4
4
 
5
5
  import type { PlanVerdict, PlanVerdictCode } from './plan-oracle';
6
+ import type { PlanUnservable } from './plan-sort-key';
7
+ import { sortKeyPart } from './plan-sort-key';
6
8
  import type { SeedEntity } from './seed-graph-shape';
7
9
 
8
10
  export interface PlanFinding {
@@ -15,6 +17,12 @@ export interface PlanFinding {
15
17
  readonly table: string;
16
18
  /** The `indexes: [{ on }]` entry, by property: tenant, equality, order, range. */
17
19
  readonly index: readonly string[];
20
+ /**
21
+ * Why no index is the fix: a sort no declarable index serves, or `declared` — the index the node
22
+ * asks for is one the entity already holds, so declaring it again changes no plan. `index` is
23
+ * empty with it.
24
+ */
25
+ readonly unserved?: PlanUnservable | 'declared';
18
26
  }
19
27
 
20
28
  /** Every column set the table holds unique: the primary key, unique columns, total unique rules. */
@@ -110,24 +118,74 @@ function servedByPartial(entity: SeedEntity, verdict: PlanVerdict, order: readon
110
118
 
111
119
  const ORDER_BY = /\border by\s+(.+?)(?:\s+limit\b|\s+offset\b|\s+for\b|$)/gi;
112
120
 
121
+ /** `a, f(b, c), d` → its three items: commas outside parentheses only. */
122
+ function items(list: string): string[] {
123
+ const found: string[] = [];
124
+ let depth = 0;
125
+ let start = 0;
126
+ for (let at = 0; at < list.length; at += 1) {
127
+ if (list[at] === '(') depth += 1;
128
+ else if (list[at] === ')') depth -= 1;
129
+ else if (list[at] === ',' && depth === 0) {
130
+ found.push(list.slice(start, at));
131
+ start = at + 1;
132
+ }
133
+ }
134
+ return [...found, list.slice(start)];
135
+ }
136
+
113
137
  /**
114
- * The outermost `order by`'s columns, read off the text: an index walk avoids a sort and carries no
115
- * sort key of its own, yet the index that ends the walk has to hold the order too.
138
+ * The outermost `order by`'s leading columns of the walked table, read off the text: an index walk
139
+ * avoids a sort and carries no sort key of its own, yet the index that ends the walk has to hold
140
+ * the order too. Only as far as one index can: plain columns of this table, one direction — past
141
+ * them the rows arrive presorted and Postgres finishes with an incremental sort.
116
142
  */
117
- function orderOf(statement: string, columns: ReadonlySet<string>): readonly string[] {
143
+ function orderOf(statement: string, columns: ReadonlySet<string>, verdict: PlanVerdict) {
118
144
  const clauses = [...statement.matchAll(ORDER_BY)];
119
- const last = clauses[clauses.length - 1]?.[1] ?? '';
120
- return [...last.matchAll(IDENTIFIER)].flatMap(([, quoted, bare]) => {
121
- const name = quoted ?? bare;
122
- return name !== undefined && columns.has(name) ? [name] : [];
123
- });
145
+ const order: string[] = [];
146
+ let way: string | undefined;
147
+ for (const item of items(clauses[clauses.length - 1]?.[1] ?? '')) {
148
+ const part = sortKeyPart(item);
149
+ const own =
150
+ part.column !== undefined &&
151
+ columns.has(part.column.name) &&
152
+ [undefined, verdict.table, verdict.alias].includes(part.column.qualifier);
153
+ if (!own || part.way === 'mixed' || (way !== undefined && part.way !== way)) break;
154
+ way = part.way;
155
+ if (part.column !== undefined) order.push(part.column.name);
156
+ }
157
+ return order;
158
+ }
159
+
160
+ /**
161
+ * An OR the read is bounded by and nothing else: one index per arm answers it, as a BitmapOr. An
162
+ * arm whose column leads a declared index is answered already — the sweep's settings discourage
163
+ * the BitmapOr, so the plan alone cannot say; credited, never re-proven.
164
+ */
165
+ function armFindings(
166
+ verdict: PlanVerdict,
167
+ declared: readonly (readonly string[])[],
168
+ property: (column: string) => string | undefined,
169
+ ): readonly (readonly string[])[] {
170
+ const arms = new Map((verdict.arms ?? []).map((arm) => [arm.join('\u0000'), arm]));
171
+ return [...arms.values()]
172
+ .filter((arm) => !declared.some((key) => key[0] !== undefined && arm.includes(key[0])))
173
+ .map((arm) => arm.flatMap((column) => property(column) ?? []));
124
174
  }
125
175
 
176
+ /**
177
+ * The plan was taken with every declared index in place: one that leads with exactly these
178
+ * columns is already there, and printing it as the fix would change nothing.
179
+ */
180
+ const held = (keys: readonly (readonly string[])[], columns: readonly string[]): boolean =>
181
+ columns.length > 0 && keys.some((key) => columns.every((column, at) => key[at] === column));
182
+
126
183
  /**
127
184
  * The findings one statement's verdicts make. A verdict whose equality columns pin a unique key is
128
185
  * dropped: one row at most. A table's sort verdict makes its scan verdict redundant — the index it
129
186
  * names leads with the same equality columns. The tenant column leads the index only when the
130
- * statement compares it: a read across tenants is served by no index that leads with one.
187
+ * statement compares it: a read across tenants is served by no index that leads with one. A sort
188
+ * no declarable index serves, or an index the entity already holds, is red with no index to paste.
131
189
  */
132
190
  export function planFindings(
133
191
  statement: string,
@@ -145,31 +203,39 @@ export function planFindings(
145
203
  key.every((column) => verdict.equality.includes(column)),
146
204
  );
147
205
  if (pinned) return [];
206
+ const finding = {
207
+ code: verdict.code,
208
+ statement,
209
+ node: verdict.node,
210
+ entity: entity.$name,
211
+ table: verdict.table,
212
+ };
213
+ if (verdict.unservable !== undefined) {
214
+ return [{ ...finding, index: [], unserved: verdict.unservable }];
215
+ }
148
216
  const description = entity.$describe();
149
217
  const byColumn = new Map(description.columns.map((c) => [c.column, c.property]));
150
218
  const walked = verdict.code === 'X_QUERY_UNINDEXED' && verdict.node !== 'Seq Scan';
151
219
  // A walk filtered by a range already has its order: what it lacks is the bound. A btree serves
152
220
  // no order after a range column, so the index is the equality columns, then the range.
153
221
  const ranged = walked && verdict.range.length > 0;
154
- if (ranged && servedByRange(declaredIndexes(entity, statement), verdict)) return [];
222
+ const declared = declaredIndexes(entity, statement);
223
+ const bounded = verdict.equality.length > 0 || verdict.range.length > 0;
224
+ if (!bounded && verdict.arms !== undefined && verdict.code === 'X_QUERY_UNINDEXED') {
225
+ const arms = armFindings(verdict, declared, (column) => byColumn.get(column));
226
+ return arms.map((index) => ({ ...finding, index }));
227
+ }
228
+ if (ranged && servedByRange(declared, verdict)) return [];
155
229
  const order = ranged
156
230
  ? []
157
231
  : walked
158
- ? orderOf(statement, new Set(description.columns.map((c) => c.column)))
232
+ ? orderOf(statement, new Set(description.columns.map((c) => c.column)), verdict)
159
233
  : verdict.order;
160
234
  if (servedByPartial(entity, verdict, order)) return [];
161
235
  const tenant = description.columns.find((c) => c.property === entity.$tenantColumn)?.column;
162
236
  const lead = tenant !== undefined && verdict.equality.includes(tenant) ? [tenant] : [];
163
237
  const columns = [...new Set([...lead, ...verdict.equality, ...order, ...verdict.range])];
164
- return [
165
- {
166
- code: verdict.code,
167
- statement,
168
- node: verdict.node,
169
- entity: entity.$name,
170
- table: verdict.table,
171
- index: columns.flatMap((column) => byColumn.get(column) ?? []),
172
- },
173
- ];
238
+ if (held(declared, columns)) return [{ ...finding, index: [], unserved: 'declared' }];
239
+ return [{ ...finding, index: columns.flatMap((column) => byColumn.get(column) ?? []) }];
174
240
  });
175
241
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { renderCauseValue, renderFixLiteral, renderFixShellArg } from '@ultimat3/core';
6
6
  import type { ExerciseRecord } from './exercise-report';
7
+ import type { PlanUnservable } from './plan-sort-key';
7
8
  import type { PlanFinding } from './plan-sweep-fix';
8
9
 
9
10
  export interface SweepFinding {
@@ -41,7 +42,47 @@ function wholeRead(finding: PlanFinding, senders: readonly ExerciseRecord[]): Sw
41
42
  };
42
43
  }
43
44
 
45
+ /** Why no index serves a sort, as the cause says it. */
46
+ const UNSERVED_SORT: Readonly<Record<PlanUnservable, string>> = {
47
+ expression: 'an expression over its columns, and entity() declares no expression index',
48
+ aggregate: 'a value an aggregate or window computes, which no stored order holds',
49
+ derived: 'a value a subquery or CTE derives, which no index on a table holds',
50
+ tables: 'columns of more than one table, and one index holds one table',
51
+ directions: 'columns in different directions, and an index declares one order for all of them',
52
+ };
53
+
54
+ /**
55
+ * A red node no `indexes:` entry ends: a sort no declarable index holds, or one whose index the
56
+ * entity already declares. Printing an index would change no plan, so the fix is the rewrite that
57
+ * lets one apply, or the stated reason the rows stay few.
58
+ */
59
+ function unservedRead(
60
+ finding: PlanFinding,
61
+ unserved: PlanUnservable | 'declared',
62
+ senders: readonly ExerciseRecord[],
63
+ ): SweepFinding {
64
+ const by = senders.map(primitive).join(', ');
65
+ const entity = renderFixLiteral(finding.entity, '<entity>');
66
+ const rerun = renderFixShellArg(senders[0]?.name ?? '', '<primitive>');
67
+ const sent = `${by} sent ${renderCauseValue(excerpt(finding.statement))}`;
68
+ const cause =
69
+ unserved === 'declared'
70
+ ? `${sent}, which reads ${finding.table} unbounded (${finding.node}) though entity(${entity}) already declares the index that would bound it: declaring it again changes no plan, the statement as written cannot use it`
71
+ : `${sent}, which sorts its rows to answer a limited read (${finding.node} under Limit) by ${UNSERVED_SORT[unserved]}: no index on ${finding.table} serves that sort, so it slows with every row the table gains`;
72
+ const rewrite =
73
+ unserved === 'declared'
74
+ ? "the read compares the index's leading column by = or a range, no function around it"
75
+ : `an order by of plain ${finding.table} columns, all one direction (the rerun names their index)`;
76
+ return {
77
+ code: finding.code,
78
+ cause,
79
+ fix: `x exercise ${rerun} --json # after ${rewrite}, or expectedSeqScan('<why the rows stay few>', fn) around the read`,
80
+ at: senders[0] === undefined ? finding.entity : primitive(senders[0]),
81
+ };
82
+ }
83
+
44
84
  function planFinding(finding: PlanFinding, senders: readonly ExerciseRecord[]): SweepFinding {
85
+ if (finding.unserved !== undefined) return unservedRead(finding, finding.unserved, senders);
45
86
  if (finding.index.length === 0) return wholeRead(finding, senders);
46
87
  const by = senders.map(primitive).join(', ');
47
88
  const entity = renderFixLiteral(finding.entity, '<entity>');
@@ -67,7 +108,8 @@ export function planSweepFindings(records: readonly ExerciseRecord[]): readonly
67
108
  const groups = new Map<string, { finding: PlanFinding; senders: ExerciseRecord[] }>();
68
109
  for (const record of records) {
69
110
  for (const finding of record.plans?.findings ?? []) {
70
- const key = `${finding.code}\u0000${finding.table}\u0000${finding.statement}`;
111
+ // An OR's arms are one finding each, on one statement: the index is part of what is red.
112
+ const key = [finding.code, finding.table, finding.statement, ...finding.index].join('\u0000');
71
113
  const group = groups.get(key) ?? { finding, senders: [] };
72
114
  group.senders.push(record);
73
115
  groups.set(key, group);
@@ -91,9 +133,11 @@ export function planSweepFindings(records: readonly ExerciseRecord[]): readonly
91
133
  export function planSweepLines(records: readonly ExerciseRecord[]): readonly string[] {
92
134
  return records.flatMap((record) => [
93
135
  ...(record.plans?.findings ?? []).map((finding) =>
94
- finding.index.length === 0
95
- ? `${primitive(record)} · ${finding.code} ${finding.table}, every row (no column to index)`
96
- : `${primitive(record)} · ${finding.code} ${finding.table} on [${finding.index.join(', ')}]`,
136
+ finding.unserved !== undefined
137
+ ? `${primitive(record)} · ${finding.code} ${finding.table}, no index serves it (${finding.unserved})`
138
+ : finding.index.length === 0
139
+ ? `${primitive(record)} · ${finding.code} ${finding.table}, every row (no column to index)`
140
+ : `${primitive(record)} · ${finding.code} ${finding.table} on [${finding.index.join(', ')}]`,
97
141
  ),
98
142
  ...(record.scale?.verdict === 'grows' ? record.scale.shapes : []).map(
99
143
  (shape) =>
@@ -6,7 +6,6 @@
6
6
  import { fnv1a } from '@ultimat3/core';
7
7
  import type { ColumnMeta } from '@ultimat3/entity';
8
8
  import type * as Sample from '@ultimat3/schema/sample';
9
- import { patternSamples } from './seed-pattern-sample';
10
9
 
11
10
  /** A column as `entity()` declares it, structurally: this barrel never loads `@ultimat3/entity`. */
12
11
  export interface SeedColumn {
@@ -72,19 +71,11 @@ const textCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext):
72
71
  return [...sized, ...fromPatterns(property, ctx)];
73
72
  };
74
73
 
75
- /** Strings drawn from each declared pattern, kept only where every other pattern agrees. */
76
- const fromPatterns = (property: string, ctx: ValueContext): string[] => {
77
- const patterns = ctx.patterns ?? [];
78
- return patterns
79
- .flatMap((pattern) => patternSamples(pattern, `${property}:${ctx.canary}`))
80
- .filter((sample) => patterns.every((pattern) => pattern.test(sample)));
81
- };
82
-
83
74
  let loadedSampler: typeof Sample | undefined;
84
75
 
85
76
  /**
86
- * `@ultimat3/schema/sample`, loaded on the first `json(schema)` column and never at import: this
87
- * module is on the barrel, and the walker is a dynamic-only dependency there (`CLAUDE.md`).
77
+ * `@ultimat3/schema/sample`, loaded on the first pattern or `json(schema)` column, never at import:
78
+ * this module is on the barrel, and the walker is a dynamic-only dependency there (`CLAUDE.md`).
88
79
  * `require` and not `import()` because every caller of `candidatesFor` is synchronous.
89
80
  */
90
81
  const sampler = (): typeof Sample => {
@@ -92,6 +83,24 @@ const sampler = (): typeof Sample => {
92
83
  return loadedSampler;
93
84
  };
94
85
 
86
+ /**
87
+ * Strings drawn from each declared pattern by schema's one generator (`patternSamples`), kept only
88
+ * where every other pattern agrees. The text seed hashes with the pattern, so two columns differ.
89
+ */
90
+ const fromPatterns = (property: string, ctx: ValueContext): string[] => {
91
+ const patterns = ctx.patterns ?? [];
92
+ if (patterns.length === 0) return [];
93
+ const { patternSamples } = sampler();
94
+ return patterns
95
+ .flatMap((pattern) =>
96
+ patternSamples(
97
+ pattern,
98
+ fnv1a(`${pattern.source}/${pattern.flags}:${property}:${ctx.canary}`) || 1,
99
+ ),
100
+ )
101
+ .filter((sample) => patterns.every((pattern) => pattern.test(sample)));
102
+ };
103
+
95
104
  /**
96
105
  * Values the column's own schema accepts, seeded by the row so two rows differ and a rerun does
97
106
  * not; then the bare `{}`/`[]`, which a schema-less `jsonb` column stores.
package/src/seed-graph.ts CHANGED
@@ -63,6 +63,33 @@ function ownedByTenant(
63
63
  return owned;
64
64
  }
65
65
 
66
+ /**
67
+ * Every untenanted entity (`shared`, or of no tenant) a tenant-owned table points at through a key
68
+ * unique across the TABLE — a unique set holding the reference and not the tenant column. Each
69
+ * tenant's children then take parent rows no other tenant may reuse, so the parent seeds
70
+ * `perTable` rows per tenant: `perTable` alone ran out before tenant 2 (`X_SEED_GRAPH_UNSATISFIED`).
71
+ */
72
+ function fannedOut(
73
+ shapes: ReadonlyMap<string, EntityShape>,
74
+ scoped: (name: string) => boolean,
75
+ ): ReadonlySet<string> {
76
+ const out = new Set<string>();
77
+ for (const shape of shapes.values()) {
78
+ if (!scoped(shape.name)) continue;
79
+ const tenantColumn = shape.entity.$tenantColumn;
80
+ const tableWide = [...shape.uniques, ...shape.partialUniques].filter(
81
+ (set) => tenantColumn === null || !set.includes(tenantColumn),
82
+ );
83
+ for (const reference of shape.references.values()) {
84
+ if (scoped(reference.targetEntity) || reference.targetEntity === shape.name) continue;
85
+ if (tableWide.some((set) => set.includes(reference.property))) {
86
+ out.add(reference.targetEntity);
87
+ }
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
66
93
  export function seedGraph(
67
94
  entities: Readonly<Record<string, SeedEntity>>,
68
95
  options: SeedGraphOptions = {},
@@ -108,6 +135,7 @@ export function seedGraph(
108
135
  }
109
136
  const owned = ownedByTenant(shapes, roots);
110
137
  const scoped = (name: string): boolean => owned.has(name);
138
+ const fanned = fannedOut(shapes, scoped);
111
139
  for (const factory of Object.values(options.factories ?? {})) factory.reset();
112
140
 
113
141
  const placed = new Map<string, Placed[]>();
@@ -170,7 +198,8 @@ export function seedGraph(
170
198
  const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
171
199
  let ordinal = 0;
172
200
  for (const [bucket, tenant] of buckets.entries()) {
173
- const size = rootProperty === undefined ? perTable : 1;
201
+ const size =
202
+ rootProperty !== undefined ? 1 : fanned.has(name) ? perTable * tenantCount : perTable;
174
203
  for (let index = 0; index < size; index += 1) {
175
204
  ordinal += 1;
176
205
  const canary = stream().replace(/-/g, '').slice(0, 10);
@@ -34,10 +34,19 @@ export function tenantFindingLines(records: readonly ExerciseRecord[]): readonly
34
34
  );
35
35
  }
36
36
 
37
+ /**
38
+ * B sends A's own first input, A's ids bound in only where it takes any: `{ occurrenceMs }` carries
39
+ * none, and B still crosses when a statement it ran names no tenant. Both readings, always.
40
+ */
37
41
  const causeOf = (record: ExerciseRecord, finding: TenantFinding): string =>
38
42
  finding.code === 'X_TENANT_LEAK'
39
- ? `${record.kind} ${record.name}, called by tenant B with tenant A's ids ${LEG[finding.leg]}, handed back A's seeded data at ${where(finding)}: a read reached A's row by an id the caller chose, past the tenant guard`
40
- : `${record.kind} ${record.name}, called by tenant B with tenant A's ids ${LEG[finding.leg]}, changed A's ${where(finding)}: a write reached A's row by an id the caller chose, past the tenant guard`;
43
+ ? `${record.kind} ${record.name}, called by tenant B with A's own input ${LEG[finding.leg]}, handed back A's seeded data at ${where(finding)}: a read reached A's row past the tenant guard — by an id the caller chose, or by a statement no tenant predicate scopes`
44
+ : `${record.kind} ${record.name}, called by tenant B with A's own input ${LEG[finding.leg]}, changed A's ${where(finding)}: a write reached A's row past the tenant guard — by an id the caller chose, or by a statement no tenant predicate scopes`;
45
+
46
+ const fixOf = (name: string, finding: TenantFinding): string =>
47
+ finding.code === 'X_TENANT_LEAK'
48
+ ? `x exercise ${name} --json — then read the row behind ${where(finding)} through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement`
49
+ : `x exercise ${name} --json — then write ${where(finding)} through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement`;
41
50
 
42
51
  /**
43
52
  * One coded finding per tenant finding. The fix reruns the one primitive: what it reads or writes
@@ -54,7 +63,7 @@ export function tenantProofFindings(
54
63
  (finding): TenantProofFinding => ({
55
64
  code: finding.code,
56
65
  cause: causeOf(record, finding),
57
- fix: `x exercise ${name} --json — then read ${finding.code === 'X_TENANT_LEAK' ? 'the row behind ' : ''}${where(finding)} through the repository scoped to ctx.actor, never by the input id alone`,
66
+ fix: fixOf(name, finding),
58
67
  at: `${record.kind} ${record.name}`,
59
68
  }),
60
69
  );
@@ -79,6 +79,12 @@ export interface TenantAttack {
79
79
  * half is skipped. An action or a query answers B directly, so a declared scope excuses nothing.
80
80
  */
81
81
  readonly sweep: boolean;
82
+ /**
83
+ * A job declaring `tenant: 'none'` (`bindJobTenant`'s `none`): no org reaches its run. Handed
84
+ * nothing of A's, B's run is A's run — what it writes to A's rows it writes for every enqueuer,
85
+ * so the write half's premise, B choosing A's row, cannot hold: skipped (`tenantlessReason`).
86
+ */
87
+ readonly tenantless: boolean;
82
88
  attack(leg: TenantLeg): Promise<LegAnswer>;
83
89
  /** A's positive control: today's exercise of the primitive, and what A's last answer was. */
84
90
  control(): Promise<{ readonly record: ExerciseRecord; readonly output: unknown }>;
@@ -129,11 +135,25 @@ export function sweepReason(
129
135
  return `crossTenant(${JSON.stringify(reason)}) declares this job reads across tenants, and B's input named nothing of A's: read half skipped, write half kept`;
130
136
  }
131
137
 
138
+ /**
139
+ * The reason the write half is skipped, or `undefined`: a `tenant: 'none'` job whose input carries
140
+ * none of A's marks — no org and no id of B's choosing reaches the run. Handed one of A's ids, B
141
+ * chose A's row and the write half stands. An action or a query runs as B's actor, so B's org
142
+ * reaches it: a write to A's row with no id handed is an unscoped statement, crossed as ever.
143
+ */
144
+ export function tenantlessReason(
145
+ plan: Pick<TenantAttack, 'tenantless' | 'taint'>,
146
+ hunted: readonly string[],
147
+ ): string | undefined {
148
+ if (!plan.tenantless || hunted.length !== plan.taint.marks.length) return undefined;
149
+ return "tenant: 'none' runs this job under no org, and B's input named nothing of A's: B's run is A's run, so what it writes it writes for every enqueuer — write half skipped, read half kept";
150
+ }
151
+
132
152
  const verdictOf = (
133
153
  plan: TenantAttack,
134
154
  control: { readonly record: ExerciseRecord; readonly output: unknown },
135
155
  findings: readonly TenantFinding[],
136
- swept: string | undefined,
156
+ skipped: readonly string[],
137
157
  ): TenantProof => {
138
158
  const { record } = control;
139
159
  if (findings.length > 0) return { verdict: 'crossed', findings };
@@ -144,7 +164,7 @@ const verdictOf = (
144
164
  };
145
165
  }
146
166
  if (plan.public !== undefined) return { verdict: 'public', reason: plan.public };
147
- if (swept !== undefined) return { verdict: 'public', reason: swept };
167
+ if (skipped.length > 0) return { verdict: 'public', reason: skipped.join('; ') };
148
168
  // Every mark of A's, handed ones included: A was handed its own ids, and reading them is the point.
149
169
  if (plan.read && findTaint(control.output, 'output', plan.taint.marks).length === 0) {
150
170
  return {
@@ -159,12 +179,19 @@ const verdictOf = (
159
179
  export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
160
180
  const marks = huntedMarks(plan.taint, plan.input);
161
181
  const findings: TenantFinding[] = [];
182
+ const unwritten = tenantlessReason(plan, marks);
162
183
  let swept: string | undefined;
163
- const leg = async (name: TenantLeg): Promise<void> => {
184
+ /** B's call, between two snapshots unless the write half is skipped: then none is read. */
185
+ const watched = async (name: TenantLeg): Promise<LegAnswer> => {
186
+ if (unwritten !== undefined) return plan.attack(name);
164
187
  const before = await plan.snapshot(plan.taint.rows);
165
188
  const answer = await plan.attack(name);
166
189
  const after = await plan.snapshot(plan.taint.rows);
167
190
  findings.push(...crossedWrites(name, plan.taint, before, after));
191
+ return answer;
192
+ };
193
+ const leg = async (name: TenantLeg): Promise<void> => {
194
+ const answer = await watched(name);
168
195
  if (plan.public !== undefined) return;
169
196
  const sweep = sweepReason(plan, answer, marks);
170
197
  if (sweep === undefined) findings.push(...leaksIn(name, answer, marks));
@@ -173,5 +200,6 @@ export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
173
200
  await leg('before');
174
201
  const control = await plan.control();
175
202
  await leg('after');
176
- return { ...control.record, tenant: verdictOf(plan, control, findings, swept) };
203
+ const skipped = [swept, unwritten].filter((reason) => reason !== undefined);
204
+ return { ...control.record, tenant: verdictOf(plan, control, findings, skipped) };
177
205
  }