diff --git a/CHANGELOG.md b/CHANGELOG.md
index d3516f0..e35f3c8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
# Changelog
+## Java 2.10.4 — 2026-07-02
+
+**Fix: ν-net join match dropped by `bindActions` (NU-030)**
+
+Follow-up to 2.10.3, which fixed the composition drop sites (`mergeTransitions`, `rebuild_with_name`) but missed a third: `PetriNet.bindActions` rebuilds every transition (`rebuildWithAction`) to attach its action and never carried `matchSpec` forward. A net composed with an intact ν-net join therefore lost its correlation the moment actions were bound, reverting a correlated join-by-id to a plain FIFO AND join at runtime — pairing tokens by arrival order across overlapping fork/join generations (a stale cross-group result).
+
+- **Java:** `rebuildWithAction` now carries the transition's `matchSpec` as-is (bindActions does not rename places, so no remap is applied — unlike `rebuildWithName`).
+- Regression tests added: `bindActions_preservesMatchSpec` (structural) and `bindActions_matchedJoin_correlatesByName_notFifo` (behavioral — a bound matched join pairs by name, not FIFO).
+
## Java 2.10.3 / TypeScript 2.10.3 / Rust 3.4.3 / Python 2.13.1 — 2026-07-01
**Fix: ν-net join match dropped during modular composition (NU-030 / NU-060)**
diff --git a/java/pom.xml b/java/pom.xml
index bd978f2..5f16257 100644
--- a/java/pom.xml
+++ b/java/pom.xml
@@ -6,7 +6,7 @@
org.libpetri
libpetri
- 2.10.3
+ 2.10.4
jar
libpetri
diff --git a/java/src/main/java/org/libpetri/core/PetriNet.java b/java/src/main/java/org/libpetri/core/PetriNet.java
index 5cd9d79..e300ff1 100644
--- a/java/src/main/java/org/libpetri/core/PetriNet.java
+++ b/java/src/main/java/org/libpetri/core/PetriNet.java
@@ -171,6 +171,15 @@ private static Transition rebuildWithAction(Transition t, TransitionAction actio
t.reads().forEach(builder::readArc);
t.resets().forEach(builder::resetArc);
+ // NU-030: carry the ν-net join correlation forward. bindActions only
+ // attaches an action — it does not rename places — so the matchSpec's
+ // input-place references are still valid and it is carried as-is (no
+ // remap, unlike rebuildWithName). Dropping it here would silently revert
+ // a correlated join-by-id to a plain FIFO AND join at runtime.
+ if (t.matchSpec() != null) {
+ builder.match(t.matchSpec());
+ }
+
return builder.build();
}
diff --git a/java/src/test/java/org/libpetri/core/PetriNetTest.java b/java/src/test/java/org/libpetri/core/PetriNetTest.java
index 61e4afd..f6ff33d 100644
--- a/java/src/test/java/org/libpetri/core/PetriNetTest.java
+++ b/java/src/test/java/org/libpetri/core/PetriNetTest.java
@@ -1,7 +1,11 @@
package org.libpetri.core;
import org.junit.jupiter.api.Test;
+import org.libpetri.runtime.BitmapNetExecutor;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@@ -227,4 +231,85 @@ void bindActions_preservesInputSpecs() {
assertNotNull(boundTransition.outputSpec(),
"bindActions should preserve outputSpec");
}
+
+ @Test
+ void bindActions_preservesMatchSpec() {
+ // NU-030 regression: bindActions rebuilds each transition to attach its
+ // action (rebuildWithAction). It must carry a ν-net join's matchSpec
+ // forward — dropping it silently reverts a correlated join-by-id to a
+ // plain FIFO AND join at runtime.
+ Place a = Place.of("branchA", String.class);
+ Place b = Place.of("branchB", String.class);
+ Place out = Place.of("merged", String.class);
+
+ var join = Transition.builder("join")
+ .inputs(Arc.In.one(a), Arc.In.one(b))
+ .match(MatchSpec.builder()
+ .key(a, (String s) -> NameId.of(s))
+ .key(b, (String s) -> NameId.of(s))
+ .build())
+ .outputs(Arc.Out.place(out))
+ .build();
+
+ var net = PetriNet.builder("matchBind").transitions(join).build();
+ assertNotNull(net.transitions().iterator().next().matchSpec(),
+ "precondition: structure carries the matchSpec");
+
+ var bound = net.bindActions(Map.of("join", ctx -> {
+ ctx.output(out, ctx.input(a) + "+" + ctx.input(b));
+ return CompletableFuture.completedFuture(null);
+ }));
+
+ var boundJoin = bound.transitions().iterator().next();
+ assertNotNull(boundJoin.matchSpec(),
+ "bindActions must preserve the ν-net matchSpec (regression: rebuildWithAction dropped it)");
+ assertTrue(boundJoin.matchSpec().correlates(a), "match still correlates branchA");
+ assertTrue(boundJoin.matchSpec().correlates(b), "match still correlates branchB");
+ }
+
+ @Test
+ void bindActions_matchedJoin_correlatesByName_notFifo() {
+ // Behavioral regression for the reported production symptom (marvin text
+ // guard join): a matched join whose action is attached via bindActions
+ // must still pair tokens by name — not by FIFO arrival order (which
+ // would serve a stale cross-group result).
+ Place a = Place.of("branchA", String.class);
+ Place b = Place.of("branchB", String.class);
+ Place out = Place.of("merged", String.class);
+
+ var join = Transition.builder("join")
+ .inputs(Arc.In.one(a), Arc.In.one(b))
+ .match(MatchSpec.builder()
+ .key(a, (String s) -> NameId.of(s))
+ .key(b, (String s) -> NameId.of(s))
+ .build())
+ .outputs(Arc.Out.place(out))
+ .build();
+
+ var net = PetriNet.builder("matchBindRun")
+ .transitions(join)
+ .build()
+ .bindActions(Map.of("join", ctx -> {
+ ctx.output(out, ctx.input(a) + "+" + ctx.input(b));
+ return CompletableFuture.completedFuture(null);
+ }));
+
+ // Interleaved timestamps: FIFO would cross-pair (X+Y / Y+X); a live
+ // match yields the correlated X+X / Y+Y.
+ var initial = Map., List>>of(
+ a, List.of(new Token<>("X", Instant.ofEpochMilli(0)),
+ new Token<>("Y", Instant.ofEpochMilli(1))),
+ b, List.of(new Token<>("Y", Instant.ofEpochMilli(0)),
+ new Token<>("X", Instant.ofEpochMilli(1)))
+ );
+
+ try (var executor = BitmapNetExecutor.create(net, initial)) {
+ var marking = executor.run();
+ var vals = new ArrayList();
+ for (var tk : marking.peekTokens(out)) vals.add(tk.value());
+ java.util.Collections.sort(vals);
+ assertEquals(List.of("X+X", "Y+Y"), vals,
+ "bound matched join must correlate by name (regression: bindActions dropped match FIFO-pairs to X+Y/Y+X)");
+ }
+ }
}