Commit Graph

3 Commits

Author SHA1 Message Date
xarmian 1e40c972ae refactor(events): one home for the asymmetry, one assertion for the count (BUG-2736)
Codex round 14 was a prune pass rather than a defect hunt: 695 comment lines
to 243 code lines in internal/events and internal/idspace is a ratio worth
looking at, and my day-51 close records what happens when review prose grows
under a diff.

MOSTLY DECLINED, and the reason matters more than the decision. The list would
have stripped this diff to terse definitions -- deleting bug archaeology,
rollout tradeoffs and codex-round attributions -- in files whose EXISTING
comments are dense narrative and carry exactly those things, "(codex round 6)"
attributions included. It also named several blocks on code this diff never
touched. Applying it would have made the new code inconsistent with the file
it lives in, which is a style change to someone else's codebase wearing a
review's clothes.

APPLIED, because both match lessons this team already recorded rather than a
reviewer's preference:

- The numeric-base-vs-travelling-epoch argument existed in FOUR copies, in
  both packages. The lead's requirement was that the asymmetry declare itself
  in both buses' comments -- not that the argument be written four times. It
  now lives once, in internal/idspace's package comment (the shared code both
  halves depend on), and the four sites state the asymmetry in a sentence and
  point there. Four copies is four things to update when the deferred
  follow-on lands.

- "Costs at most ONE drop per instance per roll" was a countable claim in
  prose with nothing asserting it. A comment asserting countable behaviour
  belongs in the suite: later messages in the same generation must leave the
  buffers alone, or "bounded" in that sentence is doing no work.

Honest measurement, since the point of the round was volume: the net is +47
-41. The prune did not shrink the diff; it removed three copies of one
argument and turned one claim into an assertion.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 20:48:58 +00:00
xarmian 378dec5244 docs(idspace): name the assumption the incarnation bound rests on (BUG-2736)
Codex round 8, on a fresh angle. The invariant was stated in terms of publish
RATE -- an id can repeat across incarnations only if the earlier process
published more than 2^20 events per millisecond of its life -- and quietly
assumed the other half: that the next start lands in a LATER millisecond.

The bases are separated by the clock at millisecond resolution, and the CAS
separates only buses built inside one process. A second process starting
inside the same millisecond as the first would take the same base and reissue
its ids.

Not closed, and the reason it is acceptable is physical rather than hopeful:
reaching the constructor means the OS reaped the old process and the new one
bound its listener, opened its database and ran migrations. Closing it for
real needs persistence, which BUG-2736's body rules out for a separate and
stronger reason. So it is accepted and NAMED -- in the package comment and in
deployment.md -- rather than left for the next reader to find during an
incident.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 19:54:19 +00:00
xarmian c017ad359d fix(events): give each in-memory bus incarnation its own ID space (BUG-2736)
Both in-process buses assigned Last-Event-ID values from a counter that
restarted at 1 on every process start. A client holding cursor 2 from a
previous incarnation could reconnect to a restarted server, pass every
coverage check BUG-2731 added, and be replayed the NEW space's 3, 4, 5 as
though they followed the OLD space's 2 -- silently missing everything the
dead space held above 2.

Nothing local could tell the two 2s apart. The cursor carries no epoch, and
in internal/events per-workspace IDs are non-consecutive by construction, so
"did we issue this ID?" was numerically undecidable. The four adjacent levers
were checked rather than assumed: comparing in memory has nothing to compare
against; persisting the counter makes single-process Pad carry durable
event-bus state and still resets on data loss; refusing cursors we did not
issue is the undecidable one; and a nonce on a second channel is unavailable
because EventSource echoes Last-Event-ID and nothing else, and cannot rewrite
its URL on an automatic reconnect.

So the ID space's identity goes in the ID's VALUE while its FORMAT is
unchanged: still a bare int64, still ParseInt on the way back. internal/idspace
mints a base of processStartUnixMilli<<20 and each bus counts up from it. Two
incarnations can only collide if the earlier process published more than 2^20
events per millisecond of its own lifetime -- a deterministic bound, not the
probabilistic one BUG-2736's body rules out. A CAS makes bases strictly
increasing within a process too, which the clock alone does not do for two
buses constructed in the same millisecond.

A backwards clock step degrades in the SAFE direction: a lower base puts old
cursors ABOVE the new buffer's newest ID, so they are refused rather than
answered wrongly. The overflow bound is computed, not estimated: the last
start instant that fits is 2248-09-26T15:10:22Z.

Each bus then answers the resume question exactly instead of inferring it: a
non-zero cursor at or below this incarnation's base was issued by a dead
space. That is strictly stronger than the coverage check alone, which serves
the ADJACENT cursor on reasoning that only holds within one ID space.

In internal/watchevents the check lives in one helper both entry points call.
Written inline in EventsSince it was absent from SubscribeAndReplaySince --
the path the SSE handler actually uses -- so the component was fixed and its
wiring was not (team CONVE-19). A test now drives both.

web's ItemEvent no longer declares `id?: number`. Nothing read it, which is
the only reason it was harmless; a base of ~1.8e18 is past JavaScript's
MAX_SAFE_INTEGER, so the first reader would have silently got a rounded
number. Defused while still unread.

Tests that spelled out IDs now read back what the bus assigned -- a literal 1
is a cursor from a dead space, which turned two negative controls into their
own opposite. The two watchevents guards (cold buffer, dead incarnation) are
tested separately, because a single test covering both would keep passing
with either deleted.

The Redis half is not here. Its counter is shared across processes, so
identifying its ID space needs an epoch travelling with each message; that is
the next commit on this branch.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 18:14:45 +00:00