Skip to content

The dynamic lane-conflict guard misses an omitted lane vs a declared one (#1583 on the dynamic path) #1598

Description

@Rotorsoft

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:sharedShared librariesbugSomething isn't workingpriority:mediumNice-to-have improvements

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions