Why a zero-deviation channel lock feature invalidated a freshly updated time window for an hour


I considered the review workflow for this project to be quite strict: I personally gatekeep the spec phase, the plan goes through two external reviewers, and the final implementation undergoes cross-checking by dual reviewers. Yet a design flaw that completely invalidated the “most important acceptance criterion” slipped through every single gate. It was finally caught not because any particular gate did its job, but because a specific perspective in the final gate happened to align with it.

This post is the complete picture pieced together by looking back and asking, “How did this get through?”

What matters most is where it breaks

The feature developed in this phase was the time window for channel locking—users can configure it to “lock a specific channel during a specific time period.” The core is a single observeBlockActive flow, continuously observing “whether the lock for this channel is active at the current time.”

The plan explicitly listed an acceptance criterion called AC-D30 in black and white, marking it as “the most important acceptance criterion for this phase”:

Change the time window to exclude the current time, then switch to the same channel → do not lock, play directly; the lock icon must not appear in the top-right corner of the infobar.

However, the post-implementation review revealed that after a user updates the time window and returns to LiveTV to switch channels, the lock state would go stale for up to sixty minutes. AC-D30 was guaranteed to fail.

The root cause lay in the design of observeBlockActive:

/** Whether the channel lock time window is active at the current time. Automatically re-emits at the next time window boundary (up to 60 minutes). */
fun observeBlockActive(): Flow<Boolean> = flow {
    while (true) {
        val schedule = loadSchedule()
        val now = java.time.LocalTime.now()
        val minuteOfDay = now.hour * 60 + now.minute
        emit(schedule.isActiveAt(minuteOfDay))
        val waitMin = schedule.minutesToNextBoundary(minuteOfDay).coerceAtMost(60)
        kotlinx.coroutines.delay(waitMin * 60_000L - now.second * 1000L)
    }
}.distinctUntilChanged().flowOn(Dispatchers.IO)

This flow only re-emits in one place: loadSchedule() at the top of the loop, paired with a delay that sleeps until the next time window boundary. In other words, it only responds to one trigger condition: “the advancement of time.”

The user updates the schedule → saveSchedule() writes to the database → but this flow is unaware; it must wait for the next delay to wake up (up to sixty minutes) before reloading the schedule. For those sixty minutes, the lock evaluation relies entirely on the old schedule.

Notice that doc comment: “Automatically re-emits at the next time window boundary (up to 60 minutes)“—that sentence itself is a living fossil of the blind spot. The designer had only one trigger condition in mind, and wrote it right into the comments.

How this flaw was born

To clarify why it was not caught, its origins must be explained first. This relates to the nature of the project: it is a rewrite, migrating from the old version (Android 9, Java) to the new version (Android 16, Kotlin, Jetpack Compose).

The lock evaluation in the old version was reset on channel switch. Every channel switch immediately read the latest schedule and the current time, recalculating whether this channel should be locked. This was a stateless model—it maintained no “current lock state”, and every switch triggered a recalculation, so the latest schedule was invariably used. Architecturally, the old version could not possibly produce a “stale after schedule update” bug, because there was no state that could go stale.

The new version replaced this with observeBlockActive—a reactive flow continuously observing “whether the lock is currently active,” converted via stateIn within the ViewModel into a persistent boolean. This is a stateful model: the lock state is continuously maintained, and the UI simply reads that boolean. This “modernisation” silently introduced a responsibility absent in the old version: maintaining the real-time consistency of this state. Any event that alters the lock result must trigger a state recalculation.

The old version did not need this responsibility because there was no “maintained state” to go stale. The new version brought the state in, and this responsibility followed—yet no one recognised it.

Looking back at the design rationale in the spec makes it even clearer:

The new version is equivalent and simpler: LiveTV subscribes to a flow { emit(isActiveNow); delay(到下一個時窗邊界) }, eliminating the need for a persistent service or AlarmManager. The effect is identical (watching a locked channel when the time window hits will immediately pop up the lock), leaving one less lifecycle-managed subsystem.

The entire rationale only addressed the “time advancing to the boundary” vector, taking pride in having “one less lifecycle-managed subsystem.” No one realised that the old version’s “reset on channel switch” essentially exempted it from the responsibility of “reflecting data changes in real-time,” a responsibility the new version had now shouldered.

The old version’s “stateless, recalculate on switch” was not some brilliant design; it was a natural byproduct of Java imperative programming from that era. But it had a side effect: an architectural decision implicitly provided immunity to this bug. Switching to a reactive stateful model in the new version was a more modern, more correct engineering choice, but it inadvertently stripped away this immunity. This was not the fault of the new version; rather, the act of “rewriting” inherently unfolds and re-examines the implicit guarantees provided by the old architecture—the problem is that this time, it was not unfolded.

How it slipped through the entire review chain

Knowing how the flaw originated, the next question is: why was it not caught earlier?

My workflow deliberately divides gatekeeping responsibilities across the spec and plan phases. The spec phase leans towards “understanding old version behaviour and deciding how the new version corresponds.” Believing this should be human-led, I bypassed external reviews; it was just me conversing with Opus, coupled with brainstorming to produce the output. External review was only introduced in the plan phase—my thinking was that the plan phase is about refining details, where AIs cross-checking each other yields better results than humans. I would act as the final gate, reviewing an already mature plan, undisturbed by cluttered issues, allowing for more precise problem detection.

This division assumed that spec issues are “intent/understanding” issues, while plan issues are “detail” issues. But this flaw fell exactly into an unassigned gap between the two—it was an issue of “identifying what implicit responsibilities the new architecture introduced, and confirming each one is fulfilled.”

Thus, it slipped all the way through:

  • In the spec phase, I was the gatekeeper, and I was sitting right in this blind spot. When excavating the old version, the focus was on “how the old version did it,” completely missing “what responsibilities the old architecture exempted.”
  • In the plan phase, the plan faithfully implemented the spec’s design. It even provided extensive, thoughtful rationale for this flow—why coerceAtMost(60) (the clock might not be synced during early boot), why use a cold flow instead of @Singleton (the while(true) would outlive the screen lifecycle)—all revolving around “time triggers,” without a single word considering “data changes.” The two external reviewers missed it as well.
  • The code-facing reviewer (the reviewer checking line-by-line against the plan for “implementation deviation”) passed it. Because the implementation had zero deviation—a line-by-line check was flawless. The post-fix categorisation was precisely written: “plan-design gap, not an implementation deviation.”

It was not until the project-context reviewer—the reviewer looking from the perspective of “is this system truly correct under every trigger condition?”—that it was finally caught.

None of these review perspectives could see this type of flaw:

  • Line-by-line deviation checking: Zero implementation deviation; invisible.
  • Logical consistency: The flow itself had no contradictions, “emit when time reaches the boundary” was logically sound; invisible.
  • Archaeological correspondence with the old version: The focus was on “old mechanism → new mechanism,” but architectural responsibility shifts like “implicitly exempted by the old architecture, re-shouldered by the new” were outside the scope of “mechanism correspondence”; invisible.

Catching it required a specific perspective: “What are all the trigger sources for this data flow? Verify one by one that each causes a re-emit.” And in my workflow, this perspective did not enter the stage until the final project-context review—after the code was already fully written.

There is an easy misjudgment here that must be clarified. Because other phases had landed, this plan later underwent a “sync with current state” revision, which I approved with the ruling “constitutes syncing with current state, no re-review.” Hearing this story, intuition might blame the “no re-review” decision—but this is wrong. That revision only changed enum literals and corrected line numbers; it never touched observeBlockActive. This flaw was already present in the earlier, fully externally reviewed version. Even if re-reviewed, applying the same perspectives would still have missed it, because the previous full external review missed it. Leading the conclusion toward “just do one more review” misses the true lesson entirely.

The fix

The fix was straightforward: add a scheduleRevision counter, increment it upon saveSchedule; change observeBlockActive to use combine(tickerFlow(), scheduleRevision), ensuring both trigger sources—time advancement and data changes—cause a re-emit:

private val scheduleRevision = MutableStateFlow(0L)

// On saveSchedule:
scheduleRevision.value = scheduleRevision.value + 1   // ← Triggers observeBlockActive to recalculate

fun observeBlockActive(): Flow<Boolean> =
    combine(tickerFlow(), scheduleRevision) { _, _ -> ... }

The staleness dropped from a maximum of sixty minutes to instantaneous, and AC-D30 passed.

The true lesson

This was a genuine surprise to me. I had believed this division of labour was robust—humans leading the spec, AIs cross-reviewing the plan, and me performing the final gatekeeping, each fulfilling their duties. Yet this flaw fell into the cracks between responsibilities: no gate’s duty definition included “identifying what implicit responsibilities the new architecture introduced that the old version lacked.”

“The earlier a problem is caught, the lower the cost” is true, provided that the gate develops the right perspective first. But I later discovered something interesting: the perspective that saved AC-D30 this time—looking at it from “is this system correct under every trigger condition?”—already existed in my workflow; it was the final project-context reviewer. The only problem was that it appeared too late, taking the stage only after the code was written.

Therefore, the direction for fixing this workflow is not “adding more layers of review”—that merely multiplies the same type of perspective, remaining equally blind to this kind of flaw. Instead, it involves two concrete actions.

First, move the already existing, but excessively delayed project-context perspective forward. Explicitly add an item to the spec or plan review checklist: For every reactive data flow, list all trigger sources that mutate the state, and verify one by one that each causes a re-emit. The same perspective, at an earlier cost.

Second, rewrite projects require a dedicated check: “What responsibilities did the architecture of this old subsystem implicitly exempt? With the architecture changed in the new version, have these responsibilities been re-shouldered? If so, does the new version explicitly fulfil them?” This rule is only meaningful for rewrite projects, but it is acutely critical for them—because “modernisation” inherently unfolds the implicit guarantees of old architectures for re-examination, and the archaeological perspective of the old version is precisely what cannot see what it has been exempted from.

Reviews can only check what is explicitly stated. And the most dangerous flaws often hide in the one trigger condition that no one speaks aloud.