Commit Graph

15 Commits

Author SHA1 Message Date
xarmian 0900be6241 chore(deps): bump golang.org/x/crypto to v0.56.0 (BUG-2851)
Two advisories published 2026-09-02 19:12Z (GO-2026-6354, GO-2026-6355; DoS in golang.org/x/crypto/ssh, fixed in v0.56.0) made govulncheck fail the Nix job on runners whose vulnerability database had them — intermittently across runners, not as a threshold: main at 704ba874 and a PR tip based on it passed while another failed on an identical dependency tree, four minutes apart. x/crypto/ssh is not linked into pad (go list -deps ./cmd/pad shows no crypto/ssh; go mod why: bcrypt), so this is a CI unblock, not an exposure. The bump beats an accepted-advisories entry: an exception would encode "not linked today" as permanent and would sit beside a check that disagrees with itself.

go.mod one line, go.sum two lines, nix/package.nix vendorHash one line. No nix on the build box, so the hash was lifted from CI's own mismatch on a lib.fakeHash placeholder, which is why it is a build-sourced value and not a guess:

    specified: sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
       got:    sha256-8L7gH7Yy5+Fig3wK2SPLYSJjcY9nF/jumQ7PATJ3RIE=

Squashed so the placeholder commit (fails to build by design) never enters main's history. Gates on the tip: Nix green, Go suite green on SQLite and Postgres uncached (PG legs verified by timing), lint 0, vet clean; CI 7/7 on 51a0efd2.

Claude-Session: https://claude.ai/code/session_01TkxKnJpLgk5UxKS8T896dk
2026-09-02 16:52:22 -04:00
xarmian 0e2cb06add chore(nix): bump package version to 0.15.0 ahead of the v0.15.0-rc.1 tag
Claude-Session: https://claude.ai/code/session_01HvAuiZ7JaWyCqqyV99LyWt
2026-08-31 01:32:36 +00:00
dependabot[bot] ff969d23e0 chore(deps)(deps): bump the go-minor-and-patch group across 1 directory with 4 updates (#1180)
* chore(deps)(deps): bump the go-minor-and-patch group across 1 directory with 4 updates

Bumps the go-minor-and-patch group with 4 updates in the / directory: [github.com/go-chi/chi/v5](https://github.com/go-chi/chi), [github.com/mark3labs/mcp-go](https://github.com/mark3labs/mcp-go), [golang.org/x/crypto](https://github.com/golang/crypto) and [modernc.org/sqlite](https://gitlab.com/cznic/sqlite).


Updates `github.com/go-chi/chi/v5` from 5.3.1 to 5.3.2
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.3.1...v5.3.2)

Updates `github.com/mark3labs/mcp-go` from 0.57.0 to 0.58.0
- [Release notes](https://github.com/mark3labs/mcp-go/releases)
- [Commits](https://github.com/mark3labs/mcp-go/compare/v0.57.0...v0.58.0)

Updates `golang.org/x/crypto` from 0.54.0 to 0.55.0
- [Commits](https://github.com/golang/crypto/compare/v0.54.0...v0.55.0)

Updates `modernc.org/sqlite` from 1.56.0 to 1.57.0
- [Changelog](https://gitlab.com/cznic/sqlite/blob/master/CHANGELOG.md)
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.56.0...v1.57.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: github.com/mark3labs/mcp-go
  dependency-version: 0.58.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/crypto
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: modernc.org/sqlite
  dependency-version: 1.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* nix: vendorHash for the go-minor-and-patch bump

The four module updates change the vendored dep set; hash taken from the
fixed-output derivation mismatch on this PR's own Nix run (the in-branch
fix the day-26 batch established on #1041).

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: xarmian <xarmian@gmail.com>
2026-08-23 08:52:42 -04:00
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
xarmian 94441b4eb2 chore(nix): bump package version to 0.14.0 ahead of the release tag
Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-16 20:26:51 +00:00
xarmian ad1e919290 chore(deps): bump otel exporters to v1.45.0, clearing GO-2026-4985 from the Nix baseline (#1097)
* chore(deps): bump otel exporter cluster to v1.45.0 (GO-2026-4985)

Clears GO-2026-4985 (otlptracehttp oversized response bodies, fixed
v1.43.0) from the Nix artifact's accepted-advisories baseline. The
whole cluster is transitive — pad has no direct otel usage; it arrives
via fosite → ory/x → otelx, and fosite's latest (v0.49.0, already
pinned) still requires the vulnerable exporter, so MVS override is the
only path. Pulls otel core/metric/sdk/trace v1.44→v1.45, proto/otlp
v1.0.0→v1.11.0, grpc v1.82.1→v1.83.0, genproto refresh. The jaeger
exporter stays at v1.17.0 (its final release) and coexists.

BUG-2085 deferred this bump pending a blast-radius assessment; the
assessment is this diff, measured: go build ./..., go vet, full SQLite
test suite, and golangci-lint all green; artifact-faithful proxy scan
(GOTOOLCHAIN=go1.26.5, -s -w) reports 9/9 accepted with no new
advisories. Remaining baseline: 8 stdlib (nixos-26.05 backport) +
openpgp (no upstream fix exists).

vendorHash refresh follows in the next commit via the PR's Nix CI run.

Refs BUG-2085, BUG-2567.

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

* chore(nix): refresh vendorHash for the otel exporter bump

Same flow as #1096: value from the PR's own failed Nix CI run.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-14 21:41:49 -04:00
xarmian ff201939b9 chore(deps): bump x/image to v0.45.0, clearing GO-2026-6222 from the Nix baseline (#1096)
* chore(deps): bump golang.org/x/image to v0.45.0 (GO-2026-6222)

Clears GO-2026-6222 (VP8L decode memory allocation) from the Nix
artifact's accepted-advisories baseline — the advisory's fixed version
is exactly v0.45.0. Pulls x/text v0.41.0, x/mod v0.38.0, x/tools
v0.48.0 as transitive requirements.

Verified against a build-faithful proxy (GOTOOLCHAIN=go1.26.5, -s -w):
scan reports 10/10 accepted, no new advisories, no prune warnings.
Full SQLite test suite and golangci-lint clean locally.

nix/package.nix vendorHash refresh follows in the next commit, using
the PR's Nix CI job as the builder (no local nix; the flow is the one
package.nix documents).

Refs BUG-2085, BUG-2567.

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

* chore(nix): refresh vendorHash for the x/image bump

Codex round 1 P1: go.sum changed, so buildGoModule's fixed-output
vendor derivation no longer matches the pinned hash. Value taken from
the PR's own failed Nix CI run (the got: line), per the regeneration
flow package.nix documents.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-14 21:12:19 -04:00
xarmian cfad8d989e ci(nix): gate the Nix-built binary with govulncheck (BUG-2567) (#1095)
* ci(nix): gate the Nix-built binary with govulncheck (BUG-2567)

The main CI govulncheck job scans a go-built binary, which honours
go.mod's toolchain line — so the Nix artifact (GOTOOLCHAIN=local in
nixpkgs, go 1.26.5 until nixos-26.05 backports 1.26.6) shipped with no
vulnerability gate over it at all.

Add nix/vulnscan.sh: binary-mode govulncheck against result/bin/pad,
compared to nix/accepted-advisories.txt. Known advisories stay green
and recorded in-repo; any NEW advisory fails the Nix job; a cleared
advisory emits a warning annotation so the list gets pruned and
BUG-2567 closed when the backport lands.

The accepted list carries 11 entries, measured against a
build-faithful proxy (GOTOOLCHAIN=go1.26.5, CGO_ENABLED=0,
ldflags "-s -w"): the 8 reachable stdlib advisories from BUG-2565,
plus 3 module-level entries that only appear because -s -w strips the
symbols govulncheck needs for call-graph precision — a symbol-precise
scan of the same source shows all three uncalled.

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

* ci(nix): guard vulnscan against empty or non-binary govulncheck output

Codex round 2: an exit-0 govulncheck run that produced empty, truncated,
or garbled JSON — or silently ran in a mode other than binary — was
indistinguishable from a clean scan. Assert the stream's config message
reports scan_mode=binary and make both jq extractions fail closed
(exit 2, operational error).

Also sharpen the accepted-list comment on the three module-level
entries: on the stripped artifact govulncheck reports them as affected
with symbol frames (it cannot prune the call graph, so every vulnerable
symbol of an imported package counts as potentially called); the
round-2 reading of "degrades to module-level reporting" as functionless
findings was wrong, verified against the actual JSON stream.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-14 20:39:42 -04:00
xarmian f9195c5b09 ci: make the go test timeout explicit everywhere (TASK-2545) (#1089)
* ci: make the go test timeout explicit everywhere (TASK-2545)

The v0.13.0 release pre-flight died on `panic: test timed out after
10m0s` in internal/store, on a commit whose Go tree was identical to a
green run an hour earlier. Nothing hung — the package's runtime simply
crossed a budget nobody had chosen.

`go test` without -timeout uses a 10m per-test-binary default. This repo
raised the two RACE steps to 45m twice as the suite grew (BUG-1371 30m,
BUG-1913 30m→45m), each time with a careful comment — and each time left
their non-race siblings on the silent default. Three steps were still
running on it, including the release gate:

  ci.yml       "Run tests"                    (SQLite)
  ci.yml       "Run tests against PostgreSQL" (the one that panicked)
  release.yml  "Run tests"                    (the release gate itself)

All three now carry -timeout=45m, matching the race legs so the file has
one number, with comments saying it is a hang-catcher rather than a
performance budget and that job wall-clock is the signal for "the suite
got slow".

Measured at 212d59e7 on a dev box, both drivers, before and after:

  PostgreSQL  whole suite 4m43s wall; internal/store 280s; server 103s
  SQLite      whole suite 1m52s wall; internal/server 107s; store 64s

CI runners are roughly 2x slower, which is what put store's PG binary
over 10m. 45m is ~4.5x current CI headroom.

This raises the ceiling; it does not change the slope. internal/store on
PG costs ~0.43s per test in database setup alone (CREATE DATABASE plus a
full migration replay, where the SQLite harness copies a pre-migrated
template — IDEA-1914), so every test added costs PG CI ~0.43s forever
and that package is 99% of the job's critical path. Measured and filed
as IDEA-2550 rather than fixed here: it changes shared test
infrastructure that gates every merge and deserves its own review.

Verified by running the exact post-change commands on both drivers: PG
green in 4m42s, SQLite green in 1m52s, 25 packages each.

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

* ci: time the Makefile's go test targets too (TASK-2545)

The previous commit said the timeout was explicit "everywhere" and it
wasn't — `make test`, `make test-pg`, and `make check` were all still on
the 10m default. That matters twice over: it's the same trap the commit
is about, and `make test-pg` is the local mirror of the CI leg that
actually panicked, so a developer reproducing the failure would have hit
a different budget than the one they were debugging.

Found by sweeping every `go test` in the repo rather than only the
workflows — which is what the commit message's own claim required and I
hadn't done when I wrote it.

Verified: `make test` green, 25 packages.

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

* ci: time the nix checkPhase, cap the Go jobs, correct two claims (TASK-2545)

Codex review. No P1s; the two P2s were both right and one of them
catches me stating an explanation I had not checked.

COVERAGE. `nix/package.nix`'s checkPhase runs `go test ./...` on the
default too, and .github/workflows/nix.yml exercises it — a fourth site
after the three workflow steps and the three Makefile targets. Now
timed. Every `go test` invocation in the repo carries an explicit
-timeout; the sweep is `grep -rn "go test"` over workflows, Makefile and
nix, not just the workflows I happened to be looking at.

JOB CAPS. Codex objected that 45m lets a hung binary burn a
release-gating job. Fair, and the real hole was worse: `go` and
`go-postgres` had NO `timeout-minutes`, so they inherit GitHub's 6-HOUR
default. Both now capped at 100m — deliberately above the two 45m test
steps so the per-binary timeout always fires first, because that is the
one that prints the goroutine dump naming the hung test. The cap only
catches a runaway that isn't a single test (wedged service container,
stuck download).

CORRECTIONS to 496f521f's message:

- It said the race steps were raised "twice (BUG-1371 30m, BUG-1913
  30m→45m)". BUG-1371 kept 30m and fixed the bcrypt cost that had blown
  past it; BUG-1913 made the only 30m→45m change. One raise, not two.
- It said CI runners are "roughly 2x slower, which is what put store's
  PG binary over 10m". That does not survive its own arithmetic: 280s
  local x 2 is 9m20s, under the budget. What is actually known is that
  the CI binary exceeded 10m and the local one takes 280s, so CI is
  >2.14x slower on that binary — a lower bound derived from the failure,
  not an explanation of it. I have not measured CI's runtime and should
  not have written a factor as if I had.
- "Nothing hung" and "cost the cut ~40 minutes" are TASK-2545's findings
  from the goroutine dump and the release timeline, not mine. Attributed
  rather than restated as my own observation.

The 0.43s per-test setup figure and both driver runtimes are mine, taken
on this box at 212d59e7 and reproducible with the commands in IDEA-2550.

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

* ci: put the corrections in the file, not only in a commit message (TASK-2545)

Codex's re-review came back with no P1s or P2s and four nits, all the
same shape: the claims I retracted in 4623cae9's COMMIT MESSAGE were
still sitting in the workflow comments. That's the half that matters —
nobody reads a commit message while editing a CI file, and a correction
that lives only in git log is a correction almost nobody receives.

Fixed in place:

- The raise history: BUG-1913 raised 30m→45m once. BUG-1371 kept 30m and
  dropped the test-only bcrypt cost that had blown past it. My comment
  said "raised twice (BUG-1371, BUG-1913)".
- The pre-existing race-step comment claiming BUG-1371 kept the step
  "well under the 30m budget" — contradicted by BUG-1913 having to raise
  it later. Reworded to say what each change actually did. Not my text,
  but it is wrong in the file I am editing and the next reader inherits
  it either way.
- The "~2x slower, which put store over 10m" line, which its own
  arithmetic refutes (280s x 2 = 9m20s). Now states the lower bound the
  failure actually supports — CI's store binary exceeded 10m, so >2.14x
  this box — and names the retracted claim so a reader who saw the old
  version knows it was withdrawn rather than lost.
- "so it never fires before they do" on the job caps, which a job-level
  timeout cannot promise: it covers setup and every step, not just the
  two 45m ones. Now says "in practice", not a guarantee.

Attribution of TASK-2545's own findings (the ~40 minutes, the goroutine
dump showing nothing hung) moved into the comment too.

Claude-Session: https://claude.ai/code/session_01QGbUKZBAZoWdEgiTNWsXag
2026-08-13 17:15:30 -04:00
xarmian b87b3028e7 chore(nix): bump package version to 0.13.0 ahead of the release tag
Claude-Session: https://claude.ai/code/session_01CXHLbTC1AiwSC87xwThRGT
2026-08-13 17:19:10 +00:00
xarmian a66c9ab402 chore(nix): bump version to 0.12.0
Pre-tag version sync required by PLAYB-1160 step 1 — the flake builds
from source at whatever ref the user names, so the tag snapshot must
self-report the version being tagged. Without this, binaries from
'nix run github:PerpetualSoftware/pad/release' report 0.11.0 forever.

Refs TASK-2445.
2026-08-11 17:37:53 +00:00
xarmian a62a50d672 fix(nix): refresh vendorHash for go-minor-and-patch group bump
Claude-Session: https://claude.ai/code/session_01RNcrc3CtXwJwreubtHTgN6
2026-08-06 23:37:39 +00:00
xarmian 0d31bae61b fix(nix): refresh vendorHash for current main's go.sum
The hash was computed against July-27 main; go.sum has since moved
(dependabot + mainline work), and CI builds the PR merged with main.

Claude-Session: https://claude.ai/code/session_01RNcrc3CtXwJwreubtHTgN6
2026-08-06 05:02:29 +00:00
Claude a65bfcc4d4 fix(nix): skip DNS-dependent webhook validation subtests in checkPhase
ValidateWebhookURL does a real net.LookupIP as an SSRF guard, and four
TestValidateWebhookURL subtests exercise that path against example.com.
That works fine in CI (real network) but fails under a properly
sandboxed Nix build (no network), which is what real users hit. Skip
just those subtests; the rest of the package's tests (invalid schemes,
private-IP rejection, etc.) need no network and keep running.
2026-07-26 23:31:06 +00:00
Claude 02b302519e feat(nix): add flake packaging for pad with CI build
Adds a Nix flake exposing the pad binary as packages.default (buildGoModule
+ importNpmLock for the embedded SvelteKit UI), a devShell, and flake
checks (package build with `go test ./...`, plus a `pad --version` smoke
test). nix/package.nix is written nixpkgs-submission-ready (no
flake-specific inputs) so it can later be adapted for pkgs/by-name.

Also adds a GitHub Actions workflow that runs `nix flake check` and
`nix build` on push/PR, and documents `nix run` / `nix profile install`
/ `nix develop` in the README.
2026-07-26 22:49:10 +00:00