@jevitate/cli 0.28.1 → 0.29.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.
Files changed (180) hide show
  1. package/README.md +1 -1
  2. package/dist/approval-provenance.d.ts +9 -1
  3. package/dist/approval-provenance.js +12 -4
  4. package/dist/approval-provenance.js.map +1 -1
  5. package/dist/author-journey-cli.js +1 -1
  6. package/dist/author-journey-cli.js.map +1 -1
  7. package/dist/bin.js +12657 -8284
  8. package/dist/bin.js.map +1 -1
  9. package/dist/build-info.generated.js +3 -3
  10. package/dist/catalog-analysis.js +3 -2
  11. package/dist/catalog-analysis.js.map +1 -1
  12. package/dist/catalog-bundle.js +2 -2
  13. package/dist/catalog-bundle.js.map +1 -1
  14. package/dist/check-plan.js +12 -0
  15. package/dist/check-plan.js.map +1 -1
  16. package/dist/demo-aspect-cli.d.ts +10 -0
  17. package/dist/demo-aspect-cli.js +4 -1
  18. package/dist/demo-aspect-cli.js.map +1 -1
  19. package/dist/engine.d.ts +8 -1
  20. package/dist/engine.js +16 -2
  21. package/dist/engine.js.map +1 -1
  22. package/dist/explore-adversarial.js +4 -0
  23. package/dist/explore-adversarial.js.map +1 -1
  24. package/dist/explore-cli/feature-strategy.js +1 -0
  25. package/dist/explore-cli/feature-strategy.js.map +1 -1
  26. package/dist/explore-cli/run.js +7 -0
  27. package/dist/explore-cli/run.js.map +1 -1
  28. package/dist/explore-feature.d.ts +2 -0
  29. package/dist/explore-feature.js +15 -8
  30. package/dist/explore-feature.js.map +1 -1
  31. package/dist/explore-goal.js +12 -0
  32. package/dist/explore-goal.js.map +1 -1
  33. package/dist/explore-specs.js +2 -2
  34. package/dist/explore-specs.js.map +1 -1
  35. package/dist/heal-probe-guard.d.ts +7 -3
  36. package/dist/heal-probe-guard.js +8 -2
  37. package/dist/heal-probe-guard.js.map +1 -1
  38. package/dist/http-probe.js +2 -2
  39. package/dist/http-probe.js.map +1 -1
  40. package/dist/journey-annotate-api.d.ts +5 -0
  41. package/dist/journey-annotate-api.js +49 -24
  42. package/dist/journey-annotate-api.js.map +1 -1
  43. package/dist/journey-annotate-cli.js +4 -1
  44. package/dist/journey-annotate-cli.js.map +1 -1
  45. package/dist/journey-api.d.ts +46 -7
  46. package/dist/journey-api.js +111 -43
  47. package/dist/journey-api.js.map +1 -1
  48. package/dist/journey-chain.d.ts +0 -6
  49. package/dist/journey-chain.js +36 -5
  50. package/dist/journey-chain.js.map +1 -1
  51. package/dist/journey-cli/chain.js +6 -0
  52. package/dist/journey-cli/chain.js.map +1 -1
  53. package/dist/journey-cli/lint.js +10 -3
  54. package/dist/journey-cli/lint.js.map +1 -1
  55. package/dist/journey-cli/run.js +39 -4
  56. package/dist/journey-cli/run.js.map +1 -1
  57. package/dist/journey-cli/verify.d.ts +8 -1
  58. package/dist/journey-cli/verify.js +111 -14
  59. package/dist/journey-cli/verify.js.map +1 -1
  60. package/dist/journey-cli-readiness.js +2 -0
  61. package/dist/journey-cli-readiness.js.map +1 -1
  62. package/dist/journey-demo-cli.js +2 -0
  63. package/dist/journey-demo-cli.js.map +1 -1
  64. package/dist/journey-network-checks.d.ts +4 -0
  65. package/dist/journey-network-checks.js +6 -2
  66. package/dist/journey-network-checks.js.map +1 -1
  67. package/dist/journey-prefix.d.ts +2 -0
  68. package/dist/journey-prefix.js +20 -1
  69. package/dist/journey-prefix.js.map +1 -1
  70. package/dist/journey-proof-context.d.ts +7 -1
  71. package/dist/journey-proof-context.js +19 -1
  72. package/dist/journey-proof-context.js.map +1 -1
  73. package/dist/journey-proof-waiver-cli.js +6 -3
  74. package/dist/journey-proof-waiver-cli.js.map +1 -1
  75. package/dist/journey-readiness.js +4 -0
  76. package/dist/journey-readiness.js.map +1 -1
  77. package/dist/journey-review-api.js +23 -0
  78. package/dist/journey-review-api.js.map +1 -1
  79. package/dist/journey-review.d.ts +30 -10
  80. package/dist/journey-review.js +33 -39
  81. package/dist/journey-review.js.map +1 -1
  82. package/dist/journey-run-record.d.ts +91 -0
  83. package/dist/journey-run-record.js +239 -0
  84. package/dist/journey-run-record.js.map +1 -0
  85. package/dist/journey-stale-api.d.ts +7 -0
  86. package/dist/journey-stale-api.js +50 -6
  87. package/dist/journey-stale-api.js.map +1 -1
  88. package/dist/journey-verify-checkpoint.d.ts +656 -0
  89. package/dist/journey-verify-checkpoint.js +923 -0
  90. package/dist/journey-verify-checkpoint.js.map +1 -0
  91. package/dist/journey-verify.d.ts +84 -3
  92. package/dist/journey-verify.js +1387 -110
  93. package/dist/journey-verify.js.map +1 -1
  94. package/dist/kill-signal.d.ts +24 -2
  95. package/dist/kill-signal.js +39 -11
  96. package/dist/kill-signal.js.map +1 -1
  97. package/dist/load-api.js +31 -2
  98. package/dist/load-api.js.map +1 -1
  99. package/dist/load-cli.js +6 -0
  100. package/dist/load-cli.js.map +1 -1
  101. package/dist/logs-retention.js +28 -3
  102. package/dist/logs-retention.js.map +1 -1
  103. package/dist/mask-values.d.ts +5 -0
  104. package/dist/mask-values.js +12 -0
  105. package/dist/mask-values.js.map +1 -0
  106. package/dist/mcp-api.d.ts +2 -0
  107. package/dist/mcp-api.js +19 -3
  108. package/dist/mcp-api.js.map +1 -1
  109. package/dist/mcp-cli-runner.d.ts +7 -1
  110. package/dist/mcp-cli-runner.js +30 -3
  111. package/dist/mcp-cli-runner.js.map +1 -1
  112. package/dist/mcp-cli-tools.d.ts +9 -1
  113. package/dist/mcp-cli-tools.js +29 -4
  114. package/dist/mcp-cli-tools.js.map +1 -1
  115. package/dist/mcp-paths.js +11 -0
  116. package/dist/mcp-paths.js.map +1 -1
  117. package/dist/mcp-run-guard.d.ts +79 -0
  118. package/dist/mcp-run-guard.js +371 -0
  119. package/dist/mcp-run-guard.js.map +1 -0
  120. package/dist/mcp-tools/cli.js +1 -1
  121. package/dist/mcp-tools/cli.js.map +1 -1
  122. package/dist/mcp-tools/findings.js +12 -1
  123. package/dist/mcp-tools/findings.js.map +1 -1
  124. package/dist/mcp-tools/journey.js +25 -8
  125. package/dist/mcp-tools/journey.js.map +1 -1
  126. package/dist/mission-fixtures.d.ts +3 -1
  127. package/dist/mission-fixtures.js +23 -6
  128. package/dist/mission-fixtures.js.map +1 -1
  129. package/dist/mutation-proof-policy.d.ts +2 -0
  130. package/dist/mutation-proof-policy.js +2 -2
  131. package/dist/mutation-proof-policy.js.map +1 -1
  132. package/dist/program.d.ts +6 -0
  133. package/dist/program.js +6 -9
  134. package/dist/program.js.map +1 -1
  135. package/dist/project-dir.d.ts +5 -1
  136. package/dist/project-dir.js +54 -5
  137. package/dist/project-dir.js.map +1 -1
  138. package/dist/readiness.js +3 -1
  139. package/dist/readiness.js.map +1 -1
  140. package/dist/regression-api.d.ts +5 -0
  141. package/dist/regression-api.js +17 -10
  142. package/dist/regression-api.js.map +1 -1
  143. package/dist/regression-cli.js +14 -1
  144. package/dist/regression-cli.js.map +1 -1
  145. package/dist/report-api.js +6 -0
  146. package/dist/report-api.js.map +1 -1
  147. package/dist/resource-preflight.d.ts +2 -1
  148. package/dist/resource-preflight.js +2 -1
  149. package/dist/resource-preflight.js.map +1 -1
  150. package/dist/secret-params.d.ts +82 -0
  151. package/dist/secret-params.js +234 -0
  152. package/dist/secret-params.js.map +1 -0
  153. package/dist/self-heal-adapter.d.ts +2 -2
  154. package/dist/self-heal-adapter.js +7 -9
  155. package/dist/self-heal-adapter.js.map +1 -1
  156. package/dist/source-cli.js +3 -1
  157. package/dist/source-cli.js.map +1 -1
  158. package/dist/source-run-api.d.ts +2 -0
  159. package/dist/source-run-api.js +21 -2
  160. package/dist/source-run-api.js.map +1 -1
  161. package/dist/target-config.d.ts +39 -0
  162. package/dist/target-config.js +76 -1
  163. package/dist/target-config.js.map +1 -1
  164. package/dist/terminal-result.d.ts +181 -0
  165. package/dist/terminal-result.js +609 -0
  166. package/dist/terminal-result.js.map +1 -0
  167. package/dist/usability-capture.js +2 -2
  168. package/dist/usability-capture.js.map +1 -1
  169. package/dist/ux-usability/hang.js +2 -0
  170. package/dist/ux-usability/hang.js.map +1 -1
  171. package/dist/ux-usability.js +6 -0
  172. package/dist/ux-usability.js.map +1 -1
  173. package/dist/verify-fix-api.d.ts +43 -0
  174. package/dist/verify-fix-api.js +138 -10
  175. package/dist/verify-fix-api.js.map +1 -1
  176. package/dist/verify-fix-cli.d.ts +6 -1
  177. package/dist/verify-fix-cli.js +65 -11
  178. package/dist/verify-fix-cli.js.map +1 -1
  179. package/package.json +11 -11
  180. package/skills/jevitate-run-journey/SKILL.md +23 -3
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * #402 — `journey verify --mutate`: a built-in negative proof that each of a Journey's assertions
3
- * FAILS when its outcome is absent. The Journey is replayed once as recorded (it must pass), then
3
+ * FAILS when its outcome is absent. The Journey is replayed as recorded (it must pass), then
4
4
  * once per mutation (`planJourneyMutations`: skip a write step, abort its writes, type an empty or
5
5
  * stale value into a checked fill, or — #565 — swap or truncate a checked non-secret parameter); each assertion is sensitive only when its paired mutation broke it, at
6
6
  * that assertion, with nothing failing before the mutated step.
@@ -9,14 +9,731 @@
9
9
  * environment as `journey run`. Mutations never send, fabricate or answer a request: they leave a
10
10
  * step's action out, ABORT the app's own writes in one step's window, or type `""`.
11
11
  */
12
- import { FsJourneyStore, JourneyRegistry, assertionSiteKey, classifyMutation, declaredTargetedProofFor, journeyAssertions, judgeAssertions, mutationProofVerdict, mutationReplay, needsRelaxedReplay, planJourneyMutations, relaxedMutationReplay, runDeclaredTargetedProof, runTargetedProof, suggestTargetedProofPlan, } from "@jevitate/journey";
13
- import { describeCheck } from "@jevitate/explore";
12
+ import { FsJourneyStore, JourneyRegistry, assertionSiteKey, classifyMutation, classifyReplay, controlCanChangeVerdict, declaredTargetedProofFor, flatJourneySteps, heldSites, journeyAssertions, ParamValidationError, journeyEndState, journeyParamSchema, journeyWriteSteps, judgeAssertions, mutationErrored, mutationProofVerdict, mutationReplay, needsRelaxedReplay, paceBeforeMutationMs, paceEndStateFloorMs, paceFloorMs, paceMarginBoundMs, paceMarginOf, paceTailMs, pacedReplay, planJourneyMutations, relaxedMutationReplay, runDeclaredTargetedProof, runTargetedProof, siblingDisagreements, sitesReachedEarly, sitesUnmatchedBy, suggestTargetedProofPlan, targetedMutationReplay, thinSites, validateParams, } from "@jevitate/journey";
13
+ import { DEFAULT_STEP_RESPONSE_TIMEOUT_MS, describeCheck, evaluateNetworkCheckAwaiting, monitorFor } from "@jevitate/explore";
14
14
  import { clock } from "@jevitate/domain";
15
+ import { checkAssertion, waitForOutcome } from "@jevitate/interpreter";
16
+ import { AssertionRefError, assertionRefs, resolveAssertionRefs } from "@jevitate/recording";
17
+ import { BrowseTheWebToken } from "@jevitate/screenplay";
18
+ import { DEFAULT_ADMISSION_TIMEOUT_MS, DEFAULT_CLOSE_TIMEOUT_MS, DEFAULT_OPEN_TIMEOUT_MS, PlaywrightBrowserPort, pageUnresponsiveMsFromEnv, sharedResourceGovernor, } from "@jevitate/playwright";
15
19
  import { journeyContentHash } from "./journey-annotate-api.js";
16
20
  import { journeyReviewHash } from "./journey-review.js";
17
21
  import { preflightVerifyRecord, writePreflightedVerifyRecord } from "./journey-review-store.js";
18
- import { UnknownJourneyError, redactSecretParams, runJourneyProgrammatically } from "./journey-api.js";
22
+ import { UnknownJourneyError, effectiveAllowedOrigins, redactSecretParams, runJourneyProgrammatically } from "./journey-api.js";
19
23
  import { journeyProofContext, journeyProofHashForContext } from "./journey-proof-context.js";
24
+ import { JourneyVerifyResumeRefusedError, RETRY_REASON_NOT_KEPT, checkpointPairOf, checkpointReplayOf, isRepairableIsolation, openProofCheckpoint, resumeRefusalAgainstBase, reusedProofFields, } from "./journey-verify-checkpoint.js";
25
+ import { composeObservers } from "./run-screenshots.js";
26
+ /** Hands a proof's bound the Journey it is sized from. */
27
+ export async function loadJourneyToVerify(dir, id) {
28
+ const journey = await new JourneyRegistry(new FsJourneyStore(dir)).get(id);
29
+ if (!journey)
30
+ throw new UnknownJourneyError(`unknown journey '${id}'`);
31
+ return journey;
32
+ }
33
+ // What one replay can legally wait for, by the bounds of the code it runs.
34
+ const ACTION_BOUND_MS = 30_000; // `page.goto` and default actions (Playwright's default).
35
+ const RELOAD_BOUND_MS = 15_000; // `outcome-wait.ts`, `act.ts`.
36
+ const SITE_GATE_WAIT_MS = 5_000; // `site-gate.ts`.
37
+ /** A hook's default limit, before and after a replay. A spec that sets longer limits leans on the rest of the budget. */
38
+ const FIXTURE_ALLOWANCE_MS = 120_000;
39
+ /** The least a replay is given, however short its Journey. */
40
+ const REPLAY_FLOOR_MS = 360_000;
41
+ const MAX_TIMER_MS = 2_147_483_647;
42
+ /** How long after the inactivity limit the in-process bound fires, so a CLI's terminal layer ends the run first. */
43
+ const STALL_MARGIN_MS = 30_000;
44
+ /** How long a stalled replay's session close and its own unwinding (fixture restore) may take. */
45
+ const STALL_TEARDOWN_MS = 90_000;
46
+ function admissionBoundMs(env = process.env) {
47
+ const set = Number(env["JEVITATE_ADMISSION_TIMEOUT_MS"]);
48
+ return Number.isInteger(set) && set > 0 ? set : DEFAULT_ADMISSION_TIMEOUT_MS;
49
+ }
50
+ /**
51
+ * The longest a proof can legally go between two replays' progress: a machine-slot admission wait,
52
+ * then the longest legal single replay of THIS Journey, each step at its worst. A sum of worst cases,
53
+ * so it is far above a typical replay. It limits silence and never the whole run: a proof whose
54
+ * replays keep starting is never ended, however long it takes (#778).
55
+ */
56
+ export function proofInactivityMs(journey, opts = {}) {
57
+ const responseDefault = opts.stepResponseTimeoutMs ?? DEFAULT_STEP_RESPONSE_TIMEOUT_MS;
58
+ let replay = DEFAULT_OPEN_TIMEOUT_MS + pageUnresponsiveMsFromEnv() + DEFAULT_CLOSE_TIMEOUT_MS + FIXTURE_ALLOWANCE_MS;
59
+ let longestStepMs = 0;
60
+ for (const { recorded } of flatJourneySteps(journey)) {
61
+ const wait = "waitFor" in recorded.step ? recorded.step.waitFor : undefined;
62
+ // A relaxed site's kept response wait (`RelaxedSiteWait`) runs per request check, one after another.
63
+ let stepMs = ACTION_BOUND_MS + SITE_GATE_WAIT_MS + RELOAD_BOUND_MS + (recorded.responseTimeoutMs ?? responseDefault) * Math.max(1, recorded.expectRequests?.length ?? 0);
64
+ // A wait's last poll can start just before `maxMs` and still reload.
65
+ if (wait !== undefined)
66
+ stepMs += wait.maxMs + ACTION_BOUND_MS;
67
+ replay += stepMs;
68
+ longestStepMs = Math.max(longestStepMs, stepMs);
69
+ }
70
+ // Only a Journey with steps is paced: its floors' margin, and waiting for open writes before its end state and its close.
71
+ if (longestStepMs > 0)
72
+ replay += paceMarginBoundMs(longestStepMs) + 2 * (Math.max(responseDefault, longestStepMs) + ABORTED_WRITE_GRACE_MS);
73
+ replay += journeyEndState(journey).length * (ACTION_BOUND_MS + RELOAD_BOUND_MS);
74
+ return Math.min(admissionBoundMs() + Math.max(REPLAY_FLOOR_MS, replay), MAX_TIMER_MS - STALL_MARGIN_MS);
75
+ }
76
+ /**
77
+ * A replay went past the proof's inactivity limit: the proof ended with no verdict and no receipt.
78
+ * A CLI run normally ends at the terminal layer's own limit first, so this is what an MCP call (where
79
+ * that layer stays out) and `journey readiness` get. Its text carries names only, never a value.
80
+ */
81
+ export class JourneyVerifyStalledError extends Error {
82
+ where;
83
+ inactivityMs;
84
+ sessionsClosed;
85
+ code = "E_TERMINAL_WATCHDOG";
86
+ constructor(where, inactivityMs,
87
+ /** Every session this replay opened has closed. False: its close was still pending when the teardown bound ran out. */
88
+ sessionsClosed) {
89
+ const at = where.mutation === undefined ? `replay ${where.replay}` : `replay ${where.replay}, mutation ${where.mutation}`;
90
+ super(`journey verify: no progress for the ${inactivityMs}ms inactivity limit at ${at}; no verdict, and any earlier receipt stands${sessionsClosed ? "" : ". Its browser session did not close in time"}`);
91
+ this.where = where;
92
+ this.inactivityMs = inactivityMs;
93
+ this.sessionsClosed = sessionsClosed;
94
+ this.name = "JourneyVerifyStalledError";
95
+ }
96
+ }
97
+ /**
98
+ * The sessions one replay opens, so a stalled replay can be ended from outside: `runJourneyProgrammatically`
99
+ * takes no cancel signal, but closing its session makes every page call it is waiting on reject, and
100
+ * it then unwinds by its own paths, fixture restore included. An open still pending at the stall is
101
+ * closed the moment it lands.
102
+ */
103
+ function trackedPortFactory(factory, onOpened) {
104
+ const pending = new Set();
105
+ const opened = new Set();
106
+ let abandoned = false;
107
+ const closeOf = (session) => session.close().catch(() => undefined);
108
+ const openOn = async (port, o) => {
109
+ const session = await port.open(o);
110
+ if (abandoned) {
111
+ await closeOf(session);
112
+ throw new Error("the replay was abandoned while its browser session opened");
113
+ }
114
+ opened.add(session);
115
+ onOpened();
116
+ return session;
117
+ };
118
+ return {
119
+ factory: () => {
120
+ const port = factory();
121
+ return {
122
+ open: (o) => {
123
+ const opening = openOn(port, o);
124
+ const done = () => void pending.delete(opening);
125
+ pending.add(opening);
126
+ opening.then(done, done);
127
+ return opening;
128
+ },
129
+ };
130
+ },
131
+ abandon: async () => {
132
+ abandoned = true;
133
+ await Promise.allSettled([...[...opened].map(closeOf), ...pending]);
134
+ },
135
+ };
136
+ }
137
+ /** `start` under the limit. Past it: close what the replay opened, give it a bounded time to unwind, and throw. */
138
+ async function replayWithinBound(start, portFactory, where, inactivityMs, onSessionOpened) {
139
+ const tracked = trackedPortFactory(portFactory, onSessionOpened);
140
+ const running = start(tracked.factory);
141
+ let timer;
142
+ const stalled = new Promise((resolve) => {
143
+ // Ref'd: a replay with nothing else left to hold the process open still reaches this bound.
144
+ timer = clock.setTimeout(() => resolve({ stalled: true }), inactivityMs + STALL_MARGIN_MS);
145
+ });
146
+ try {
147
+ const first = await Promise.race([running.then((value) => ({ stalled: false, value })), stalled]);
148
+ if (!first.stalled)
149
+ return first.value;
150
+ }
151
+ finally {
152
+ clock.clearTimeout(timer);
153
+ }
154
+ let sessionsClosed = false;
155
+ let teardownTimer;
156
+ const closing = tracked.abandon().then(() => {
157
+ sessionsClosed = true;
158
+ });
159
+ await Promise.race([
160
+ Promise.allSettled([closing, running]),
161
+ new Promise((resolve) => {
162
+ teardownTimer = clock.setTimeout(resolve, STALL_TEARDOWN_MS);
163
+ }),
164
+ ]);
165
+ clock.clearTimeout(teardownTimer);
166
+ throw new JourneyVerifyStalledError(where, inactivityMs, sessionsClosed);
167
+ }
168
+ const siteKeyOf = (f) => f.where === "step" ? `step:${f.step}` : f.where === "step-request" ? `step-request:${f.step}:${f.checkIndex}` : f.where === "end-state" ? `end-state:${f.index}` : "unevaluable";
169
+ /**
170
+ * #785: runs the waits a replay's relaxed sites keep (`RelaxedSiteWait`), as each step ends. Each is
171
+ * bounded, and how it ended is only recorded: an observer never changes the replay's verdict.
172
+ */
173
+ function relaxedWaitObserver(waits, params, responseTimeoutMs, record) {
174
+ const captures = new Map();
175
+ const stopCapture = (actor, index) => {
176
+ const capture = captures.get(index);
177
+ if (capture === undefined)
178
+ return;
179
+ monitorFor(actor.ability(BrowseTheWebToken).session.page).stopCapture(capture);
180
+ captures.delete(index);
181
+ };
182
+ const ownWaits = (index) => waits().filter((w) => w.step === index + 1);
183
+ const pageWait = async (actor, w, step) => {
184
+ let assertion = w.assertion;
185
+ if (assertionRefs(assertion).length > 0) {
186
+ try {
187
+ assertion = resolveAssertionRefs(assertion, (name) => (Object.hasOwn(params, name) ? { value: params[name], secret: false } : undefined));
188
+ }
189
+ catch (err) {
190
+ if (err instanceof AssertionRefError)
191
+ return "unresolved";
192
+ throw err;
193
+ }
194
+ }
195
+ if (w.wait !== undefined)
196
+ return (await waitForOutcome(actor, assertion, w.wait, step)).ending;
197
+ return (await checkAssertion(actor, assertion)) ? "held" : "timeout";
198
+ };
199
+ return {
200
+ beforeStep: async ({ actor, index }) => {
201
+ if (!ownWaits(index).some((w) => w.kind === "response"))
202
+ return;
203
+ await monitorFor(actor.ability(BrowseTheWebToken).session.page).instrument();
204
+ stopCapture(actor, index);
205
+ captures.set(index, monitorFor(actor.ability(BrowseTheWebToken).session.page).startCapture());
206
+ },
207
+ afterStep: async ({ actor, index, outcome }) => {
208
+ try {
209
+ if (outcome !== "done")
210
+ return;
211
+ for (const w of ownWaits(index)) {
212
+ const startedAt = clock.monotonicMs();
213
+ let ending = "not-run";
214
+ try {
215
+ if (w.kind === "page")
216
+ ending = await pageWait(actor, w, index + 1);
217
+ else {
218
+ const capture = captures.get(index);
219
+ if (capture !== undefined) {
220
+ const { result } = await evaluateNetworkCheckAwaiting(w.check, () => ({ requests: capture.sent(), truncated: capture.truncated }), w.responseTimeoutMs ?? responseTimeoutMs);
221
+ ending = result.passed ? "held" : "timeout";
222
+ }
223
+ }
224
+ }
225
+ catch {
226
+ // A closed or broken page ends the wait. The replay's own next action reports it.
227
+ }
228
+ record({ site: w.site, ending, waitedMs: Math.round(clock.monotonicMs() - startedAt) });
229
+ }
230
+ }
231
+ finally {
232
+ stopCapture(actor, index);
233
+ }
234
+ },
235
+ };
236
+ }
237
+ const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
238
+ /** How long a replay stays open after a write the browser gave up on: the server may still be working, and nothing will say when it stops. */
239
+ const ABORTED_WRITE_GRACE_MS = 2_000;
240
+ /**
241
+ * How long a replay stays open once a `no-store` response's body has been reported and the browser has not said it ended.
242
+ * Chromium never says so for such a body the page does not read, but one the page reads, clones or beacons reports its
243
+ * bytes as they come, so this can be a body still streaming.
244
+ */
245
+ const RESPONDED_BODY_GRACE_MS = 2_000;
246
+ /** How long a replay waits to watch a page's bodies. It takes milliseconds, so past this the browser is wedged: no body is seen, and the replay carries on. */
247
+ const BODY_WATCH_BOUND_MS = 2_000;
248
+ /** How often a replay waiting for its open writes looks again. */
249
+ const OPEN_WRITE_POLL_MS = 50;
250
+ /** How long the replay that follows a failed independence replay waits: past most of what the failed one started. */
251
+ const QUIET_GAP_MIN_MS = 5_000;
252
+ /** How long a diagnostic read of the page may take: a frozen renderer must not hold up a replay's close. */
253
+ const STORAGE_READ_BOUND_MS = 1_000;
254
+ /** A page can redefine what these read, so anything but a real count is no reading: page text never reaches a report. */
255
+ const isCount = (n) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0;
256
+ /** Key and cookie counts of the page, never a value. Null when the page cannot say in time, or says anything but a count. */
257
+ async function readStorageCounts(page) {
258
+ let timer;
259
+ const read = async () => {
260
+ const [sessionStorageKeys, localStorageKeys, cookies] = await Promise.all([
261
+ page.evaluate("sessionStorage.length"),
262
+ page.evaluate("localStorage.length"),
263
+ page.context().cookies(),
264
+ ]);
265
+ return isCount(sessionStorageKeys) && isCount(localStorageKeys) ? { sessionStorageKeys, localStorageKeys, cookies: cookies.length } : null;
266
+ };
267
+ try {
268
+ return await Promise.race([
269
+ read(),
270
+ new Promise((resolve) => {
271
+ timer = clock.setTimeout(() => resolve(null), STORAGE_READ_BOUND_MS);
272
+ }),
273
+ ]);
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ finally {
279
+ clock.clearTimeout(timer);
280
+ }
281
+ }
282
+ const lineOfRequest = (method, url) => `${method.toUpperCase()} ${pathOf(url)}`;
283
+ /**
284
+ * Which writes can land in a later replay and are worth waiting for: to the Journey's own origins, or
285
+ * on a line it recorded. A line it did not record that repeats within a replay, or was seen repeating
286
+ * in the base, is the page's background (a keepalive, telemetry), and so is one that stayed open
287
+ * through both the base and the replay after it (a long-lived connection): those are not waited for.
288
+ */
289
+ class WriteScope {
290
+ #origins;
291
+ #recorded;
292
+ #background = new Set();
293
+ constructor(allowedOrigins, recordedLines) {
294
+ this.#origins = new Set(allowedOrigins.flatMap((o) => {
295
+ try {
296
+ return [new URL(o).origin];
297
+ }
298
+ catch {
299
+ return [];
300
+ }
301
+ }));
302
+ this.#recorded = new Set(recordedLines.flatMap((l) => {
303
+ const m = REQUEST_LINE.exec(l.trim());
304
+ return m === null ? [] : [lineOfRequest(m[1], m[2])];
305
+ }));
306
+ }
307
+ counts(line, url) {
308
+ if (this.#recorded.has(line))
309
+ return true;
310
+ try {
311
+ return this.#origins.has(new URL(url).origin);
312
+ }
313
+ catch {
314
+ return false;
315
+ }
316
+ }
317
+ isBackground(line, sentThisReplay) {
318
+ return !this.#recorded.has(line) && (sentThisReplay >= 2 || this.#background.has(line));
319
+ }
320
+ isRecorded(line) {
321
+ return this.#recorded.has(line);
322
+ }
323
+ addBackground(lines) {
324
+ for (const line of lines)
325
+ this.#background.add(line);
326
+ }
327
+ }
328
+ /**
329
+ * #785: watches one replay for the counts a failed control's diagnostics compare with the base
330
+ * replay's, and for the writes it still has open. Observation only: it adds listeners and reads
331
+ * counts, and never acts on the page. It watches the context, so a popup's requests count too.
332
+ */
333
+ class ReplayProbe {
334
+ #stepsCompleted = 0;
335
+ #sessionsOpened = 0;
336
+ #pageReplaced = false;
337
+ #writesSent = 0;
338
+ #writesAnswered = 0;
339
+ #writesAborted = 0;
340
+ #before = null;
341
+ #after = null;
342
+ #openedPage;
343
+ #openOptions;
344
+ #stepsTotal;
345
+ #relaxedWaits = [];
346
+ #beforeClose;
347
+ #scope;
348
+ #open = new Map();
349
+ /** Writes in scope the browser has not said ended, by its own request id: the document that sent it, whether the response is `no-store`, and when its body was reported. */
350
+ #bodies = new Map();
351
+ /** The opened page's top frame and its latest document, as the browser names them. */
352
+ #topFrame;
353
+ #topLoader;
354
+ #responded = new WeakSet();
355
+ #requestSeq = 0;
356
+ #navigationSeq = 0;
357
+ #sent = new Map();
358
+ #lastAbortAt;
359
+ #openAtClose = [];
360
+ constructor(stepsTotal, beforeClose, scope) {
361
+ this.#stepsTotal = stepsTotal;
362
+ this.#beforeClose = beforeClose;
363
+ this.#scope = scope;
364
+ }
365
+ /** The replay opened a browser. */
366
+ get opened() {
367
+ return this.#sessionsOpened > 0;
368
+ }
369
+ /** Writes in scope the replay still had open as its session closed: each may yet land in the next replay. */
370
+ get writesInFlightAtClose() {
371
+ return this.#openAtClose.length;
372
+ }
373
+ /** The lines of those writes (`METHOD /path`). */
374
+ get openLinesAtClose() {
375
+ return this.#openAtClose;
376
+ }
377
+ /** Lines this replay sent more than once and never recorded: the page's own background. */
378
+ get repeatedLines() {
379
+ return [...this.#sent].filter(([line, n]) => n >= 2 && this.#scope?.isRecorded(line) !== true).map(([line]) => line);
380
+ }
381
+ #openInScope() {
382
+ // A response with no reported body is a server still working. Playwright's requests can't be matched to the browser's ids, so
383
+ // this counts by line: as many answered writes as the line has reported bodies that have not ended. It is worked out each time,
384
+ // so a body that ends stops counting for whichever write was read as answered by it.
385
+ const bodies = new Map();
386
+ for (const b of this.#bodies.values())
387
+ if (b.arrivedAt !== undefined)
388
+ bodies.set(b.line, (bodies.get(b.line) ?? 0) + 1);
389
+ const open = [...this.#open.values()].filter((o) => {
390
+ const left = bodies.get(o.line) ?? 0;
391
+ if (o.responded !== true || left === 0)
392
+ return true;
393
+ bodies.set(o.line, left - 1);
394
+ return false;
395
+ });
396
+ return open.map((o) => o.line).filter((line) => this.#scope?.isBackground(line, this.#sent.get(line) ?? 0) !== true);
397
+ }
398
+ /**
399
+ * Waits for the writes in scope to be answered, new ones they lead to included, up to `boundMs`,
400
+ * then a moment more after one the browser gave up on: a browser-side abort does not mean the server stopped.
401
+ * A reported `no-store` body gets a moment too. Past that it reads as answered: Chromium never reports the end of
402
+ * such a body the page does not read. Any other response keeps the write open until it finishes, so a server that
403
+ * answers and keeps working is waited for, up to the bound. See "What a proof assumes" in docs/outcomes.md for what is left.
404
+ */
405
+ async settleOpenWrites(boundMs) {
406
+ const startedAt = clock.monotonicMs();
407
+ while (clock.monotonicMs() - startedAt < boundMs) {
408
+ const graceUntil = Math.max(this.#lastAbortAt === undefined ? 0 : this.#lastAbortAt + ABORTED_WRITE_GRACE_MS, ...[...this.#bodies.values()].map((b) => (b.arrivedAt === undefined ? 0 : b.arrivedAt + RESPONDED_BODY_GRACE_MS)));
409
+ const graceLeft = graceUntil - clock.monotonicMs();
410
+ if (this.#openInScope().length === 0 && graceLeft <= 0)
411
+ return;
412
+ await clock.sleep(Math.max(0, Math.min(OPEN_WRITE_POLL_MS, boundMs - (clock.monotonicMs() - startedAt))));
413
+ }
414
+ }
415
+ get openOptions() {
416
+ return this.#openOptions;
417
+ }
418
+ recordWait(ended) {
419
+ this.#relaxedWaits.push(ended);
420
+ }
421
+ /**
422
+ * Listens for `Network.dataReceived`, the one thing that tells a complete unread `no-store` body from one the server has
423
+ * not finished: Chromium reports such a body's bytes only when it ends. Any other body reports bytes as they come, and
424
+ * reaches `requestfinished`, so only `no-store` counts. Observation only: it reads no body. With no CDP, or if this fails
425
+ * or takes too long, no body is ever seen and the write stays open, which reads `inconclusive` rather than a verdict nothing supports.
426
+ */
427
+ async #watchBodies(page) {
428
+ const context = typeof page?.context === "function" ? page.context() : undefined;
429
+ if (typeof context?.newCDPSession !== "function")
430
+ return;
431
+ const attach = async (target) => {
432
+ const cdp = await context.newCDPSession(target);
433
+ cdp.on("Network.requestWillBeSent", ({ requestId, request, loaderId, frameId, type }) => {
434
+ if (target === page && type === "Document" && requestId === loaderId && (this.#topFrame ?? frameId) === frameId) {
435
+ this.#topFrame = frameId;
436
+ this.#topLoader = loaderId;
437
+ }
438
+ const line = lineOfRequest(request.method, request.url);
439
+ // A redirect keeps its id, and may have turned the write into a read.
440
+ if (READ_METHODS.has(request.method.toUpperCase()) || this.#scope?.counts(line, request.url) !== true)
441
+ this.#bodies.delete(requestId);
442
+ else
443
+ this.#bodies.set(requestId, { line, loaderId, noStore: false });
444
+ });
445
+ cdp.on("Network.responseReceived", ({ requestId, response }) => {
446
+ const body = this.#bodies.get(requestId);
447
+ const cacheControl = Object.entries(response.headers).find(([name]) => name.toLowerCase() === "cache-control")?.[1] ?? "";
448
+ if (body !== undefined)
449
+ body.noStore = /(?:^|[\s,])no-store(?:$|[\s,;])/i.test(cacheControl);
450
+ });
451
+ cdp.on("Network.dataReceived", ({ requestId }) => {
452
+ const body = this.#bodies.get(requestId);
453
+ if (body !== undefined && body.noStore && body.arrivedAt === undefined)
454
+ body.arrivedAt = clock.monotonicMs();
455
+ });
456
+ cdp.on("Network.loadingFinished", ({ requestId }) => void this.#bodies.delete(requestId));
457
+ cdp.on("Network.loadingFailed", ({ requestId }) => void this.#bodies.delete(requestId));
458
+ await cdp.send("Network.enable");
459
+ };
460
+ const attaching = async (target) => {
461
+ let timer;
462
+ try {
463
+ await Promise.race([attach(target), new Promise((resolve) => (timer = clock.setTimeout(resolve, BODY_WATCH_BOUND_MS)))]);
464
+ }
465
+ catch {
466
+ // A page that closed first has nothing to watch. Observation never stops a replay.
467
+ }
468
+ finally {
469
+ clock.clearTimeout(timer);
470
+ }
471
+ };
472
+ context.on("page", (popup) => void attaching(popup));
473
+ await attaching(page);
474
+ }
475
+ #watch(session) {
476
+ const page = session.page;
477
+ this.#openedPage = page;
478
+ const context = typeof page?.context === "function" ? page.context() : undefined;
479
+ const source = (typeof context?.on === "function" ? context : page);
480
+ if (typeof source?.on !== "function")
481
+ return;
482
+ const isWrite = (r) => !READ_METHODS.has(r.method().toUpperCase());
483
+ // Only the opened page's top frame replaces the document that sent a write. A frame or a popup starting to load does not,
484
+ // and Playwright throws for a popup's first request. Anything it can't say keeps the write open.
485
+ const startsNewDocument = (r) => {
486
+ try {
487
+ const top = page?.mainFrame?.();
488
+ return top !== undefined && r.isNavigationRequest?.() === true && r.frame?.() === top;
489
+ }
490
+ catch {
491
+ return false;
492
+ }
493
+ };
494
+ source.on("request", (r) => {
495
+ this.#requestSeq += 1;
496
+ if (startsNewDocument(r))
497
+ this.#navigationSeq = this.#requestSeq;
498
+ if (!isWrite(r))
499
+ return;
500
+ this.#writesSent += 1;
501
+ const line = lineOfRequest(r.method(), r.url());
502
+ this.#sent.set(line, (this.#sent.get(line) ?? 0) + 1);
503
+ if (this.#scope?.counts(line, r.url()) === true)
504
+ this.#open.set(r, { line, seq: this.#requestSeq });
505
+ });
506
+ // A new document drops what the old one had open, and the browser may report nothing: that is an abort too.
507
+ if (typeof page?.on === "function") {
508
+ page.on("domcontentloaded", () => {
509
+ for (const [r, o] of this.#open) {
510
+ if (o.seq >= this.#navigationSeq)
511
+ continue;
512
+ this.#open.delete(r);
513
+ this.#lastAbortAt = clock.monotonicMs();
514
+ }
515
+ // A body an older document had reported may never say it ended. The new document's own writes keep theirs, and with no way to tell whose it is, it goes, which reads as open.
516
+ for (const [id, b] of this.#bodies)
517
+ if (b.arrivedAt !== undefined && (b.loaderId === undefined || b.loaderId !== this.#topLoader))
518
+ this.#bodies.delete(id);
519
+ });
520
+ }
521
+ source.on("response", (res) => {
522
+ const r = res.request();
523
+ if (!isWrite(r))
524
+ return;
525
+ this.#writesAnswered += 1;
526
+ this.#responded.add(r);
527
+ const open = this.#open.get(r);
528
+ if (open !== undefined)
529
+ open.responded = true;
530
+ });
531
+ source.on("requestfinished", (r) => {
532
+ if (!isWrite(r))
533
+ return;
534
+ if (!this.#responded.has(r))
535
+ this.#writesAnswered += 1;
536
+ this.#open.delete(r);
537
+ });
538
+ source.on("requestfailed", (r) => {
539
+ if (!isWrite(r))
540
+ return;
541
+ // Answered, then the page stopped reading: it was no dropped write, but the server may still be working.
542
+ if (this.#responded.has(r)) {
543
+ if (this.#open.delete(r))
544
+ this.#lastAbortAt = clock.monotonicMs();
545
+ return;
546
+ }
547
+ this.#writesAborted += 1;
548
+ // A block of ours never left the browser. Any other abort may have reached the server, which goes on working.
549
+ if (this.#open.delete(r) && r.failure?.()?.errorText.includes("BLOCKED_BY_CLIENT") !== true)
550
+ this.#lastAbortAt = clock.monotonicMs();
551
+ });
552
+ }
553
+ /** The port factory with this probe on every session it opens, reading the final page before it closes. */
554
+ wrap(factory) {
555
+ return () => {
556
+ const port = factory();
557
+ return {
558
+ open: async (o) => {
559
+ this.#sessionsOpened += 1;
560
+ try {
561
+ this.#openOptions = JSON.stringify(o);
562
+ }
563
+ catch {
564
+ // Options that cannot be compared stay unknown: observation never stops a replay.
565
+ }
566
+ const session = await port.open(o);
567
+ this.#watch(session);
568
+ await this.#watchBodies(session.page);
569
+ return new Proxy(session, {
570
+ get: (target, prop) => {
571
+ const value = Reflect.get(target, prop, target);
572
+ if (prop === "close") {
573
+ return async () => {
574
+ await this.#beforeClose?.(this.#writesSent);
575
+ this.#openAtClose = this.#openInScope();
576
+ if (typeof target.page?.evaluate === "function")
577
+ this.#after = await readStorageCounts(target.page);
578
+ return target.close();
579
+ };
580
+ }
581
+ return typeof value === "function" ? value.bind(target) : value;
582
+ },
583
+ });
584
+ },
585
+ };
586
+ };
587
+ }
588
+ observer() {
589
+ return {
590
+ afterStep: async ({ actor, index, outcome }) => {
591
+ if (outcome !== "done")
592
+ return;
593
+ this.#stepsCompleted += 1;
594
+ const page = actor.ability(BrowseTheWebToken).session.page;
595
+ if (this.#openedPage !== undefined && page !== this.#openedPage)
596
+ this.#pageReplaced = true;
597
+ if (index === this.#stepsTotal - 1 && typeof page?.evaluate === "function")
598
+ this.#before = await readStorageCounts(page);
599
+ },
600
+ };
601
+ }
602
+ read() {
603
+ if (this.#sessionsOpened === 0)
604
+ return undefined;
605
+ return {
606
+ stepsTotal: this.#stepsTotal,
607
+ stepsCompleted: this.#stepsCompleted,
608
+ sessionsOpened: this.#sessionsOpened,
609
+ pageReplaced: this.#pageReplaced,
610
+ writesSent: this.#writesSent,
611
+ writesAnswered: this.#writesAnswered,
612
+ writesAborted: this.#writesAborted,
613
+ writesUnfinished: Math.max(0, this.#writesSent - this.#writesAnswered - this.#writesAborted),
614
+ storageBeforeEndState: this.#before,
615
+ storageAfterEndState: this.#after,
616
+ relaxedWaits: [...this.#relaxedWaits],
617
+ };
618
+ }
619
+ }
620
+ /**
621
+ * One replay's time against the base's (`ReplayPace`), from `originIndex`'s start. As each step ends it
622
+ * holds the replay to `floor`, then notes how long it had taken. Observation and waiting only: it never
623
+ * acts on the page or changes how a step ended.
624
+ */
625
+ class ReplayPacer {
626
+ originIndex;
627
+ floor;
628
+ beforeOriginMs;
629
+ notHeld;
630
+ /** Ms from the origin to each step's end. */
631
+ reachedMs = [];
632
+ /** Ms from the origin to each step's start: when the replay got to that site. */
633
+ arrivedMs = [];
634
+ /** How long this replay had been held for when it got to each step. */
635
+ heldMs = [];
636
+ /** Ms from the origin to the moment each step's own claim had held. */
637
+ claimHeldMs = [];
638
+ endStateReachedMs;
639
+ endStateHeldMs;
640
+ #heldSoFar = 0;
641
+ #firstStepAt;
642
+ #originAt;
643
+ constructor(originIndex, floor,
644
+ /** How long the base took to reach the origin: what a replay that stopped before it is measured against. */
645
+ beforeOriginMs = 0,
646
+ /** A step nothing reads the time after: it is not held, and counts as if it had been. */
647
+ notHeld = () => false) {
648
+ this.originIndex = originIndex;
649
+ this.floor = floor;
650
+ this.beforeOriginMs = beforeOriginMs;
651
+ this.notHeld = notHeld;
652
+ }
653
+ /** Ms since this replay's first step began. */
654
+ elapsedMs() {
655
+ return this.#firstStepAt === undefined ? 0 : clock.monotonicMs() - this.#firstStepAt;
656
+ }
657
+ /** Holds until `untilMs` from the origin. A replay that stopped before the origin counts from its first step, as far as the base's origin. */
658
+ async hold(untilMs) {
659
+ if (untilMs === undefined)
660
+ return;
661
+ const left = this.#originAt !== undefined ? untilMs - (clock.monotonicMs() - this.#originAt) : this.#firstStepAt === undefined ? 0 : untilMs + this.beforeOriginMs - this.elapsedMs();
662
+ if (left <= 0)
663
+ return;
664
+ await clock.sleep(left);
665
+ this.#heldSoFar += left;
666
+ }
667
+ /** Time the replay spent waiting for something it would not otherwise have waited for, counted as held. */
668
+ async holding(wait) {
669
+ const startedAt = clock.monotonicMs();
670
+ await wait();
671
+ this.#heldSoFar += clock.monotonicMs() - startedAt;
672
+ }
673
+ /** Notes how long the replay had taken to get to its end state. */
674
+ noteEndState() {
675
+ if (this.#originAt === undefined)
676
+ return;
677
+ this.endStateReachedMs = clock.monotonicMs() - this.#originAt;
678
+ this.endStateHeldMs = this.#heldSoFar;
679
+ }
680
+ /** First among a replay's observers, so it sees a step's claim hold before anything else that step waits for. */
681
+ claimObserver() {
682
+ return {
683
+ afterStep: async ({ index, outcome }) => {
684
+ if (this.#originAt !== undefined && outcome === "done")
685
+ this.claimHeldMs[index] = clock.monotonicMs() - this.#originAt;
686
+ },
687
+ };
688
+ }
689
+ observer() {
690
+ return {
691
+ beforeStep: async ({ index }) => {
692
+ this.#firstStepAt ??= clock.monotonicMs();
693
+ if (index === this.originIndex)
694
+ this.#originAt = clock.monotonicMs();
695
+ if (this.#originAt !== undefined) {
696
+ this.arrivedMs[index] = clock.monotonicMs() - this.#originAt;
697
+ this.heldMs[index] = this.#heldSoFar;
698
+ }
699
+ },
700
+ afterStep: async ({ index, outcome }) => {
701
+ if (this.#originAt === undefined)
702
+ return;
703
+ const floor = this.floor(index);
704
+ if (outcome === "done" && !this.notHeld(index))
705
+ await this.hold(floor);
706
+ const reached = clock.monotonicMs() - this.#originAt;
707
+ this.reachedMs[index] = outcome === "done" && this.notHeld(index) ? Math.max(reached, floor ?? 0) : reached;
708
+ },
709
+ };
710
+ }
711
+ }
712
+ /**
713
+ * What a proof that reaches a verdict runs when nothing needs more: the base, the independence replay,
714
+ * one per mutation, and the closing replay, which only a verdict that can still change gets. Relaxed and
715
+ * targeted replays, controls and re-runs add to it. A proof that fails or is tainted partway stops sooner.
716
+ */
717
+ function minimumReplaysOf(plan) {
718
+ if (plan.mutations.length === 0)
719
+ return 1;
720
+ return plan.mutations.length + (plan.pairs.length === 0 ? 2 : 3);
721
+ }
722
+ /**
723
+ * How many of those replays can send a real write: all but a mutation that removes the Journey's only
724
+ * write step. Replays that stop at an earlier failure send less, so this is what they can send, not what they will.
725
+ */
726
+ function writeReplaysOf(plan, writeSteps) {
727
+ if (writeSteps.length === 0)
728
+ return 0;
729
+ const removesEveryWrite = (m) => (m.kind === "skip" || m.kind === "block-write") && writeSteps.every((w) => w.step === m.step);
730
+ return minimumReplaysOf(plan) - plan.mutations.filter(removesEveryWrite).length;
731
+ }
732
+ /** The one line a proof with no reset hook or fixture prints, in `--plan` and in its report. */
733
+ export function verifyNoResetLine(summary) {
734
+ const more = summary.writeReplays === 0 ? "" : " (relaxed replays, controls and re-runs can add more)";
735
+ return `no reset hook or fixture: nothing undoes what a replay writes, and ${summary.writeReplays} of the ${summary.minimumReplays} planned replays can send real writes${more}`;
736
+ }
20
737
  /**
21
738
  * Static preview of `journey verify --mutate`: no browser, fixtures, hooks, target, session or
22
739
  * credential is opened. Values of params are used only to derive the plan and are never returned.
@@ -47,7 +764,8 @@ export async function planJourneyVerification(opts) {
47
764
  pairs: pairs.length,
48
765
  assertions: assertions.length,
49
766
  unpaired: assertions.filter((assertion) => assertion.mutations.length === 0).length,
50
- minimumReplays: plan.mutations.length + 1,
767
+ minimumReplays: minimumReplaysOf(plan),
768
+ writeReplays: writeReplaysOf(plan, journeyWriteSteps(journey, opts.readRequests === undefined ? {} : { readRequests: opts.readRequests })),
51
769
  },
52
770
  };
53
771
  }
@@ -84,115 +802,420 @@ function failedSitesOf(result, failures) {
84
802
  /**
85
803
  * Runs the proof for one Journey by id. Usage errors (unknown id, bad params, an environment that
86
804
  * refuses a step, a site-gate refusal) throw exactly as `journey run`'s do, before any mutation.
805
+ * The proof keeps its machine slot between replays unless another process waits for it (#766), so
806
+ * one admission wait cannot cost a proof that took 100 minutes to get there.
87
807
  */
88
808
  export async function verifyJourneyMutations(opts, run = runJourneyProgrammatically) {
89
- const journey = await new JourneyRegistry(new FsJourneyStore(opts.dir)).get(opts.id);
90
- if (!journey)
91
- throw new UnknownJourneyError(`unknown journey '${opts.id}'`);
92
- // #711: receipt authority is proven before the first baseline/mutation replay spends a browser.
93
- const receiptDestination = await preflightVerifyRecord(opts.dir, journey.metadata.id);
94
- const { readRequests, ...runOpts } = opts;
95
- const lintOpts = readRequests === undefined ? {} : { readRequests };
96
- const journeyHash = journeyContentHash(journey);
97
- const base = await run(runOpts, { structuredFailures: true });
98
- const baseFields = { outcome: base.outcome, ...(base.outcome === "quarantined" ? { reason: base.reason } : {}) };
99
- const sites = journeyAssertions(journey);
100
- const checkOf = new Map(sites.map((s) => [assertionSiteKey(s), describeCheck(s.check)]));
101
- const plan = planJourneyMutations(journey, { ...lintOpts, params: opts.params });
102
- const mutations = [];
103
- // #616: automatic targeted proofs stopped at their relaxation bound, each a reviewable start.
104
- const suggested = [];
105
- const seenSuggestions = new Set();
106
- if (base.outcome === "ok") {
107
- // #565: a param mutation's replay runs with altered params (never a secret's), so it needs them.
108
- const replayOpts = { ...lintOpts, params: opts.params };
109
- // The replay is DESCRIBED, not fixed: `mutateJourney` re-describes it on the Journey the run moved
110
- // onto the environment (#247), so a mutated copy never drops `--env`/`--base-url`.
111
- const runReplay = async (m, describe) => {
112
- try {
113
- const how = describe(journey);
114
- const r = await run(how.params === undefined ? runOpts : { ...runOpts, params: { ...how.params } }, {
115
- structuredFailures: true,
116
- ...(how.relaxedStepRequests === undefined ? {} : { relaxedStepRequests: how.relaxedStepRequests }),
117
- ...(how.journey === journey ? {} : { mutateJourney: (j) => describe(j).journey }),
118
- ...(how.skipIndex === undefined ? {} : { skipStep: (i) => i === how.skipIndex }),
119
- ...(how.swapSelectAtStep === undefined ? {} : { selectAlternativeAtStep: how.swapSelectAtStep }),
120
- ...(how.blockIndex === undefined ? {} : { blockWritesAtStep: how.blockIndex }),
121
- });
122
- const blocked = r.blockedWrites ?? [];
123
- const blockedFields = how.blockIndex === undefined ? {} : { blockedWrites: blocked.map((b) => `${b.method} ${b.path}`) };
124
- // #579: a select mutation found no other enabled live option — not applied, never proof.
125
- if (r.outcome === "quarantined" && /no-alternative/.test(r.reason)) {
126
- return { outcome: "not-applied", failedSites: [], ...blockedFields, reason: r.reason };
127
- }
128
- if (how.blockIndex !== undefined && !blockedOwnWrite(how.blockRequests ?? [], blocked)) {
129
- return {
130
- outcome: "not-applied",
131
- failedSites: [],
132
- ...blockedFields,
133
- reason: `step ${m.step}'s own write was not blocked${(how.blockRequests ?? []).length === 0 ? "" : ` (recorded: ${(how.blockRequests ?? []).join(", ")})`}`,
134
- };
135
- }
136
- return {
137
- outcome: r.outcome === "quarantined" ? "failed" : "passed",
138
- failedSites: failedSitesOf(r, r.outcomeFailures ?? []),
139
- ...blockedFields,
140
- ...(r.outcome === "quarantined" ? { reason: r.reason } : {}),
141
- };
142
- }
143
- catch (err) {
144
- return { outcome: "error", failedSites: [], reason: err instanceof Error ? err.message : String(err) };
145
- }
809
+ const endHold = (opts.slotGovernor ?? sharedResourceGovernor()).holdSlotBetweenSessions();
810
+ const owned = {};
811
+ try {
812
+ return await proveJourneyMutations(opts, run, owned);
813
+ }
814
+ catch (err) {
815
+ // In an MCP server the process outlives the proof, so its pid no longer says the proof is running.
816
+ owned.session?.interrupted();
817
+ throw err;
818
+ }
819
+ finally {
820
+ endHold();
821
+ }
822
+ }
823
+ function paramsAccepted(journey, params) {
824
+ try {
825
+ validateParams(journeyParamSchema(journey), params);
826
+ return true;
827
+ }
828
+ catch (err) {
829
+ if (err instanceof ParamValidationError)
830
+ return false;
831
+ throw err;
832
+ }
833
+ }
834
+ const CROSSED = "so state from one replay may cross into the next (a job it started, by any route, still running); isolate the replays or let the job end, and run the proof again";
835
+ /** Why a replay may have decided another, from names alone, so an interrupted proof can give the same reason. */
836
+ export function isolationReason(note, by, openLines = []) {
837
+ const open = `closed with a write still unanswered${openLines.length === 0 ? "" : ` (${openLines.join(", ")})`}, which may land inside a later replay; the proof cannot say which replay it decided`;
838
+ if (note === "write-open")
839
+ return `a replay of ${by ?? "a mutation"} ${open}`;
840
+ if (note === "independence-write-open")
841
+ return `the independence replay (the Journey as recorded, straight after the base) ${open}`;
842
+ if (note === "control-write-open")
843
+ return `the control for ${by ?? "a mutation"} (the Journey as recorded, no mutation applied) ${open}`;
844
+ if (note === "base-write-open")
845
+ return `the base replay ${open}`;
846
+ if (note === "state-crossed")
847
+ return `the Journey as recorded failed straight after the base replay and passed after a quiet gap, ${CROSSED}`;
848
+ if (note === "state-stuck")
849
+ return `the Journey as recorded no longer passes after the base replay, ${CROSSED}`;
850
+ if (note === "independence-errored")
851
+ return "the replay as recorded after the base errored, so the proof cannot say the replays are independent; retry it (--resume keeps what finished and runs this replay again)";
852
+ return "sibling mutations disagree on a site, and a control and a re-run could not settle it; run the proof again";
853
+ }
854
+ /** A pair's result from its checkpoint, in the report's shape. */
855
+ function reusedMutation(pair) {
856
+ return {
857
+ kind: pair.kind,
858
+ step: pair.step,
859
+ id: pair.id,
860
+ ...(pair.param === undefined ? {} : { param: pair.param }),
861
+ ...(pair.source === undefined ? {} : { source: pair.source }),
862
+ ...reusedProofFields(pair),
863
+ ...(pair.retries === 0 ? {} : { retries: Array.from({ length: pair.retries }, () => ({ reason: RETRY_REASON_NOT_KEPT })) }),
864
+ reused: true,
865
+ };
866
+ }
867
+ const isUnmutated = (kind) => kind === "control" || kind === "targeted-control" || kind === "independence";
868
+ const labelOf = (m, kind) => {
869
+ if (kind === "control" || kind === "targeted-control")
870
+ return `control for step ${m.step}`;
871
+ if (kind === "independence")
872
+ return "independence replay, as recorded";
873
+ if (kind === "rerun")
874
+ return `${m.id} (re-run)`;
875
+ if (kind === "at-base-time")
876
+ return `${m.id} (at the base's time)`;
877
+ return m.id;
878
+ };
879
+ /** Which replay left a write open, as the taint records it: an unmutated replay applied no mutation, so none is blamed. */
880
+ const openWriteOwner = (m, kind) => kind === "independence" ? ["independence-write-open", null] : kind === "control" || kind === "targeted-control" ? ["control-write-open", m.id] : ["write-open", m.id];
881
+ const resultOfEntry = (m) => ({
882
+ outcome: m.outcome,
883
+ failedSites: m.failedSites,
884
+ ...(m.waitsCut === undefined ? {} : { waitsCut: m.waitsCut }),
885
+ ...(m.relaxed === undefined ? {} : { relaxed: m.relaxed }),
886
+ ...(m.targeted === undefined ? {} : { targeted: m.targeted }),
887
+ ...(m.atBaseTime === undefined ? {} : { atBaseTime: m.atBaseTime }),
888
+ });
889
+ /**
890
+ * A paced replay waits as long as the proof says, not as long as the Journey does, so a wait that ran out
891
+ * says both. For the reader only: nothing reads `reason` for a verdict but `failedSitesOf`, which is given the run's own.
892
+ */
893
+ function withStatedWaitLimit(reason, at, journey) {
894
+ const step = at === undefined ? undefined : flatJourneySteps(journey)[at]?.recorded.step;
895
+ const stated = step !== undefined && "waitFor" in step ? step.waitFor?.maxMs : undefined;
896
+ return reason.replace(/\(waitFor maxMs (\d+)\); it never held/, (whole, limit) => Number(limit) === stated ? whole : `(this replay's waitFor maxMs ${limit}; the Journey's ${stated === undefined ? "sets none" : `is ${stated}`}); it never held`);
897
+ }
898
+ const reported = ({ reachedMs: _reachedMs, arrivedMs: _arrivedMs, heldMs: _heldMs, endStateReachedMs: _end, endStateHeldMs: _endHeld, marginMs: _margin, ...result }) => result;
899
+ // The replay is DESCRIBED, not fixed: `mutateJourney` re-describes it on the Journey the run moved
900
+ // onto the environment (#247), so a mutated copy never drops `--env`/`--base-url`.
901
+ async function attemptReplay(c, m, describe, retry, kind, matchTo) {
902
+ const { opts, runOpts, journey, pace, stepsTotal, settleBoundMs, writeScope, writeSteps, baseProbe, boundedRun, taint } = c;
903
+ const lastIndex = stepsTotal - 1;
904
+ const baseOpenLines = baseProbe.openLinesAtClose;
905
+ const noEndState = journeyEndState(journey).length === 0;
906
+ let opened;
907
+ // What a replay left open may land in the next one, however the replay ended.
908
+ const checkWritesLeftOpen = (p) => {
909
+ c.noteWritesInFlight(p.writesInFlightAtClose);
910
+ if (kind === "independence")
911
+ c.noteIndependenceOpen(p.openLinesAtClose);
912
+ const excused = kind === "independence" && p.openLinesAtClose.every((line) => baseOpenLines.includes(line));
913
+ if (p.writesInFlightAtClose > 0 && !excused) {
914
+ const [note, by] = openWriteOwner(m, kind);
915
+ taint(note, by, isolationReason(note, by, p.openLinesAtClose));
916
+ }
917
+ };
918
+ try {
919
+ const paced = (j) => pacedReplay(j, describe(j), m, pace);
920
+ const how = paced(journey);
921
+ const withMargin = kind !== "at-base-time";
922
+ // Nothing reads the time after a last step with no end state, so holding it would only cost.
923
+ // A control also gets to each site no sooner than the mutated replay it answers for did, dead waits included.
924
+ const atLeast = (floor, arrival) => (floor === undefined || arrival === undefined ? (floor ?? arrival) : Math.max(floor, arrival));
925
+ const pacer = new ReplayPacer(m.step - 1, (index) => atLeast(paceFloorMs(pace, m, index, withMargin), matchTo?.arrivedMs?.[index + 1]), paceBeforeMutationMs(pace, m), (index) => index === lastIndex && noEndState);
926
+ // #785: a control's counts say, on failure, what it did against the base replay. Any replay that
927
+ // sent a write stays open as long as the base did, then until its writes have settled, so what it
928
+ // started lands before its restore.
929
+ const probe = new ReplayProbe(stepsTotal, async (writesSent) => {
930
+ if (writesSent > 0)
931
+ await pacer.hold(paceTailMs(pace, m));
932
+ await probe.settleOpenWrites(settleBoundMs);
933
+ }, writeScope);
934
+ opened = probe;
935
+ // Described on the Journey the run moved onto the environment, as the mutated copy is.
936
+ let waits = how.relaxedSiteWaits ?? [];
937
+ const params = how.params ?? opts.params;
938
+ const observer = composeObservers(waits.length === 0 ? undefined : relaxedWaitObserver(() => waits, params, opts.stepResponseTimeoutMs ?? DEFAULT_STEP_RESPONSE_TIMEOUT_MS, (ended) => probe.recordWait(ended)), isUnmutated(kind) ? probe.observer() : undefined, pacer.observer());
939
+ // The step's own lines stay blocked for the rest of the replay. A later step that records the same line sends its own.
940
+ const blockLines = how.blockRequests ?? [];
941
+ const lineOfRecorded = (line) => {
942
+ const parsed = REQUEST_LINE.exec(line.trim());
943
+ return parsed === null ? undefined : lineOfRequest(parsed[1], parsed[2]);
146
944
  };
147
- // #402: a named mutation lowers to its `MutationReplay`; #609's targeted proof passes one directly.
148
- const replay = (m, describe) => runReplay(m, (j) => describe(j, m, replayOpts));
149
- const siteByKey = new Map(sites.map((s) => [assertionSiteKey(s), s]));
150
- for (const m of plan.mutations) {
151
- const first = await replay(m, mutationReplay);
152
- // #554: a first failure at the mutated step's own postcondition hides the LATER paired sites
153
- // behind it; one relaxed replay (same action, the step's own expect/waitFor left out) judges them.
154
- const relaxed = needsRelaxedReplay(plan, m, first) ? await replay(m, relaxedMutationReplay) : undefined;
155
- const initial = { outcome: first.outcome, failedSites: first.failedSites, ...(relaxed === undefined ? {} : { relaxed }) };
156
- // #609: a later assertion still a `cascade` is only hidden by an earlier postcondition; a
157
- // targeted proof relaxes them one at a time (in a clone) to reach the target itself.
158
- const targeted = [];
159
- for (const pair of plan.pairs) {
160
- if (pair.mutation !== m.id)
161
- continue;
162
- const site = siteByKey.get(pair.site);
163
- if (site === undefined || classifyMutation(site, m, initial) !== "cascade")
164
- continue;
165
- // #616: a declared plan (reviewed earlier relaxations) replaces the automatic bound search.
166
- const declared = declaredTargetedProofFor(journey, m, pair.site);
167
- targeted.push(declared === undefined
168
- ? await runTargetedProof(journey, plan, m, site, initial, (describe) => runReplay(m, describe), replayOpts)
169
- : await runDeclaredTargetedProof(journey, plan, m, site, declared, (describe) => runReplay(m, describe), replayOpts));
170
- // #616: a proof the automatic bound stopped names the plan a human could declare instead.
171
- const suggestion = suggestTargetedProofPlan(targeted[targeted.length - 1], m);
172
- if (suggestion !== null) {
173
- const key = `${suggestion.check}\u0000${suggestion.mustFailWhen}\u0000${suggestion.relax.join(",")}`;
174
- if (!seenSuggestions.has(key)) {
175
- seenSuggestions.add(key);
176
- suggested.push(suggestion);
177
- }
178
- }
945
+ const heldLines = blockLines.flatMap((l) => lineOfRecorded(l) ?? []);
946
+ const r = await boundedRun({ ...runOpts, ...(how.params === undefined ? {} : { params: { ...how.params } }), observer }, {
947
+ structuredFailures: true,
948
+ beforeEndState: async () => {
949
+ await pacer.hold(atLeast(paceEndStateFloorMs(pace, m, withMargin), matchTo?.endStateReachedMs));
950
+ // A claim is judged with what the app was already doing finished, as far as the Journey allows.
951
+ await pacer.holding(() => probe.settleOpenWrites(settleBoundMs));
952
+ pacer.noteEndState();
953
+ },
954
+ ...(how.relaxedStepRequests === undefined ? {} : { relaxedStepRequests: how.relaxedStepRequests }),
955
+ ...(how.journey === journey
956
+ ? {}
957
+ : {
958
+ mutateJourney: (j) => {
959
+ const onEnvironment = paced(j);
960
+ waits = onEnvironment.relaxedSiteWaits ?? [];
961
+ return onEnvironment.journey;
962
+ },
963
+ }),
964
+ ...(how.skipIndex === undefined ? {} : { skipStep: (i) => i === how.skipIndex }),
965
+ ...(how.swapSelectAtStep === undefined ? {} : { selectAlternativeAtStep: how.swapSelectAtStep }),
966
+ ...(how.blockIndex === undefined
967
+ ? {}
968
+ : {
969
+ blockWritesAtStep: how.blockIndex,
970
+ blockAfterWindow: {
971
+ requests: heldLines,
972
+ exemptSteps: writeSteps.filter((w) => w.step - 1 !== how.blockIndex && w.requests.some((l) => { const line = lineOfRecorded(l); return line !== undefined && heldLines.includes(line); })).map((w) => w.step - 1),
973
+ },
974
+ }),
975
+ }, { id: labelOf(m, kind) }, retry, probe);
976
+ checkWritesLeftOpen(probe);
977
+ const blocked = r.blockedWrites ?? [];
978
+ const blockedFields = how.blockIndex === undefined ? {} : { blockedWrites: blocked.map((b) => `${b.method} ${b.path}`) };
979
+ // #579: a select mutation found no other enabled live option — not applied, never proof.
980
+ if (r.outcome === "quarantined" && /no-alternative/.test(r.reason)) {
981
+ return { outcome: "not-applied", failedSites: [], ...blockedFields, reason: r.reason };
982
+ }
983
+ if (how.blockIndex !== undefined && !blockedOwnWrite(how.blockRequests ?? [], blocked)) {
984
+ return {
985
+ outcome: "not-applied",
986
+ failedSites: [],
987
+ ...blockedFields,
988
+ reason: `step ${m.step}'s own write was not blocked${(how.blockRequests ?? []).length === 0 ? "" : ` (recorded: ${(how.blockRequests ?? []).join(", ")})`}`,
989
+ };
990
+ }
991
+ // The block did not hold: a server answered the step's own write after its window.
992
+ if (how.blockIndex !== undefined && (r.blockEscaped?.length ?? 0) > 0) {
993
+ return { outcome: "not-applied", failedSites: [], ...blockedFields, reason: `step ${m.step}'s own write was answered after it was blocked (${r.blockEscaped.join(", ")}), so the mutation did not hold` };
994
+ }
995
+ const failedSites = failedSitesOf(r, r.outcomeFailures ?? []);
996
+ const early = isUnmutated(kind) ? [] : sitesReachedEarly(failedSites, m, pace, pacer.reachedMs, pacer.endStateReachedMs);
997
+ const control = r.outcome === "quarantined" && isUnmutated(kind) ? probe.read() : undefined;
998
+ const baseDiagnostics = baseProbe.read();
999
+ const contextOptionsMatchBase = baseProbe.openOptions === undefined || probe.openOptions === undefined ? undefined : baseProbe.openOptions === probe.openOptions;
1000
+ return {
1001
+ outcome: r.outcome === "quarantined" ? "failed" : "passed",
1002
+ failedSites,
1003
+ ...blockedFields,
1004
+ ...(r.outcome === "quarantined" ? { reason: withStatedWaitLimit(r.reason, r.at, journey) } : {}),
1005
+ ...(early.length === 0 ? {} : { waitsCut: early }),
1006
+ reachedMs: pacer.reachedMs,
1007
+ arrivedMs: pacer.arrivedMs,
1008
+ heldMs: pacer.heldMs,
1009
+ marginMs: paceMarginOf(pace, m),
1010
+ ...(pacer.endStateReachedMs === undefined ? {} : { endStateReachedMs: pacer.endStateReachedMs }),
1011
+ ...(pacer.endStateHeldMs === undefined ? {} : { endStateHeldMs: pacer.endStateHeldMs }),
1012
+ ...(control === undefined
1013
+ ? {}
1014
+ : {
1015
+ diagnostics: {
1016
+ failedSites: failedSites.map(siteKeyOf),
1017
+ control,
1018
+ ...(baseDiagnostics === undefined ? {} : { base: baseDiagnostics }),
1019
+ ...(contextOptionsMatchBase === undefined ? {} : { contextOptionsMatchBase }),
1020
+ },
1021
+ }),
1022
+ };
1023
+ }
1024
+ catch (err) {
1025
+ // A stall ends the proof: it never reads as one mutation's error, which could still end `proven`.
1026
+ if (err instanceof JourneyVerifyStalledError) {
1027
+ // A stall leaves a resumable checkpoint, so what the replay left open has to be on it. A replay whose close
1028
+ // never finished (a frozen renderer, a hold) can't say what was open: that counts as open.
1029
+ if (opened !== undefined && err.sessionsClosed)
1030
+ checkWritesLeftOpen(opened);
1031
+ else if (opened !== undefined && opened.opened)
1032
+ taint(...openWriteOwner(m, kind));
1033
+ throw err;
1034
+ }
1035
+ if (opened !== undefined)
1036
+ checkWritesLeftOpen(opened);
1037
+ return { outcome: "error", failedSites: [], reason: err instanceof Error ? err.message : String(err) };
1038
+ }
1039
+ }
1040
+ /** One planned mutation with all its replays: first, relaxed, control, at the base's time, targeted. */
1041
+ async function runPair(c, m) {
1042
+ const { journey, plan, pace, siteByKey, replayOpts, mutations, checkpoint, tainted, replay, runReplay, controlFor, resultsSoFar, noteSuggestion, retriesOf, pairReplays } = c;
1043
+ checkpoint?.startPair(m.id);
1044
+ pairReplays.length = 0;
1045
+ const firstRun = await replay(m, mutationReplay, "first");
1046
+ // #554: a first failure at the mutated step's own postcondition hides the LATER paired sites
1047
+ // behind it; one relaxed replay (same action, the step's own expect/waitFor left out) judges them.
1048
+ const relaxedRun = !tainted() && needsRelaxedReplay(plan, m, firstRun) ? await replay(m, relaxedMutationReplay, "relaxed") : undefined;
1049
+ const resultOf = (f, r) => ({ outcome: f.outcome, failedSites: f.failedSites, ...(f.waitsCut === undefined ? {} : { waitsCut: f.waitsCut }), ...(r === undefined ? {} : { relaxed: r }) });
1050
+ // The sites this mutation reads sensitive that a matched control must vouch for: thin, or held.
1051
+ const provisional = resultOf(firstRun, relaxedRun);
1052
+ const wouldCount = new Set(plan.pairs.filter((p) => p.mutation === m.id).map((p) => p.site).filter((key) => {
1053
+ const site = siteByKey.get(key);
1054
+ return site !== undefined && classifyMutation(site, m, provisional) === "sensitive";
1055
+ }));
1056
+ const needing = (r) => [...new Set([...thinSites(r.failedSites, m, pace, r.reachedMs ?? [], r.endStateReachedMs), ...heldSites(r.failedSites, r)])].filter((key) => wouldCount.has(key));
1057
+ const atStake = [...new Set([...needing(firstRun), ...(relaxedRun === undefined ? [] : needing(relaxedRun))])];
1058
+ const wanted = atStake.length > 0;
1059
+ const canChange = (keys) => !tainted() && controlCanChangeVerdict(journey, plan, resultsSoFar([m.id, provisional]), keys);
1060
+ const fresh = wanted && canChange(atStake) ? await controlFor(m, relaxedRun === undefined ? [firstRun] : [firstRun, relaxedRun], atStake) : undefined;
1061
+ const checked = async (r, describe) => {
1062
+ if (fresh === undefined)
1063
+ return r;
1064
+ const keys = needing(r);
1065
+ if (keys.length === 0)
1066
+ return r;
1067
+ const held = r.failedSites.filter((f) => keys.includes(siteKeyOf(f)));
1068
+ const cut = fresh.outcome === "passed" ? sitesUnmatchedBy(held, r, fresh, paceMarginOf(pace, m)) : keys;
1069
+ if (cut.length === 0)
1070
+ return r;
1071
+ const withCut = { ...r, waitsCut: [...new Set([...(r.waitsCut ?? []), ...cut])] };
1072
+ // The unmutated replay failed at this very site on these floors, so the hold may be what broke it. Only
1073
+ // the mutation at the base's own time can say the claim holds without it.
1074
+ const controlFailedHere = fresh.outcome === "failed" && cut.some((key) => fresh.failedSites.some((f) => siteKeyOf(f) === key));
1075
+ if (!controlFailedHere || !canChange(cut))
1076
+ return withCut;
1077
+ const timed = await replay(m, describe, "at-base-time");
1078
+ return { ...withCut, atBaseTime: { outcome: timed.outcome, failedSites: timed.failedSites } };
1079
+ };
1080
+ const first = reported(await checked(firstRun, mutationReplay));
1081
+ const relaxed = relaxedRun === undefined ? undefined : reported(await checked(relaxedRun, relaxedMutationReplay));
1082
+ const controlFields = fresh === undefined ? {} : { control: fresh.outcome, ...(fresh.diagnostics === undefined ? {} : { controlDiagnostics: fresh.diagnostics }) };
1083
+ const initial = { ...resultOf(first, relaxed), ...(first.atBaseTime === undefined ? {} : { atBaseTime: first.atBaseTime }), ...controlFields };
1084
+ // #609: a later assertion still a `cascade` is only hidden by an earlier postcondition; a
1085
+ // targeted proof relaxes them one at a time (in a clone) to reach the target itself.
1086
+ const targeted = [];
1087
+ // One replay per set of relaxations: sites of this mutation that relax the same ones share it, and its control.
1088
+ const targetedRuns = new Map();
1089
+ const targetedReplay = (describe, kind, key) => {
1090
+ const memoKey = `${kind}\u0000${key ?? ""}`;
1091
+ const known = targetedRuns.get(memoKey);
1092
+ if (known !== undefined)
1093
+ return known;
1094
+ // The proof is already over: a targeted proof's next replay is not run, and whatever it reads, the verdict is decided.
1095
+ if (tainted())
1096
+ return Promise.resolve({ outcome: "error", failedSites: [], reason: "not run: the proof had already ended" });
1097
+ // A control runs after the targeted replay it answers for, and gets to each site no sooner than it did.
1098
+ const running = kind === "control" ? (targetedRuns.get(`targeted\u0000${key ?? ""}`) ?? Promise.resolve(undefined)).then((run) => runReplay(m, describe, "targeted-control", run)) : runReplay(m, describe, "targeted");
1099
+ targetedRuns.set(memoKey, running);
1100
+ return running;
1101
+ };
1102
+ for (const pair of plan.pairs) {
1103
+ if (tainted())
1104
+ break;
1105
+ if (pair.mutation !== m.id)
1106
+ continue;
1107
+ const site = siteByKey.get(pair.site);
1108
+ if (site === undefined || classifyMutation(site, m, initial) !== "cascade")
1109
+ continue;
1110
+ // #616: a declared plan (reviewed earlier relaxations) replaces the automatic bound search.
1111
+ const declared = declaredTargetedProofFor(journey, m, pair.site);
1112
+ targeted.push(declared === undefined
1113
+ ? await runTargetedProof(journey, plan, m, site, initial, targetedReplay, replayOpts)
1114
+ : await runDeclaredTargetedProof(journey, plan, m, site, declared, targetedReplay, replayOpts));
1115
+ noteSuggestion(targeted[targeted.length - 1], m);
1116
+ }
1117
+ const finished = {
1118
+ kind: m.kind,
1119
+ step: m.step,
1120
+ id: m.id,
1121
+ ...(m.param === undefined ? {} : { param: m.param }),
1122
+ ...(m.source === undefined ? {} : { source: m.source }),
1123
+ ...first,
1124
+ ...(relaxed === undefined ? {} : { relaxed }),
1125
+ ...controlFields,
1126
+ ...(wanted && fresh === undefined && !tainted() ? { controlSkipped: true } : {}),
1127
+ ...(targeted.length === 0 ? {} : { targeted }),
1128
+ ...(retriesOf.has(m.id) ? { retries: retriesOf.get(m.id) } : {}),
1129
+ };
1130
+ mutations.push(finished);
1131
+ // A tainted proof ends here, and a pair cut short by the taint is not a result a resume could stand on.
1132
+ if (!tainted())
1133
+ checkpoint?.finishPair(checkpointPairOf(finished, pairReplays));
1134
+ }
1135
+ /**
1136
+ * Point 3 of the experiment: one sibling failed a site another passed. The reading stands only if a
1137
+ * matched control passes and the failure reproduces on one re-run. If it does not reproduce, the
1138
+ * failure is struck and the site falls to the sibling's pass: `insensitive`, since a replay that
1139
+ * passed with the mutation applied saw the claim hold without it, twice over. If the two cannot be
1140
+ * told apart (the re-run stopped short, the control did not match), neither reading is trusted and
1141
+ * the proof is `inconclusive`: a false `insensitive` is as wrong as a false `proven`.
1142
+ */
1143
+ async function settleDisagreements(c) {
1144
+ const { journey, plan, pace, siteByKey, replayOpts, mutations, checkpoint, tainted, taint, runReplay, controlFor, resultsSoFar } = c;
1145
+ // A resume runs these again: only a finished pair is kept, and a re-run answers for the proof it is part of.
1146
+ if (plan.mutations.length > 0)
1147
+ checkpoint?.phase("disagreements");
1148
+ const settled = new Set();
1149
+ for (;;) {
1150
+ if (tainted())
1151
+ break;
1152
+ const next = siblingDisagreements(journey, plan, resultsSoFar()).find((d) => !settled.has(`${d.site}\u0000${d.sensitive}`));
1153
+ if (next === undefined)
1154
+ break;
1155
+ settled.add(`${next.site}\u0000${next.sensitive}`);
1156
+ const index = mutations.findIndex((e) => e.id === next.sensitive);
1157
+ const entry = mutations[index];
1158
+ const m = plan.mutations.find((x) => x.id === entry.id);
1159
+ const site = siteByKey.get(next.site);
1160
+ // Which replay made this reading, so it is the one run again.
1161
+ const proof = entry.targeted?.find((t) => t.site === next.site && t.refused === undefined);
1162
+ const fromRelaxed = entry.relaxed !== undefined && !(site.where === "step" && site.step === m.step) && entry.failedSites.some((f) => f.where === "step" && f.step === m.step && f.postcondition);
1163
+ const describe = proof !== undefined ? (j) => targetedMutationReplay(j, m, next.site, proof.relaxedSites, replayOpts) : (j) => (fromRelaxed ? relaxedMutationReplay : mutationReplay)(j, m, replayOpts);
1164
+ const rerun = await runReplay(m, describe, "rerun");
1165
+ if (tainted()) {
1166
+ mutations[index] = { ...entry, rerun: rerun.outcome };
1167
+ break;
1168
+ }
1169
+ const control = await controlFor(m, [rerun], [next.site]);
1170
+ const undecided = `sibling mutations disagree on ${next.site} (${next.sensitive} failed it, ${next.passed} passed it), and a control and a re-run could not settle it; run the proof again`;
1171
+ const controlFields = { control: control.outcome, ...(control.diagnostics === undefined ? {} : { controlDiagnostics: control.diagnostics }) };
1172
+ if (rerun.outcome === "error" || control.outcome === "error") {
1173
+ mutations[index] = { ...entry, ...controlFields, rerun: rerun.outcome };
1174
+ continue;
1175
+ }
1176
+ const reread = classifyReplay(site, m, rerun);
1177
+ const strike = (e) => {
1178
+ const cut = (r) => ({ waitsCut: [...new Set([...(r.waitsCut ?? []), next.site])] });
1179
+ if (proof !== undefined) {
1180
+ return { ...e, targeted: e.targeted.map((t) => (t === proof ? { ...t, refused: `${next.sensitive} failed ${next.site} once and did not reproduce, while ${next.passed} passed it` } : t)) };
179
1181
  }
180
- mutations.push({
181
- kind: m.kind,
182
- step: m.step,
183
- id: m.id,
184
- ...(m.param === undefined ? {} : { param: m.param }),
185
- ...(m.source === undefined ? {} : { source: m.source }),
186
- ...first,
187
- ...(relaxed === undefined ? {} : { relaxed }),
188
- ...(targeted.length === 0 ? {} : { targeted }),
189
- });
1182
+ return fromRelaxed ? { ...e, relaxed: { ...e.relaxed, ...cut(e.relaxed) } } : { ...e, ...cut(e) };
1183
+ };
1184
+ const firstReading = fromRelaxed ? entry.relaxed : entry;
1185
+ const matched = control.outcome === "passed" && sitesUnmatchedBy(firstReading.failedSites.filter((f) => siteKeyOf(f) === next.site), rerun, control, paceMarginOf(pace, m)).length === 0;
1186
+ if (reread === "insensitive")
1187
+ mutations[index] = { ...strike(entry), ...controlFields, rerun: rerun.outcome };
1188
+ else if (reread === "sensitive" && matched)
1189
+ mutations[index] = { ...entry, ...controlFields, rerun: rerun.outcome };
1190
+ else if (control.outcome === "failed" && reread === "sensitive")
1191
+ mutations[index] = { ...strike(entry), ...controlFields, rerun: rerun.outcome };
1192
+ else {
1193
+ mutations[index] = { ...entry, ...controlFields, rerun: rerun.outcome };
1194
+ taint("siblings-undecided", null, undecided);
190
1195
  }
191
1196
  }
192
- const results = new Map(mutations.map((m) => [
193
- m.id,
194
- { outcome: m.outcome, failedSites: m.failedSites, ...(m.relaxed === undefined ? {} : { relaxed: m.relaxed }), ...(m.targeted === undefined ? {} : { targeted: m.targeted }) },
195
- ]));
1197
+ }
1198
+ /**
1199
+ * A proof with no pairs says what to add, in the Journey's own terms. A check's method does not make its
1200
+ * request a write (read RPCs are POSTs too), so the words leave that to whoever knows: this adds no pair.
1201
+ */
1202
+ function withWhatToAdd(reason, journey) {
1203
+ const deltaWith = (line) => `"delta":${JSON.stringify({ verdict: "relevant-change", why: "sends this write", changes: [], requests: [`${line} → 200`], overheadMs: 0 })}`;
1204
+ const lineFor = (method, pathGlob) => `${method.toUpperCase()} ${pathGlob}`;
1205
+ const named = journeyAssertions(journey).flatMap((s) => (s.where === "end-state" && (s.check.kind === "responseStatus" || s.check.kind === "requestMade") && !READ_METHODS.has(s.check.method.toUpperCase()) ? [{ site: assertionSiteKey(s), line: lineFor(s.check.method, s.check.pathGlob) }] : []));
1206
+ if (named.length === 0)
1207
+ return `${reason}; if a step sends a write, add it to that step's delta.requests (a step with no delta: ${deltaWith("POST /path")}), which gives the step skip and block-write mutations, or pair a check yourself in metadata.mutationPairs`;
1208
+ return [
1209
+ reason,
1210
+ ...named.map((n) => `${n.site} names ${n.line}, and no step records it as a write: a check names a request, not the step that sends it. If this request is the step's write, add it to that step's delta.requests, with the path it sends (a step with no delta: ${deltaWith(n.line)}), which gives the step skip and block-write mutations, or add {"check":"${n.site}","mustFailWhen":"skip:<step>"} to metadata.mutationPairs. If it is a read, there is no write here to prove`),
1211
+ ].join("; ");
1212
+ }
1213
+ /** The last replay as recorded, the verdict, the report and its receipt. */
1214
+ async function concludeProof(c) {
1215
+ const { opts, runOpts, journey, journeyHash, plan, sites, checkOf, base, baseFields, pace, mutations, suggested, receiptDestination, boundedRun, checkpoint, resuming, restoreFailed } = c;
1216
+ let notIsolated = c.notIsolated;
1217
+ const tainted = () => restoreFailed() || notIsolated !== undefined;
1218
+ const results = new Map(mutations.map((m) => [m.id, resultOfEntry(m)]));
196
1219
  // Base failed: nothing ran, so every paired assertion is an `error` (no proof either way).
197
1220
  const judged = judgeAssertions(journey, plan, results);
198
1221
  const assertions = judged.map((a) => ({
@@ -207,7 +1230,25 @@ export async function verifyJourneyMutations(opts, run = runJourneyProgrammatica
207
1230
  }));
208
1231
  const order = new Map(journeyAssertions(journey).map((s, i) => [assertionSiteKey(s), i]));
209
1232
  const siteOrder = (key) => order.get(key) ?? Number.MAX_SAFE_INTEGER;
210
- const { verdict, reason } = mutationProofVerdict({ basePassed: base.outcome === "ok", mutations, assertions: judged });
1233
+ const verdictInput = { basePassed: base.outcome === "ok", mutations, assertions: judged };
1234
+ // A last replay as recorded, where its result can still change the verdict: a replay's slow job has no
1235
+ // request to watch, so a late landing that stuck is only seen here. It cannot see one a restore then cleared.
1236
+ // #787: a resume always runs it: nothing from before the interruption can vouch for how the proof ended.
1237
+ if (!tainted() && (resuming !== undefined || (pace.stepEndMs.length > 0 && mutationProofVerdict({ ...verdictInput, restoreFailed: restoreFailed(), ...(notIsolated === undefined ? {} : { notIsolated }) }).verdict !== "inconclusive"))) {
1238
+ checkpoint?.phase("closing");
1239
+ try {
1240
+ const closing = await boundedRun({ ...runOpts }, { structuredFailures: true }, { id: "closing replay, as recorded" });
1241
+ if (closing.outcome !== "ok")
1242
+ notIsolated = "the Journey as recorded no longer passes after the mutated replays, so state one left behind may have decided another; isolate the replays (a fixture restore, or a longer job's end) and run the proof again";
1243
+ }
1244
+ catch (err) {
1245
+ if (err instanceof JourneyVerifyStalledError)
1246
+ throw err;
1247
+ notIsolated = "the closing replay as recorded errored, so the proof cannot say the replays stayed isolated; retry it";
1248
+ }
1249
+ }
1250
+ const { verdict, reason: judgedReason } = mutationProofVerdict({ ...verdictInput, restoreFailed: restoreFailed(), ...(notIsolated === undefined ? {} : { notIsolated }) });
1251
+ const reason = plan.pairs.length === 0 && judgedReason?.startsWith("no assertion is paired") === true ? withWhatToAdd(judgedReason, journey) : judgedReason;
211
1252
  const count = (v) => assertions.filter((a) => a.verdict === v).length;
212
1253
  const report = {
213
1254
  journeyId: journey.metadata.id,
@@ -229,10 +1270,11 @@ export async function verifyJourneyMutations(opts, run = runJourneyProgrammatica
229
1270
  },
230
1271
  // #616: in assertion order, so the first suggestion is the one for the refusal the verdict quotes.
231
1272
  ...(suggested.length === 0 ? {} : { suggestedTargetedProofs: [...suggested].sort((a, b) => siteOrder(a.check) - siteOrder(b.check)) }),
1273
+ reusedPairs: resuming?.pairs.size ?? 0,
232
1274
  };
233
1275
  // Reasons and check texts can quote what the page showed: a secret parameter never comes back.
234
1276
  const redacted = redactSecretParams(report, journey, opts.params);
235
- const proofContext = journeyProofContext(opts.browser, opts.emulation);
1277
+ const proofContext = journeyProofContext(opts.browser, opts.emulation, opts.readRequests);
236
1278
  // #432: the verdict is kept beside the Journey (bound to its review hash) for `journey review`.
237
1279
  await writePreflightedVerifyRecord(receiptDestination, {
238
1280
  journeyId: journey.metadata.id,
@@ -243,10 +1285,245 @@ export async function verifyJourneyMutations(opts, run = runJourneyProgrammatica
243
1285
  ...(redacted.reason === undefined ? {} : { reason: redacted.reason }),
244
1286
  summary: { ...redacted.summary },
245
1287
  ...(redacted.suggestedTargetedProofs === undefined ? {} : { suggestedTargetedProofs: redacted.suggestedTargetedProofs }),
1288
+ ...(redacted.reusedPairs === 0 ? {} : { reusedPairs: redacted.reusedPairs }),
246
1289
  at: clock.nowIso(),
247
1290
  });
1291
+ const errorGap = checkpoint !== undefined && isRepairableIsolation(checkpoint.body.taint) ? true : !tainted() && mutations.some(mutationErrored);
1292
+ checkpoint?.finish(redacted.verdict, errorGap);
248
1293
  return redacted;
249
1294
  }
1295
+ async function proveJourneyMutations(opts, run, owned) {
1296
+ const journey = await loadJourneyToVerify(opts.dir, opts.id);
1297
+ // #711: receipt authority is proven before the first baseline/mutation replay spends a browser.
1298
+ const receiptDestination = await preflightVerifyRecord(opts.dir, journey.metadata.id);
1299
+ const { readRequests, onProgress, slotGovernor: _slotGovernor, checkpoint: checkpointRequest, ...runOpts } = opts;
1300
+ const inactivityMs = proofInactivityMs(journey, opts);
1301
+ const portFactory = opts.browserPortFactory ?? (() => new PlaywrightBrowserPort());
1302
+ let replays = 0;
1303
+ // Every replay of the proof goes through here: it is reported, and it is bounded.
1304
+ let sessionsOpened = 0;
1305
+ // A replay's restore may fail whether it passes, fails or throws, so the run reports it as it happens.
1306
+ let restoreFailed = false;
1307
+ const lintOpts = readRequests === undefined ? {} : { readRequests };
1308
+ const journeyHash = journeyContentHash(journey);
1309
+ const sites = journeyAssertions(journey);
1310
+ const plan = planJourneyMutations(journey, { ...lintOpts, params: opts.params });
1311
+ // #787: claimed before the first replay: a refused resume spends no browser and runs no hook.
1312
+ // Params the replay will refuse keep no progress, so a mistyped command cannot replace a checkpoint. The replay raises its own refusal.
1313
+ const opened = checkpointRequest === undefined || (!checkpointRequest.resume && !paramsAccepted(journey, opts.params)) ? {} : await openProofCheckpoint(checkpointRequest, { journey, journeyHash, plan, params: opts.params, sites });
1314
+ const checkpoint = opened.session;
1315
+ if (checkpoint !== undefined)
1316
+ owned.session = checkpoint;
1317
+ const resuming = opened.resume;
1318
+ // A taint belongs to the whole proof: an earlier run's stands, or a resume could read `proven` where the uninterrupted proof could not.
1319
+ if (resuming?.restoreFailed === true)
1320
+ restoreFailed = true;
1321
+ const boundedRun = (options, mutation, m, retry = false, probe) => {
1322
+ replays += 1;
1323
+ checkpoint?.replayStarted(replays);
1324
+ const where = { replay: replays, ...(m === undefined ? {} : { mutation: m.id }), ...(retry ? { retry: true } : {}) };
1325
+ onProgress?.(retry && m !== undefined ? { ...where, mutation: `${m.id} (retry after error)` } : where);
1326
+ return replayWithinBound((browserPortFactory) => run({ ...options, browserPortFactory }, { ...mutation, onFixtureRestoreFailed: () => {
1327
+ restoreFailed = true;
1328
+ checkpoint?.taint({ restoreFailed: true });
1329
+ } }), probe === undefined ? portFactory : probe.wrap(portFactory), where, inactivityMs, () => void (sessionsOpened += 1));
1330
+ };
1331
+ const stepsTotal = flatJourneySteps(journey).length;
1332
+ const writeSteps = journeyWriteSteps(journey, lintOpts);
1333
+ const writeScope = new WriteScope(effectiveAllowedOrigins(journey, opts.environment, opts.allow), writeSteps.flatMap((w) => w.requests));
1334
+ const longestResponseMs = Math.max(opts.stepResponseTimeoutMs ?? DEFAULT_STEP_RESPONSE_TIMEOUT_MS, ...flatJourneySteps(journey).map(({ recorded }) => recorded.responseTimeoutMs ?? 0));
1335
+ const basePacer = new ReplayPacer(0, () => undefined);
1336
+ // The base stays open until its writes have settled: what it started must not land in the next replay.
1337
+ const baseProbe = new ReplayProbe(stepsTotal, () => baseProbe.settleOpenWrites(Math.max(longestResponseMs, basePacer.elapsedMs())), writeScope);
1338
+ const base = await boundedRun({ ...runOpts, observer: composeObservers(baseProbe.observer(), basePacer.observer()) }, { structuredFailures: true, earliestObserver: basePacer.claimObserver(), afterEndState: () => basePacer.noteEndState() }, undefined, false, baseProbe);
1339
+ // Every other replay waits as this one did, so this one is their control (`pacedReplay`).
1340
+ const pace = { stepEndMs: basePacer.reachedMs.filter((ms) => ms !== undefined), ...(basePacer.endStateReachedMs === undefined ? {} : { endStateMs: basePacer.endStateReachedMs }), claimHeldMs: [...basePacer.claimHeldMs] };
1341
+ const baseMs = basePacer.elapsedMs();
1342
+ const settleBoundMs = Math.max(longestResponseMs, baseMs);
1343
+ writeScope.addBackground(baseProbe.repeatedLines);
1344
+ // Why a replay may have decided another (a write still open at a close, state crossing replays).
1345
+ const resumedNote = resuming === undefined ? null : (resuming.isolation ?? (resuming.notIsolatedBy === null ? null : "write-open"));
1346
+ let notIsolated = resumedNote === null ? undefined : isolationReason(resumedNote, resuming?.notIsolatedBy ?? null);
1347
+ // Once set it stays, and an interrupted proof keeps it: a resume could otherwise read `proven` where the uninterrupted proof could not.
1348
+ const taint = (note, by = null, detail) => {
1349
+ if (notIsolated !== undefined)
1350
+ return;
1351
+ notIsolated = detail ?? isolationReason(note, by);
1352
+ checkpoint?.taint({ isolation: note, notIsolatedBy: by });
1353
+ };
1354
+ // Once a replay may have decided another, the proof is `inconclusive` whatever is left: nothing else is run.
1355
+ const tainted = () => restoreFailed || notIsolated !== undefined;
1356
+ const baseFields = { outcome: base.outcome, ...(base.outcome === "quarantined" ? { reason: base.reason } : {}) };
1357
+ const checkOf = new Map(sites.map((s) => [assertionSiteKey(s), describeCheck(s.check)]));
1358
+ if (resuming !== undefined) {
1359
+ // Nothing is reused against an app whose as-recorded flow no longer passes, or a base it was not paced for.
1360
+ if (base.outcome !== "ok") {
1361
+ const why = base.outcome === "quarantined" ? redactSecretParams({ reason: base.reason }, journey, opts.params).reason : base.outcome;
1362
+ throw new JourneyVerifyResumeRefusedError("base-failed", `the Journey as recorded did not pass the re-run of the base replay (${why})`);
1363
+ }
1364
+ const refused = resumeRefusalAgainstBase(resuming, plan, sites, pace, baseProbe.writesInFlightAtClose);
1365
+ if (refused !== undefined)
1366
+ throw refused;
1367
+ }
1368
+ const mutations = [];
1369
+ // #616: automatic targeted proofs stopped at their relaxation bound, each a reviewable start.
1370
+ const suggested = [];
1371
+ const seenSuggestions = new Set();
1372
+ // #787: this pair's replays as records, by kind, for a later base to be held against.
1373
+ const pairReplays = [];
1374
+ let replayWritesInFlight = 0;
1375
+ if (base.outcome === "ok") {
1376
+ // #565: a param mutation's replay runs with altered params (never a secret's), so it needs them.
1377
+ const replayOpts = { ...lintOpts, params: opts.params };
1378
+ const baseOpenLines = baseProbe.openLinesAtClose;
1379
+ let independenceOpen = [];
1380
+ const replayContext = {
1381
+ opts, runOpts, journey, pace, stepsTotal, settleBoundMs, writeScope, writeSteps, baseProbe, boundedRun, taint,
1382
+ noteWritesInFlight: (n) => void (replayWritesInFlight = Math.max(replayWritesInFlight, n)),
1383
+ noteIndependenceOpen: (lines) => void (independenceOpen = lines),
1384
+ };
1385
+ const retriesOf = new Map();
1386
+ // #778: one infrastructure error must not cost the proof, so a replay that errored before opening a
1387
+ // browser, as on an admission timeout, runs once more. One that opened a browser may have changed the
1388
+ // app, and a retry failing on that leftover state would read as proof, so it stays an error. A
1389
+ // `failed` replay is a result and a stall ends the proof: neither gets here. A replay that never
1390
+ // started (`describe` refused it, as a targeted proof's bad relaxation) would only be refused again.
1391
+ const attemptWithRetry = async (m, describe, kind, matchTo) => {
1392
+ const before = replays;
1393
+ const sessionsBefore = sessionsOpened;
1394
+ const first = await attemptReplay(replayContext, m, describe, false, kind, matchTo);
1395
+ if (first.outcome !== "error" || replays === before || sessionsOpened !== sessionsBefore)
1396
+ return first;
1397
+ retriesOf.set(m.id, [...(retriesOf.get(m.id) ?? []), { reason: first.reason ?? "unknown error" }]);
1398
+ return attemptReplay(replayContext, m, describe, true, kind, matchTo);
1399
+ };
1400
+ // #787: the one place a replay of a pair runs, so each is kept as a record of its kind. A new kind is a new name here and in `REPLAY_RECORD_KINDS`.
1401
+ const runReplay = async (m, describe, kind, matchTo) => {
1402
+ replayWritesInFlight = 0;
1403
+ const ran = await attemptWithRetry(m, describe, kind, matchTo);
1404
+ pairReplays.push(checkpointReplayOf(kind, ran, replayWritesInFlight));
1405
+ return ran;
1406
+ };
1407
+ // #402: a named mutation lowers to its `MutationReplay`; #609's targeted proof passes one directly.
1408
+ const replay = (m, describe, kind) => runReplay(m, (j) => describe(j, m, replayOpts), kind);
1409
+ const siteByKey = new Map(sites.map((s) => [assertionSiteKey(s), s]));
1410
+ // #616: a proof the automatic bound stopped names the plan a human could declare instead.
1411
+ const noteSuggestion = (proof, m) => {
1412
+ const suggestion = suggestTargetedProofPlan(proof, m);
1413
+ if (suggestion === null)
1414
+ return;
1415
+ const key = `${suggestion.check}\u0000${suggestion.mustFailWhen}\u0000${suggestion.relax.join(",")}`;
1416
+ if (seenSuggestions.has(key))
1417
+ return;
1418
+ seenSuggestions.add(key);
1419
+ suggested.push(suggestion);
1420
+ };
1421
+ const asRecorded = (j) => ({ journey: j });
1422
+ // Sibling mutations of a step have the same floors, so they share a matched control while it reached their sites together with them.
1423
+ const stepControls = new Map();
1424
+ const controlFor = async (m, runs, sitesAtStake) => {
1425
+ const known = (stepControls.get(m.step) ?? []).find((c) => runs.every((r) => sitesUnmatchedBy(r.failedSites.filter((f) => sitesAtStake.includes(siteKeyOf(f))), r, c, paceMarginOf(pace, m)).length === 0));
1426
+ if (known !== undefined) {
1427
+ // Each pair keeps a record of the control it read, whoever ran it: one that left a write open ended the proof.
1428
+ pairReplays.push(checkpointReplayOf("control", known, 0));
1429
+ return known;
1430
+ }
1431
+ const latest = (of) => {
1432
+ const known = runs.map(of).filter((ms) => ms !== undefined);
1433
+ return known.length === 0 ? undefined : Math.max(...known);
1434
+ };
1435
+ const longest = Math.max(0, ...runs.map((r) => r.arrivedMs?.length ?? 0));
1436
+ const end = latest((r) => r.endStateReachedMs);
1437
+ const matchTo = { arrivedMs: Array.from({ length: longest }, (_, i) => latest((r) => r.arrivedMs?.[i])), ...(end === undefined ? {} : { endStateReachedMs: end }) };
1438
+ const ran = await runReplay(m, asRecorded, "control", matchTo);
1439
+ stepControls.set(m.step, [...(stepControls.get(m.step) ?? []), ran]);
1440
+ return ran;
1441
+ };
1442
+ const resultsSoFar = (extra) => new Map([...mutations.map((e) => [e.id, resultOfEntry(e)]), ...(extra === undefined ? [] : [extra])]);
1443
+ /**
1444
+ * A replay straight after the base, floored as the first mutated replay will be. What the base left
1445
+ * running, however it started (a GET, a socket, a popup), meets it where it would meet that replay.
1446
+ * A failure alone cannot say whether state crossed over or the floor itself breaks a claim that only
1447
+ * lasts so long, so it is run again after a quiet gap: a pass means state crossed. A second failure
1448
+ * is the floor if the Journey still passes unheld, and state that stuck if it does not.
1449
+ */
1450
+ const proveIndependence = async (first) => {
1451
+ const floored = await runReplay(first, asRecorded, "independence");
1452
+ // A write still open at the bound in the base and the next replay is a long-lived connection. Open only in the base, it was a real one.
1453
+ if (baseOpenLines.some((line) => !independenceOpen.includes(line))) {
1454
+ taint("base-write-open", null, isolationReason("base-write-open", null, baseOpenLines));
1455
+ return;
1456
+ }
1457
+ writeScope.addBackground(baseOpenLines);
1458
+ if (floored.outcome === "error") {
1459
+ taint("independence-errored");
1460
+ return;
1461
+ }
1462
+ if (floored.outcome === "passed") {
1463
+ stepControls.set(first.step, [floored]);
1464
+ checkpoint?.independence("passed");
1465
+ return;
1466
+ }
1467
+ if (tainted())
1468
+ return;
1469
+ await clock.sleep(Math.max(QUIET_GAP_MIN_MS, 2 * baseMs));
1470
+ const again = await runReplay(first, asRecorded, "independence");
1471
+ if (again.outcome === "passed") {
1472
+ taint("state-crossed");
1473
+ return;
1474
+ }
1475
+ if (again.outcome === "error") {
1476
+ taint("independence-errored");
1477
+ return;
1478
+ }
1479
+ if (tainted())
1480
+ return;
1481
+ const unheldProbe = new ReplayProbe(stepsTotal, () => unheldProbe.settleOpenWrites(settleBoundMs), writeScope);
1482
+ try {
1483
+ const unheld = await boundedRun({ ...runOpts, observer: unheldProbe.observer() }, { structuredFailures: true }, { id: "independence replay, unheld" }, false, unheldProbe);
1484
+ if (unheld.outcome !== "ok")
1485
+ taint("state-stuck");
1486
+ }
1487
+ catch (err) {
1488
+ if (err instanceof JourneyVerifyStalledError)
1489
+ throw err;
1490
+ taint("independence-errored");
1491
+ return;
1492
+ }
1493
+ if (tainted())
1494
+ return;
1495
+ stepControls.set(first.step, [again]);
1496
+ checkpoint?.independence("hold-breaks-claim");
1497
+ };
1498
+ const pairContext = { journey, plan, pace, siteByKey, replayOpts, mutations, checkpoint, tainted, taint, replay, runReplay, controlFor, resultsSoFar, noteSuggestion, retriesOf, pairReplays };
1499
+ const firstMutation = plan.mutations[0];
1500
+ // On a resume too: it answers for the base just measured, so a checkpoint never stands in for it.
1501
+ if (firstMutation !== undefined && pace.stepEndMs.length > 0 && !tainted()) {
1502
+ checkpoint?.phase("independence");
1503
+ if (baseOpenLines.some((line) => writeScope.isRecorded(line))) {
1504
+ taint("base-write-open", null, isolationReason("base-write-open", null, baseOpenLines));
1505
+ }
1506
+ else {
1507
+ await proveIndependence(firstMutation);
1508
+ }
1509
+ }
1510
+ for (const m of plan.mutations) {
1511
+ if (tainted())
1512
+ break;
1513
+ // #787: a finished pair is not run again. It reaches the same judging below as a pair just run.
1514
+ const kept = resuming?.pairs.get(m.id);
1515
+ if (kept !== undefined) {
1516
+ mutations.push(reusedMutation(kept));
1517
+ for (const proof of reusedProofFields(kept).targeted ?? [])
1518
+ noteSuggestion(proof, m);
1519
+ continue;
1520
+ }
1521
+ await runPair(pairContext, m);
1522
+ }
1523
+ await settleDisagreements(pairContext);
1524
+ }
1525
+ return concludeProof({ opts, runOpts, journey, journeyHash, plan, sites, checkOf, base, baseFields, pace, mutations, suggested, receiptDestination, boundedRun, checkpoint, resuming, restoreFailed: () => restoreFailed, notIsolated });
1526
+ }
250
1527
  /** One human line per assertion — verdict, site, check, and the mutation that proved it. */
251
1528
  export function verifyAssertionLine(a) {
252
1529
  // #609: a sensitive verdict reached by a targeted proof names the earlier postconditions it relaxed.