From abf7c1142e759a291d2267fedf15b0dd444a5684 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:57:50 +0800 Subject: [PATCH] refactor(process): remove fixed scenario ceilings --- ...controlled-replay-authority-and-sandbox.md | 3 +- docs/process-capture.md | 36 +++-- .../JavaScriptReplayPlanning.test.ts | 109 +++++++++++++ src/application/ProcessCaptureLifecycle.ts | 5 +- .../ProcessCaptureObservation.test.ts | 10 +- src/application/ProcessHarness.ts | 1 - src/application/ProcessReactiveCoordinator.ts | 22 ++- src/application/TerminalRenderer.ts | 8 +- src/domain/javascriptReplay.ts | 17 +- .../processCaptureReactiveSchema.test.ts | 34 ++++ src/domain/processCaptureReactiveSchema.ts | 34 ++-- src/domain/processReactiveMatching.ts | 97 ++++------- src/domain/processReactiveRuntime.ts | 16 +- .../processReactiveRuntimePart3.test.ts | 62 +++---- src/domain/processReactiveScenario.test.ts | 59 ++++++- src/domain/processReactiveScenario.ts | 112 +++---------- .../processReactiveScenarioPreflight.ts | 50 +----- src/domain/processScenario.test.ts | 17 ++ src/domain/processScenario.ts | 5 +- src/domain/replayMachine.ts | 48 +++--- src/domain/replayMachineRun.test.ts | 74 ++++++--- src/domain/replayMachineRun.ts | 123 +++----------- src/domain/replayMachineRuntime.ts | 153 ++++++++++++++---- src/domain/replayMachineUncapped.test.ts | 129 +++++++++++++++ .../providers/replay/javascriptReplay.test.ts | 16 +- 25 files changed, 712 insertions(+), 528 deletions(-) create mode 100644 src/application/JavaScriptReplayPlanning.test.ts create mode 100644 src/domain/processCaptureReactiveSchema.test.ts create mode 100644 src/domain/replayMachineUncapped.test.ts diff --git a/docs/adr/0002-controlled-replay-authority-and-sandbox.md b/docs/adr/0002-controlled-replay-authority-and-sandbox.md index e8e91b61e..dced78982 100644 --- a/docs/adr/0002-controlled-replay-authority-and-sandbox.md +++ b/docs/adr/0002-controlled-replay-authority-and-sandbox.md @@ -321,7 +321,8 @@ Every limit has a conservative product default. The v1 plan and result commit: - cgroup memory, swap, task, and CPU ceilings; - Node/V8 heap ceiling as defense in depth; - private tmpfs bytes; -- module count and aggregate bytes, case count, and input bytes; +- serialized worker-protocol bytes, aggregate module bytes, and case-input + bytes; preset-generated cases also have a fixed preflight count ceiling; - stdout, stderr, exception, stack, and aggregate retained-output bytes; and - structured-result depth and node count. diff --git a/docs/process-capture.md b/docs/process-capture.md index 4215922dc..d2e801b4f 100644 --- a/docs/process-capture.md +++ b/docs/process-capture.md @@ -118,8 +118,8 @@ inherited under operator policy. Scenario arguments, environment names, timed interactions, shim routes and output chunks, filesystem checkpoints, and static HTTP/WebSocket scripts are accepted without fixed item-count ceilings. The run still obeys its timeout -and the configured output, frame, process, filesystem, protocol-event, -body-byte, and connection budgets; each filesystem checkpoint uses those same +and the configured output-byte, process, filesystem, protocol-event, body-byte, +and connection budgets; each filesystem checkpoint uses those same bounded snapshot limits. ## Reactive process scenarios @@ -127,8 +127,8 @@ bounded snapshot limits. Set `reactive` to a process reactive scenario declaration when interaction must follow observed output instead of guessed delays. The graph declares an initial state, absolute scenario and state deadlines, explicitly prioritized -transitions, bounded uses and visits, trigger predicates, ordered actions, and -either a next state or `passed` outcome. +transitions, caller-chosen use and visit counts, trigger predicates, ordered +actions, and either a next state or `passed` outcome. The admitted runtime slice matches decoded UTF-8 terminal literals and exact `terminal_raw`, `interaction`, `process`, `filesystem`, `http`, `websocket`, or @@ -136,7 +136,7 @@ The admitted runtime slice matches decoded UTF-8 terminal literals and exact event-complete lifecycle. Filesystem events identify named checkpoints; shim events identify a command and route. Live predicates receive the same normalized and redacted payload later persisted in Evidence. Predicates may compose with -bounded `all`, `any`, `sequence`, and `repeat` nodes and may select a frontier at +`all`, `any`, `sequence`, and `repeat` nodes and may select a frontier at scenario start, state entry, a prior checkpoint, or an exact event ID. Actions can write PTY input, resize it, signal the root process, or capture a named filesystem checkpoint. `terminal_rendered`, `lifecycle`, and `replay_transition` @@ -145,9 +145,12 @@ target launches. Reactive results are recorded in `reactive_run`, including the terminal outcome, active state, matched event IDs, action evidence IDs, and deterministic -transition journal. Terminal controls also commit the last admitted observation -order, so later output cannot be moved ahead of a timeout, cancellation, or -target loss during validation. If the process exits before the graph finishes, +transition journal. The reducer retains and evaluates every admitted +observation through the scenario deadline; it has no fixed state, transition, +predicate, action, repeat, or history-count ceilings. Recursive input depth has +a stack safety guard. Terminal controls also commit the last admitted +observation order, so later output cannot be moved ahead of a timeout, +cancellation, or target loss during validation. If the process exits before the graph finishes, the outcome is `target_lost`; state/scenario deadlines remain distinct `predicate_timeout`/`scenario_deadline` outcomes. Timed `events` remain available for fixed schedules, but they are not silently translated into the @@ -163,9 +166,10 @@ the initial tree. Each command shim matches an exact argument array. Routes can emit timed stdout or stderr chunks and then exit or receive `SIGINT`, `SIGTERM`, or `SIGKILL`. -Unmatched and exhausted calls are recorded. Shim observations are bounded by -the scenario protocol-event limit; exceeding it marks the whole capture -truncated while replay continues. +Unmatched and exhausted calls are recorded. Shim observations are included in +the capture without a separate fixed count ceiling. Other configured +observation and byte budgets remain explicit in the capture result when they +affect completeness. ## Run a replay machine directly @@ -178,9 +182,13 @@ transition. It never returns request bodies, request headers, or captured values. It also reports the initial and final states, whether the final state is terminal, configured limits, and committed usage. -The direct boundary accepts at most 10,000 events, 4 MiB of declared action -JSON, 1,024 action text fields, and 1,024 possible sensitive-capture operations. -These caps bound both returned content and secret-redaction work. +The direct evaluator has no fixed state, transition, event, guard, capture, +action, or action-byte count ceiling. It indexes transitions by protocol and +path, evaluates events in order, and returns a decision for each offered event +plus actions for used transitions. Caller-declared `max_uses`, `max_visits`, +`max_transitions`, and protocol budgets determine the finite run; there is no +upper schema cap on those values. Result size follows the input and is not +truncated by an arbitrary item quota. Refused events remain data rather than aborting the run. Outcomes distinguish unmatched events, invalid states, failed guards, exhausted transitions, invalid diff --git a/src/application/JavaScriptReplayPlanning.test.ts b/src/application/JavaScriptReplayPlanning.test.ts new file mode 100644 index 000000000..b545fff21 --- /dev/null +++ b/src/application/JavaScriptReplayPlanning.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + prepareReplayPlan, + type EnabledJavaScriptReplayPolicy, + type JavaScriptReplayHost, +} from "./JavaScriptReplayPlanning.js"; +import { controlledReplayInputSchema } from "../domain/javascriptReplay.js"; + +const root = "/approved"; +const policy: EnabledJavaScriptReplayPolicy = { + status: "enabled", + roots: [root], + nodePath: "/runtime/node", + bubblewrapPath: "/runtime/bwrap", + systemdRunPath: "/runtime/systemd-run", + systemctlPath: "/runtime/systemctl", + shellPath: "/runtime/sh", +}; + +const inputWithExpandedManifest = () => { + const modules = Array.from({ length: 130 }, (_, index) => ({ + alias: `module-${index}`, + path: `${root}/module.mjs`, + format: "esm" as const, + role: "module" as const, + dependencies: {}, + })); + const first = modules[0]; + if (first === undefined) throw new TypeError("Missing fixture module"); + modules[0] = { + ...first, + dependencies: Object.fromEntries( + Array.from({ length: 129 }, (_, index) => [ + `dependency-${index}`, + `module-${index + 1}`, + ]), + ), + }; + return controlledReplayInputSchema.parse({ + mode: "plan", + left: { + modules, + entry_alias: "module-0", + entry_export: "default", + }, + cases: Array.from({ length: 129 }, (_, index) => ({ + case_id: `case-${index}`, + arguments: Array.from({ length: 17 }, () => "value"), + })), + }); +}; + +describe("controlled replay planning input bounds", () => { + it("accepts module, dependency, case, and argument counts beyond old quotas", () => { + const input = inputWithExpandedManifest(); + + expect(input.left.modules).toHaveLength(130); + expect(Object.keys(input.left.modules[0]?.dependencies ?? {})).toHaveLength( + 129, + ); + expect(input.cases).toHaveLength(129); + expect(input.cases[0]?.arguments).toHaveLength(17); + }); + + it("rejects an oversized protocol manifest before probing or reading", async () => { + const probe = vi.fn(async () => undefined); + const readSource = vi.fn(async () => ({ + canonicalPath: `${root}/module.mjs`, + bytes: new Uint8Array(), + })); + const host: JavaScriptReplayHost = { + readSource, + identifyExecutable: vi.fn(async (path) => ({ + path, + version: "1", + sha256: "0".repeat(64), + })), + identifyWorker: vi.fn(async () => ({ + path: "/worker.js", + version: "1", + sha256: "0".repeat(64), + })), + identifyRuntimeClosure: vi.fn(async () => []), + seccompDigest: () => "0".repeat(64), + probe, + }; + const input = controlledReplayInputSchema.parse({ + mode: "plan", + left: { + modules: Array.from({ length: 300 }, (_, index) => ({ + alias: `module-${index}`, + path: `/${"p".repeat(4_095)}`, + format: "esm", + dependencies: {}, + })), + entry_alias: "module-0", + }, + cases: [{ case_id: "empty", arguments: [] }], + limits: { protocol_bytes: 1024 * 1024 }, + }); + + await expect(prepareReplayPlan(input, policy, host)).rejects.toThrow( + "Replay plan input exceeds its protocol byte limit", + ); + expect(probe).not.toHaveBeenCalled(); + expect(readSource).not.toHaveBeenCalled(); + }); +}); diff --git a/src/application/ProcessCaptureLifecycle.ts b/src/application/ProcessCaptureLifecycle.ts index ccc13ba62..b9eb2d1f3 100644 --- a/src/application/ProcessCaptureLifecycle.ts +++ b/src/application/ProcessCaptureLifecycle.ts @@ -373,10 +373,7 @@ export const captureTerminalFrames = (options: { options.terminal.onData((data) => { options.onOutput(); const bytes = Buffer.byteLength(data); - if ( - options.frames.length >= options.scenario.limits.frames || - outputBytes + bytes > options.scenario.limits.output_bytes - ) { + if (outputBytes + bytes > options.scenario.limits.output_bytes) { truncated = true; return; } diff --git a/src/application/ProcessCaptureObservation.test.ts b/src/application/ProcessCaptureObservation.test.ts index 3be38f447..7899f499b 100644 --- a/src/application/ProcessCaptureObservation.test.ts +++ b/src/application/ProcessCaptureObservation.test.ts @@ -30,17 +30,19 @@ it("returns detached terminal and filesystem checkpoint observations", async () columns: 40, rows: 12, scrollback: 100, - maxFrames: 10, - maxBytes: 10_000, + maxBytes: 100_000, normalize: (value) => value, recordEvent: (collection, index) => observed.push(`${collection}:${String(index)}`), }); renderer.write("A", 20); renderer.resize(40, 12, 10); + for (let index = 0; index < 20; index += 1) + renderer.resize(40, 12, 11 + index); const frames = await renderer.frames(); - expect(frames.map(({ at_ms }) => at_ms)).toEqual([20, 10]); - expect(observed).toEqual(["rendered_frames:0", "rendered_frames:1"]); + expect(frames).toHaveLength(22); + expect(frames.slice(0, 2).map(({ at_ms }) => at_ms)).toEqual([20, 10]); + expect(observed).toHaveLength(22); if (frames[0] !== undefined) Reflect.set(frames[0], "cursor_x", 999); expect((await renderer.frames())[0]?.cursor_x).not.toBe(999); await renderer.dispose(); diff --git a/src/application/ProcessHarness.ts b/src/application/ProcessHarness.ts index e5f92bb67..cd76e1f94 100644 --- a/src/application/ProcessHarness.ts +++ b/src/application/ProcessHarness.ts @@ -106,7 +106,6 @@ const createTerminalRenderer = ( columns: scenario.terminal.columns, rows: scenario.terminal.rows, scrollback: scenario.terminal.scrollback, - maxFrames: scenario.limits.frames, maxBytes: scenario.limits.output_bytes, normalize: (value) => normalizeProcessText(value, scenario, temporaryRoot, terminalPid()), diff --git a/src/application/ProcessReactiveCoordinator.ts b/src/application/ProcessReactiveCoordinator.ts index 63f3b6540..97b67b671 100644 --- a/src/application/ProcessReactiveCoordinator.ts +++ b/src/application/ProcessReactiveCoordinator.ts @@ -35,8 +35,26 @@ export interface ProcessReactiveTimerHost { const systemTimerHost: ProcessReactiveTimerHost = { schedule: (callback, delayMs) => { - const timer = setTimeout(callback, delayMs); - return { cancel: () => clearTimeout(timer) }; + const maximumTimerDelay = 2_147_483_647; + let remaining = Math.max(0, delayMs); + let timer: NodeJS.Timeout | undefined; + let cancelled = false; + const scheduleNext = (): void => { + if (cancelled) return; + const interval = Math.min(remaining, maximumTimerDelay); + timer = setTimeout(() => { + remaining -= interval; + if (remaining === 0) callback(); + else scheduleNext(); + }, interval); + }; + scheduleNext(); + return { + cancel: () => { + cancelled = true; + if (timer !== undefined) clearTimeout(timer); + }, + }; }, }; diff --git a/src/application/TerminalRenderer.ts b/src/application/TerminalRenderer.ts index 314f7532c..44bf4d580 100644 --- a/src/application/TerminalRenderer.ts +++ b/src/application/TerminalRenderer.ts @@ -17,14 +17,13 @@ interface TerminalRendererOptions { readonly columns: number; readonly rows: number; readonly scrollback: number; - readonly maxFrames: number; readonly maxBytes: number; readonly normalize: (value: string) => string; readonly recordEvent?: RecordProcessCaptureEvent; } -/** Owns one headless terminal and serializes writes into deterministic frames. */ /** + * Owns one headless terminal and serializes writes into deterministic frames. * Reconstructs bounded terminal states while raw PTY chunks remain authoritative. * * Rendered frames answer what an operator saw after control-sequence handling; @@ -108,10 +107,7 @@ export class TerminalRenderer { const bytes = Buffer.byteLength(serializedState) + lines.reduce((total, line) => total + Buffer.byteLength(line), 0); - if ( - this.#frames.length >= this.options.maxFrames || - this.#capturedBytes + bytes > this.options.maxBytes - ) { + if (this.#capturedBytes + bytes > this.options.maxBytes) { this.#truncated = true; return; } diff --git a/src/domain/javascriptReplay.ts b/src/domain/javascriptReplay.ts index b145be839..1b795aac6 100644 --- a/src/domain/javascriptReplay.ts +++ b/src/domain/javascriptReplay.ts @@ -16,18 +16,13 @@ const replayModuleSchema = z path: absolutePathSchema, format: z.enum(["esm", "commonjs-factory"]), role: z.enum(["module", "stub"]).default("module"), - dependencies: z - .record(z.string().min(1).max(256), aliasSchema) - .refine((value) => Object.keys(value).length <= 128, { - message: "Replay modules may declare at most 128 dependencies", - }) - .default({}), + dependencies: z.record(z.string().min(1).max(256), aliasSchema).default({}), }) .strict(); const replaySideSchema = z .object({ - modules: z.array(replayModuleSchema).min(1).max(128), + modules: z.array(replayModuleSchema).min(1), entry_alias: aliasSchema, entry_export: z.string().min(1).max(256).default("default"), }) @@ -117,7 +112,7 @@ const determinismSchema = z const controlledReplayInputShape = { left: replaySideSchema, right: replaySideSchema.optional(), - cases: z.array(explicitCaseSchema).max(128).default([]), + cases: z.array(explicitCaseSchema).default([]), generator: generatorSchema.optional(), determinism: determinismSchema.default({ clock_iso: "2000-01-01T00:00:00.000Z", @@ -174,12 +169,6 @@ export const controlledReplayInputSchema = z path: ["cases"], message: "At least one explicit or generated case is required", }); - if (value.cases.length + (value.generator?.count ?? 0) > 128) - context.addIssue({ - code: "custom", - path: ["generator", "count"], - message: "Explicit and generated replay cases must total at most 128", - }); }); const moduleCommitmentSchema = z diff --git a/src/domain/processCaptureReactiveSchema.test.ts b/src/domain/processCaptureReactiveSchema.test.ts new file mode 100644 index 000000000..8dd1687ab --- /dev/null +++ b/src/domain/processCaptureReactiveSchema.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { processReactiveRunSchema } from "./processCaptureReactiveSchema.js"; + +describe("process reactive capture schema", () => { + it("accepts every committed transition without a fixed journal ceiling", () => { + const transitions = Array.from({ length: 130 }, (_, sequence) => ({ + sequence, + transition_id: `transition_${String(sequence)}`, + state_before: "active", + state_after: "active", + outcome: null, + trigger_event_ids: Array.from( + { length: 80 }, + (_, index) => `event_${String(index)}`, + ), + action_event_ids: Array.from( + { length: 300 }, + (_, index) => `action_${String(index)}`, + ), + action_types: Array.from({ length: 300 }, () => "send_input" as const), + })); + + const parsed = processReactiveRunSchema.safeParse({ + status: "running", + outcome: null, + active_state: "active", + transitions, + }); + + expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.transitions).toHaveLength(130); + }); +}); diff --git a/src/domain/processCaptureReactiveSchema.ts b/src/domain/processCaptureReactiveSchema.ts index f9773cb08..a515b5895 100644 --- a/src/domain/processCaptureReactiveSchema.ts +++ b/src/domain/processCaptureReactiveSchema.ts @@ -1,7 +1,5 @@ import { z } from "zod"; -import { PROCESS_REACTIVE_LIMITS } from "./processReactiveScenario.js"; - const processReactiveOutcomeSchema = z.enum([ "passed", "predicate_timeout", @@ -18,23 +16,17 @@ const processReactiveTransitionShape = { sequence: z.number().int().nonnegative(), transition_id: z.string().min(1).max(64), state_before: z.string().min(1).max(64), - trigger_event_ids: z - .array(z.string().min(1)) - .max(PROCESS_REACTIVE_LIMITS.predicates), - action_event_ids: z - .array(z.string().min(1)) - .max(PROCESS_REACTIVE_LIMITS.actionsPerTransition), - action_types: z - .array( - z.enum([ - "send_input", - "resize", - "close_stdin", - "send_signal", - "checkpoint", - ]), - ) - .max(PROCESS_REACTIVE_LIMITS.actionsPerTransition), + trigger_event_ids: z.array(z.string().min(1)), + action_event_ids: z.array(z.string().min(1)), + action_types: z.array( + z.enum([ + "send_input", + "resize", + "close_stdin", + "send_signal", + "checkpoint", + ]), + ), }; const processReactiveTransitionSchema = z.union([ @@ -67,9 +59,7 @@ const processReactiveRunShape = { }), ) .default([]), - transitions: z - .array(processReactiveTransitionSchema) - .max(PROCESS_REACTIVE_LIMITS.transitions), + transitions: z.array(processReactiveTransitionSchema), }; /** Serialized reactive reducer state admitted by process capture. */ diff --git a/src/domain/processReactiveMatching.ts b/src/domain/processReactiveMatching.ts index b0fc3688a..dea1e7ef4 100644 --- a/src/domain/processReactiveMatching.ts +++ b/src/domain/processReactiveMatching.ts @@ -2,49 +2,31 @@ import canonicalize from "canonicalize"; import type { ProcessObservation } from "./processObservation.js"; import { comparableProcessObservationPayload } from "./processObservation.js"; -import { - PROCESS_REACTIVE_LIMITS, - type ProcessReactiveFrontier, - type ProcessReactiveTrigger, +import type { + ProcessReactiveFrontier, + ProcessReactiveTrigger, } from "./processReactiveScenario.js"; import type { ProcessReactiveSnapshot } from "./processReactiveRuntime.js"; -/** Internal bounded trigger-match result used by the pure reducer. */ +/** Trigger match result used by the pure reducer. */ export type ProcessReactiveTriggerMatch = { readonly eventIds: readonly string[]; readonly consumeIds: readonly string[]; readonly lastOrder: number; -} & ( - | { readonly matched: true; readonly overflow: false } - | { readonly matched: false; readonly overflow: boolean } -); - -interface EvaluationBudget { - remaining: number; -} + readonly matched: boolean; +}; interface TriggerMatchContext { readonly snapshot: ProcessReactiveSnapshot; - readonly budget: EvaluationBudget; readonly afterOrder: number; readonly upperOrder: number | null; } -const spendEvaluationWork = ( - budget: EvaluationBudget, - units: number, -): boolean => { - if (units > budget.remaining) return false; - budget.remaining -= units; - return true; -}; - -const noMatch = (overflow = false): ProcessReactiveTriggerMatch => ({ - matched: false, +const noMatch = (): ProcessReactiveTriggerMatch => ({ eventIds: [], consumeIds: [], lastOrder: -1, - overflow, + matched: false, }); const frontierOrder = ( @@ -102,7 +84,6 @@ const matchEvent = ( ); const matches: ProcessObservation[] = []; for (const observation of eligibleObservations(trigger.since, context)) { - if (!spendEvaluationWork(context.budget, 1)) return noMatch(true); if ( observation.source === trigger.source && canonicalJson( @@ -121,11 +102,10 @@ const matchEvent = ( return noMatch(); const eventIds = matches.map(({ event_id }) => event_id); return { - matched: true, eventIds, consumeIds: trigger.consume ? eventIds : [], lastOrder: matches.at(-1)?.capture_order ?? context.afterOrder, - overflow: false, + matched: true, }; }; @@ -150,7 +130,6 @@ const matchTerminalText = ( ): ProcessReactiveTriggerMatch => { const chunks: { observation: ProcessObservation; text: string }[] = []; for (const observation of eligibleObservations(trigger.since, context)) { - if (!spendEvaluationWork(context.budget, 1)) return noMatch(true); if ( observation.source === "terminal_raw" && typeof observation.payload === "object" && @@ -160,18 +139,6 @@ const matchTerminalText = ( ) chunks.push({ observation, text: observation.payload["data"] }); } - let rawBytes = 0; - for (const { text } of chunks) { - if (text.length > PROCESS_REACTIVE_LIMITS.terminalMatchBytes) - return noMatch(true); - const bytes = Buffer.byteLength(text); - rawBytes += bytes; - if ( - rawBytes > PROCESS_REACTIVE_LIMITS.terminalMatchBytes || - !spendEvaluationWork(context.budget, bytes) - ) - return noMatch(true); - } const range = occurrenceRange( chunks.map(({ text }) => text).join(""), trigger.literal, @@ -194,29 +161,32 @@ const matchTerminalText = ( .map(({ observation }) => observation); const eventIds = matched.map(({ event_id }) => event_id); return { - matched: true, eventIds, consumeIds: trigger.consume ? eventIds : [], lastOrder: matched.at(-1)?.capture_order ?? context.afterOrder, - overflow: false, + matched: true, }; }; const mergeMatches = ( matches: readonly ProcessReactiveTriggerMatch[], ): ProcessReactiveTriggerMatch => { - const base = { - eventIds: [...new Set(matches.flatMap(({ eventIds }) => eventIds))], - consumeIds: [...new Set(matches.flatMap(({ consumeIds }) => consumeIds))], - lastOrder: Math.max(-1, ...matches.map(({ lastOrder }) => lastOrder)), + const eventIds = new Set(); + const consumeIds = new Set(); + let lastOrder = -1; + let matched = true; + for (const match of matches) { + for (const eventId of match.eventIds) eventIds.add(eventId); + for (const eventId of match.consumeIds) consumeIds.add(eventId); + lastOrder = Math.max(lastOrder, match.lastOrder); + matched &&= match.matched; + } + return { + eventIds: [...eventIds], + consumeIds: [...consumeIds], + lastOrder, + matched, }; - return matches.every(({ matched }) => matched) - ? { ...base, matched: true, overflow: false } - : { - ...base, - matched: false, - overflow: matches.some(({ overflow }) => overflow), - }; }; const matchEarliestPrefix = ( @@ -234,7 +204,7 @@ const matchEarliestPrefix = ( ...context, upperOrder: observation.capture_order, }); - if (matched.matched || matched.overflow) return matched; + if (matched.matched) return matched; } return noMatch(); }; @@ -255,9 +225,7 @@ const matchTrigger = ( matchTrigger(child, context), ); const matches = evaluated.filter(({ matched }) => matched); - return matches.length === 0 - ? noMatch(evaluated.some(({ overflow }) => overflow)) - : mergeMatches(matches); + return matches.length === 0 ? noMatch() : mergeMatches(matches); } if (trigger.kind === "sequence") { const matches: ProcessReactiveTriggerMatch[] = []; @@ -267,7 +235,7 @@ const matchTrigger = ( ...context, afterOrder: cursor, }); - if (!matched.matched) return noMatch(matched.overflow); + if (!matched.matched) return noMatch(); matches.push(matched); cursor = matched.lastOrder; } @@ -289,22 +257,19 @@ const matchTrigger = ( ...context, afterOrder: cursor, }); - if (excess.matched || excess.overflow) return noMatch(excess.overflow); + if (excess.matched) return noMatch(); } - if (matches.length < trigger.min) - return noMatch(matches.some(({ overflow }) => overflow)); + if (matches.length < trigger.min) return noMatch(); return mergeMatches(matches); }; -/** Match one trigger using a shared deterministic work budget. */ +/** Match one trigger against every eligible retained observation. */ export const matchProcessReactiveTrigger = ( trigger: ProcessReactiveTrigger, snapshot: ProcessReactiveSnapshot, - budget: EvaluationBudget, ): ProcessReactiveTriggerMatch => matchTrigger(trigger, { snapshot, - budget, afterOrder: -1, upperOrder: null, }); diff --git a/src/domain/processReactiveRuntime.ts b/src/domain/processReactiveRuntime.ts index ce8993b3e..4e122ef68 100644 --- a/src/domain/processReactiveRuntime.ts +++ b/src/domain/processReactiveRuntime.ts @@ -1,7 +1,6 @@ import type { ProcessObservation } from "./processObservation.js"; import { matchProcessReactiveTrigger } from "./processReactiveMatching.js"; import { - PROCESS_REACTIVE_LIMITS, type ProcessReactiveAction, type ProcessReactiveScenario, } from "./processReactiveScenario.js"; @@ -150,10 +149,7 @@ const observeInput = ( ); if (duplicate !== undefined) return { kind: "invalid" }; const prior = snapshot.observations.at(-1); - if ( - (prior !== undefined && observation.capture_order <= prior.capture_order) || - snapshot.observations.length >= PROCESS_REACTIVE_LIMITS.retainedObservations - ) + if (prior !== undefined && observation.capture_order <= prior.capture_order) return { kind: "invalid" }; return { kind: "new", @@ -228,14 +224,11 @@ export const reduceProcessReactiveScenario = ( const observed = admitted.snapshot; const state = scenario.states.find(({ id }) => id === observed.active_state); if (state === undefined) return finishedDecision(observed, "target_lost"); - const budget = { remaining: PROCESS_REACTIVE_LIMITS.evaluationWork }; const matches = state.on.map((transition) => ({ transition, - match: matchProcessReactiveTrigger(transition.when, observed, budget), + match: matchProcessReactiveTrigger(transition.when, observed), uses: counterValue(observed.transition_uses, transition.id), })); - if (matches.some(({ match }) => match.overflow)) - return finishedDecision(observed, "capture_incomplete"); const evaluations: ProcessReactiveEvaluation[] = matches.map( ({ transition, match, uses }) => ({ transition_id: transition.id, @@ -254,8 +247,9 @@ export const reduceProcessReactiveScenario = ( ); if (eligible.length === 0) return { kind: "waiting", snapshot: observed, evaluations }; - const minimumPriority = Math.min( - ...eligible.map(({ transition }) => transition.priority), + const minimumPriority = eligible.reduce( + (minimum, { transition }) => Math.min(minimum, transition.priority), + Number.POSITIVE_INFINITY, ); const winners = eligible.filter( ({ transition }) => transition.priority === minimumPriority, diff --git a/src/domain/processReactiveRuntimePart3.test.ts b/src/domain/processReactiveRuntimePart3.test.ts index f8b603894..21ad3423d 100644 --- a/src/domain/processReactiveRuntimePart3.test.ts +++ b/src/domain/processReactiveRuntimePart3.test.ts @@ -10,7 +10,6 @@ import { reduceProcessReactiveScenario, } from "./processReactiveRuntime.js"; import { - PROCESS_REACTIVE_LIMITS, processReactiveScenarioSchema, type ProcessReactiveAction, type ProcessReactiveScenario, @@ -154,55 +153,35 @@ const offer = ( }; describe("process reactive runtime lifecycle", () => { - it("preserves overflow when no any branch matches", () => { - const scenario = scenarioWith([ - finish("bounded_any", { - kind: "any", - triggers: [ - terminalTrigger("never"), - { - kind: "event", - source: "shim", - exact: { name: "also-never" }, - ignore_fields: [], - since: { kind: "scenario_start" }, - consume: false, - cardinality: { min: 1, max: 1 }, - }, - ], - }), - ]); + it("matches terminal text after the previous retained-data ceiling", () => { + const scenario = scenarioWith([finish("large_output", terminalTrigger())]); const decision = reduceProcessReactiveScenario( scenario, createProcessReactiveSnapshot(scenario), { kind: "observation", observation: observation("terminal_raw", 0, { - data: "x".repeat(PROCESS_REACTIVE_LIMITS.evaluationWork + 1), + data: `${"x".repeat(70_000)}Ready`, }), }, ); - expect(decision).toMatchObject({ - kind: "finished", - outcome: "capture_incomplete", - }); + expect(decision.kind).toBe("proposal"); }); - it("fails closed at the deterministic per-reduction work budget", () => { - const transitions = Array.from( - { length: PROCESS_REACTIVE_LIMITS.predicates }, - (_, index) => - finish(`event_${String(index)}`, { - kind: "event", - source: "shim", - exact: { name: `expected_${String(index)}` }, - ignore_fields: ["sequence"], - since: { kind: "scenario_start" }, - consume: false, - cardinality: { min: 1, max: 1 }, - }), + + it("evaluates all configured predicates against all retained observations", () => { + const transitions = Array.from({ length: 80 }, (_, index) => + finish(`event_${String(index)}`, { + kind: "event", + source: "shim", + exact: { name: `expected_${String(index)}` }, + ignore_fields: ["sequence"], + since: { kind: "scenario_start" }, + consume: false, + cardinality: { min: 1, max: 1 }, + }), ); const scenario = scenarioWith(transitions); - const retained = Array.from({ length: 128 }, (_, order) => + const retained = Array.from({ length: 130 }, (_, order) => observation("shim", order, { name: "other", sequence: order }), ); const snapshot = { @@ -217,9 +196,8 @@ describe("process reactive runtime lifecycle", () => { sequence: retained.length, }), ); - expect(decision).toMatchObject({ - kind: "finished", - outcome: "capture_incomplete", - }); + expect(decision).toMatchObject({ kind: "waiting" }); + if (decision.kind === "waiting") + expect(decision.snapshot.observations).toHaveLength(131); }); }); diff --git a/src/domain/processReactiveScenario.test.ts b/src/domain/processReactiveScenario.test.ts index 9a4e20772..b268c898f 100644 --- a/src/domain/processReactiveScenario.test.ts +++ b/src/domain/processReactiveScenario.test.ts @@ -40,11 +40,66 @@ const baseScenario = () => ({ }); describe("process reactive scenario schema", () => { - it("parses a bounded terminal scenario", () => { + it("parses a terminal scenario", () => { expect(processReactiveScenarioSchema.parse(baseScenario())).toMatchObject({ initial_state: "starting", }); - expect(PROCESS_REACTIVE_LIMITS.triggerDepth).toBe(6); + expect(PROCESS_REACTIVE_LIMITS.triggerDepth).toBe(256); + }); + + it("accepts caller-defined scenario sizes without count ceilings", () => { + const states = Array.from({ length: 40 }, (_, index) => ({ + id: `state_${String(index)}`, + max_visits: 300, + deadline_ms: 30_000, + on: [] as unknown[], + })); + const manyPredicates = { + kind: "repeat", + trigger: { + kind: "all", + triggers: Array.from({ length: 20 }, () => terminalTrigger()), + }, + min: 1, + max: 1_000, + }; + const sendInputs = Array.from({ length: 300 }, () => ({ + type: "send_input", + data: "x", + sensitive: false, + })); + const transitions = Array.from({ length: 130 }, (_, index) => ({ + id: `transition_${String(index)}`, + priority: index, + max_uses: 300, + when: index === 0 ? manyPredicates : terminalTrigger(), + actions: index === 0 ? sendInputs : [], + target: + index > 0 && index <= 39 + ? { kind: "goto", state: `state_${String(index)}` } + : { kind: "finish", outcome: "passed" }, + })); + states[0]!.on = transitions; + for (let index = 1; index < states.length; index += 1) { + const state = states[index]!; + state.on = [ + { + ...finishTransition(), + id: `finish_${String(index)}`, + max_uses: 300, + actions: [{ type: "checkpoint", name: `ready_${String(index)}` }], + }, + ]; + } + const parsed = processReactiveScenarioSchema.safeParse({ + initial_state: "state_0", + deadline_ms: 30_000, + states, + }); + expect( + parsed.success, + parsed.success ? "" : JSON.stringify(parsed.error.issues), + ).toBe(true); }); it.each([ diff --git a/src/domain/processReactiveScenario.ts b/src/domain/processReactiveScenario.ts index 7fd07a793..9004e4624 100644 --- a/src/domain/processReactiveScenario.ts +++ b/src/domain/processReactiveScenario.ts @@ -5,35 +5,18 @@ import { processObservationSourceSchema } from "./processObservation.js"; import { definitelyAvailableCheckpoints } from "./processReactiveCheckpointDataflow.js"; import { preflightProcessReactiveScenario } from "./processReactiveScenarioPreflight.js"; -/** Fixed public bounds for process reactive scenarios. */ +/** Structural depth guard for recursive Zod parsing. */ export const PROCESS_REACTIVE_LIMITS = { - states: 32, - transitions: 128, - triggerDepth: 6, - predicates: 64, - actions: 256, - actionsPerTransition: 16, - childrenPerComposite: 16, - stateVisits: 256, - transitionUses: 256, - repeat: 64, - retainedObservations: 256, - terminalMatchBytes: 65_536, - evaluationWork: 8_192, - jsonDepth: 20, - jsonNodes: 1_024, - runtimeMs: 300_000, + // Recursive Zod schemas need a practical stack guard, not a small tree quota. + triggerDepth: 256, + jsonDepth: 256, } as const; const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,63}$/u); const checkpointNameSchema = z .string() .regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u); -const positiveRuntime = z - .number() - .int() - .positive() - .max(PROCESS_REACTIVE_LIMITS.runtimeMs); +const positiveRuntime = z.number().int().safe().positive(); const frontierSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("scenario_start") }), @@ -118,8 +101,8 @@ const processReactiveTriggerSchema: z.ZodType = z.lazy( consume: z.boolean(), cardinality: z .strictObject({ - min: z.number().int().positive().max(256), - max: z.number().int().positive().max(256), + min: z.number().int().safe().positive(), + max: z.number().int().safe().positive(), }) .refine(({ min, max }) => min <= max, { message: "cardinality min must not exceed max", @@ -132,37 +115,28 @@ const processReactiveTriggerSchema: z.ZodType = z.lazy( literal: z.string().min(1).max(10_000), case_sensitive: z.literal(true), control_sequences: z.literal("include"), - occurrence: z.number().int().positive().max(1_000), + occurrence: z.number().int().safe().positive(), since: frontierSchema, consume: z.boolean(), }), z.strictObject({ kind: z.literal("all"), - triggers: z - .array(processReactiveTriggerSchema) - .min(2) - .max(PROCESS_REACTIVE_LIMITS.childrenPerComposite), + triggers: z.array(processReactiveTriggerSchema).min(2), }), z.strictObject({ kind: z.literal("any"), - triggers: z - .array(processReactiveTriggerSchema) - .min(2) - .max(PROCESS_REACTIVE_LIMITS.childrenPerComposite), + triggers: z.array(processReactiveTriggerSchema).min(2), }), z.strictObject({ kind: z.literal("sequence"), - triggers: z - .array(processReactiveTriggerSchema) - .min(1) - .max(PROCESS_REACTIVE_LIMITS.childrenPerComposite), + triggers: z.array(processReactiveTriggerSchema).min(1), }), z .strictObject({ kind: z.literal("repeat"), trigger: processReactiveTriggerSchema, - min: z.number().int().positive().max(PROCESS_REACTIVE_LIMITS.repeat), - max: z.number().int().positive().max(PROCESS_REACTIVE_LIMITS.repeat), + min: z.number().int().safe().positive(), + max: z.number().int().safe().positive(), }) .refine(({ min, max }) => min <= max, { message: "repeat min must not exceed max", @@ -207,34 +181,24 @@ const transitionTargetSchema = z.discriminatedUnion("kind", [ const transitionSchema = z.strictObject({ id: identifierSchema, - priority: z.number().int().min(0).max(1_000), - max_uses: z - .number() - .int() - .positive() - .max(PROCESS_REACTIVE_LIMITS.transitionUses), + priority: z.number().int().safe().min(0), + max_uses: z.number().int().safe().positive(), when: processReactiveTriggerSchema, - actions: z - .array(processReactiveActionSchema) - .max(PROCESS_REACTIVE_LIMITS.actionsPerTransition), + actions: z.array(processReactiveActionSchema), target: transitionTargetSchema, }); const stateSchema = z.strictObject({ id: identifierSchema, - max_visits: z - .number() - .int() - .positive() - .max(PROCESS_REACTIVE_LIMITS.stateVisits), + max_visits: z.number().int().safe().positive(), deadline_ms: positiveRuntime, - on: z.array(transitionSchema).min(1).max(128), + on: z.array(transitionSchema).min(1), }); const scenarioShapeSchema = z.strictObject({ initial_state: identifierSchema, deadline_ms: positiveRuntime, - states: z.array(stateSchema).min(1).max(PROCESS_REACTIVE_LIMITS.states), + states: z.array(stateSchema).min(1), }); /** Parsed process reactive scenario declaration. */ @@ -242,23 +206,19 @@ export type ProcessReactiveScenario = z.infer; type TriggerMeasurements = { readonly depth: number; - readonly predicates: number; }; const measureTrigger = ( trigger: ProcessReactiveTrigger, ): TriggerMeasurements => { if (trigger.kind === "event" || trigger.kind === "terminal_text") - return { depth: 1, predicates: 1 }; + return { depth: 1 }; const children = trigger.kind === "repeat" ? [trigger.trigger] : trigger.triggers; const measured = children.map(measureTrigger); return { - depth: 1 + Math.max(...measured.map(({ depth }) => depth)), - predicates: measured.reduce( - (total, { predicates }) => total + predicates, - 0, - ), + depth: + 1 + measured.reduce((maximum, { depth }) => Math.max(maximum, depth), 0), }; }; @@ -320,9 +280,6 @@ type GraphValidation = { readonly path: PropertyKey[]; }>; readonly adjacency: ReadonlyMap>; - transitions: number; - predicates: number; - actions: number; }; type ReactiveTransition = @@ -336,10 +293,7 @@ const validateTransition = (input: { readonly context: z.RefinementCtx; }): void => { const { transition, validation, path, context } = input; - validation.transitions += 1; - validation.actions += transition.actions.length; const measured = measureTrigger(transition.when); - validation.predicates += measured.predicates; validateTriggerSemantics(transition.when, [...path, "when"], context); if (measured.depth > PROCESS_REACTIVE_LIMITS.triggerDepth) context.addIssue({ @@ -387,25 +341,6 @@ const validateTotalsAndReferences = ( validation: GraphValidation, context: z.RefinementCtx, ): void => { - for (const [count, limit, message] of [ - [ - validation.transitions, - PROCESS_REACTIVE_LIMITS.transitions, - "scenario has too many transitions", - ], - [ - validation.predicates, - PROCESS_REACTIVE_LIMITS.predicates, - "scenario has too many predicates", - ], - [ - validation.actions, - PROCESS_REACTIVE_LIMITS.actions, - "scenario has too many actions", - ], - ] as const) - if (count > limit) - context.addIssue({ code: "custom", message, path: ["states"] }); const available = definitelyAvailableCheckpoints(scenario); for (const reference of validation.checkpointReferences) if (!available.get(reference.stateId)?.has(reference.name)) @@ -466,9 +401,6 @@ const validateScenarioGraph = ( checkpoints: new Set(), checkpointReferences: [], adjacency: new Map(stateIds.map((id) => [id, new Set()])), - transitions: 0, - predicates: 0, - actions: 0, }; for (const [stateIndex, state] of scenario.states.entries()) { if (state.deadline_ms > scenario.deadline_ms) diff --git a/src/domain/processReactiveScenarioPreflight.ts b/src/domain/processReactiveScenarioPreflight.ts index 0c3526ad5..a1b736766 100644 --- a/src/domain/processReactiveScenarioPreflight.ts +++ b/src/domain/processReactiveScenarioPreflight.ts @@ -1,13 +1,8 @@ import { z } from "zod"; interface ReactivePreflightLimits { - readonly states: number; - readonly transitions: number; - readonly predicates: number; readonly triggerDepth: number; - readonly childrenPerComposite: number; readonly jsonDepth: number; - readonly jsonNodes: number; } type PendingTrigger = { readonly value: unknown; readonly depth: number }; @@ -33,16 +28,9 @@ const inspectJsonValue = ( ): boolean => { const pending: PendingTrigger[] = [{ value, depth: 1 }]; const seen = new WeakSet(); - let nodes = 0; while (pending.length > 0) { const current = pending.pop(); if (current === undefined) break; - nodes += 1; - if (nodes > limits.jsonNodes) - return reject( - context, - "event exact JSON exceeds the configured node limit", - ); if (current.depth > limits.jsonDepth) return reject( context, @@ -53,11 +41,6 @@ const inspectJsonValue = ( return reject(context, "event exact JSON must not contain cycles"); seen.add(current.value); if (Array.isArray(current.value)) { - if (current.value.length + nodes > limits.jsonNodes) - return reject( - context, - "event exact JSON exceeds the configured node limit", - ); for (const child of current.value) pending.push({ value: child, depth: current.depth + 1 }); continue; @@ -65,11 +48,6 @@ const inspectJsonValue = ( if (!isUnknownRecord(current.value)) continue; for (const key in current.value) { if (!Object.hasOwn(current.value, key)) continue; - if (nodes + pending.length >= limits.jsonNodes) - return reject( - context, - "event exact JSON exceeds the configured node limit", - ); pending.push({ value: current.value[key], depth: current.depth + 1, @@ -79,21 +57,11 @@ const inspectJsonValue = ( return true; }; -const collectTriggerRoots = ( - states: readonly unknown[], - context: z.RefinementCtx, - limits: ReactivePreflightLimits, -): PendingTrigger[] | null => { +const collectTriggerRoots = (states: readonly unknown[]): PendingTrigger[] => { const pending: PendingTrigger[] = []; - let transitions = 0; for (const stateValue of states) { if (!isUnknownRecord(stateValue) || !Array.isArray(stateValue["on"])) continue; - transitions += stateValue["on"].length; - if (transitions > limits.transitions) { - reject(context, "scenario has too many transitions"); - return null; - } for (const transitionValue of stateValue["on"]) if (isUnknownRecord(transitionValue)) pending.push({ value: transitionValue["when"], depth: 1 }); @@ -118,8 +86,6 @@ const enqueueTriggerChildren = ( return true; const children = trigger["triggers"]; if (!Array.isArray(children)) return true; - if (children.length > traversal.limits.childrenPerComposite) - return reject(traversal.context, "trigger group has too many children"); for (const child of children) traversal.pending.push({ value: child, depth: depth + 1 }); return true; @@ -130,14 +96,9 @@ const inspectTriggerTree = ( context: z.RefinementCtx, limits: ReactivePreflightLimits, ): boolean => { - let nodes = 0; - const maximumNodes = limits.predicates * limits.triggerDepth; while (pending.length > 0) { const current = pending.pop(); if (current === undefined) break; - nodes += 1; - if (nodes > maximumNodes) - return reject(context, "trigger tree exceeds the configured limit"); if (current.depth > limits.triggerDepth) return reject(context, "trigger nesting exceeds the configured limit"); if ( @@ -166,12 +127,7 @@ export const preflightProcessReactiveScenario = ( limits: ReactivePreflightLimits, ): unknown => { if (!isUnknownRecord(input) || !Array.isArray(input["states"])) return input; - if (input["states"].length > limits.states) { - reject(context, "scenario has too many states"); - return z.NEVER; - } - const pending = collectTriggerRoots(input["states"], context, limits); - if (pending === null || !inspectTriggerTree(pending, context, limits)) - return z.NEVER; + const pending = collectTriggerRoots(input["states"]); + if (!inspectTriggerTree(pending, context, limits)) return z.NEVER; return input; }; diff --git a/src/domain/processScenario.test.ts b/src/domain/processScenario.test.ts index 61a3e8ddc..9a607f854 100644 --- a/src/domain/processScenario.test.ts +++ b/src/domain/processScenario.test.ts @@ -16,6 +16,23 @@ describe("process scenario collection inputs", () => { expect(scenario.checkpoints).toHaveLength(65); }); + it("lets callers raise the output byte budget without a tool-side ceiling", () => { + const scenario = processScenarioSchema.parse({ + executable: "/bin/echo", + working_directory: "/tmp", + limits: { output_bytes: 10_000_001 }, + }); + + expect(scenario.limits.output_bytes).toBe(10_000_001); + expect( + processScenarioSchema.safeParse({ + executable: "/bin/echo", + working_directory: "/tmp", + limits: { frames: 100_001 }, + }).success, + ).toBe(false); + }); + it("accepts large interaction and replay descriptions under the run budgets", () => { const environment = Object.fromEntries( Array.from({ length: 65 }, (_, index) => [`APP_VALUE_${index}`, "x"]), diff --git a/src/domain/processScenario.ts b/src/domain/processScenario.ts index 2fe011ccb..1dae696c8 100644 --- a/src/domain/processScenario.ts +++ b/src/domain/processScenario.ts @@ -207,8 +207,7 @@ export const processScenarioSchema = z settle_ms: z.number().int().nonnegative().max(10_000).default(100), limits: z .object({ - output_bytes: positiveBudget.max(10_000_000).default(1_000_000), - frames: positiveBudget.max(100_000).default(10_000), + output_bytes: positiveBudget.safe().default(1_000_000), files: positiveBudget.max(100_000).default(10_000), file_bytes: positiveBudget.max(100_000_000).default(10_000_000), processes: positiveBudget.max(10_000).default(1_000), @@ -217,9 +216,9 @@ export const processScenarioSchema = z connections: positiveBudget.max(1_000).default(100), filesystem_depth: z.number().int().min(1).max(64).default(16), }) + .strict() .default({ output_bytes: 1_000_000, - frames: 10_000, files: 10_000, file_bytes: 10_000_000, processes: 1_000, diff --git a/src/domain/replayMachine.ts b/src/domain/replayMachine.ts index 342b35f9e..f1fdf24f8 100644 --- a/src/domain/replayMachine.ts +++ b/src/domain/replayMachine.ts @@ -6,9 +6,9 @@ import { z } from "zod"; const replayIdentifierSchema = z .string() .regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u); -const replayPathSchema = z - .array(z.union([z.string().max(256), z.number().int().nonnegative()])) - .max(32); +const replayPathSchema = z.array( + z.union([z.string(), z.number().int().nonnegative()]), +); const sensitiveHeaderNames = new Set([ "authorization", "cookie", @@ -18,12 +18,8 @@ const sensitiveHeaderNames = new Set([ const headerNameSchema = z .string() .min(1) - .max(256) .regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u); -const headerValueSchema = z - .string() - .max(8_192) - .regex(/^[\t\x20-\x7e\x80-\xff]*$/u); +const headerValueSchema = z.string().regex(/^[\t\x20-\x7e\x80-\xff]*$/u); const replayHeaderMatchersSchema = z .record(headerNameSchema, headerValueSchema) .superRefine((headers, context) => { @@ -73,11 +69,10 @@ const replayTriggerSchema = z.discriminatedUnion("protocol", [ method: z .string() .min(1) - .max(16) .transform((method) => method.toUpperCase()), path: z.string().startsWith("/"), headers: replayHeaderMatchersSchema.default({}), - body: z.string().max(1_000_000).nullable().default(null), + body: z.string().nullable().default(null), }), z.strictObject({ protocol: z.literal("websocket_connect"), @@ -87,7 +82,7 @@ const replayTriggerSchema = z.discriminatedUnion("protocol", [ z.strictObject({ protocol: z.literal("websocket_message"), path: z.string().startsWith("/"), - body: z.string().max(1_000_000).nullable().default(null), + body: z.string().nullable().default(null), }), ]); @@ -96,16 +91,16 @@ const replayActionSchema = z.discriminatedUnion("type", [ type: z.literal("http_response"), status: z.number().int().min(100).max(599), headers: z.record(headerNameSchema, headerValueSchema).default({}), - body: z.string().max(1_000_000), + body: z.string(), }), z.strictObject({ type: z.literal("websocket_send"), - data: z.string().max(1_000_000), + data: z.string(), }), z.strictObject({ type: z.literal("disconnect") }), z.strictObject({ type: z.literal("delay"), - duration_ms: z.number().int().nonnegative().max(30_000), + duration_ms: z.number().int().nonnegative(), }), ]); @@ -113,7 +108,7 @@ const replayTransitionSchema = z.strictObject({ id: replayIdentifierSchema, from: replayIdentifierSchema, to: replayIdentifierSchema, - priority: z.number().int().min(0).max(1_000).default(100), + priority: z.number().int().min(0).default(100), trigger: replayTriggerSchema, guards: z .array( @@ -122,7 +117,6 @@ const replayTransitionSchema = z.strictObject({ value: replayValueSourceSchema, }), ) - .max(32) .default([]), captures: z .array( @@ -132,10 +126,9 @@ const replayTransitionSchema = z.strictObject({ sensitive: z.boolean().default(false), }), ) - .max(32) .default([]), - actions: z.array(replayActionSchema).min(1).max(32), - max_uses: z.number().int().min(1).max(10_000), + actions: z.array(replayActionSchema).min(1), + max_uses: z.number().int().min(1), }); const transitionSignature = ( @@ -158,19 +151,18 @@ const replayMachineBaseSchema = z.strictObject({ z.strictObject({ name: replayIdentifierSchema, terminal: z.boolean().default(false), - max_visits: z.number().int().min(1).max(100_000).default(100_000), + max_visits: z.number().int().min(1).default(100_000), }), ) - .min(1) - .max(256), - transitions: z.array(replayTransitionSchema).min(1).max(2_000), - max_transitions: z.number().int().min(1).max(100_000), + .min(1), + transitions: z.array(replayTransitionSchema).min(1), + max_transitions: z.number().int().min(1), limits: z .strictObject({ - connections: z.number().int().min(1).max(1_000).default(100), - messages: z.number().int().min(1).max(100_000).default(10_000), - bytes: z.number().int().min(1).max(10_000_000).default(1_000_000), - duration_ms: z.number().int().min(1).max(300_000).default(30_000), + connections: z.number().int().min(1).default(100), + messages: z.number().int().min(1).default(10_000), + bytes: z.number().int().min(1).default(1_000_000), + duration_ms: z.number().int().min(1).default(30_000), }) .default({ connections: 100, diff --git a/src/domain/replayMachineRun.test.ts b/src/domain/replayMachineRun.test.ts index d5927cf64..5b7fb25a1 100644 --- a/src/domain/replayMachineRun.test.ts +++ b/src/domain/replayMachineRun.test.ts @@ -335,7 +335,7 @@ describe("direct replay machine protocol and limits", () => { }); }); -describe("direct replay machine output bounds", () => { +describe("direct replay machine large inputs", () => { it("records large repeated actions once instead of amplifying each match", () => { const largeBody = "x".repeat(1_000_000); const input = parseMachineRun( @@ -374,7 +374,7 @@ describe("direct replay machine output bounds", () => { expect(JSON.stringify(result).length).toBeLessThan(1_100_000); }); - it("rejects aggregate action output above four MiB", () => { + it("retains actions above the former four MiB aggregate cap", () => { const largeBody = "x".repeat(1_000_000); const parsed = replayMachineRunInputSchema.safeParse({ machine: { @@ -395,31 +395,42 @@ describe("direct replay machine output bounds", () => { ], max_transitions: 1, }, - events: [], + events: [ + { + protocol: "websocket_connect", + connection: "initial", + at_ms: 0, + method: null, + path: "/socket", + headers: {}, + body: "", + }, + ], }); - expect(parsed.success).toBe(false); - if (!parsed.success) - expect(parsed.error.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: ["machine", "transitions"] }), - ]), - ); + expect(parsed.success).toBe(true); + if (parsed.success) { + const result = runReplayMachine(parsed.data); + expect(result.transition_actions[0]?.actions).toHaveLength(5); + expect(JSON.stringify(result).length).toBeGreaterThan(5 * 1_000_000); + } }); - it("bounds action-field and sensitive-redaction work structurally", () => { + it("accepts event counts and action fields beyond former caps", () => { const input = loginRun(); - const excessiveCaptures = replayMachineRunInputSchema.safeParse({ + const largeEventRun = replayMachineRunInputSchema.safeParse({ ...input, - events: Array.from({ length: 1_025 }, () => input.events[0]), + events: Array.from({ length: 10_001 }, (_, index) => + httpEvent("/missing", index), + ), }); - expect(excessiveCaptures.success).toBe(false); - if (!excessiveCaptures.success) - expect(excessiveCaptures.error.issues).toEqual( - expect.arrayContaining([expect.objectContaining({ path: ["events"] })]), + expect(largeEventRun.success).toBe(true); + if (largeEventRun.success) + expect(runReplayMachine(largeEventRun.data).decisions).toHaveLength( + 10_001, ); - const excessiveFields = replayMachineRunInputSchema.safeParse({ + const largeHeaderTable = replayMachineRunInputSchema.safeParse({ machine: { initial_state: "waiting", states: [{ name: "waiting" }, { name: "done", terminal: true }], @@ -447,14 +458,25 @@ describe("direct replay machine output bounds", () => { ], max_transitions: 1, }, - events: [], + events: [ + { + protocol: "http", + connection: "not_applicable", + at_ms: 0, + method: "GET", + path: "/done", + headers: {}, + body: "", + }, + ], }); - expect(excessiveFields.success).toBe(false); - if (!excessiveFields.success) - expect(excessiveFields.error.issues).toEqual( - expect.arrayContaining([ - expect.objectContaining({ path: ["machine", "transitions"] }), - ]), - ); + expect(largeHeaderTable.success).toBe(true); + if (largeHeaderTable.success) { + const result = runReplayMachine(largeHeaderTable.data); + const action = result.transition_actions[0]?.actions[0]; + expect(action?.type).toBe("http_response"); + if (action?.type === "http_response") + expect(Object.keys(action.headers)).toHaveLength(1_024); + } }); }); diff --git a/src/domain/replayMachineRun.ts b/src/domain/replayMachineRun.ts index c631eea4a..623fc9d40 100644 --- a/src/domain/replayMachineRun.ts +++ b/src/domain/replayMachineRun.ts @@ -16,74 +16,11 @@ const refusalOutcomeSchema = z.enum([ "limit_exhausted", ]); -const MAX_DIRECT_REPLAY_EVENTS = 10_000; -const MAX_DIRECT_REPLAY_ACTION_BYTES = 4 * 1_024 * 1_024; -const MAX_DIRECT_REPLAY_ACTION_FIELDS = 1_024; -const MAX_DIRECT_REPLAY_SENSITIVE_CAPTURES = 1_024; - -const actionFieldCount = (machine: ReplayMachine): number => - machine.transitions.reduce( - (total, transition) => - total + - transition.actions.reduce( - (fields, action) => - fields + - (action.type === "http_response" - ? Object.keys(action.headers).length + 1 - : action.type === "websocket_send" - ? 1 - : 0), - 0, - ), - 0, - ); - -const maximumSensitiveCapturesPerEvent = (machine: ReplayMachine): number => - Math.max( - 0, - ...machine.transitions.map( - ({ captures }) => captures.filter(({ sensitive }) => sensitive).length, - ), - ); - /** Complete declarative input for one direct replay-machine run. */ -export const replayMachineRunInputSchema = z - .strictObject({ - machine: replayMachineSchema, - events: z.array(replayMachineEventSchema).max(MAX_DIRECT_REPLAY_EVENTS), - }) - .superRefine(({ machine, events }, context) => { - const actionBytes = Buffer.byteLength( - JSON.stringify( - machine.transitions.map(({ id, actions }) => ({ id, actions })), - ), - ); - if (actionBytes > MAX_DIRECT_REPLAY_ACTION_BYTES) - context.addIssue({ - code: "custom", - path: ["machine", "transitions"], - message: "direct replay actions exceed the 4 MiB output boundary", - input: machine.transitions, - }); - if (actionFieldCount(machine) > MAX_DIRECT_REPLAY_ACTION_FIELDS) - context.addIssue({ - code: "custom", - path: ["machine", "transitions"], - message: "direct replay actions exceed the 1,024-field output boundary", - input: machine.transitions, - }); - if ( - maximumSensitiveCapturesPerEvent(machine) * events.length > - MAX_DIRECT_REPLAY_SENSITIVE_CAPTURES - ) - context.addIssue({ - code: "custom", - path: ["events"], - message: - "direct replay events exceed the 1,024 sensitive-capture operation boundary", - input: events, - }); - }); +export const replayMachineRunInputSchema = z.strictObject({ + machine: replayMachineSchema, + events: z.array(replayMachineEventSchema), +}); const replayMachineRunDecisionSchema = z.strictObject({ event_sequence: z.number().int().nonnegative(), @@ -99,20 +36,19 @@ const replayRunActionSchema = z.discriminatedUnion("type", [ z .string() .min(1) - .max(256) .regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u), - z.string().max(8_192), + z.string(), ), - body: z.string().max(1_000_000), + body: z.string(), }), z.strictObject({ type: z.literal("websocket_send"), - data: z.string().max(1_000_000), + data: z.string(), }), z.strictObject({ type: z.literal("disconnect") }), z.strictObject({ type: z.literal("delay"), - duration_ms: z.number().int().nonnegative().max(30_000), + duration_ms: z.number().int().nonnegative(), }), ]); @@ -126,14 +62,12 @@ const replayMachineRunJournalEntrySchema = z.strictObject({ transition_id: replayRunIdentifierSchema, state_before: replayRunIdentifierSchema, state_after: replayRunIdentifierSchema, - captured_aliases: z - .array( - z.strictObject({ - name: replayRunIdentifierSchema, - sensitive: z.boolean(), - }), - ) - .max(32), + captured_aliases: z.array( + z.strictObject({ + name: replayRunIdentifierSchema, + sensitive: z.boolean(), + }), + ), }); /** Capture-value-free result of directly evaluating a finite replay machine. */ @@ -141,20 +75,14 @@ export const replayMachineRunOutputSchema = z.strictObject({ initial_state: replayRunIdentifierSchema, final_state: replayRunIdentifierSchema, terminal: z.boolean(), - decisions: z - .array(replayMachineRunDecisionSchema) - .max(MAX_DIRECT_REPLAY_EVENTS), - transition_journal: z - .array(replayMachineRunJournalEntrySchema) - .max(MAX_DIRECT_REPLAY_EVENTS), - transition_actions: z - .array( - z.strictObject({ - transition_id: replayRunIdentifierSchema, - actions: z.array(replayRunActionSchema).max(32), - }), - ) - .max(2_000), + decisions: z.array(replayMachineRunDecisionSchema), + transition_journal: z.array(replayMachineRunJournalEntrySchema), + transition_actions: z.array( + z.strictObject({ + transition_id: replayRunIdentifierSchema, + actions: z.array(replayRunActionSchema), + }), + ), limits: z.strictObject({ configured: z.strictObject({ transitions: z.number().int().positive(), @@ -214,6 +142,9 @@ export const runReplayMachine = ( const decisions: ReplayMachineRunOutput["decisions"] = []; const transitionJournal: ReplayMachineRunOutput["transition_journal"] = []; const usedTransitionIds = new Set(); + const transitionById = new Map( + input.machine.transitions.map((transition) => [transition.id, transition]), + ); for (const [eventSequence, event] of input.events.entries()) { const decision = runtime.dispatch(event); @@ -224,9 +155,7 @@ export const runReplayMachine = ( }); if (decision.outcome !== "matched") continue; usedTransitionIds.add(decision.transition.transition_id); - const definition = input.machine.transitions.find( - ({ id }) => id === decision.transition.transition_id, - ); + const definition = transitionById.get(decision.transition.transition_id); if (definition === undefined) throw new TypeError("Replay runtime selected an unknown transition"); transitionJournal.push({ diff --git a/src/domain/replayMachineRuntime.ts b/src/domain/replayMachineRuntime.ts index 825f5259b..aa3709cc9 100644 --- a/src/domain/replayMachineRuntime.ts +++ b/src/domain/replayMachineRuntime.ts @@ -1,9 +1,12 @@ -import canonicalize from "canonicalize"; import { z } from "zod"; import type { ReplayMachine } from "./replayMachine.js"; type ReplayTransition = ReplayMachine["transitions"][number]; +type IndexedReplayTransition = { + readonly transition: ReplayTransition; + readonly declarationOrder: number; +}; type ReplayValueSource = ReplayTransition["captures"][number]["value"]; interface TransitionRecordOptions { readonly sequence: number; @@ -22,12 +25,8 @@ const replayEventHeadersSchema = z z .string() .min(1) - .max(256) .regex(/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u), - z - .string() - .max(8_192) - .regex(/^[\t\x20-\x7e\x80-\xff]*$/u), + z.string().regex(/^[\t\x20-\x7e\x80-\xff]*$/u), ) .superRefine((headers, context) => { const normalized = Object.keys(headers).map((name) => name.toLowerCase()); @@ -41,9 +40,9 @@ const replayEventHeadersSchema = z const replayEventFields = { at_ms: z.number().int().safe().nonnegative(), - path: z.string().startsWith("/").max(8_192), + path: z.string().startsWith("/"), headers: replayEventHeadersSchema, - body: z.string().max(1_000_000), + body: z.string(), }; export const replayMachineEventSchema = z.discriminatedUnion("protocol", [ @@ -53,7 +52,6 @@ export const replayMachineEventSchema = z.discriminatedUnion("protocol", [ method: z .string() .min(1) - .max(16) .transform((method) => method.toUpperCase()), ...replayEventFields, }), @@ -162,6 +160,9 @@ const actionJsonBody = (transition: ReplayTransition): string | undefined => { return undefined; }; +const triggerKey = (transition: ReplayTransition): string => + `${transition.trigger.protocol}\0${transition.trigger.path}`; + const readValue = ( source: ReplayValueSource, event: ReplayMachineEvent, @@ -207,19 +208,87 @@ const triggerMatches = ( return true; }; -const isJsonCompatible = (value: unknown): boolean => { - if (value === null || typeof value === "string" || typeof value === "boolean") - return true; - if (typeof value === "number") return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonCompatible); - if (typeof value !== "object") return false; - return Object.values(value).every(isJsonCompatible); +const replayValuesEqual = (left: unknown, right: unknown): boolean => { + const pending: [unknown, unknown][] = [[left, right]]; + while (pending.length > 0) { + const pair = pending.pop(); + if (pair === undefined) continue; + const [currentLeft, currentRight] = pair; + if ( + currentLeft === null || + typeof currentLeft === "string" || + typeof currentLeft === "boolean" + ) { + if (currentLeft !== currentRight) return false; + continue; + } + if (typeof currentLeft === "number") { + if ( + !Number.isFinite(currentLeft) || + currentLeft !== currentRight || + typeof currentRight !== "number" || + !Number.isFinite(currentRight) + ) + return false; + continue; + } + if (Array.isArray(currentLeft)) { + if ( + !Array.isArray(currentRight) || + currentLeft.length !== currentRight.length + ) + return false; + for (let index = 0; index < currentLeft.length; index += 1) + pending.push([currentLeft[index], currentRight[index]]); + continue; + } + if ( + typeof currentLeft !== "object" || + currentLeft === null || + typeof currentRight !== "object" || + currentRight === null || + Array.isArray(currentRight) + ) + return false; + const leftKeys = Object.keys(currentLeft).sort(); + const rightKeys = Object.keys(currentRight).sort(); + if ( + leftKeys.length !== rightKeys.length || + leftKeys.some((key, index) => key !== rightKeys[index]) + ) + return false; + for (const key of leftKeys) + pending.push([ + Reflect.get(currentLeft, key), + Reflect.get(currentRight, key), + ]); + } + return true; }; -const replayValuesEqual = (left: unknown, right: unknown): boolean => - isJsonCompatible(left) && - isJsonCompatible(right) && - (Object.is(left, right) || canonicalize(left) === canonicalize(right)); +const isJsonCompatible = (value: unknown): boolean => { + const pending: unknown[] = [value]; + while (pending.length > 0) { + const candidate = pending.pop(); + if ( + candidate === null || + typeof candidate === "string" || + typeof candidate === "boolean" + ) + continue; + if (typeof candidate === "number") { + if (!Number.isFinite(candidate)) return false; + continue; + } + if (Array.isArray(candidate)) { + for (const item of candidate) pending.push(item); + continue; + } + if (typeof candidate !== "object") return false; + for (const item of Object.values(candidate)) pending.push(item); + } + return true; +}; const createTransitionRecord = ({ sequence, @@ -241,6 +310,10 @@ const createTransitionRecord = ({ /** Stateful evaluator for one validated replay-machine instance. */ export class ReplayMachineRuntime { + readonly #transitionsByTrigger = new Map< + string, + readonly IndexedReplayTransition[] + >(); readonly #variables = new Map(); readonly #sensitiveValues = new Map>(); readonly #uses = new Map(); @@ -256,6 +329,25 @@ export class ReplayMachineRuntime { constructor(readonly machine: ReplayMachine) { this.#state = machine.initial_state; this.#stateVisits.set(machine.initial_state, 1); + const byTrigger = new Map(); + for (const [ + declarationOrder, + transition, + ] of machine.transitions.entries()) { + const key = triggerKey(transition); + const indexed = byTrigger.get(key) ?? []; + indexed.push({ transition, declarationOrder }); + byTrigger.set(key, indexed); + } + for (const [key, indexed] of byTrigger) + this.#transitionsByTrigger.set( + key, + indexed.sort( + (left, right) => + left.transition.priority - right.transition.priority || + left.declarationOrder - right.declarationOrder, + ), + ); } /** Current machine state after all accepted transitions. */ @@ -321,19 +413,14 @@ export class ReplayMachineRuntime { }; if (!this.#admitEvent(normalizedEvent)) return this.#refusal("limit_exhausted"); - const matching = this.machine.transitions - .map((transition, declarationOrder) => ({ - transition, - declarationOrder, - })) - .filter(({ transition }) => triggerMatches(transition, normalizedEvent)); - const eligible = matching - .filter(({ transition }) => transition.from === this.#state) - .sort( - (left, right) => - left.transition.priority - right.transition.priority || - left.declarationOrder - right.declarationOrder, - ); + const matching = ( + this.#transitionsByTrigger.get( + `${normalizedEvent.protocol}\0${normalizedEvent.path}`, + ) ?? [] + ).filter(({ transition }) => triggerMatches(transition, normalizedEvent)); + const eligible = matching.filter( + ({ transition }) => transition.from === this.#state, + ); if (eligible.length === 0) return this.#refusal( matching.length === 0 diff --git a/src/domain/replayMachineUncapped.test.ts b/src/domain/replayMachineUncapped.test.ts new file mode 100644 index 000000000..fc8760198 --- /dev/null +++ b/src/domain/replayMachineUncapped.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { replayMachineSchema } from "./replayMachine.js"; + +const websocketActions = (count: number) => + Array.from({ length: count }, () => ({ + type: "websocket_send" as const, + data: "ok", + })); + +const websocketMachine = () => ({ + initial_state: "start", + states: [ + { name: "start" }, + { name: "active", max_visits: 100_001 }, + { name: "done", terminal: true }, + ], + transitions: [ + { + id: "begin", + from: "start", + to: "active", + trigger: { protocol: "websocket_connect", path: "/begin" }, + actions: websocketActions(1), + max_uses: 10_001, + }, + { + id: "finish", + from: "active", + to: "done", + trigger: { protocol: "websocket_message", path: "/finish" }, + actions: websocketActions(1), + max_uses: 10_001, + }, + ], + max_transitions: 100_001, + limits: { + connections: 1_001, + messages: 100_001, + bytes: 10_000_001, + duration_ms: 300_001, + }, +}); + +describe("finite replay machine schema input size", () => { + it("accepts states, transitions, guards, captures, actions, and paths beyond old caps", () => { + const states: { name: string; terminal?: boolean }[] = Array.from( + { length: 257 }, + (_, index) => ({ + name: `state-${index}`, + }), + ); + states.push({ name: "complete", terminal: true }); + const transitions = Array.from({ length: 257 }, (_, index) => ({ + id: `chain-${index}`, + from: `state-${index}`, + to: index === 256 ? "complete" : `state-${index + 1}`, + trigger: { protocol: "http", method: "GET", path: `/chain-${index}` }, + actions: [{ type: "http_response", status: 200, body: "" }], + max_uses: 10_001, + })); + transitions.push( + ...Array.from({ length: 1_745 }, (_, index) => ({ + id: `loop-${index}`, + from: "state-0", + to: "state-0", + trigger: { protocol: "http", method: "GET", path: `/loop-${index}` }, + actions: [{ type: "http_response", status: 200, body: "" }], + max_uses: 10_001, + })), + ); + + const parsed = replayMachineSchema.safeParse({ + initial_state: "state-0", + states, + transitions, + max_transitions: 100_001, + limits: { + connections: 1_001, + messages: 100_001, + bytes: 10_000_001, + duration_ms: 300_001, + }, + }); + + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.states).toHaveLength(258); + expect(parsed.data.transitions).toHaveLength(2_002); + } + }); + + it("accepts over 32 guards, captures, actions, and path segments", () => { + const path = Array.from({ length: 33 }, (_, index) => `key-${index}`); + const input = websocketMachine(); + const machine = { + ...input, + transitions: [ + { + id: "capture", + from: "start", + to: "active", + trigger: { protocol: "websocket_connect", path: "/begin" }, + captures: Array.from({ length: 33 }, () => ({ + variable: "value", + value: { source: "request_json", path }, + sensitive: false, + })), + actions: websocketActions(33), + max_uses: 10_001, + }, + { + id: "guarded-finish", + from: "active", + to: "done", + trigger: { protocol: "websocket_message", path: "/finish" }, + guards: Array.from({ length: 33 }, () => ({ + variable: "value", + value: { source: "request_json", path }, + })), + actions: websocketActions(33), + max_uses: 10_001, + }, + ], + }; + + expect(replayMachineSchema.safeParse(machine).success).toBe(true); + }); +}); diff --git a/tests/boundary/providers/replay/javascriptReplay.test.ts b/tests/boundary/providers/replay/javascriptReplay.test.ts index 7a9ee359a..9580cb46d 100644 --- a/tests/boundary/providers/replay/javascriptReplay.test.ts +++ b/tests/boundary/providers/replay/javascriptReplay.test.ts @@ -185,26 +185,12 @@ describe("controlled JavaScript replay planning", () => { ).toBe(true); }); - it("rejects relative paths and more than 128 combined cases", () => { + it("rejects relative module paths", () => { expect( controlledReplayInputSchema.safeParse({ ...input("plan", "relative/parser.mjs"), }).success, ).toBe(false); - expect( - controlledReplayInputSchema.safeParse({ - ...input("plan"), - cases: Array.from({ length: 65 }, (_, index) => ({ - case_id: `explicit-${String(index)}`, - arguments: [], - })), - generator: { - preset: "parser-boundaries", - seed: 1, - count: 64, - }, - }).success, - ).toBe(false); }); it("rejects oversized aggregate cases before probing or reading", async () => {