From 53a9b44158efdbb3d34f7d1719b2d8bb688d1df5 Mon Sep 17 00:00:00 2001 From: rotorsoft Date: Fri, 4 Sep 2026 13:50:18 -0400 Subject: [PATCH] fix(act): normalize omitted lanes in the dynamic lane-conflict report The correlate-side lane guard compared resolutions as spelled, so an omitted lane (undefined) never compared against a declared one and the disagreement the static guard throws on went unreported. Compare both sides by resolved lane name, as build-classify does since #1583. Normalizing alone would report every first sighting of a laned target against a "default" lane no record holds, so the report is gated on the prior resolution existing rather than on its lane being defined. The tie test now reads the held resolution's own priority: comparing against the accumulating entry made a priority upgrade tie with itself and report a conflict where the max() rule had already decided. Closes #1598 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015xAdM431gFeFBZTW5kRrjg --- book/1598-an-omitted-lane-is-a-lane-name.md | 15 ++ docs/docs/architecture/behavior-contracts.md | 1 + docs/docs/concepts/configuration.md | 4 +- .../all-packages-stability.spec.ts.snap | 72 ++++-- libs/act/src/internal/correlate-cycle.ts | 36 ++- libs/act/test/dynamic-lane-guard.spec.ts | 225 ++++++++++++++++++ 6 files changed, 312 insertions(+), 41 deletions(-) create mode 100644 book/1598-an-omitted-lane-is-a-lane-name.md diff --git a/book/1598-an-omitted-lane-is-a-lane-name.md b/book/1598-an-omitted-lane-is-a-lane-name.md new file mode 100644 index 0000000000..12cf7de746 --- /dev/null +++ b/book/1598-an-omitted-lane-is-a-lane-name.md @@ -0,0 +1,15 @@ +# An omitted lane is a lane name + +A build-time guard rejects two reactions that route to the same stream on different lanes, because a stream drains on exactly one lane and lanes have no ordering to merge across. That guard can only read what the declaration says out loud. A reaction whose target is a function is opaque until an event arrives, so correlate resolves it and applies the same rules at the only moment the answer exists. It cannot throw there, since an exception inside correlate stops the checkpoint for every stream in the app, so it logs instead. The log is the entire diagnostic. When it stays quiet, the operator has nothing. + +The quiet case was the one an operator was most likely to write. Two resolvers agreeing on a target and disagreeing on a lane were compared as they were spelled, and one of the two ways to spell the default lane is to say nothing at all. So a resolver that returned a bare target and one that asked for the slow lane compared as undefined against a string, the comparison was skipped for want of a second lane name, and nothing was reported. The static form of the identical pair had been taught the opposite lesson a few tickets earlier, when the build-time guard learned to normalize both sides before comparing so that an omitted lane and an explicit default would stop being reported as a conflict with each other. That fix normalized one side of a family and left its sibling reading raw values. + +What that costs is the whole reason the lane was named. First discovery wins, so the target lands on the default lane, and a worker started with `onlyLanes` set to the slow lane never claims it. The slow reaction does run, inside the default lane's lease and stream budget, on a process nobody sized for it. Every symptom points at the deployment: a lane provisioned and idle, work appearing on the wrong instance, handlers occasionally outliving a lease they were never measured against. The one line that would have named the cause is the line that did not print. + +Normalizing both operands is a one-line change and it opens a second hole immediately. If undefined becomes default on the held side, then a target nobody has ever resolved before, whose lane is undefined because there is no record of it at all, suddenly holds the default lane and disagrees with the first reaction that asks for anything else. Every first sighting of a laned target would report a conflict with a stream that does not exist yet. Absent and default look identical once you normalize, so the distinction has to be carried somewhere else: whether there is a record, not what the record says. That is why the report is gated on the existence of the prior resolution rather than on the definedness of its lane. + +Reading the two sides against each other turned up a third thing worth correcting in the same pass. The report is meant for ties, the case where neither resolution outranks the other and the winner is therefore arbitrary from the operator's chair. When priority decides, the outcome is the documented maximum rule and not a surprise. But the tie was being tested against the accumulating entry rather than against the thing the lane was actually held by, and for a resolution that beat the target's recorded priority the entry had already taken that resolution's own priority. The test compared a number with itself, passed, and reported a conflict for what was in fact a clean priority upgrade. Comparing against the recorded priority instead makes the predicate say what the comment always claimed it said. + +The rejected repair was to make the dynamic path throw like the static one, which reads as consistency and is the one thing this path may never do. The other was to correct the lane rather than report it, moving the stream onto whichever lane the newest resolution asked for. A live stream's lane is the one its in-flight leases were taken under; re-laning it mid-run moves it out from under a worker that is holding it. Re-laning stays restart-driven, and the report stays a report. + +The lesson is smaller than the machinery around it. A value with two spellings has one meaning, and every place that compares it has to agree on which spelling it compares in. Fixing one comparison and leaving its sibling raw is how a normalization bug survives its own fix, which is precisely what happened between the static guard and the dynamic one. diff --git a/docs/docs/architecture/behavior-contracts.md b/docs/docs/architecture/behavior-contracts.md index 47c18732e8..03194202b3 100644 --- a/docs/docs/architecture/behavior-contracts.md +++ b/docs/docs/architecture/behavior-contracts.md @@ -107,6 +107,7 @@ backed by unit/integration specs under `libs/act/test/`. | A dynamic resolution naming an **undeclared** lane is rerouted to `"default"` and reported once per offending declaration, rather than stranding the stream at watermark `-1` where no controller claims it and no health surface shows it. The build-time guard sees static lanes only; `TLanes` rejects both forms at compile time, so this backstop fires only when the types are bypassed | `configuration.md` § Conflicting lane assignments; CLAUDE.md "Lanes give intra-process responsiveness" | `dynamic-lane-guard.spec.ts` → "still runs the reaction instead of stranding the stream", "says so — a rerouted lane is not silent", "reports once, not once per matching event" **(#1564)** | | Both dynamic-lane reports are keyed on the **declaration** — handler name plus lane(s) — never on the resolved target, so one misdeclaration costs one report no matter how many aggregates its resolver mints targets for. Two distinct misdeclarations still report separately | `configuration.md` § Conflicting lane assignments; `report-once.ts` module doc | `dynamic-lane-guard.spec.ts` → describe("reporting once per declaration, not once per aggregate (#1584)"): "still one report when the same declaration reroutes 25 targets", "still one report when the same pair disagrees on 25 targets", "CONTROL — two declarations naming the same bad lane report twice", "CONTROL — two distinct bad pairs report twice" **(#1584)** | | Two dynamic resolutions disagreeing on one target's lane at **equal priority** keep the first-discovered lane and report the conflict — the lane is not corrected mid-run, because a live stream's lane is the one its in-flight leases were taken under and re-laning is restart-driven. The equivalent static declaration still throws at build | `configuration.md` § Conflicting lane assignments | `dynamic-lane-guard.spec.ts` → "CONTROL — the static form is still rejected at build", "says so — first-discovery-wins is not silent" **(#1567)** | +| The **dynamic** lane-conflict report compares the resolved lane name too, so an omitted lane and a declared one are the disagreement the static guard throws on — and it fires only on a genuine tie: a never-seen target has no held lane to disagree with, and a resolution that beats the target's recorded priority outranks rather than conflicts | `configuration.md` § Conflicting lane assignments | `dynamic-lane-guard.spec.ts` → describe("an omitted lane disagreeing with a declared one (#1598)"): "reports the same disagreement on the dynamic path", "reports it in the other discovery order too", "reports it across scans, where the held lane comes from the row", "reports a rerouted undeclared lane against a stream already laned", "CONTROL — a first resolution onto a never-seen target is not a conflict", "CONTROL — a higher-priority resolution outranks, it does not conflict" **(#1598)** | | The static lane-agreement guard compares the **resolved** lane name, so an omitted `lane` and an explicit `lane: "default"` on the same target agree and the build succeeds; a genuine disagreement still throws, naming the two distinct lanes | `configuration.md` § Conflicting lane assignments | `lanes.spec.ts` → "accepts an explicit 'default' followed by an omitted lane", "accepts an omitted lane followed by an explicit 'default'", "builds the failure scenario from #1583 with no cast anywhere", "still rejects a declared lane against an omitted one" **(#1583)** | | `prioritize` sets priority **directly**, overriding `subscribe`'s max() rule | `Store.prioritize` doc | `store-tck.ts` → "sets priority directly, overriding subscribe's max() rule" | | `unblock` returns 0 when the stream is not blocked; `reset`/`unblock` return 0 for unknown/empty input | `Store.unblock` / `Store.reset` docs | `store-tck.ts` → "returns 0 when the stream is not blocked", "returns 0 for unknown streams and empty input" | diff --git a/docs/docs/concepts/configuration.md b/docs/docs/concepts/configuration.md index ea67b99074..83208b1c18 100644 --- a/docs/docs/concepts/configuration.md +++ b/docs/docs/concepts/configuration.md @@ -303,8 +303,8 @@ act() A **dynamic** `.to(fn)` resolver is a function until an event arrives, so the build-time scan has nothing to inspect. Correlate applies the same rules where the answer finally exists: -- Two dynamic resolutions disagreeing on one target's lane at equal priority keep the lane the target was first discovered on, and log an error naming both. The lane isn't corrected mid-run — a live stream's lane is the one its in-flight leases were taken under, and re-laning is restart-driven. Until you align the resolvers, the losing reaction runs inside the winner's `leaseMillis` and `streamLimit`, and a process restricted to the losing lane via `onlyLanes` never runs it at all. -- A resolution naming an **undeclared** lane is rerouted to `default` and logged. No controller claims an undeclared lane, so the stream would otherwise sit at watermark `-1` forever, invisible to `blocked_streams()` and every other health surface. Note that `TLanes` already rejects an undeclared lane at compile time for both resolver forms, so this backstop only fires when the types are bypassed — JavaScript callers, a cast, or a helper whose return type widens `lane` to `string`. +- Two dynamic resolutions disagreeing on one target's lane at equal priority keep the lane the target was first discovered on, and log an error naming both. The lane isn't corrected mid-run — a live stream's lane is the one its in-flight leases were taken under, and re-laning is restart-driven. Until you align the resolvers, the losing reaction runs inside the winner's `leaseMillis` and `streamLimit`, and a process restricted to the losing lane via `onlyLanes` never runs it at all. "Disagreeing" is decided on the **resolved** lane name here too: an omitted `lane` is the default lane, so a resolution that leaves it off and one that names `slow` are the same conflict the build-time guard throws on, reported with `"default"` spelled out. A resolution that outranks what the target already carries isn't a conflict — priority decides the lane, and that outcome is the documented `max()` rule rather than a silent tie. +- A resolution naming an **undeclared** lane is rerouted to `default` and logged — and the rerouted lane is compared like any other, so a target already on `slow` reports the conflict too. No controller claims an undeclared lane, so the stream would otherwise sit at watermark `-1` forever, invisible to `blocked_streams()` and every other health surface. Note that `TLanes` already rejects an undeclared lane at compile time for both resolver forms, so this backstop only fires when the types are bypassed — JavaScript callers, a cast, or a helper whose return type widens `lane` to `string`. Both are reported **once per offending declaration, not once per target**. A resolver fires for every matching event and the documented per-aggregate shape `.to(e => ({target: e.stream}))` mints a fresh target each time, so a per-target report would scale the log with your aggregate count instead of with the number of declarations you have to fix. The report is keyed on the reaction's handler name plus the lane(s) involved; one resolved target rides along in the message as a concrete example to go look at. Two different misdeclarations still report separately — and a lane conflict reports once per direction it lands, since which side wins is "first discovered" and can differ from target to target. diff --git a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap index 2dc50e4922..e2cac8c18e 100644 --- a/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap +++ b/libs/act-tck/test/__snapshots__/all-packages-stability.spec.ts.snap @@ -7971,7 +7971,7 @@ export class ConsoleLogger implements Logger { */ import { createHash, randomUUID } from "node:crypto"; -import { log, store } from "../ports.js"; +import { DEFAULT_LANE, log, store } from "../ports.js"; import type { EventRegister, Query, @@ -8657,22 +8657,32 @@ export class CorrelateCycle< // rejects for static declarations (#1567). Priority still decides // below; this only reports the tie the operator can't otherwise // see. Compare against what this target already carries, whether - // that came from an earlier reaction in this scan or a past one. - const held = correlated.has(resolved.target) - ? entry.lane - : recorded?.lane; - if ( - held !== undefined && - lane !== undefined && - held !== lane && - priority === entry.priority - ) + // that came from an earlier reaction in this scan or a past one, + // and against that same source's priority — a resolution that + // beat the floor outranks what it found rather than tying with + // itself. + // + // Both lanes are compared by their resolved name, exactly as the + // static guard does (#1583): an omitted lane *is* the default + // lane, and the default lane is what the subscription row ends up + // holding, so an omitted lane against a declared one is a real + // disagreement. A never-seen target holds no lane at all, which is + // not the same as holding "default" — the record's existence is + // what gates the report. + const seen_in_scan = correlated.has(resolved.target); + const held_lane = + (seen_in_scan ? entry.lane : recorded?.lane) ?? DEFAULT_LANE; + const held_priority = seen_in_scan + ? entry.priority + : recorded?.priority; + const resolved_lane = lane ?? DEFAULT_LANE; + if (held_priority === priority && held_lane !== resolved_lane) report_lane_conflict( this._reported, reaction.handler.name, resolved.target, - held, - lane + held_lane, + resolved_lane ); // Multiple reactions targeting the same stream within a // single correlate scan — keep the max priority, and carry the @@ -27614,7 +27624,7 @@ export class ConsoleLogger implements Logger { */ import { createHash, randomUUID } from "node:crypto"; -import { log, store } from "../ports.js"; +import { DEFAULT_LANE, log, store } from "../ports.js"; import type { EventRegister, Query, @@ -28300,22 +28310,32 @@ export class CorrelateCycle< // rejects for static declarations (#1567). Priority still decides // below; this only reports the tie the operator can't otherwise // see. Compare against what this target already carries, whether - // that came from an earlier reaction in this scan or a past one. - const held = correlated.has(resolved.target) - ? entry.lane - : recorded?.lane; - if ( - held !== undefined && - lane !== undefined && - held !== lane && - priority === entry.priority - ) + // that came from an earlier reaction in this scan or a past one, + // and against that same source's priority — a resolution that + // beat the floor outranks what it found rather than tying with + // itself. + // + // Both lanes are compared by their resolved name, exactly as the + // static guard does (#1583): an omitted lane *is* the default + // lane, and the default lane is what the subscription row ends up + // holding, so an omitted lane against a declared one is a real + // disagreement. A never-seen target holds no lane at all, which is + // not the same as holding "default" — the record's existence is + // what gates the report. + const seen_in_scan = correlated.has(resolved.target); + const held_lane = + (seen_in_scan ? entry.lane : recorded?.lane) ?? DEFAULT_LANE; + const held_priority = seen_in_scan + ? entry.priority + : recorded?.priority; + const resolved_lane = lane ?? DEFAULT_LANE; + if (held_priority === priority && held_lane !== resolved_lane) report_lane_conflict( this._reported, reaction.handler.name, resolved.target, - held, - lane + held_lane, + resolved_lane ); // Multiple reactions targeting the same stream within a // single correlate scan — keep the max priority, and carry the diff --git a/libs/act/src/internal/correlate-cycle.ts b/libs/act/src/internal/correlate-cycle.ts index 5d4a1b29b8..2bfe494321 100644 --- a/libs/act/src/internal/correlate-cycle.ts +++ b/libs/act/src/internal/correlate-cycle.ts @@ -19,7 +19,7 @@ */ import { createHash, randomUUID } from "node:crypto"; -import { log, store } from "../ports.js"; +import { DEFAULT_LANE, log, store } from "../ports.js"; import type { EventRegister, Query, @@ -705,22 +705,32 @@ export class CorrelateCycle< // rejects for static declarations (#1567). Priority still decides // below; this only reports the tie the operator can't otherwise // see. Compare against what this target already carries, whether - // that came from an earlier reaction in this scan or a past one. - const held = correlated.has(resolved.target) - ? entry.lane - : recorded?.lane; - if ( - held !== undefined && - lane !== undefined && - held !== lane && - priority === entry.priority - ) + // that came from an earlier reaction in this scan or a past one, + // and against that same source's priority — a resolution that + // beat the floor outranks what it found rather than tying with + // itself. + // + // Both lanes are compared by their resolved name, exactly as the + // static guard does (#1583): an omitted lane *is* the default + // lane, and the default lane is what the subscription row ends up + // holding, so an omitted lane against a declared one is a real + // disagreement. A never-seen target holds no lane at all, which is + // not the same as holding "default" — the record's existence is + // what gates the report. + const seen_in_scan = correlated.has(resolved.target); + const held_lane = + (seen_in_scan ? entry.lane : recorded?.lane) ?? DEFAULT_LANE; + const held_priority = seen_in_scan + ? entry.priority + : recorded?.priority; + const resolved_lane = lane ?? DEFAULT_LANE; + if (held_priority === priority && held_lane !== resolved_lane) report_lane_conflict( this._reported, reaction.handler.name, resolved.target, - held, - lane + held_lane, + resolved_lane ); // Multiple reactions targeting the same stream within a // single correlate scan — keep the max priority, and carry the diff --git a/libs/act/test/dynamic-lane-guard.spec.ts b/libs/act/test/dynamic-lane-guard.spec.ts index f68af1f6d0..73483c6d17 100644 --- a/libs/act/test/dynamic-lane-guard.spec.ts +++ b/libs/act/test/dynamic-lane-guard.spec.ts @@ -367,3 +367,228 @@ describe("reporting once per declaration, not once per aggregate (#1584)", () => }); }); }); + +/** + * An omitted lane *is* `"default"` (#1598). + * + * That is what #1583 settled on the static side, where the build-time guard + * normalizes both operands before comparing, so `.to({target})` and + * `.to({target, lane: "slow"})` are rejected as the disagreement they are. + * The dynamic reporter compared the raw resolutions, so the one shape the + * operator has no other diagnostic for — undefined vs a declared lane — was + * the one shape it stayed silent about. + * + * Silence there costs the whole point of the lane: `T` lands on whichever + * lane was discovered first, and a worker sharded `onlyLanes: ["slow"]` + * never runs the reaction that asked for "slow". + */ +describe("an omitted lane disagreeing with a declared one (#1598)", () => { + it("CONTROL — the static form is still rejected at build", () => { + expect(() => + act() + .withState(Counter) + .withLane({ name: "slow" }) + .on("Bumped") + .do(async function onBumped() {}) + .to({ target: "T" }) + .on("Pinged") + .do(async function onPinged() {}) + .to({ target: "T", lane: "slow" }) + .build() + ).toThrow(/conflicting lane assignments \("slow" vs "default"\)/); + }); + + it("reports the same disagreement on the dynamic path", async () => { + const errors = await captured(async () => { + const { app, dispose } = await sandbox( + act() + .withState(Counter) + .withLane({ name: "slow" }) + .on("Bumped") + .do(async function onBumpedDefault() {}) + .to(() => ({ target: "T-1598" })) + .on("Pinged") + .do(async function onPingedSlow() {}) + .to(() => ({ target: "T-1598", lane: "slow" })) + ); + await app.do("bump", { stream: "c1", actor }, {}); + await app.do("ping", { stream: "c1", actor }, {}); + for (let i = 0; i < 2; i++) { + await app.correlate(); + await app.drain(); + } + await dispose(); + }); + + const reported = errors.find((m) => /conflicting lane/.test(m)); + expect(reported).toMatch(/T-1598/); + expect(reported).toMatch(/"default"/); + expect(reported).toMatch(/"slow"/); + }); + + it("reports it in the other discovery order too", async () => { + const errors = await captured(async () => { + const { app, dispose } = await sandbox( + act() + .withState(Counter) + .withLane({ name: "slow" }) + .on("Bumped") + .do(async function onBumpedSlow() {}) + .to(() => ({ target: "T-1598-rev", lane: "slow" })) + .on("Pinged") + .do(async function onPingedDefault() {}) + .to(() => ({ target: "T-1598-rev" })) + ); + await app.do("bump", { stream: "c1", actor }, {}); + await app.do("ping", { stream: "c1", actor }, {}); + for (let i = 0; i < 2; i++) { + await app.correlate(); + await app.drain(); + } + await dispose(); + }); + + const reported = errors.find((m) => /conflicting lane/.test(m)); + expect(reported).toMatch(/T-1598-rev/); + expect(reported).toMatch(/"slow"/); + expect(reported).toMatch(/"default"/); + }); + + it("reports it across scans, where the held lane comes from the row", async () => { + const errors = await captured(async () => { + const { app, dispose } = await sandbox( + act() + .withState(Counter) + .withLane({ name: "slow" }) + .on("Bumped") + .do(async function onBumpedAcross() {}) + .to(() => ({ target: "T-1598-across" })) + .on("Pinged") + .do(async function onPingedAcross() {}) + .to(() => ({ target: "T-1598-across", lane: "slow" })) + ); + // Two scans: the first records the target's lane on its subscription + // row, the second reads it back from there rather than from the + // running scan — the second of the two sources of `held`. + await app.do("bump", { stream: "c1", actor }, {}); + await app.correlate(); + await app.drain(); + await app.do("ping", { stream: "c1", actor }, {}); + await app.correlate(); + await app.drain(); + await dispose(); + }); + + expect(errors.find((m) => /conflicting lane/.test(m))).toMatch( + /T-1598-across/ + ); + }); + + it("reports a rerouted undeclared lane against a stream already laned", async () => { + const errors = await captured(async () => { + const { app, dispose } = await sandbox( + act() + .withState(Counter) + .withLane({ name: "slow" }) + .on("Bumped") + .do(async function onBumpedLaned() {}) + .to(() => ({ target: "T-1598-reroute", lane: "slow" })) + .on("Pinged") + .do(async function onPingedTypo() {}) + .to(() => ({ target: "T-1598-reroute", lane: "typo" as "slow" })) + ); + await app.do("bump", { stream: "c1", actor }, {}); + await app.do("ping", { stream: "c1", actor }, {}); + for (let i = 0; i < 2; i++) { + await app.correlate(); + await app.drain(); + } + await dispose(); + }); + + // The reroute lands the reaction on "default", which is a different + // lane from the one the stream is on — the reroute is not the end of + // the story, and the operator needs both halves. + expect(errors.find((m) => /undeclared lane/.test(m))).toMatch(/"typo"/); + expect(errors.find((m) => /conflicting lane/.test(m))).toMatch( + /T-1598-reroute/ + ); + }); + + it('CONTROL — an omitted lane and an explicit "default" agree', async () => { + const errors = await captured(async () => { + const { app, dispose } = await sandbox( + act() + .withState(Counter) + .withLane({ name: "slow" }) + .on("Bumped") + .do(async function onBumpedOmitted() {}) + .to(() => ({ target: "T-1598-agree" })) + .on("Pinged") + .do(async function onPingedExplicit() {}) + .to(() => ({ target: "T-1598-agree", lane: "default" as "slow" })) + ); + await app.do("bump", { stream: "c1", actor }, {}); + await app.do("ping", { stream: "c1", actor }, {}); + for (let i = 0; i < 2; i++) { + await app.correlate(); + await app.drain(); + } + await dispose(); + }); + + expect(errors.filter((m) => /conflicting lane/.test(m))).toHaveLength(0); + }); + + it("CONTROL — a first resolution onto a never-seen target is not a conflict", async () => { + const errors = await captured(async () => { + const { app, dispose } = await sandbox( + act() + .withState(Counter) + .withLane({ name: "slow" }) + .on("Pinged") + .do(async function onPingedFirst() {}) + .to(() => ({ target: "T-1598-first", lane: "slow" })) + ); + await app.do("ping", { stream: "c1", actor }, {}); + for (let i = 0; i < 2; i++) { + await app.correlate(); + await app.drain(); + } + await dispose(); + }); + + // There is no held lane to disagree with — normalizing "no record" to + // "default" would turn every first sighting into a false report. + expect(errors.filter((m) => /conflicting lane/.test(m))).toHaveLength(0); + }); + + it("CONTROL — a higher-priority resolution outranks, it does not conflict", async () => { + const errors = await captured(async () => { + const { app, dispose } = await sandbox( + act() + .withState(Counter) + .withLane({ name: "slow" }) + .on("Bumped") + .do(async function onBumpedLow() {}) + .to(() => ({ target: "T-1598-rank" })) + .on("Pinged") + .do(async function onPingedHigh() {}) + .to(() => ({ target: "T-1598-rank", lane: "slow", priority: 5 })) + ); + // Separate scans, so the second resolution meets the first through + // the recorded row and beats its floor. + await app.do("bump", { stream: "c1", actor }, {}); + await app.correlate(); + await app.drain(); + await app.do("ping", { stream: "c1", actor }, {}); + await app.correlate(); + await app.drain(); + await dispose(); + }); + + // Priority decides the lane, deterministically and documented — that is + // not the silent tie this report exists for. + expect(errors.filter((m) => /conflicting lane/.test(m))).toHaveLength(0); + }); +});