Stuart Rowlands

opencode · debugging · upstream

The sortable IDs that stopped sorting on 14 August 2026

By updated 3 September 20266 min read

A session in my QuantCode fork stopped answering. The agent would run a turn, produce a message, and then the message would vanish from the transcript. Nothing threw. The logs were clean.

The cause was a counter that had rolled over eighteen days earlier, on 14 August 2026. It is a Y2K bug with a date in the recent past, and I did not enjoy how long it took me to look at a clock.

The fix is now open upstream as anomalyco/opencode#46978.

What the IDs are for

opencode gives every session, message and part a 26-character ID that begins with a time field, so that sorting the IDs as strings sorts them in the order they were created. Sortable IDs are a nice trick. You get ordering for free from any store that can sort strings, without carrying a separate timestamp column or trusting one.

The generator packs the time and a per-millisecond counter into one integer:

const current = BigInt(timestamp) * 0x1000n + BigInt(counter)

Multiplying by 0x1000 shifts the milliseconds up by twelve bits and leaves room for 4,096 IDs in the same millisecond. Date.now() is about 41 bits today, so the packed value needs 53.

Then it writes that value into six bytes:

const time = Array.from({ length: 6 }, (_, index) =>
  Number((value >> BigInt(40 - 8 * index)) & 0xffn).toString(16).padStart(2, "0"),
).join("")

Six bytes is 48 bits. The value needs 53. The top five bits are dropped, and no error is raised, because shifting and masking a BigInt is a perfectly legal thing to do.

What that does

A field that holds 48 bits of a value that grows by 4,096 every millisecond runs out every 2^36 milliseconds. That is 795.36 days. When it runs out it wraps to zero, and every ID minted afterwards sorts below every ID minted in the preceding two years.

opencode's sortable time field, as a fraction of what six bytes can hold
00.200.400.600.8012023202420252026202720282029yearfield value14 Aug 2026

Each vertical drop is a moment when every new ID starts sorting below two years of history. The one before August 2026 was 10 June 2024, when the project was three months old and had almost nothing to sort.

Here are two real IDs from the session that stopped answering, either side of the boundary:

msg_ffffffc18001YXtK206zff4sjr    minted just before
msg_0000003e8001WYeoSbt07l        minted just after

Sorted as strings, the newer one comes first.

Why it looked like three bugs

None of the symptoms pointed at time. What I saw was:

  • The turn loop exiting early, because it compared message IDs as strings to decide whether it had reached the newest message.
  • The terminal UI inserting the new message in the wrong place in its virtualised list, then evicting it when the list was trimmed.
  • Nothing in the logs, because the error that came out of the second one was swallowed by an empty .catch().

Each of those is a plausible standalone bug in a different part of the codebase. I spent a while in the list-rendering code. An ordering key that silently inverts does not look like a clock problem from the outside, it looks like whatever code happened to trust the ordering.

The decoder is wrong too

There is a second defect that is easier to demonstrate. Identifier.timestamp() reads the time field back out and divides by 0x1000:

const hex = id.slice(prefix.length + 1, prefix.length + 13)
return Number(BigInt("0x" + hex) / BigInt(0x1000))

Since the stored field is missing its top bits, what comes back is the time since the last rollover rather than the time the ID was minted. Run it today against an ID minted a second ago and you get January 1970:

Identifier.timestamp(Identifier.ascending("session"))
// 1704218070  ->  1970-01-20T17:20:01Z

The fix, and what it cannot do

Seven bytes hold the packed value until 2527. IDs stay 26 characters, because the random suffix drops from 14 base62 characters to 12, which still leaves 62^12 of entropy on top of a per-millisecond counter. Both decoders read the wider field.

What widening cannot do is repair the ordering of IDs that already exist. An ID minted in June 2026 still has a time field near the top of the old range, and an ID minted today still sorts below it. Nothing you do to the encoder changes two years of stored strings. That is why the real fix has two halves, and the upstream project had already shipped the other one: the places that compared IDs to establish order now compare time-ordered positions instead.

I think that is the part worth taking away. When the encoding of a sort key changes, the key stops being a sort key for everything written before the change, and no amount of widening brings it back. The comparison sites have to stop trusting it.

On finding it late

The rollover was reported upstream by other people on the day it happened, several times over, and one of those reports came from someone else running a fork who found 7,228 of their 7,235 sessions had stopped producing assistant messages. I found it three days later and did not go looking for prior art until much later than I should have. The issues were sitting there.

What upstream had not done, and still had not done when I checked this week, was widen the field. Both generators still truncate on dev today. That is what the pull request is for.

A dated prediction, and the part of it I will not claim

If the encoding is still six bytes, the prefix resets at 2028-10-17T20:04:31.872Z, which is a quarter past seven on the morning of 18 October 2028 where I live. Inside one millisecond it goes from fffffffff001 to 000000000001. Every ID minted after that instant sorts below every ID minted in the preceding 795 days. The reset after that is 22 December 2030. None of that is a forecast, it is arithmetic, and you can check it against the code in a couple of lines.

What I will not claim is that the same three bugs come back. The comparison sites were moved to time ordering in August, and that is precisely the fix for this failure mode. If every one of them was found, and nobody adds a new one in the next two years, the reset passes quietly.

That is really the argument for changing the encoding. Leaving it means keeping an invariant alive instead of fixing a bug: nothing anywhere may sort by ID string. Not the existing code, not the next feature, not a plugin, not a fork. Widening the field retires the invariant, and then it does not matter who forgets.

And the decoder does not need a date at all. It returns January 1970 today, on every run, and no amount of careful comparison fixes anything about that.

Verifying it

The tests are the part I would want to see as a reviewer, so they are written to fail on the current code rather than to describe the fix. The ordering tests never mention the field width, so the same test file runs against both versions: three fail on dev and pass with the change. The decoder test asserts that an ID minted now decodes to now, which currently returns 1970.

Beyond that, 3,545 tests pass in the opencode package and 1,098 in core, and typecheck is clean in all three packages I touched. Two failures in the schema package also fail on an unmodified checkout, which I confirmed by stashing my change and re-running them.