Defect
The dynamic lane-conflict reporter never fires when one of the two disagreeing resolutions omits the lane:
libs/act/src/internal/correlate-cycle.ts:711-717
if (
held !== undefined &&
lane !== undefined &&
held !== lane &&
priority === entry.priority
)
report_lane_conflict(...)
An omitted lane is "default" — that is exactly what #1583 established on the static side, where build-classify.ts normalizes both operands to lane ?? "default" before comparing. So undefined vs "slow" is a genuine conflict that the static guard throws on and the dynamic guard stays silent about.
This is the same normalization bug as #1583, on the sibling path #1583 did not touch.
Failure scenario
Two reactions on one event resolving to the same target, one omitting the lane and one declaring "slow", at equal priority.
STATIC control: throws at build —
'Stream "T" has conflicting lane assignments ("default" vs "slow")'
DYNAMIC (.to(fn)): reports [] <-- silent
Harm follows the documented first-discovery-wins rule: T lands on whichever lane was discovered first — "default" — and a worker sharded onlyLanes: ["slow"] never runs the slow reaction. Silent, because the one diagnostic that exists for this case is the report that does not fire.
A sibling reported by the same wave, worth fixing in the same pass: the undeclared-lane reroute sets lane = undefined before re-entering this comparison, which walks straight back into the same blind spot.
Proof (red)
Reproduced independently in the main loop. Both halves in one probe, one variable (static vs dynamic declaration of the identical disagreement), no cast and no private-state mutation:
STATIC control threw: "Stream \"T\" has conflicting lane assignments (\"default\" vs \"slow\")"
DYNAMIC undefined-vs-slow reports: []
AssertionError: expected +0 to be 1
Contract violated
The report-once module's stated purpose (libs/act/src/internal/report-once.ts:8-12): the guards can only inspect a static .to({...}), so "the pipelines that resolve them (correlate for lane and target, drain for the payload) are therefore where the same rules have to be applied, and where the operator has to be told." Here the same rule is not applied — the dynamic path enforces a strictly weaker predicate than the static one it mirrors.
Fix direction
Normalize both sides before comparing, exactly as #1583 did on the static side:
const held_lane = held ?? DEFAULT_LANE;
const this_lane = lane ?? DEFAULT_LANE;
if (held_lane !== this_lane && priority === entry.priority) report_lane_conflict(...)
Keep the reporting semantics unchanged — log and continue, never throw (#1420: a throw inside correlate pins the checkpoint for the whole app).
Two things to settle while in here:
- the undeclared-lane reroute path that sets
lane = undefined should normalize too, or it re-enters the same hole;
- whether
held being read from recorded?.lane (a past scan) vs entry.lane (this scan) should normalize identically — it should, but the two sources are worth checking together.
Found by debug wave 21 (fresh-code lens), reproduced independently in the main loop.
Defect
The dynamic lane-conflict reporter never fires when one of the two disagreeing resolutions omits the lane:
libs/act/src/internal/correlate-cycle.ts:711-717An omitted lane is
"default"— that is exactly what #1583 established on the static side, wherebuild-classify.tsnormalizes both operands tolane ?? "default"before comparing. Soundefinedvs"slow"is a genuine conflict that the static guard throws on and the dynamic guard stays silent about.This is the same normalization bug as #1583, on the sibling path #1583 did not touch.
Failure scenario
Two reactions on one event resolving to the same target, one omitting the lane and one declaring
"slow", at equal priority.Harm follows the documented first-discovery-wins rule:
Tlands on whichever lane was discovered first —"default"— and a worker shardedonlyLanes: ["slow"]never runs the slow reaction. Silent, because the one diagnostic that exists for this case is the report that does not fire.A sibling reported by the same wave, worth fixing in the same pass: the undeclared-lane reroute sets
lane = undefinedbefore re-entering this comparison, which walks straight back into the same blind spot.Proof (red)
Reproduced independently in the main loop. Both halves in one probe, one variable (static vs dynamic declaration of the identical disagreement), no cast and no private-state mutation:
Contract violated
The
report-oncemodule's stated purpose (libs/act/src/internal/report-once.ts:8-12): the guards can only inspect a static.to({...}), so "the pipelines that resolve them (correlate for lane and target, drain for the payload) are therefore where the same rules have to be applied, and where the operator has to be told." Here the same rule is not applied — the dynamic path enforces a strictly weaker predicate than the static one it mirrors.Fix direction
Normalize both sides before comparing, exactly as #1583 did on the static side:
Keep the reporting semantics unchanged — log and continue, never throw (#1420: a throw inside correlate pins the checkpoint for the whole app).
Two things to settle while in here:
lane = undefinedshould normalize too, or it re-enters the same hole;heldbeing read fromrecorded?.lane(a past scan) vsentry.lane(this scan) should normalize identically — it should, but the two sources are worth checking together.Found by debug wave 21 (fresh-code lens), reproduced independently in the main loop.