From b88dc44e863dd7b8a3d70b078c6d4885b171ea62 Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 16 Aug 2026 00:21:26 -0400 Subject: [PATCH 1/3] fix: exempt Just Lift leases from target mismatch and terminal checks in RepNotificationFreshnessGate Issue #698: Echo Just Lift commands use unlimited target semantics (0xFF/252), but the modern rep freshness gate required the device-reported repsSetTotal to equal the finite UI lease workingRepTarget. This caused every valid Just Lift packet to be dropped as TARGET_MISMATCH before rep counting, warmup, audio feedback, or auto-stop could fire. Fix: gate the target-equality and finite-terminal checks on !lease.isJustLift using the existing isJustLift field on ExecutionLease. Acceptance criteria: - Just Lift modern packets with repsSetTotal=252 pass the freshness gate - Just Lift repsSetCount is not treated as terminal - Finite-target executions still reject nonzero mismatched targets - Pre-cutover, invalidated, and non-current packets remain rejected Fixes #698 --- .../manager/RepNotificationFreshnessGate.kt | 11 ++++- .../RepNotificationFreshnessGateTest.kt | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt index db1d18015..7c7d36170 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt @@ -56,12 +56,19 @@ internal class RepNotificationFreshnessGate { val identity = lease.identity() if (notification.isLegacyFormat) return evaluateLegacy(identity, notification) - val targetMatches = notification.repsSetTotal == 0 || + // Issue #698: Just Lift uses unlimited target semantics (0xFF/252), + // so the device-reported repsSetTotal will never match the finite UI + // lease target. Exempt Just Lift leases from target equality check. + val targetMatches = lease.isJustLift || + notification.repsSetTotal == 0 || notification.repsSetTotal == lease.workingRepTarget if (!targetMatches) return RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH) if (stateFor(lease) is RepFreshnessState.Armed) return RepFreshnessDecision.Process - val terminal = lease.workingRepTarget > 0 && + // Issue #698: Just Lift has no finite rep target, so repsSetCount + // should never be treated as terminal. Exempt from terminal check. + val terminal = !lease.isJustLift && + lease.workingRepTarget > 0 && notification.repsSetCount >= lease.workingRepTarget val allZero = notification.topCounter == 0 && notification.completeCounter == 0 && diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt index 17acf3c5a..ebacab8f8 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt @@ -154,6 +154,49 @@ class RepNotificationFreshnessGateTest { ) } + // --- Issue #698: Just Lift target mismatch exemption --- + + @Test + fun `just lift lease accepts repsSetTotal 252 despite finite UI target`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 10, cutover = 1_000L).copy(isJustLift = true) + + // First packet establishes baseline and arms + assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) + assertEquals(RepFreshnessState.Armed, gate.stateFor(lease)) + + // repsSetTotal=252 (unlimited) should NOT be dropped as TARGET_MISMATCH + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_002L)), + ) + } + + @Test + fun `just lift lease does not treat repsSetCount as terminal`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 3, cutover = 1_000L).copy(isJustLift = true) + + // repsSetCount=3 >= workingRepTarget=3 would be terminal for finite, + // but Just Lift should process it normally after baseline + assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 3, repsSetTotal = 252, timestamp = 1_002L)), + ) + } + + @Test + fun `finite lease still rejects mismatched repsSetTotal after fix`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 3, cutover = 1_000L) // isJustLift = false + + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_001L)), + ) + } + private fun activeLease(target: Int, cutover: Long) = ExecutionLease( executionId = 1L, sessionId = "session-a", From f288cefbacd4f4c9acf14eb5c5c4d5daf8bceefa Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 16 Aug 2026 08:06:39 -0400 Subject: [PATCH 2/3] fix: exempt AMRAP leases from rep freshness gate (Issue #700) Extend PR #699's Just Lift exemption to also cover AMRAP leases in RepNotificationFreshnessGate.evaluate(): - targetMatches: add lease.isAmrap alongside lease.isJustLift so repsSetTotal=252 (UNLIMITED_REPS) is accepted for AMRAP sets - terminal check: add lease.isAmrap to the exemption so repsSetCount is never treated as terminal for AMRAP leases Add four mirror test cases for isAmrap=true: - AMRAP accepts repsSetTotal 252 despite finite UI target - AMRAP does not treat repsSetCount as terminal - Finite AMRAP mismatch still rejected (repsSetTotal != target) - Pre-cutover AMRAP packet still rejected Fixes #700 --- .../manager/RepNotificationFreshnessGate.kt | 14 ++--- .../RepNotificationFreshnessGateTest.kt | 54 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt index 7c7d36170..7c3a6952d 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt @@ -56,18 +56,18 @@ internal class RepNotificationFreshnessGate { val identity = lease.identity() if (notification.isLegacyFormat) return evaluateLegacy(identity, notification) - // Issue #698: Just Lift uses unlimited target semantics (0xFF/252), - // so the device-reported repsSetTotal will never match the finite UI - // lease target. Exempt Just Lift leases from target equality check. - val targetMatches = lease.isJustLift || + // Issue #698/#700: Just Lift and AMRAP use unlimited target semantics + // (0xFF/252), so the device-reported repsSetTotal will never match the + // finite UI lease target. Exempt both from target equality check. + val targetMatches = lease.isJustLift || lease.isAmrap || notification.repsSetTotal == 0 || notification.repsSetTotal == lease.workingRepTarget if (!targetMatches) return RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH) if (stateFor(lease) is RepFreshnessState.Armed) return RepFreshnessDecision.Process - // Issue #698: Just Lift has no finite rep target, so repsSetCount - // should never be treated as terminal. Exempt from terminal check. - val terminal = !lease.isJustLift && + // Issue #698/#700: Just Lift and AMRAP have no finite rep target, + // so repsSetCount should never be treated as terminal. + val terminal = !(lease.isJustLift || lease.isAmrap) && lease.workingRepTarget > 0 && notification.repsSetCount >= lease.workingRepTarget val allZero = notification.topCounter == 0 && diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt index ebacab8f8..28f4910a9 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGateTest.kt @@ -197,6 +197,60 @@ class RepNotificationFreshnessGateTest { ) } + // --- Issue #700: AMRAP target mismatch exemption (mirrors #698 Just Lift tests) --- + + @Test + fun `amrap lease accepts repsSetTotal 252 despite finite UI target`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 0, cutover = 1_000L).copy(isAmrap = true) + + // First packet establishes baseline and arms + assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) + assertEquals(RepFreshnessState.Armed, gate.stateFor(lease)) + + // repsSetTotal=252 (unlimited) should NOT be dropped as TARGET_MISMATCH for AMRAP + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 1_002L)), + ) + } + + @Test + fun `amrap lease does not treat repsSetCount as terminal`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 0, cutover = 1_000L).copy(isAmrap = true) + + // AMRAP should never treat repsSetCount as terminal, same as Just Lift + assertEquals(RepFreshnessDecision.BaselineOnly, gate.evaluate(lease, modernPacket(timestamp = 1_001L))) + assertEquals( + RepFreshnessDecision.Process, + gate.evaluate(lease, modernPacket(repsSetCount = 5, repsSetTotal = 252, timestamp = 1_002L)), + ) + } + + @Test + fun `finite amrap lease still rejects mismatched repsSetTotal`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 3, cutover = 1_000L).copy(isAmrap = true) + + // Even with isAmrap=true, a mismatched finite repsSetTotal should be rejected + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 4, timestamp = 1_001L)), + ) + } + + @Test + fun `pre-cutover amrap packet is still rejected`() { + val gate = RepNotificationFreshnessGate() + val lease = activeLease(target = 0, cutover = 1_000L).copy(isAmrap = true) + + assertEquals( + RepFreshnessDecision.Drop(RepDropReason.PRE_CUTOVER_TIMESTAMP), + gate.evaluate(lease, modernPacket(repsSetCount = 1, repsSetTotal = 252, timestamp = 999L)), + ) + } + private fun activeLease(target: Int, cutover: Long) = ExecutionLease( executionId = 1L, sessionId = "session-a", From 4989b06f075a65f82984d09158249fa77e828acf Mon Sep 17 00:00:00 2001 From: Devil Date: Sun, 16 Aug 2026 08:15:45 -0400 Subject: [PATCH 3/3] fix: restrict AMRAP target exemption to unlimited target (target=0) The blanket isAmrap exemption in targetMatches allowed ANY AMRAP lease to bypass target equality, even when the lease has a finite target (e.g., target=3). This caused mismatched repsSetTotal to be accepted instead of dropped as TARGET_MISMATCH. Fix: only exempt AMRAP from target matching when workingRepTarget == 0 (unlimited). AMRAP with a finite target must still match. Fixes test: finite amrap lease still rejects mismatched repsSetTotal --- .../manager/RepNotificationFreshnessGate.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt index 7c3a6952d..ef9e9dd3f 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RepNotificationFreshnessGate.kt @@ -56,10 +56,13 @@ internal class RepNotificationFreshnessGate { val identity = lease.identity() if (notification.isLegacyFormat) return evaluateLegacy(identity, notification) - // Issue #698/#700: Just Lift and AMRAP use unlimited target semantics - // (0xFF/252), so the device-reported repsSetTotal will never match the - // finite UI lease target. Exempt both from target equality check. - val targetMatches = lease.isJustLift || lease.isAmrap || + // Issue #698/#700: Just Lift and AMRAP with target=0 use unlimited + // target semantics (0xFF/252), so the device-reported repsSetTotal + // will never match the finite UI lease target. Exempt both from + // target equality check. AMRAP with a finite target (>0) must still + // match — only unlimited AMRAP gets the exemption. + val targetMatches = lease.isJustLift || + (lease.isAmrap && lease.workingRepTarget == 0) || notification.repsSetTotal == 0 || notification.repsSetTotal == lease.workingRepTarget if (!targetMatches) return RepFreshnessDecision.Drop(RepDropReason.TARGET_MISMATCH)