Files
pad/go.mod
T
xarmian 25c7cd20f5 feat(watchevents): Redis-backed bus so watch notifications cross instances (BUG-2651) (#1167)
* feat(watchevents): Redis-backed bus so watch notifications cross instances (BUG-2651)

internal/watchevents shipped MemoryBus only, so in a multi-instance
deployment a notification published on instance A never reached a stream
held open on instance B — watches appeared to work and silently dropped.
Bus was an interface from day one for exactly this; adding RedisBus
changed no producer and no consumer.

NOT A MECHANICAL PORT of internal/events.RedisBus. Three deliberate
divergences, each documented at the point someone diffing the two files
would call it a mistake:

- ONE channel and ONE replay buffer, because this package has exactly one
  logical stream by contract (DOC-2479 DR-2: all per-caller filtering
  happens in the consumer). Most of the template's bookkeeping — per-
  workspace counts, subscriptions, buffers — has nothing to key on here.

- EAGER subscription for the bus's lifetime, not lazily on first local
  subscriber. The replay buffer fills from the RECEIVE path, so a lazily
  torn-down subscription stops filling it at precisely the moment before
  a Last-Event-ID resume — for one harness monitor holding one stream,
  that makes resume structurally useless. The template can afford lazy
  because per-workspace means N idle subscriptions; here it is one.

- ONE mutex across subscriber membership and the replay buffer, held
  through the whole local fan-out. The template uses two and offers only
  separate Subscribe + EventsSince, which cannot provide
  SubscribeAndReplaySince's guarantee. Copying its locking would have
  handed back the double-delivery window this package's interface exists
  to close.

Publish fails CLOSED when INCR fails, where the template falls back to a
local counter. Two instances falling back at once mint ids from
independent counters into a shared stream, and replayBuffer.since()
reasons on monotonicity — so the damage is silent replay corruption, not
a visible error. INCR and PUBLISH share a connection anyway, so the
fallback mostly lets a doomed publish proceed carrying a poisoned id.

Both load-bearing tests were VACUOUS as first written; the mutation
matrix is the only reason I know:
- the concurrency test's producer finished before the subscriber joined,
  so the channel leg was never exercised and a split-lock mutant survived
  50 iterations. Now paced, with a both-legs-non-empty precondition that
  fails a run which never approached the boundary, plus a dedicated
  detector (600 attempts, 8/8 kills, 0.02s after switching the drain to
  non-blocking — exact, because the duplicate is already buffered when
  the call returns).
- the fail-closed test asserted nothing was delivered, which is true of
  the fallback too: Publish never delivers locally, so with Redis down
  neither policy delivers. Rewritten around a go-redis ProcessHook that
  records attempted commands, which is where the policies actually
  differ (INCR-then-stop vs INCR-then-PUBLISH).

Also corrects session_presence.go, which told the next person these two
had to be fixed together. Delivery is now cross-instance; the registry's
under-report is unchanged, so the remaining defect is a picker that
under-reports rather than a push that lies. The PLAN-2558 S3 gate stays,
for that reason instead of the old one.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents): make id assignment and publish atomic; close the bus on shutdown (Codex round 1)

P1 — INCR and PUBLISH as two client calls are not order-preserving, and
the failure is concrete: A gets id 1 and is descheduled, B gets id 2 and
publishes, A publishes 1. Every subscriber receives 2 before 1, the
replay buffer appends in ARRIVAL order, and replayBuffer.since() reasons
on monotonicity — so a resume from 2 hits the sinceID > newestID branch
and answers 'gap too large', turning a healthy reconnect into a spurious
sync_required, while a resume from 1 silently skips the late arrival.

Fixed at the source with a Lua script: Redis runs it atomically on its
single thread, so INCR and PUBLISH for one instance both complete before
another's script begins, and publish order equals id order globally with
no coordination on our side. The id rides as a '<id>|<json>' prefix
rather than being edited into the JSON from Lua; the id is digits and the
FIRST '|' separates, so a '|' in the body is unambiguous.

A pleasant consequence: there is no longer a window where an id exists
but the publish has not happened, so the fail-closed decision and the
publish decision became the same decision.

P2 — Stop() never closed the watch bus. That was survivable for
MemoryBus, whose Close only drops channels; RedisBus holds a receive
goroutine and a Redis subscription from construction, so it leaked both
for the process's life. Closed after bg.Wait(), so a background producer
cannot publish into a bus already tearing down.

nits, all real, all in artifacts someone reads:
- 'exactly-once delivery' was simply wrong. Redis pub/sub is at-most-once
  and the local send is deliberately non-blocking. The property the round
  trip actually buys is NO DOUBLE DELIVERY to the publishing instance;
  the comment now says that and names the replay buffer as the bounded
  recovery mechanism for the rest.
- the Bus interface comment still said only MemoryBus existed.
- cmd_server.go's session-presence note still claimed the same caveat as
  'the watch bus directly above', which had just stopped applying.
- session_presence.go now says delivery is fixed WHEN PAD_REDIS_URL is
  set, rather than unconditionally.

Tests: the fail-closed assertion moved from 'nothing was delivered' —
still true under the two-call version — to 'no bare INCR or PUBLISH was
issued', which is what distinguishes atomic from not. Mutation-verified
by splitting the script back into two calls. Added a decode round-trip
test covering the new wire format, a '|' inside the body, and four
malformed payloads, since that decoder consumes bytes from a channel any
holder of the Redis credentials can publish to.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents,server): correct the targeted-push claim; close the bus before HTTP shutdown (Codex round 2)

P2 — I claimed cross-instance DELIVERY was fixed. Half true, and the
false half was mine to catch: handlers_push.go gates a session-targeted
push on the LOCAL presence registry and skips the publish entirely when
the id is not there, so a POST landing on A for a session held on B
still delivers nothing. The bus would carry it; the gate means it never
reaches the bus. Broadcast pushes and every other notification kind ARE
fixed.

I asserted that behaviour from reading the bus and session_presence.go
without reading the push handler — the exact thing I hold myself to not
doing. Corrected in all three places the claim was made (the package
doc, session_presence.go, and the KindPush comment), with the correction
recorded rather than quietly overwritten.

The gate's own justification is now stale too, and worth more than a
tweak: 'a target this instance cannot see is a guaranteed no-op' was
TRUE under MemoryBus and is FALSE under RedisBus, where another instance
may hold that session. Left in place deliberately — publishing
unconditionally would fix delivery and immediately make
delivered_sessions=0 a lie in the other direction, which is a question
about what that field promises. It belongs with the shared-state
SessionPresence that PLAN-2558 S3 already gates on: fixing the registry
makes the snapshot right, and then the skip is correct again for its
original reason. Both open halves collapse into that one implementation.

P2 — the watch bus was closed only in Server.Stop(), which runs AFTER
http.Server.Shutdown. The event bus is closed before Shutdown precisely
so its SSE handlers unblock; the watch stream is the same shape, so an
open one would have held Shutdown to its full 30s deadline. Now closed
alongside eventBus, with the Stop() close kept as the path for other
callers — both implementations are idempotent.

nit — MemoryBus and RedisBus disagreed after Close: RedisBus handed a
late Subscribe an already-closed channel, MemoryBus registered one
nobody would ever close, so a consumer racing shutdown blocked forever.
MemoryBus now matches, and its Close is idempotent, which the CLI's
double close relies on.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents): report a missed notification as a replay gap (Codex round 3)

P2 — a divergence MemoryBus structurally cannot have. It assigns every
id itself, so its replay buffer is contiguous and the only gap it can
report is eviction. RedisBus receives ids over at-most-once pub/sub, so
a blipped subscription can miss 101 and receive 102: the buffer holds a
hole, is nowhere near full, and replayBuffer.since() answers a resume
from 100 with just [102]. The consumer loses a nudge and is never told.

RedisBus now tracks the id at which the sequence resumed after the most
recent hole, and answers nil — the same signal eviction already gives,
which the SSE handler already turns into sync_required — for a resume
that would have to span it. Resumes that do not span it still replay
normally, and sinceID=0 is treated as a fresh subscriber rather than a
resume, so a hole nobody spanned is not turned into a spurious resync.
The atomic publish script is what makes this readable: publish order is
id order globally, so a non-consecutive id means MISSED, not reordered.

Mutation-verified by disabling the check; the test fails on both the
spanning resumes and would have failed the over-broad version too (it
asserts the non-spanning resumes still work).

Two residuals documented rather than fixed, both because the fix is the
same shared-state SessionPresence that PLAN-2558 S3 gates on:

- delivered_sessions is now wrong in BOTH directions for a broadcast
  push — the count is local while delivery is global, so a replica can
  report 1 while two sessions receive it, or 0 while a remote one does.
  No local arithmetic fixes that; it is asking one replica what all of
  them are doing.
- the Redis channel and counter names are not deployment-scoped, so two
  installations sharing a Redis endpoint cross-feed (and picking
  different logical DBs does not help — pub/sub ignores them). Left flat
  to match internal/events rather than giving one of the two buses a
  prefix the other lacks; the rule is one Redis endpoint per
  installation, and relaxing it should cover both buses at once.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents): a cold-started replica must report a gap too (Codex round 4)

P1 — the round-3 hole check only fired BETWEEN two received messages, so
it never fired for the first one. A replica restarting while Redis is
already at 101 has an empty buffer; its first received message is 102,
nothing looks like a hole, and a client reconnecting to that replica
with Last-Event-ID 100 was handed [102] — skipping 101 exactly as
silently as the case round 3 fixed, by a different route.

Replaced contiguousFrom with knownFrom: the lowest id from which this
instance's buffer is contiguous. SET on the first append (before which
this instance knows nothing) and RESET on every hole (before which it no
longer knows anything usable). One variable, both failures.

The boundary is pinned in both directions, which is what stops this
being an over-broad 'always gap after a restart': a resume from exactly
the id before our first (101 when we started at 102) IS contiguous with
our view and replays normally. Mutation-verified by disabling the
cold-start arm.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents): idempotent publish, confirmed subscription, and real Redis tests (Codex round 5)

P2 — go-redis retries a command whose reply is lost to a network error,
and the publish script was not idempotent: the same notification would
be published twice under two different ids. Both copies look valid —
ordered, distinct — so nothing downstream could tell them apart, and on
the push path a duplicate is a duplicate DISPATCH into an agent harness.
The script now takes a caller-generated token and SET NX's it, so a
retry carrying the same arguments returns 0 without publishing.

TWO THINGS THIS UNIT OWES ITS TESTS, both found within minutes of each
other and both invisible to the hermetic ones:

1. The idempotency script shipped indexing ARGV[3] while Publish passed
   two arguments. Caught by re-reading, which is not a control worth
   relying on for the next Lua edit.
2. NewRedisBus returned before go-redis had established the
   subscription, so notifications published in that window were lost to
   this instance, silently. Surfaced as a test flake; the production
   shape is a rolling deploy, where a replica takes traffic before its
   subscription is live. The constructor now waits for the confirmation
   (bounded, and a failure is logged rather than fatal since Channel()
   re-subscribes on reconnect).

So miniredis is now a test dependency, and the round-trip tests it
enables cover what fanOutLocally-driven tests structurally cannot: the
channel name, the KEYS/ARGV mapping, the id prefix wire format, the
shared counter across two buses, cross-instance delivery (the actual
bug), the dedupe token, and Close tearing down the SERVER-side
subscription rather than just local channels. Verified by restoring the
ARGV[3] bug: the round-trip test fails on it.

The two findings I am NOT fixing here are unchanged and documented where
the reasoning is met — the targeted-push gate and delivered_sessions are
both consequences of the per-process presence registry, and both are
closed by the shared-state SessionPresence that PLAN-2558 S3 gates on,
not by anything in this package.

make vuln: 0 vulnerabilities in imported packages.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents): survive a Redis counter reset without replaying stale ids (Codex round 6)

P2 — pad:watchevents_seq has no TTL but can still vanish: evicted under
maxmemory, dropped by a FLUSHDB, or restored from an older snapshot. Ids
then restart at 1 while this instance's ring still holds the hundreds.
Keeping both is what corrupts replay — the two id spaces are not
comparable, so a resume from 2 in the NEW space would be handed the
stale 99/100/101 as though they were newer.

A backwards id now drops the replay buffer and re-anchors knownFrom.
Every resume from the old space then exceeds the newest id held and gets
nil — the resync signal that is the only honest answer once the ids
stopped meaning what the client thinks they mean — while clients in the
new space keep working immediately.

The test asserts BOTH halves, which is what makes it a detector rather
than a description: a build that logged the reset and kept the buffer
passes 'the old resume reports a gap' and fails 'the new resume never
returns a pre-reset entry'. Mutation-verified on exactly that.

Hardened while I was here: the epoch-reset path REBUILDS the buffer at
runtime, so a bus constructed with a non-positive replay size would have
turned a counter reset into a panic (newReplayBuffer(0)'s first append
indexes a zero-length slice) rather than a resync. The constructor now
normalizes. MemoryBus has the same trap for a caller passing 0; left
alone as pre-existing and off this path, but named in the comment rather
than silently fixed or silently ignored.

nit — this file's header still claimed there was no miniredis dependency
and no round-trip coverage, which the previous commit made false.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(watchevents): actually correct the hermetic test header (Codex round 7)

The previous commit's message claimed this fix. It did not contain it:
the edit ran as one of two scripts in a single command, its assertion
failed with a traceback, and the second script's success is what I read.
The header kept saying there was no miniredis dependency and no
round-trip coverage — both false since two commits ago, in the file a
reader consults to find out what IS covered.

That is the adjacent-success-signal failure exactly: a success line from
the step next to the one I cared about. The tell was in the output and I
walked past it, then asserted the change in a commit message. Recording
it here rather than quietly fixing, because a commit that claims a
change it does not make is worse than one that omits it.

Verified this time by reading the file back and grepping for the stale
phrases: zero.

Round 7's other three findings are the documented residuals re-raised
for the third time — the targeted-push gate, delivered_sessions, and the
unnamespaced Redis keys. All three are dispositioned at the line a
reader meets them, all three are consequences of the per-process
SessionPresence registry or of matching internal/events' existing
convention, and none is fixable inside this package. They stay open, on
the record, and with the lead.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(watchevents,cli): correct pad push --help; document the reset-window residual (Codex round 8)

nit, and the one that stings — cmd_push.go's Long help still said pushes
go over the 'in-memory watch-events bus'. That is the text a user reads
when they run pad push --help, and it has been false since this branch's
first commit. I have a standing pre-push step to grep the artifacts a
CONSUMER reads for exactly this, and I ran it as a code search
(watchevents.New) rather than a prose search, so --help never came up.
The help now distinguishes broadcast (reaches every instance) from
session-targeted (still resolved against the handling server) and names
the bug.

P2 — the counter-reset handling fires when the first post-reset
notification ARRIVES, so there is a window between Redis losing the
counter and the next publish in which this instance still replays old
ids to a reconnecting client. Documented as accepted rather than closed:
nothing local can detect the reset earlier (the counter is in Redis and
we learn of it by receiving something), and the two shapes that would —
a GET per resume, or a background poller — put network I/O on a
latency-sensitive path or spend a goroutine and a round trip per tick
forever against a condition measured in years. The exposure is
redelivery of notifications the client already has, bounded by the
window and self-healing on the next publish.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents): a replica that has received nothing must not answer 'caught up' (Codex round 9)

P1 — the coverage check was skipped entirely while knownFrom was still
0, so a bus that had received NOTHING answered any cursor with an
empty-but-non-nil replay, which the SSE handler reads as caught-up.

The scenario is a restart, not an exotic one: replica B comes up while
Redis is at 100, id 101 is published before B's subscription is live,
and a client reconnects to B with Last-Event-ID 100 before 102 arrives.
B says caught-up, then delivers 102 live, and 101 is gone with nothing
to tell anyone.

The principle the code now follows: having received nothing is strictly
LESS knowledge than 'contiguous from X', so it must produce at least as
strong a signal. A non-zero cursor against an empty bus is a gap.

Both sides pinned, because the over-broad version is a real risk here —
answering every fresh connection with a resync would be its own bug. A
sinceID of 0 is not a resume and still gets an empty replay rather than
a gap. Mutation-verified on the new arm.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(watchevents,cli): name the trailing-gap and shutdown trades (Codex round 10)

Two findings that are decisions rather than defects, so both are
documented at the line where the reasoning is met and taken to the plan
instead of being settled unilaterally after ten review rounds.

P1 as reported — the TRAILING gap. Everything the coverage bookkeeping
does reasons about what this instance HAS received; it cannot see a
notification missed at the END of the sequence. Hold 100, miss 101 to a
disconnect, and a client resuming from 100 before 102 arrives is told
caught-up. The hole only becomes visible when 102 lands, which is too
late for that connection.

What would reveal it is a GET of the sequence key: a value above
lastAppendedID means ids exist we never saw, and a value BELOW it
reveals the counter reset documented last round — one mechanism, both
open windows. It is not done here because it is product-visible in the
other direction: INCR happens before the message propagates, so the
counter legitimately runs ahead of every instance for microseconds after
each publish, and a strict comparison turns ordinary in-flight traffic
into spurious sync_required responses with no principled tolerance to
pick. A resync is recoverable and a lost nudge is not, which is the
argument for doing it — but that is a call about how chatty the resync
path should be.

P2 — closing the watch bus before Shutdown drains handlers means a push
already in flight can publish into a closed bus and still return 200
with pushed:true. Closing after would instead hold every shutdown to its
30s deadline on any open stream. eventBus already makes the same trade
the same way; naming it rather than inheriting it silently. The honest
fix is Bus.Publish reporting the drop so the handler can, which is an
interface change and a different unit.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* feat(watchevents): close the trailing gap with a settle-window authority check (lead ruling)

Lead's ruling on BUG-2651: a silently lost nudge is unbounded staleness,
a spurious resync costs one redundant fetch, so the gap must not survive
— and don't pick a magnitude tolerance, because the reason the counter
legitimately runs ahead is in-flight propagation, which is TIME-bounded
while a genuinely missed message never arrives.

So the discriminator is time. On a resume (and only on a resume), read
the shared counter: if it disagrees with this instance's high-water mark,
wait out one settle window and read again. In-flight ids land during the
beat and the resume proceeds normally; missed ones never do and the
resume is answered with a gap. That converts an unprincipled 'how many
ids behind is too many' threshold into a principled propagation bound.

The same read also catches the counter having gone BACKWARDS, so the
counter-reset window documented last round is closed by the same
mechanism rather than needing its own — the arrival-time reset handling
stays, because it is what repairs the instance's own state and what
covers a bus with no reconnecting clients.

Ordering matters and is documented at the call: the check runs WITHOUT
the mutex (it sleeps and does network I/O, neither of which may happen
inside the lock fan-out needs) and BEFORE subscribing rather than between
subscribe and replay, which would reopen the double-delivery window
SubscribeAndReplaySince exists to close. Nothing is lost by waiting
first — fanOutLocally buffers regardless of subscribers.

An unreadable counter falls back to local knowledge rather than failing
closed: turning a Redis hiccup into a resync for every reconnecting
client at once is a worse failure than the one being guarded against.

EventsSince deliberately does NOT do this and says so — it is the local
primitive the Bus interface already describes as being for tests and
non-resuming callers, and making it sleep and hit the network would
surprise every one of them.

Five tests, each pinning a different half: the missed tail reports a gap;
a current instance does NOT (the control that stops this being 'always
resync'); an id arriving mid-settle is tolerated; an unreadable counter
falls back; a fresh subscriber neither waits nor gets a gap.
Mutation-verified twice — disabling the check, and removing the settle
beat — each killed by the test that names it.

Also filed at the lead's direction, so the two remaining cross-instance
defects have tracked homes rather than only comments: BUG-2698 (targeted
push resolved against local presence, plus the delivered_sessions
inaccuracy — one shared-state SessionPresence closes both) and BUG-2699
(push returns 200 pushed:true for a dropped publish; Bus.Publish reports
nothing, and fixing it is an interface change). Every disposition comment
now cites its item.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents): compare two FRESH reads, not one stale snapshot (Codex round 11)

P1 — the settle beat re-read only the local side, so the comparison was
against a counter SNAPSHOT taken before the wait. Id 2 arrives during the
beat while id 3 is published and missed: the stale remote is still 2, the
check declares convergence, and 3 is silently lost — the exact failure
this whole mechanism exists to prevent, reintroduced inside it.

P2 — the same staleness in the other direction. A GET can land just
before a publish completes and report a value BELOW what this instance
already holds; that never matches, so a client who had missed nothing got
a full resync.

Both are one defect: agreement between the authority and this instance
has to be evaluated on two FRESH reads or it is not agreement. Now
re-reads both sides after the beat, and treats any remaining disagreement
as a gap in either direction — still behind means ids never reached us,
still ahead means the counter was reset under us and our buffer belongs
to a dead id space.

Two tests, one per direction, each mutation-verified against the
re-read-locally-only version: the second counter advance must produce a
gap, and the raced read must NOT produce a resync. Without the second
test the fix could have been 'always report a gap', which passes the
first.

Documented the cost side of the lead's ruling while I was in here: the
condition is agreement, so a resume during CONTINUOUS publishing across
the whole settle window can disagree every time and resync. Bounded by
this stream being low-volume by design and resumes only happening on
reconnect; if a workload makes it chatty, the answer is a longer window,
not a magnitude threshold.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* fix(watchevents): an absent sequence key is zero, not unreadable (Codex round 12)

P2 — the counter key can DISAPPEAR after this bus has seen ids (FLUSHDB,
eviction). Reading redis.Nil as 'unreadable' meant falling back to local
knowledge and cheerfully replaying an id space the authority no longer
has — while the next publish starts again at 1 and collides with it.

Absent is a VALUE. Returning zero-and-readable makes the case fall out of
the ordinary comparison with no special branch: an instance holding 101
disagrees with an authority at 0, does not converge, and the resume is
answered with a gap. A genuinely fresh deployment still agrees at zero
and is not resynced — which is the control leg, and the reason 'absent
means gap' would have been the wrong fix: it passes the first test while
resyncing every first connection on a new install.

P1 as reported — the equality fast path returning without settling — is
not closed, and the comment now says why rather than leaving it to be
re-found. A notification published AFTER that read and missed by this
instance is invisible to any check made here, and settling anyway would
not close it: the same race exists in the instant after the function
returns. The check's honest scope is what was missed BEFORE the resume.
A message missed after it is a property of at-most-once pub/sub with no
per-connection ack, and the real answer is a durable stream (Redis
Streams with consumer groups), not a longer wait.

Mutation-verified: restoring redis.Nil to the unreadable branch fails the
disappearing-counter test.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* feat(watchevents): epoch marker, so a reset that caught up is still a reset (Codex round 13)

P2 — numeric detection is blind to a reset that has already climbed past
this instance's high-water mark. Hold 100, lose the connection, the
counter resets and ids 1-101 are published, and the only one that reaches
us is 101 — the perfect contiguous successor of 100. Every arithmetic
check passes, the buffer quietly mixes two id spaces, and a client
resuming from OLD 100 is handed NEW 101 having silently missed the new
space's 1-100.

No amount of comparing numbers fixes that, because the question is not
'is this bigger' but 'is this the same sequence'. The publish script now
mints an epoch once per id space (SET NX, so every publisher can offer
one and the first wins) and carries it on every message; a change drops
the buffer and re-anchors.

The subtle half, and the one the first attempt got wrong: after an epoch
change the cold-start rule must NOT admit its usual
contiguous-with-our-view cursor. Within an epoch, a client at n.ID-1 is
genuinely adjacent to our first id. Across one it is ambiguous — id
spaces overlap, so that cursor may be the OLD sequence's n.ID-1, a
different notification entirely — and admitting it hands them the new
epoch's id as though it followed theirs, which is exactly the failure the
epoch exists to prevent. Letting it back in one line later would have
been a poor joke. The test caught it; the control leg (a cursor genuinely
inside the new epoch is still served) is what stops the fix becoming
'resync everyone forever after any reset'.

Wire format changed to <epoch>|<id>|<json>. Free of compat cost, checked
rather than assumed: redis_bus.go does not exist on origin/main, so no
released build produces or consumes the old shape.

The numeric backward check stays — it covers a counter reset where the
epoch key survived (eviction picks keys individually), and it is what
repairs an instance with no reconnecting clients at all.

Mutation-verified: ignoring the epoch change fails the new test.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(watchevents): the wire format comments say <epoch>|<id>|<json> (Codex round 14)

Three comments still described the pre-epoch format. Worth more than a
tidy-up: a maintainer following them would conclude the epoch prefix is
vestigial and remove it, which reintroduces exactly the cross-epoch
replay corruption round 13 existed to fix. The publishScript comment now
also says outright that the epoch is not decoration and points at
redisWatchEpochKey before anyone considers it removable.

Verified by grepping for the old shape rather than by trusting the edits
— zero remaining, which is the check I owed after getting this wrong in
round 7.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* chore(nix): update vendorHash for the miniredis test dependency (BUG-2651)

CI's Nix job failed on a fixed-output hash mismatch, and it is neither a
flake nor a surprise once seen: nix/package.nix pins the vendored module
set, and adding miniredis (plus gopher-lua, its Lua interpreter) to
go.mod changed it.

Regenerated per the procedure the file itself documents — build and read
the 'got:' line. Run on CI rather than locally because this box has no
nix; the hash is a content hash of the module set determined by
go.mod/go.sum, so the same inputs produce it in either place.

Worth naming as a gate lesson rather than just fixing: my pre-merge
matrix had build, lint, test, test-pg, vuln and Codex, and none of them
can see this. A dependency change has a SEVENTH consumer — the Nix
packaging — and the only thing that checks it is the CI job that just
did. Adding a dependency means checking the packaging, not only the
security scan.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-20 02:54:53 -04:00

149 lines
7.0 KiB
Modula-2

module github.com/PerpetualSoftware/pad
go 1.26.5
// BUG-2565: build with go1.26.6, which fixes 8 reachable standard-library
// advisories (GO-2026-5942 and friends — net.Resolver.LookupCNAME,
// http.Client.Do) that turned CI's govulncheck gate red on 2026-08-13
// without any commit causing it. A `toolchain` line, not a raise of the
// `go` directive above, on purpose: GOTOOLCHAIN=auto (every GitHub
// Actions job sets it) fetches 1.26.6 and stamps it into the binary,
// while builders pinned to GOTOOLCHAIN=local ignore this line and keep
// satisfying the 1.26.5 floor. nixpkgs nixos-26.05 still ships go 1.26.5,
// so raising the floor would break the Nix build outright; see BUG-2567
// for the Nix-packaged binary, which stays on 1.26.5 until nixpkgs
// catches up.
toolchain go1.26.6
require (
github.com/BurntSushi/toml v1.6.0
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
github.com/alicebob/miniredis/v2 v2.38.0
github.com/disintegration/imaging v1.6.2
github.com/fatih/color v1.19.0
github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/cors v1.2.2
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackc/pgx/v5 v5.10.0
github.com/mark3labs/mcp-go v0.57.0
github.com/ory/fosite v0.49.0
github.com/pb33f/libopenapi v0.38.7
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.2
github.com/redis/go-redis/v9 v9.22.0
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/sergi/go-diff v1.4.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/trustelem/zxcvbn v1.0.1
go.yaml.in/yaml/v4 v4.0.0-rc.6
golang.org/x/crypto v0.54.0
golang.org/x/image v0.45.0
golang.org/x/term v0.45.0
golang.org/x/text v0.41.0
golang.org/x/time v0.15.0
modernc.org/sqlite v1.56.0
)
require (
github.com/JohannesKaufmann/dom v0.3.1 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cristalhq/jwt/v4 v4.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/ristretto v1.0.0 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.5 // indirect
github.com/go-logr/logr v1.4.4 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
github.com/gobuffalo/pop/v6 v6.1.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/goveralls v0.0.12 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/openzipkin/zipkin-go v0.4.2 // indirect
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect
github.com/ory/go-convenience v0.1.0 // indirect
github.com/ory/x v0.0.665 // indirect
github.com/pb33f/jsonpath v0.8.2 // indirect
github.com/pb33f/ordered-map/v2 v2.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.16.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.4.2 // indirect
github.com/test-go/testify v1.1.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.21.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // indirect
go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.21.0 // indirect
go.opentelemetry.io/otel/metric v1.45.0 // indirect
go.opentelemetry.io/otel/sdk v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)