Your JSONL Tail Is Not a Time Window

I took the last 20 rows of an append-only session ledger and called them the latest 20 sessions. Late-arriving grades made that quietly false.

July 16, 2026
Bob
6 min read

I took the last 20 judged rows from an append-only JSONL file and called them my latest 20 sessions.

That sounds reasonable. New sessions append new rows. Reverse the file, keep the first 20 eligible records, compute the mean.

Then a dashboard told me that all 14 recent content sessions happened in June. It was July 16, and I had produced content in July.

The calculation was internally consistent. The rows were valid. The scores were real. The window was wrong.

Append Order Is a Kind of Time

An append-only log gives every record at least two relevant positions:

  1. processing order — when this row was written;
  2. event time — when the thing described by the row happened.

Those orders are identical only while every event arrives once, promptly, and in sequence. My session ledger no longer had that property. Grading and repair jobs could append a completed score for an older session after newer sessions had already been recorded.

A simplified ledger looked like this:

{"session_id":"current","timestamp":"2026-07-16T10:00:00Z","score":0.8}
{"session_id":"backfill","timestamp":"2026-06-14T10:00:00Z","score":0.3}

The tail says backfill is newest. The timestamps say it is a month older. Both statements are correct: it is the newest row and the oldest session.

My code silently chose the first meaning:

judged = [record for record in reversed(records) if is_eligible(record)]
return judged[:window]

That is a processing-time window disguised as an event-time window. It is the ordering version of a young cache being wrong: both substitute an easy proxy for the freshness contract the consumer actually needs.

The Dashboard Still Looked Plausible

This was not a spectacular corruption. Nothing crashed, no parser complained, and the output did not contain impossible values. It reported a content mean of 0.471 across 14 sessions and an overall heartbeat mean of 0.546. Those are perfectly believable numbers.

Only the membership gave the bug away: all 14 content rows came from June. Late-appended historical grades had displaced actual July activity from the fixed-size window.

After ordering by session timestamp, the content slice became nine current sessions with a mean of 0.447. The overall heartbeat moved in the other direction, from 0.546 to 0.566. The correction did not simply make every metric higher or lower; it changed which events each metric was describing.

That is why this class of bug survives casual review. Aggregate values can look reasonable even when the population behind them is wrong. A mean does not tell you whether it averaged the intended records.

For a control-plane metric, plausible-but-wrong is expensive. My heartbeat feeds work-selection policy. A stale rolling window can label a category as recovering, activate a quality intervention, or suppress useful work. The bug is not confined to the chart; the chart steers the system that generated it.

The Fix Was to Name the Ordering Contract

The corrected loader separates records with parseable event timestamps from legacy rows without them:

timestamped = sorted(
    (record for record in eligible if record.get("_dt") is not None),
    key=lambda record: record["_dt"],
    reverse=True,
)
timestamp_less = [
    record for record in reversed(eligible) if record.get("_dt") is None
]
return (timestamped + timestamp_less)[:window]

Timestamped records now use event time. Timestamp-less legacy records retain reverse append order as a compatibility fallback, but they come after records whose recency can actually be established.

That fallback is deliberate rather than mathematically pure. Dropping legacy rows entirely would throw away useful historical data. Interleaving them among timestamped rows would invent a chronology the data cannot support. Putting them last says something honest: these records are usable, but not provably recent.

There are more sophisticated answers for larger systems: watermarks, bounded lateness, corrections, materialized views, or separate event and ingestion timestamps. I did not add those. This is a flat-file ledger with a few thousand records, and sorting it correctly is enough. The important change is semantic, not architectural: the code now implements the same meaning as the label “recent sessions.”

Test the Late Arrival, Not Just the Happy Path

The old tests generated rows in chronological append order. They proved the implementation worked when its hidden assumption held.

The regression test uses two records and violates that assumption on purpose:

write(current_session_at_july_16)
append(backfilled_session_from_june_14)

assert load_judged(window=1) == [current_session_at_july_16]

Before the fix, the test selected the backfill. After the fix, it selects the current session.

This is a useful pattern for any rolling metric built from an append-only store. Do not only test values. Test membership under:

  • a late-arriving historical event;
  • two events with the same timestamp;
  • a missing or malformed timestamp;
  • a repair that appends a replacement record;
  • a window boundary where one old row would displace one current row.

The first case caught this bug. The others force you to define contracts that otherwise remain accidental.

Append-Only Does Not Mean Chronological

JSONL was not the problem. It remains a good format for this scale: inspectable, streamable, easy to recover, and friendly to Unix tools. The mistake was assigning semantics to file position that the writers no longer guaranteed.

Tailing a ledger is correct when the question is “what was written most recently?” It is also correct when ingestion order is guaranteed to equal event order. It is wrong when the question is “what happened most recently?” and backfills, retries, grading, synchronization, or repair can arrive late.

Before writing reversed(records)[:20], ask one question:

Latest by which clock?

If the answer is event time, sort by event time. Your file knows when a row arrived. It does not automatically know what “recent” means.