Files
pad/docs/deployment.md
T
xarmian effd0199cd fix(events): detect a half-open Redis connection with a bus heartbeat (BUG-2738) (#1195)
* fix(events): detect a half-open Redis connection with a bus heartbeat (BUG-2738)

A Redis connection can stop carrying traffic without closing -- no FIN, no
RST, just a route that stopped working. The instance blocks on a read that
never returns, receives nothing, and its replay buffer goes on looking
complete, so every resume is answered "caught up" from a coverage window that
ended when the route did.

go-redis cannot see it: PubSub.Ping writes the command and never reads a
reply (v9.22.0), so its health check reports healthy for as long as the socket
accepts writes. Measured on day-52 against a proxy that silently stopped
forwarding: no reconnect in 24 seconds.

Each subscription now records when it last received ANYTHING -- event,
heartbeat, or subscription acknowledgement -- and a background pass ends the
coverage of any workspace whose stamp goes stale past 3T, then REPLACES the
connection. Drop alone would not recover: the resync it demands is served from
the same dead socket, so the detector fires again on the next pass.

Dave's day-49 ruling dissolves the threshold rather than tuning it. The bus
publishes its own frame every T=30s and fires at 3T=90s, which turns "is this
workspace quiet or is the route dead?" -- unanswerable, deployment-dependent --
into "did our heartbeat arrive?".

TWO PHASES, ORDER NOT OPTIONAL. The frame must travel on the workspace's event
channel, because that connection is what needs proving. A pre-phase-1 binary
cannot classify it: the frame reaches the event decoder, fails, and since
BUG-2739 that is a hole in coverage -- so an early flip makes every un-upgraded
instance drop its buffer and resync all its clients, every 30s, per workspace,
for the length of a mixed deployment. Phase 1 recognises and ignores;
PAD_EVENTS_HEARTBEAT is phase 2, a constructor parameter with no default so
every call site states its phase.

The idle detector is a THIRD actor in a region whose invariants were designed
around request goroutines plus Close. Four rules, each commented at
cycleIdleSubscriptions and each with a test:

  1. It refuses to cycle while pendingSubs holds a record, and MINTS the
     record itself before tearing anything down -- subscribeAndReplay checks
     pendingSubs before wsSubs, so a subscriber arriving mid-cycle joins the
     replacement instead of being admitted into the doomed subscription.
  2. lastSeen is stamped at INSTALL, not left at the zero value, which reads
     as 1970 and would cycle hardest on an unconfirmed admission -- the
     workspaces already having a bad time.
  3. wsCounts is re-read under the lock that performs the teardown.
  4. Re-establishment runs on b.ctx with a nil establisher; the bus has no
     subscriber registration of its own to unwind.

Two decisions beyond the plan:

A NEW COUNTER, not just the reset reason. dropWorkspaceCoverage reports a
reset only when a buffer existed to drop, and the incidents this detector
exists for skew hard toward having none -- a route that wedged early on a
quiet workspace. Reading cycles off the reset label alone would under-report
exactly the case it was built to find, so pad_event_subscription_cycled_total
is the dependable count and idle_timeout is corroboration. Both comments say
which is which.

THE CADENCE IS A LIVE TUNABLE -- a timer re-read under b.mu each pass plus a
buffered kick, not a ticker constructed once. A ticker captures the interval
at goroutine start, which makes the field write-once while its comment calls
it a tunable and makes any later write a data race; it also leaves no
deterministic way to test the WIRING other than a test-only constructor.

decodePayload's signature grew a payloadKind. The classification belongs to
the decoder, not the call site, so no future caller can reintroduce the
coverage drop; and the prefix (rather than an exact payload) means a later
frame version needs no third roll.

Also swept, per the team's prose convention: receiveMessages' doc comment and
deployment.md both said this gap was open and needed a decision. Both now say
what closes it -- and deployment.md says the watch stream still has the same
defect by the same mechanism, which is its own unit.

Trio kept together: ResetReasonIdleTimeout, the metric Help strings, and
docs/deployment.md's rollout order with the mixed-fleet failure named.

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

* test(events): rebuild the instruments the BUG-2738 matrix showed were blind

The mutation matrix found a defect in the fix itself and three tests that
could not have caught what they were named for.

THE DEFECT: the idle scan skipped a subscription whose lastSeen was the zero
value. That reads as belt-and-braces beside the install-time stamp and is the
opposite -- it makes a subscription that has NEVER received anything
permanently uncyclable, which is the BUG-2747 unconfirmed admission: the one
population the plan singles out as mattering most, and the one where a wedged
route would then be undetectable forever. It was also masking rule 2: with the
skip present, removing the install stamp survived every test. Skip removed;
that mutation is now caught. Re-adding it is undetectable by construction and
the comment says so, because a guard that only acts once a real one has broken
converts a caught defect into a silent one.

THREE INSTRUMENTS THAT WERE NOT MEASURING:

- "Drop only, never cycle" passed because establishSubscription overwrites
  wsSubs, so a generation check cannot see a replacement installed WITHOUT
  tearing the old connection down -- a leaked PubSub, connection and receive
  goroutine per cycle, forever, on exactly the wedged route where they never
  die on their own. Now asserted on the receive loop exiting.

- The Close test was vacuous. Close drains wsSubs, so a loop that ignored
  b.ctx entirely would find no workspaces and publish nothing: silence after
  Close was evidence of nothing. maintenanceStopped makes the goroutine's exit
  observable, which is the same reason Observer.ReceiveLoopExited exists.

- The joint test HUNG rather than failing under the drop-only mutation: the
  seam never fires, so the joiner goroutine was never spawned and an unbounded
  receive waited forever. The harness then aborted mid-run and LEFT THE
  MUTATION APPLIED to the working tree, which a grep caught and a green test
  run would not have. The wait is bounded and names the failure; the harness
  bounds each run, reports a hang as its own status, and restores in a finally.

Added: a direct test that a straggler frame from a replaced generation cannot
refresh its successor's liveness -- on a wedged route, the dead connection's
buffered tail would otherwise suppress the detector for the replacement.

RULE 3 IS AN OPTIMISATION, NOT A CORRECTNESS GUARD, and the matrix says so
rather than an argument: removing the whole second read -- liveness, generation
and count terms together -- survives every test, because
establishSubscription's abandon path already refuses to install for an emptied
workspace and retires the record in the same critical section (BUG-2749). The
first read is redundant more sharply still: reaching zero takes the
subscription down with it, so this loop never sees such a workspace. Both are
kept, because neither DEPENDS on that coupling, and both comments now carry the
per-term reading instead of describing tested defence in depth. The generation
term is unreachable while the establishment record is held, by rule 1's own
mechanism.

Matrix: 16/22 detected, plus 4 follow-ups. Every survivor is documented at its
line with why it survives.

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

* fix(events): gate idle detection on heartbeat phase 2 (BUG-2738, codex r1)

Codex round 1 found a defect the first draft had shipped WITH A COMMENT
JUSTIFYING IT, plus two coupling hazards.

P2-as-filed, P1 in effect: idle detection ran on every instance from phase 1,
on the reasoning that it could "detect off whatever traffic the deployment
already carries". That holds only for a BUSY workspace. A QUIET one on phase 1
has no events and no heartbeat, so a perfectly healthy subscription crossed
the 90s threshold on every pass and was cycled: replay coverage dropped, every
live subscriber told to resync, indefinitely -- on the DEFAULT configuration
every deployment lands in before it flips anything. A resync storm shipped as
the default, by the feature whose stated purpose is to avoid exactly that load
inversion.

Publishing and detecting are now one switch, which is what they always were:
an instance detects off its OWN frames -- it publishes to the channels it
subscribes to and receives them back -- so it never depended on peers having
flipped, and there was never a reason for the two to be separable. Phase 1 is
"recognise the frame so a phase-2 peer costs you nothing", and nothing else.
Regression test plus its counterfactual, so "no cycles" cannot be satisfied by
a detector that has simply stopped working.

P1: the maintenance loop published heartbeats and scanned for idleness on one
goroutine. publishHeartbeats makes N synchronous Redis publishes, and against
the failure this feature exists to detect those are precisely the calls that
block -- bounded by go-redis's own Dial/Read/WriteTimeout, not by any context
we can pass. A stalled publisher could therefore delay detection for as long
as those timeouts take, on the very instance whose connections had wedged, and
for longer the more workspaces it carried. Two goroutines with their own kick
channels; a stalled publisher now just produces silence, which is what the
detector reads.

P3: the cycle held the workspace's establishment record across a synchronous
observer report, so an Observer callback that subscribed to that workspace
would wait on a record only the reporting goroutine could retire. Moved the
SubscriptionCycled report past establishment. The narrower half is older than
this code -- confirmSubscription's late-acknowledgement path already reported
from inside that window -- so it is documented on the Observer interface as a
contract rather than silently worked around: a callback may publish, read and
unsubscribe; it may not subscribe.

Prose swept for what the gate falsified, per the team convention: the
constructor comment that argued for the defect, config.EventsHeartbeat's
rollback paragraph, the config test's inverted-rationale comment,
ResetReasonIdleTimeout, both metric Help strings, and deployment.md's phase
table and rollback section. All of them now say that phase 1 detects nothing
and that the cycled counter is STRUCTURALLY zero there -- a zero on phase 1
says nothing about whether a route has wedged, which is the reading an
operator would otherwise get wrong.

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

* test(events): prove a resuming joiner is told sync_required across a cycle

Codex round 2 raised that a subscriber arriving DURING an idle cycle gets no
gap signal, because dropWorkspaceCoverage only signals subscribers present
when it runs. True, and for a RESUMING caller the gap signal is not what
protects it: the registration mark is. It registers while the workspace has no
buffer, so its mark cannot match whatever buffer exists by the time it reads,
and eventsSinceMarkLocked answers nil -- sync_required rather than a false
"caught up".

A FRESH caller is deliberately not signalled and the finding is DECLINED for
that case, with reasons recorded at the test: it holds no prior position, so
there is no span it could be missing; it is admitted only after the
replacement subscription is acknowledged, because it waits on the cycle's
establishment record which finishPending closes after the confirmation; and on
the unconfirmed-admission path it IS told to reconcile when the acknowledgement
lands. Signalling it anyway would demand a resync of a client with nothing to
reconcile -- the load inversion this unit already had to fix once.

THE FIRST TWO VERSIONS OF THIS TEST DID NOT DISCRIMINATE, which is the part
worth keeping. Version one asserted the empty case: the cycle leaves no buffer,
so eventsSinceMarkLocked returned nil from its `!ok` term and removing the mark
check entirely still passed. Version two published inside
afterSubscriptionConfirmed so a FRESH buffer exists before the joiner reads --
and deleting the `mark.buffer == nil` term still survived, because the keep
arithmetic in that function already reduces to zero for a nil mark. Only
replacing eventsSinceMarkLocked with the unmarked eventsSinceLocked fails the
test, handing the joiner the post-cycle event as though it followed its cursor.
That is the mutation the test is built against, and the redundancy inside
eventsSinceMarkLocked is recorded rather than mistaken for coverage.

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

* fix(events): only count a cycle that actually replaced the connection (codex r3)

Three findings from a fresh-angle round on shutdown, wire format and doc
accuracy. The wire-format angle came back clean -- events:<workspace> cannot
collide with watchevents under validated namespaces, and no valid activity
payload can be mistaken for an hb| frame.

P3, and the one that stings: config.EventsHeartbeat still said phase 1
"already runs idle detection off whatever traffic exists". That is the exact
sentence the previous commit's sweep existed to remove, in a file that sweep
edited. A grep for the phrasing I remembered writing missed the paraphrase
sitting four lines above the paragraph I did fix.

P3: SubscriptionCycled was reported unconditionally after establishSubscription
returned, but establishment has two reasons to install nothing -- the bus
closed, or the workspace emptied while we dialled. The counter's documented
meaning is "torn down AND replaced", and counting an aborted establishment is
wrong in the direction that matters: an operator reading a non-zero rate
concludes connections are being blackholed, so a shutdown would manufacture
that signal. Now reported only when a replacement is installed, verified by
generation. Both Help strings and deployment.md say "counts replacements, not
teardowns"; the teardown stays visible through the idle_timeout reset reason.

P2: Close does not join the maintenance goroutines. Kept that way and
documented on Close, because the publish half makes synchronous Redis calls
bounded by go-redis's own timeouts -- the calls that stall on exactly the
wedged route this feature detects -- so joining would let a dead network hold
shutdown open. What has to hold instead is that a cycle already past its ctx
check leaves nothing behind, which is now pinned by a test that closes the bus
from inside the cycle's establishment: no subscription installed, no
establishment record stranded, no counter moved.

liveGen moved from the test file into the package -- production needs it now.

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

* test(events): restore the coverage the phase gate silently removed

The mutation matrix, re-run against the post-codex code, showed M3 -- removing
the install-time lastSeen stamp -- going from DETECTED back to SURVIVED. The
cause was my own round-1 fix: gating idle detection on heartbeat phase 2 means
a phase-1 bus never scans, and TestAnUnconfirmedAdmissionIsNotCycledAsIdle
built its own phase-1 bus. It was the only test that could observe a zero
lastSeen, because the plain fresh-subscription case is stamped twice over --
at install, and again by the acknowledgement. Flipped to phase 2 and
re-verified: removing the stamp fails it again.

Worth naming the shape rather than just the fix. A behaviour change that
narrows when code runs silently narrows what the tests reach, and nothing in a
green suite says so -- the tests still pass, they just stopped asking. Only
re-running the matrix after the change surfaced it.

Two harness bugs fixed alongside, both of which had been reporting
non-results as if they were readings:

- A mutation that INSERTS keeps its own anchor, so the "did the edit land?"
  check read every insertion as ANCHOR-ERROR. It compares the file now.
- The two rule-3 mutations left `sub`/`live` unused and came back BUILD-BREAK
  rather than answering the question; they carry the same discard the
  follow-up harness already used.

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

* test(events): close the wiring and barrier gaps codex round 4 found

Concurrency and lock discipline came back CLEAN -- the establishment record
and the generation checks cover two racing cycles, Unsubscribe, Publish and a
stale resubscription frame, with no lock-order deadlock. The four findings
were all about whether the tests measure what they claim.

P2, and it is the convention I had cited three commits earlier: the heartbeat
flip had no wiring test. internal/events proves a bus built with
publishHeartbeat=true emits frames and detects idleness, and every one of
those tests passes if newObservedEventBus hardcodes false -- the deployment
would simply never detect a wedged connection, which is indistinguishable from
a deployment that has none. Both directions asserted, because a helper that
ignored its config and hardcoded EITHER value passes a one-directional test.
Mutation-checked against exactly that edit.

P2: the metrics adapter test never touched SubscriptionCycled or the
idle_timeout reason, so an adapter that folded the counter into the reset
series -- destroying the very distinction those two are built to keep apart --
would have passed. Both added with counts that differ from their neighbours',
the pattern that file already uses so a label-dropping adapter cannot satisfy
the totals by coincidence.

P3: TestAHeartbeatConsumesNoEventID "waited" on a predicate that returned true
unconditionally. Not a slow wait -- no wait at all: the counter was read with
the publishes still in flight, so a heartbeat that DID consume an id could
land afterwards and the test would still pass. It now waits on the frames
arriving, and fails against a mutation that publishes an event alongside each
heartbeat.

P3: the maintenance goroutines started on phase 1, where both halves are
guaranteed no-ops -- two goroutines and two timers per process waking every
30s for the life of a deployment that asked for none of it, and phase 1 is the
DEFAULT. The flag is constructor-only so the decision is taken once. The
in-function gates stay: those are the correctness ones, and the tests reach
them directly without a loop.

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

* fix(events): validate the heartbeat frame and stop serialising recovery (r5)

Client-facing behaviour came back CLEAN: an idle cycle signals each local
subscriber, the SSE handler emits an in-band sync_required with an empty id
while holding the connection open, EventSource retires its cursor and the web
client runs the documented reconciliation. Two P2s on the other angles.

FRAME VALIDATION. Accepting any "hb|..." created a silently-ignored class on
the workspace event channel, where before this feature EVERY unreadable
payload ended coverage loudly and moved undecodable_message -- the counter
whose documented job is "suspect a namespace collision". A foreign or buggy
publisher whose bytes happened to start with the prefix slipped through that
signal without a trace. A frame is now hb|<version> plus optional short tokens
under a length cap; anything else wearing the prefix goes back to being a
coverage-ending decode failure, and the forward compatibility the prefix was
chosen for survives for a disciplined future frame.

What this deliberately does NOT try to fix, because it is not a hole: a forged
frame cannot fake liveness. Liveness means "this socket carried traffic", and a
frame that ARRIVES demonstrates exactly that whoever sent it -- which is why
stampLastSeen already fires for undecodable frames. There is no coverage claim
inside a heartbeat to forge.

CADENCE DRIFT, which was self-defeating rather than merely untidy. The timer
restarted after each pass, so the real period was T plus however long the pass
took. For the publisher that means an instance whose publishes are slow emits
heartbeats further apart, its own subscription sees them further apart, and it
can cross its own 3T threshold and cycle connections that were never wedged --
the slowness manufacturing the incident. Scheduling is deadline-based now, and
resets rather than bursting when a pass overruns badly.

SERIAL RECOVERY. One idle pass re-established every due workspace in sequence,
each re-dial bounded by go-redis's own timeouts, so recovery took N x that
timeout with the last workspaces reporting themselves uncovered throughout.
The failure that puts many workspaces on the due list at once is a Redis
failover, so the serial case was the common one. Bounded-parallel at 8 -- each
entry already owns its establishment record so they are independent by
construction, and an unbounded fan-out would answer a struggling Redis with one
dial per workspace at once. Test covers more workspaces than the cap, and
fails against a version that drops the overflow.

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

* docs(events): idle_timeout means coverage ended, not connection replaced (r6)

Codex round 6 came back clean on the non-Redis path (MemoryBus ignores the
Redis-only flag; EventBus and Close have not drifted), on the rollback
rehearsal (phase-2 to phase-1 and a mixed fleet are safe as documented,
including a bus mid-cycle -- Close cancels it, prevents installation and
retires its pending record), and on the operator surface
(PAD_EVENTS_HEARTBEAT is a server env/TOML setting; `pad configure` is client
connection config and needs no new surface).

The one finding is a contract drift I introduced two commits ago and then
wrote prose for in the same commit. Making SubscriptionCycled mean "replaced"
was right; what I missed is that the idle_timeout RESET REASON is emitted
earlier -- dropWorkspaceCoverage runs before the re-establishment -- so it can
fire when nothing is replaced, which is exactly the shutdown case the counter
was changed to exclude. Three doc sites and one log line said "replaced the
connection" anyway.

They now say what is true at the moment each fires: idle_timeout means
COVERAGE ENDED, only pad_event_subscription_cycled_total proves a replacement,
and the log says "attempting to replace" rather than "replacing". The log
wording matters on its own -- an operator correlating it with the counter
would otherwise find the log without the counter and go hunting a bug that
isn't there.

Third time this unit has produced prose the next change falsified, and each
time a different reviewer angle caught it rather than the sweep I ran at the
time. The pattern is that a behaviour change and the prose describing it land
in one commit, so there is no diff between them to notice.

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

* test(cmd): drive the heartbeat wiring test instead of sleeping at it (r7)

Codex round 7 found no leftovers across seven rounds of edits, and confirmed
the mass-cycle case does NOT produce a reconnect storm -- the SSE connections
stay open across a sync_required, so the admission limits are never consulted.

P3, and it is the failure I have been criticising in other people's tests: the
wiring test used a 300ms sleep as its ordering barrier. Under -race or on a
loaded CI box, a phase-1 bus that is correctly silent and a phase-2 goroutine
that merely has not been scheduled yet are indistinguishable, so the test could
pass or fail for reasons unrelated to the flip it exists to check. It now
drives one publish pass synchronously through a named test hook and uses an
ordinary event on the same channel as the barrier, which Redis delivers in
publish order. No timing left. Verified: still fails against the flag being
hardcoded false, and ten consecutive -race runs are green.

That replaces SetMaintenanceCadenceForTest with PublishHeartbeatsForTest rather
than adding to the exported test surface -- the loop's own wiring is covered
inside internal/events, where the unexported setter is available.

P2 is FILED, NOT FIXED, as BUG-2761: a mass coverage drop tells every connected
subscriber of every affected workspace to resync at once, and each browser tab
independently calls /changes with per-tab coalescing but no jitter and no
global budget. The fix is a web-client change plus possibly a wire-format hint,
which is independent of half-open detection and would materially expand this
diff. Worth filing rather than shrugging at because this unit makes the
simultaneous case MORE likely: it adds a third trigger of a class that already
existed (Redis failover, epoch change), and its natural cause is exactly a
network event that wedges many routes at once. deployment.md carries the
residual with the bug ref so an operator meets it before the incident does.

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

* test(events): make the tests prove what their comments claim (codex r8)

Round 8 was claim verification rather than bug hunting -- check the diff's
load-bearing assertions against the actual code -- and it was the highest-yield
round of the eight. The go-redis assertions (Ping writes without reading, the
channel path sets no read deadline, TLS dials ignore cancellation) and the four
claims about neighbouring functions all held. Seven other assertions did not.

TESTS THAT DID NOT PROVE THEIR OWN HEADLINE. This is the substance of the
round, and every one of these passed before and after:

- The JOINT TEST -- this unit's flagship -- claimed to discriminate the
  two-subscriptions failure and did not. Fan-out is per subscriber, so a joiner
  that opened its OWN second subscription still delivers the event to everyone
  exactly as the test expected. Nothing separates one subscription from two
  except counting them, which it now does at Redis, plus a duplicate-delivery
  check for the second receive loop. Fails against the pending record not being
  minted in the scan.
- The remedy test said "the old connection must also be gone" and waited for a
  receive-loop exit. stopRedisSubscription does two things and the loop exits on
  the first alone, so it passed against a version that cancelled the loop and
  left the PubSub and its health check open. Counted at Redis now; fails against
  exactly that mutation.
- The parallel-recovery test could not tell serial from parallel -- a serial
  pass cycles all thirteen workspaces too. It now uses a rendezvous, asserts the
  peak concurrency is above one AND within the cap, and fails against a serial
  implementation.
- The prefixed-garbage test only exercised the classifier. Whether
  receiveMessages ACTS on the error is a different claim, now driven through
  the real Redis path.
- The metrics adapter test's comment said "every reason this bus can emit"
  while subscription_unconfirmed was missing; its zero-assertion proved
  non-leakage, not mapping. Emitted now with a count distinct from its
  neighbour's, so a merging adapter cannot satisfy both.

PROSE THAT OUTLIVED THE CODE, again. The latency arithmetic still described the
single shared ticker that round 5 replaced with two independent loops; from
lastSeen [3T,4T) still holds, but from FAULT ONSET it is roughly [2T,4T)
because the publisher has its own phase. And a second "and replaces the
connection" in deployment.md that round 6's sweep missed.

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

* docs(events): correct three contract statements (codex r9)

Round 9 was cross-artifact conformance: every commitment the plan made was
checked against the code. All met -- wire classifier, lastSeen placement and
locking and install stamp and every-frame stamping, heartbeats bypassing
Publish and the shared counter, the drop-and-cycle remedy under the
single-establisher invariant, all four joint rules, the two-phase rollout with
its inverted-rationale test, and the reason/Help/deployment.md trio with the
rollout order. It also confirmed the three documented mutation survivors are
correctly dispositioned: both wsCounts checks are redundant-but-cheap under the
current invariant, and omitting the lastSeen.IsZero() skip is right because
adding it would mask a regression in the install stamp.

Three statements were wrong.

The env-var contract. My test comment said an unparseable PAD_EVENTS_HEARTBEAT
"must leave the flip off", which is true from a default config and false from a
config file that set it true -- there the value is left alone, as the
precedence test already asserts. The BEHAVIOUR is right and matches the epoch
flag: a typo must not move a migration in either direction, and silently
rolling an operator back to phase 1 would disable detection on a fleet that had
opted in with nothing saying so. Only the prose overclaimed, and it overclaimed
in the direction that invites someone to "fix" the ignore into a fail-closed
reset.

The constructor. NewRedisBusWithKeys documented publishEpoch and said nothing
about publishHeartbeat sitting next to it -- two adjacent booleans of the same
type belonging to two independent migrations, which is a shape that gets
swapped or dropped in a maintenance edit. Both now documented in order, with a
note that any combination is valid.

A stale count. EventSequenceResetsTotal's comment said "Five reasons" and there
are seven; it was already wrong by one before this unit added another. Replaced
with the count plus a pointer to the three artifacts that are authoritative and
move together, since the count itself is the part that goes stale first and is
read last.

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

* test(events): make the cadence arithmetic testable, and justify a guard pair

Matrix 5 (29 mutations, 21 detected) surfaced two things the previous run
could not, because both concern code the codex rounds added.

THE DRIFT FIX HAD NO TEST. Restoring the sleep-after-work form survived every
test in the package, and would have kept surviving: the only way to observe
drift through the loop is to time it, and a timing assertion is a flaky
assertion. Extracting nextTick makes the arithmetic checkable without a clock,
and the four cases now pin what the schedule is for -- a slow pass does not
push the next tick out, ten slow passes accumulate no drift, an overrun beyond
one interval resets instead of replaying the missed ticks, and an overrun
WITHIN one interval still catches up rather than re-phasing the schedule
permanently. Both directions mutation-checked.

The property is worth this much because breaking it is self-defeating rather
than merely untidy: an instance whose passes are slow emits heartbeats further
apart, its own subscription sees them further apart, and it crosses its own 3T
threshold and cycles connections that were never wedged.

A GUARD PAIR THAT ONLY DIES TOGETHER, which the team lesson says to treat as a
question rather than a clearance. The loop's ctx.Done select arm and its
post-wait ctx check each survive removal alone. Checked rather than assumed:
they cover disjoint moments and each is independently right -- the select arm
is the exit while WAITING, which is where the goroutine spends its life, and
the post-wait check stops a bus that closed DURING a pass from starting
another one against a cancelled context and a drained wsSubs. Removing BOTH is
detected. Reasoning recorded at the code, and the combined mutation added to
the matrix so the pair cannot quietly become a single point of failure.

Also fixed an ineffassign the lint gate caught in the new test.

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

* docs(events): state what the detector does not cover (codex r10)

Round 10 was adversarial: refute the unit's central claim rather than look for
defects in it. It partly succeeded, and the corrections are worth more than
most of the bug findings.

The claim was "a wedged connection is detected, coverage is ended, and the
connection is replaced so delivery resumes". Three parts of that were too
strong, and all three limits were checked against go-redis v9.22.0 rather than
argued:

IT IS A RECEIVE-SIDE DETECTOR, not a round-trip health check. It measures
whether frames ARRIVE. A subscription whose outbound direction is broken but
which still receives reads as healthy -- correctly, since nothing is lost, but
that is a narrower claim than "the connection is healthy".

IT CANNOT COVER THE PUBLISH PATH. PUBLISH travels on the client's connPool
while a subscription holds a connection from the separate pubSubPool
(redis.go:363, :1956) -- different sockets, different fates, and a reconnect of
one repairs nothing about the other. An instance whose publish path is wedged
loses its own events for every other instance and this feature will not say so.
That is a real gap in the family's coverage, now written down rather than
implied away.

REPLACEMENT IS ATTEMPTED, NOT GUARANTEED. If the path is still blackholed when
the cycle re-dials, the replacement cannot receive either. Coverage stays ended
so nothing is falsely claimed, but delivery resuming is a statement about the
network rather than about this code.

Filed BUG-2764 rather than folded in: establishSubscription's
`b.client.Subscribe(dialCtx, channel)` silently discards the SUBSCRIBE error,
because go-redis's own Client.Subscribe drops it (`_ = pubsub.Subscribe(...)`,
redis.go). A failed subscribe therefore installs a connection that looks live
and is subscribed to nothing. It is pre-existing, it lives in the establishment
path three bugs have already converged on, and changing how that function
issues its SUBSCRIBE does not belong in a diff about idle detection. Worth
knowing here because it is the one way the replacement can fail on a HEALTHY
network -- and because the detector now cycles it on the next pass, which is
why it self-heals on phase 2 and stays dead forever on phase 1.

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

* fix(events): do not cycle a workspace that recovered before its turn (r11 P1)

Codex round 11 attacked three claims. Phase-1 safety and rollback safety both
came back clean -- a phase-1 receiver stamps lastSeen and nothing else, touches
no buffer, metric, client, ID or epoch, and its maintenance loop is not started
at all, so that timestamp is inert; heartbeats leave no state in Redis or
across a process replacement, and a mid-cycle shutdown rechecks b.ctx before
installing. The third claim did not survive.

FALSE POSITIVES ON A HEALTHY SYSTEM, which is the property this design cares
about most: cycling a working subscription drops its coverage and resyncs every
one of its subscribers for nothing.

cycleIdleSubscriptions selects its victims under the lock and releases it; the
cycles run afterwards. Its re-checks asked about generation, subscriber count
and bus liveness -- and never re-asked the question the scan had asked. A
subscription that started receiving again in that window was cycled anyway.

The window is not theoretical, and this unit widened it itself: the 8-way
concurrency cap added in round 5 makes a workspace wait behind earlier batches
of slow replacement dials, and a GC or CPU pause leaves a backlog of heartbeats
undrained in the receive loop. Both are ordinary conditions on a loaded box.

cycleOne now validates, ends coverage and tears down WITHOUT RELEASING THE LOCK
in between, which needed dropWorkspaceCoverage split into a locked variant that
returns its reason for the caller to report after unlocking. That also removes
the ordering fragility the previous version documented rather than fixed: there
is no longer any window in which coverage is ended for a workspace this
function then decides to leave alone. The log moved after the decision for the
same reason -- it could previously describe a cycle that then abandoned.

The freshness term is load-bearing and says so, next to the three neighbouring
terms whose mutation survivals are recorded as redundant-but-cheap. Removing it
is detected, by a test that lands the recovery in the exact gap through a new
positional seam.

NTP steps were checked and are not a hazard: time.Time carries a monotonic
reading, so a wall-clock step cannot make a subscription look idle.

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

* perf(events): take logging and PubSub.Close off the global lock (codex r12)

Round 12 verified round 11's freshness fix: validation, coverage invalidation
and teardown are atomic under b.mu with no lock cycle,
dropWorkspaceCoverageLocked preserved the original semantics exactly including
the no-buffer branch that still signals subscribers, reset reporting happens
after unlocking, and the replacement metric still lands only when a new
generation does. Slow establishment stays outside b.mu, wg.Wait only delays the
next pass, and Close cancellation retires pending records.

Two P2s, both about what round 11 put UNDER that lock:

slog.Warn ran while b.mu was held. slog invokes the installed handler
synchronously, and b.mu is the lock every fan-out and every Subscribe on the
instance contends for -- a slow or custom handler stalls all of them, and one
that calls back into the bus deadlocks. Moved after the unlock; it still has to
come after the DECISION, for round 6's reason, so both constraints are now
stated together at the call.

PubSub.Close ran under b.mu too. It takes go-redis's own mutex, which the
health check can hold across reconnect work, so a network-bound wait sat inside
the instance's hottest lock. That was survivable when teardown only happened as
a workspace lost its last subscriber; the idle detector makes it happen on
every cycle, which is what turned a latent cost into a real one. Handed off to
a goroutine: nothing references the PubSub once the map entry is gone, and
cancel() -- which is what actually stops delivery -- still happens under the
lock.

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

* fix(events): do not read our own failed probe as a dead peer (codex r13)

Round 13 asked for a production-approval review. Four findings; the second is
the sharpest of the whole run because it is the mirror image of the failure
this feature exists to find.

A FAILED HEARTBEAT PUBLISH WAS READ AS A DEAD SUBSCRIPTION. The detector's
inference is "we published a frame and nothing came back, so the receive path
is dead" -- valid only if the publish actually happened. PUBLISH travels on the
client's connPool while the subscription holds a connection from the separate
pubSubPool, so a publish-side failure (pool exhaustion, a wedged outbound
route, Redis refusing writes) says nothing about whether that subscription can
receive. The detector was reading its own inability to probe as evidence about
the peer, and tearing down healthy connections on a schedule: a resync for
every subscriber of every workspace, every 90s, for as long as the outbound
path stayed broken. The third load inversion this unit has had to fix.

redisSub.lastProbeOK now records the last SUCCESSFUL publish, and detection is
suspended while it is stale -- checked in the scan and again in cycleOne, which
is a pair that only dies together and is therefore justified at the code:
the scan's keeps a workspace off the due list so no record is minted and no
joiner waits, cycleOne's covers the probe failing AFTER selection, a window the
concurrency cap makes real. Neither subsumes the other; removing both is
detected. New counter pad_event_heartbeat_publish_failures_total, documented as
DETECTION DEGRADED rather than as a peer being broken.

THE END-TO-END TEST THAT DID NOT EXIST. Every other test drives this through a
fake clock -- necessary, since the threshold is 90s by construction and
miniredis always answers, but it means they all ASSUME the wedge rather than
produce it. A TCP proxy that stops delivering server->client on the connections
already open, while writes keep succeeding and new connections stay healthy,
produces the real thing. The test asserts both halves of the claim: the wedge
is detected, and the replacement delivers. Both halves mutation-checked
(detector disabled; drop-only with no replacement).

The proxy's first version was vacuous -- a global flag consulted at read time
meant re-enabling delivery for future connections also revived the ones meant
to be dark. Per-connection now, and the comment says why.

Also: PubSub.Close taken off b.mu in Close() too (round 12 fixed only the cycle
path), and the replacement counter now takes an explicit installed result from
establishSubscription rather than inferring one from the live generation --
inference misattributed an unrelated caller's fresh subscription as this
cycle's replacement, and missed a real replacement that had lost its last
subscriber. Both mutation-checked.

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

* fix(events): bind the probe stamp to a generation; make the proxy test honest

Round 14 returned a BLOCK verdict on two P2s, both mine, both in the fix that
round 13 had just added.

lastProbeOK WAS NOT GENERATION-BOUND. publishHeartbeats snapshots the workspace
list, publishes off the lock -- for as long as go-redis's timeouts allow -- and
then stamped whatever subscription occupied that workspace by the time it
returned. A probe sent for generation A could credit generation B, which never
received one; if later probes then failed, B could be cycled while looking
recently probed. Exactly the hazard stampLastSeen already guards on the same
map, and I did not carry it across. The generation now travels with the
snapshot and is validated before stamping.

THE END-TO-END TEST COULD PASS WITHOUT EXERCISING WHAT IT CLAIMED. It darkened
the receive direction of every open connection, including the ordinary pooled
connection PUBLISH uses -- so the probe may have been failing too, and the run
would then have been exercising the cannot-probe path rather than a half-open
route, which is the very distinction round 13 added the premise check for. The
proxy now classifies connections as it forwards and darkens only one that has
carried a SUBSCRIBE, leaving the publish path healthy, and the test asserts
zero probe failures so a run that drifts back into the other case fails loudly
instead of passing quietly. Still fails against a disabled detector and against
drop-only.

Also covered the new counter's mapping in the metrics adapter test, with a
count distinct from both neighbours -- cycled, idle_timeout and
heartbeat-publish-failure say three different things and an operator acts on
the difference.

Verified by the same round: install-time stamping does not permanently suppress
detection, establishSubscription returns false only on abandon and true on all
three installed paths including the cancelled-establisher goroutine, and
Close's deferred PubSub.Close runs after the unlock.

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

* test(events): pin the probe-across-replacement race (closes r15's residual)

Round 15 returned CLEAN and approve-with-comments, naming one residual: the
generation binding on lastProbeOK had no deterministic test, only the argument
that it mirrors stampLastSeen. This closes it with a positional seam between
the publish and the stamp, which is the only place that interleave can be
forced.

TWO INSTRUMENT DEFECTS ON THE WAY, both caught by mutation rather than by
reading:

The first version compared the credited stamp against the PROBE's timestamp.
On a frozen clock the replacement's install stamp and a wrongly-credited probe
are the same value, so it could not tell them apart -- it failed on the install
stamp while claiming a credit had happened, and removing the generation binding
still passed. It now compares against what the replacement was INSTALLED with,
and the clock advances inside the seam so a buggy write lands strictly later.

The second version was FLAKY: 2 failures in 3 runs. The heartbeat that was just
published comes back through miniredis on another goroutine, and if it lands
between the forced-stale write and the scan it refreshes lastSeen, the
workspace is not due, and no replacement happens. Retried until the generation
actually moves. Now 5 of 5 green unmutated and 5 of 5 detected mutated -- which
is the bar, because a 2-in-3 detector reads as coverage while being noise.

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

* fix(events): on-call signals — log the cycle outcome, correct two claims (r16)

Round 16 read the diff as the person paged at 3am. Four findings.

THE CYCLE LOGGED ITS ATTEMPT AND NEVER ITS OUTCOME. The line says "attempting
to replace", which is correct and, on the one path where the replacement does
not happen, left an on-call with a warning, no counter movement, and no
explanation. Now there is a second line naming the reason.

pad_event_receive_loop_exits_total's documentation was falsified by this unit
and neither doc site said so: every idle cycle stops a receive loop while its
subscribers are still connected, and the comment still claimed exits happen
only at shutdown or when the last subscriber leaves. Both sites corrected, with
the expectation that it tracks the cycle counter during an incident.

A CLAIM I MADE AND THEN COULD NOT SUPPORT, recorded rather than quietly kept.
Round 16 argued the age-based premise check ("has a probe succeeded within the
threshold") failed to suspend detection where an ordering rule ("has a probe
succeeded since anything last arrived") would, and I rewrote the rule on that
argument and wrote a test named for the defect. The mutation matrix then
refused to confirm it: reverting to the age form leaves the test green, and so
does removing both copies of the check, and no case separates the two — on any
healthy path the two stamps advance together, because a probe whose frame
arrives sets both, and they diverge only on the wedge where both forms cycle.

The ordering rule is kept, because it states the intent exactly and is never
weaker. But the test and the comment now say what they actually establish —
that a probe which has started failing stops the detector concluding from
silence, which is the property both forms share and neither had before — rather
than claiming a fixed defect I cannot demonstrate.

The two remaining P2s are already-filed residuals: the cycled counter proves an
install rather than a working replacement (BUG-2764), and repeated cycling
amplifies /changes load with no jitter or global budget (BUG-2761). Both are
documented in deployment.md with their refs.

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

* docs(events): record what the final matrix actually says about four guards

Final matrix: 34 mutations, 22 detected, baseline restored green. Every
survivor is now documented at its line with why it survives, and two of them
turned out to be instrument defects rather than coverage gaps.

lastProbeOK's INSTALL STAMP IS REDUNDANT and the comment claimed otherwise. It
said a zero value "would permanently disqualify a subscription from ever being
cycled" -- true of the age-based premise it was written for, false under the
ordering rule that replaced it, because a zero value fails
`lastProbeOK.After(lastSeen)` exactly as an install stamp equal to lastSeen
does. Kept, for a reason it earns: it makes the field's invariant true by
construction, so a future rule reasoning about this value's AGE gets a real
timestamp rather than 1970 -- which is the trap the age-based rule fell into
one field over.

THE TWO cycleOne ABANDON GUARDS DIE ONLY TOGETHER AND ARE NOT REDUNDANT, which
took checking rather than assuming. They catch different shapes of the same
recovery: an arrival that has not been re-probed pushes lastSeen past
lastProbeOK so the premise case fires and the freshness case is unreachable --
that is the shape the test produces, and it is why removing either alone stays
green. But the publisher runs on its own goroutine at its own cadence and can
land a successful probe between the arrival and the decision, putting
lastProbeOK ahead again; there only the freshness case stops a healthy
subscription being torn down. Deleting it on the strength of the matrix would
remove the second shape's only guard.

Close's off-the-lock PubSub.Close is UNTESTED BY DESIGN, recorded rather than
papered over. It is a contention property, and the only assertion that
separates it is a timing one, which in this suite is a flaky one.

TWO HARNESS DEFECTS, both of which produced false survivors that would have
gone into the evidence package as findings. M11a inserted its mutation AFTER
the gate it was meant to disable -- unique anchor, wrong placement, so the
early return still fired and nothing changed; with a correct anchor it is
detected. M20 left variables unused and came back BUILD-BREAK rather than
answering; in compiling form it genuinely survives, consistent with
establishSubscription's abandon path already covering it.

The lesson worth keeping: when I rewrote all 34 anchors against current source
I verified each matched exactly ONCE, and uniqueness is not placement. An
anchor can be unique and still land somewhere that changes no behaviour.

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

* test(events): barrier the probe test on delivery — it was flaky, CI caught it

Go (PostgreSQL) failed on af7001ab, in a test I added two commits ago. Not a
timeout and not the race step: TestAFailedProbeAfterASuccessfulOneStillSuspends
Detection asserted no cycle and got one.

The test killed Redis immediately after a successful probe, without waiting for
that probe's frame to be delivered back. If the frame never lands, lastSeen
stays at the install stamp, the successful probe is then legitimately "after
the last arrival", the workspace is genuinely due — and the code cycles it FOR
THE RIGHT REASON under a test asserting it should not. The premise the test is
named for simply did not hold on a slower machine.

So this was not a false alarm in CI and not a defect in the code: it was my
test asserting an outcome whose precondition it never established. Waiting for
lastSeen to move makes the precondition real. Eight consecutive local runs
green, and removing both premise checks still fails it, so the barrier did not
neuter what it was measuring.

Worth naming because it is the third instrument defect in this unit found by
something other than reading it — after the harness restore that ate an edit
and the unique-but-misplaced mutation anchor. A test that depends on an
unsynchronised delivery is a test that passes on the machine that wrote it.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 16:57:52 -04:00

74 KiB
Raw Blame History

Deployment Guide

Pad is a single Go binary with an embedded web UI. It supports SQLite (default) for single-node deployments and PostgreSQL + Redis for production multi-node setups.

Architecture

                    ┌─────────────────┐
                    │  Reverse Proxy  │
                    │  (Caddy/nginx)  │
                    └────────┬────────┘
                             │ :443
                    ┌────────▼────────┐
                    │      Pad        │
                    │   Go binary     │
                    │  (web UI + API) │
                    └──┬──────────┬───┘
                       │          │
              ┌────────▼──┐  ┌───▼────────┐
              │ PostgreSQL │  │   Redis    │
              │ (storage)  │  │ (pub/sub)  │
              └────────────┘  └────────────┘
  • Pad serves the REST API and embedded SvelteKit web UI on a single port (default: 7777)
  • PostgreSQL stores all data (workspaces, items, users, activity). SQLite works for single-node.
  • Redis carries real-time events, watch/push notifications, and the shared session-presence registry across multiple Pad instances. Optional for single-node.

Quick Start with Docker Compose

# Clone the repo
git clone https://github.com/PerpetualSoftware/pad.git
cd pad

# Start everything (Pad + PostgreSQL + Redis)
docker compose up -d

# Check status
docker compose ps

# View logs
docker compose logs -f pad

Access the web UI at http://localhost:7777. On first visit, you'll be prompted to create an admin account.

Production Docker Compose

# Use the production overlay for resource limits and secure settings
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Edit docker-compose.prod.yml to set your domain, email credentials, and database password.

Environment Variables

All configuration is via environment variables or a config file (~/.pad/config.toml / /data/config.toml).

Core

Variable Default Description
PAD_HOST 127.0.0.1 Listen address (0.0.0.0 for Docker/production)
PAD_PORT 7777 Listen port
PAD_URL Public-facing base URL (e.g., https://pad.example.com). Used for invitation, password-reset, and share-link emails. Required when PAD_HOST=0.0.0.0 — otherwise emailed links point at http://0.0.0.0:port and are unreachable to recipients.
PUBLIC_URL Alternative to PAD_URL using the generic env-var convention. Server-side only — does not affect CLI mode, does not influence the CLI's API endpoint, and is not persisted to config.toml. Precedence: PAD_URL > PUBLIC_URL > constructed http://host:port.
PAD_DATA_DIR ~/.pad Data directory for SQLite DB, logs, and config
PAD_LOG_LEVEL info Log level: debug, info, warn, error
PAD_MODE local Mode: local, remote, cloud

Database

Variable Default Description
PAD_DB_DRIVER sqlite Database driver: sqlite or postgres
PAD_DB_PATH ~/.pad/pad.db SQLite database path (ignored when using PostgreSQL)
PAD_DATABASE_URL PostgreSQL connection string (required when PAD_DB_DRIVER=postgres)

Real-time Events

Variable Default Description
PAD_REDIS_URL Redis URL for cross-instance pub/sub and the session-presence registry. Without Redis, SSE events, watch notifications, and session presence are all in-process only.
PAD_REDIS_NAMESPACE Scopes every Redis key and channel to one installation. Set it when two Pad installations share a Redis endpoint. Unset means the historical names; a whitespace-only value is rejected at startup rather than treated as unset.
PAD_SSE_MAX_CONNECTIONS 1000 Maximum streaming connections per instance, across both /api/v1/events and /api/v1/events/stream
PAD_SSE_MAX_PER_WORKSPACE 100 Per-workspace maximum connections on /api/v1/events, per instance
PAD_SSE_MAX_PER_USER 50 Per-user maximum streaming connections across both endpoints, per instance
PAD_EVENTS_PUBLISH_EPOCH false Phase 2 of the event ID-space migration: publish the <epoch>|<id>|<json> wire form. Only set this once every instance runs a binary that accepts it — see Event ID-space migration below. Ignored without Redis.
PAD_EVENTS_HEARTBEAT false Phase 2 of the half-open-connection detection rollout: publish a bus-internal liveness frame on each subscribed workspace channel every 30s. Only set this once every instance runs a binary that recognises it — see Half-open connection detection below. Setting it early makes every un-upgraded instance resync all its clients every 30 seconds. Ignored without Redis.

Streaming connection limits

Pad has two SSE endpoints and they share one budget. /api/v1/events is workspace-scoped (the web UI's activity stream); /api/v1/events/stream is user-scoped (agent watch notifications, pad watch --stream). A held connection costs a goroutine and a bus subscription whichever one opened it — and, on the watch stream only, a session-presence registration in shared Redis — so PAD_SSE_MAX_CONNECTIONS and PAD_SSE_MAX_PER_USER bound them together. Only PAD_SSE_MAX_PER_WORKSPACE is endpoint-specific, because the watch stream has no workspace to count against.

Upgrading: PAD_SSE_MAX_CONNECTIONS previously bounded /api/v1/events alone. It now covers both, so a tuned value may be reached sooner than before. The server logs the effective limits at startup (Stream connection limits). /api/v1/events/stream had no limit at all before this change; if you run many agent sessions per user, check PAD_SSE_MAX_PER_USER against your fleet size.

A refused connection is 429 with code sse_limit_exceeded and a Retry-After header. The CLI monitor (pad watch --stream) treats it like any other non-200 and backs off (5s, growing linearly, capped at 5 minutes), so refusal does not produce a reconnect storm. pad project watch is interactive and exits with an actionable message instead.

Browsers do not back off. The web UI's activity stream uses EventSource, which retries on its own fixed schedule and cannot see the status code or the Retry-After header — so a refused browser tab reconnects roughly every few seconds until capacity frees up. Size PAD_SSE_MAX_CONNECTIONS with that in mind: reaching it does not shed load from browser clients the way it does from the CLI. Tracked as BUG-2733.

The per-user limit applies to every caller. On /api/v1/events, callers with no resolved user — a legacy workspace-scoped token, or the fresh-install window before the first admin exists — are bounded per workspace instead, at the same number, so two legacy tokens for one workspace share a bucket. /api/v1/events/stream has no equivalent case: it requires a resolved user and answers 401 without one.

All three limits are PER INSTANCE, not deployment-wide. They are enforced in-process; there is no shared counter. A three-replica deployment with PAD_SSE_MAX_CONNECTIONS=1000 admits up to 3000 connections in total, and a single user can hold PAD_SSE_MAX_PER_USER connections on each replica. Size them per pod and multiply by replica count for the deployment ceiling. Watch pad_stream_connections_active (per instance) rather than inferring the total from the configured number.

Redis configuration notes

One namespace per installation, or one endpoint per installation. Every Redis key and channel Pad uses carries PAD_REDIS_NAMESPACE when it is set: pad:<namespace>:events:…, pad:<namespace>:watchevents…, pad:<namespace>:session:…. When it is unset the names are the historical flat ones, so upgrading changes nothing.

Two installations sharing one Redis endpoint without distinct namespaces cross-feed notifications and merge their session-presence registries. Selecting different logical DB numbers only half helps: ordinary keys are DB-scoped, so the presence registries stay separate — but Redis pub/sub is not namespaced by DB at all, so both buses cross-feed regardless. The practical exposure is a cloned database (a staging environment restored from a production dump), because delivery is filtered on user id and user ids are per-installation UUIDs; for that case it is a genuine cross-tenant leak.

Changing the namespace on a running deployment is a cutover, not a tweak. Set it before going multi-installation rather than after, and take a brief maintenance window if you can. Three things to know:

  1. It partitions a rolling upgrade. Replicas with the namespace set and replicas without it do not share pub/sub channels, counters, or the presence registry — they behave as two separate installations for as long as the rollout takes. GET /api/v1/sessions answers differently depending on which replica handles it, and a session-targeted push aimed across the partition is skipped (reported honestly as delivered_sessions: 0, but not delivered). Roll all replicas together, or accept a split for the duration.

  2. Rolling BACK re-creates the split unless the namespace is unset at the same time. The env var and the binary version have to move together in both directions.

  3. Client resync is honest on both streams, with one documented edge. Each answers a resume whose cursor belongs to the old keyspace with sync_required (see What sync_required means to a client), by way of its cold replay-buffer coverage check rather than an epoch comparison — a freshly namespaced bus has no old epoch to compare against. Expect a burst of client reconciliation as they reconnect — for ACTIVITY-stream clients (the web UI) an incremental /changes delta each, not a full page load. WATCH-stream clients cost less: pad watch --stream answers sync_required by clearing its cursor and keeping the connection open, so it refetches nothing. Either way that is the cutover being paid for, and it is bounded by the number of reconnecting clients — each RESUME is counted, so a client that reconnects several times counts several times.

    The edge: a cursor that lands exactly one below the first ID a replica sees in the new keyspace is served rather than refused, because nothing in an integer cursor distinguishes the two keyspaces. It is narrow — that one value, on a client that reconnects before the replica has seen anything else — and closing it needs the ID space's identity to reach the client. The SSE spec would allow that (an event ID is arbitrary UTF-8); what excludes it is Pad's own id: contract, an int64 that every deployed client already parses. Tracked as BUG-2736. A maintenance window narrows it — clients reconnect against an already-cut-over instance rather than racing the cutover — but does not remove it, since their stored Last-Event-ID values still belong to the old keyspace and the wire format still cannot say which one they came from.

    Before BUG-2731 the activity stream (/api/v1/events) was the silent one: a client reconnecting with a Last-Event-ID from the old keyspace against a fresh replay buffer was treated as caught up and silently missed everything that happened during the cutover, until its next full page load. If you are running a build older than that fix, the old behaviour still applies and a namespace change wants a maintenance window rather than a live cutover.

Session-presence entries are transient — 90s TTL — and cost nothing either way.

Pad's Redis integration assumes a single Redis noderedis://…, not a cluster. Key names carry no hash tags and Pad dials a non-cluster client, so a user's presence index and their session entries would hash to different slots and the Lua scripts would fail CROSSSLOT. Pointing Pad at a Redis Cluster is not supported.

What sync_required means to a client

Both SSE endpoints — /api/v1/events (activity, workspace-scoped) and /api/v1/events/stream (watch, user-scoped) — emit a sync_required event when the server cannot honestly claim the client has seen everything. The client's answer is to reconcile: the web client runs an incremental /changes delta (not a full page load), and the pad CLI clears its cursor so its next reconnect starts fresh.

It is emitted in two situations, not one. The distinction matters for reading the metrics below, and for anyone writing a third-party consumer:

  • On a resume. The client reconnected with a Last-Event-ID this instance cannot vouch for — an evicted or cold replay buffer, coverage that starts above the cursor, an ID-space change, or a cursor it cannot parse.

  • Mid-stream, on a connection that is still open. The instance discovered it under-delivered to a client that never disconnected. Two causes: that one connection was too slow to drain its buffer, so an event was dropped for it; or this instance itself missed messages from Redis, which every subscriber on it shares.

    Both streams now detect a pub/sub resubscription and a message they could not decode (BUG-2739), and end the affected coverage when they do. Before this the watch stream detected neither, and for a client HOLDING A STREAM OPEN its only signal was a hole in the received ID sequence — which needs a LATER notification to expose it, so a flap that lost the newest notification on a stream that then went quiet left a connected CLI silently stale indefinitely. Detecting the two conditions directly is what covers the case ID arithmetic never reaches.

    A RECONNECTING client was never in that position and still is not: a resume asks the shared counter what the newest ID is rather than trusting the instance's local view, so a cursor the instance cannot vouch for is refused whether or not the instance ever noticed the flap. The gap this closes is specifically the open-stream one.

    ID-sequence detection itself is watch-stream only, and that asymmetry is by construction rather than an omission. The watch stream has one channel and one counter, so its IDs are consecutive and a hole is visible as a jump. The activity stream's IDs come from a counter shared across workspaces, so per-workspace holes are the NORMAL state and no arithmetic on them means anything. That is why pad_watchevents_sequence_gaps_total has no pad_event_* counterpart.

    What a failover now COSTS, since detecting it is not free. A watch-bus resubscription ends coverage for that instance's whole watch stream — there is one replay buffer, not one per workspace — so every /api/v1/events/stream subscriber on that instance is told mid-stream at once. Activity-stream coverage is per-workspace, so a resubscription there ends only the affected workspace's.

    What that costs depends entirely on the client, and for the one client that uses the watch stream today it is nearly nothing: pad watch --stream reacts to sync_required by clearing its cursor and KEEPING THE CONNECTION OPEN (cmd/pad/cmd_watch.go), so a failover produces no reconnect and no refetch — the next notification simply starts a fresh coverage span. The cost to watch out for is a future consumer that answers sync_required with a refetch instead: for that client the announcement is one request per connection, arriving together, since per-connection coalescing smooths repeats WITHIN a wave and not the wave itself. That is the deliberate trade this family makes — chatty-but-correct beats quiet-but-lossy — and if it ever becomes a capacity problem the answer is fewer connections per instance, not a quieter bus.

    What undecodable_message actually indicates. Genuinely unreadable input on the watch channel: a non-Pad publisher on the key, a wire format from a mixed-version fleet mid-upgrade, or corruption. It does NOT usually mean two current Pad installations sharing a Redis — those publish the same wire format, so their messages DECODE, and the damage is cross-feeding real notifications between installations while this counter stays flat. That is the failure PAD_REDIS_NAMESPACE exists to prevent, and it is both worse and quieter than the one this counter reports.

    Who can force a resync with it, and what a flood costs. Anyone who can PUBLISH onto the watch channel — which sounds worse than it is, since the same access allows publishing FORGED notifications, so a channel writer is outside the threat model already. Under a flood, what IS bounded: the announcement, a non-blocking send onto a capacity-1 flag that is already raised, so it collapses to nothing after the first; and heap GROWTH, since each discarded replay buffer is garbage immediately and the receive loop is serial. What is NOT bounded: per-message CPU and allocation — a fresh replay buffer plus a pass over every subscriber, per malformed message, on the single goroutine that also delivers real notifications, so a sustained flood is receive-loop starvation as much as it is garbage collection. And log volume, one ERROR line per message. Bounding either needs a rate threshold, which is a deployment decision this code declines to make on your behalf. Payload size is deliberately not capped in Pad, because go-redis has read the whole message into memory before Pad sees it; bound it with Redis's proto-max-bulk-len and with who holds PUBLISH.

    One gap in that detection remains everywhere, and a second remains on the watch stream only. A message lost in transit with the connection intact — no flap, no decode failure, just a message that never arrived (BUG-2735): on the watch stream a LATER notification exposes it as an ID gap, while on the activity stream, whose per-workspace IDs are non-consecutive by construction, nothing local ever does. That one is open on both.

    A HALF-OPEN connection — a route that stopped carrying traffic without closing, so nothing ever resubscribes and no message ever arrives to be non-consecutive with — is closed on the activity stream as of BUG-2738 and still open on the watch stream, which has the same defect by the same mechanism and has not been ported yet. Do not assume go-redis's pub/sub health check covers it on either: PubSub.Ping writes the command and never reads a reply, so it reports healthy for as long as the socket accepts writes. What closes it on the activity stream is application-level idle tracking with a heartbeat that makes the threshold answerable — see Half-open connection detection — and until the same lands on the watch stream, a wedged route there is still silent.

    A third residual affects RESUMES rather than open streams (BUG-2743): if the watch counter restarts without the epoch rotating — evicted under maxmemory, lost to a FLUSHDB, restored from a stale snapshot — the old and new ID spaces overlap, and a Last-Event-ID inside that overlap cannot be attributed to either. The instance refuses the cursors it can identify as stale and serves the rest, so a client holding an old-space cursor in the overlap can be handed new-space notifications as though they followed it. Arithmetic on the IDs cannot close this — telling two sequences apart is what the epoch token is for, and this is precisely the case the epoch does not see. Rotating the epoch (see Event ID-space migration) is what makes a deliberate counter reset safe.

    A RECONNECTING client is largely covered on the watch stream anyway, because a resume consults the shared counter rather than local state alone. Not entirely: that check reads the counter at one instant, so a notification published AFTER the read and missed is invisible to it — an at-most-once pub/sub residual with no per-connection ack, documented on resumeOutrunsLocalView and again in the CLI. What these two gaps reliably leave stale is the client holding a stream OPEN.

The second case is newer — before it, a held-open stream that missed events was never told, and a later delivered event advanced its cursor past the missing IDs so no replica would ever replay them. A mid-stream sync_required carries an empty id: field, exactly as the resume case does, so a client stops resending a position the server has just disclaimed.

There is no separate event name for the mid-stream case, deliberately: every client acts on the two identically.

What a client should DO with it, since the two endpoints recover differently and a third-party consumer cannot infer this from the frame:

  • Keep the connection open. The frame is not a close and does not ask for a reconnect. The server keeps streaming; a client that tears down and redials on every sync_required turns one delta into a reconnect storm.
  • Expect events after it, possibly with IDs below the hole. A mid-stream sync_required is not ordered against events the server had already queued for that connection, so a client can receive the frame and then events that predate the gap. Their IDs re-establish a cursor at a position the server has just disclaimed. This is deliberate and bounded: reconciling is what the frame asked for, and a later reconnect from such a cursor is refused by the coverage check and answered with sync_required again. Holding the announcement back until those events drained was tried and removed — every version of it could defer the announcement indefinitely while a busy workspace kept the queue full, and an unbounded silence is worse than a redundant resync.
  • Stop trusting your cursor. The empty id: retires it, so a compliant SSE client stops sending Last-Event-ID on its next reconnect. Do not re-send the old value: the server has just said it cannot vouch for that position.
  • On /api/v1/events, reconcile the workspace. Its events describe item state, so a delta refetch recovers everything missed. The web client uses /changes; any client can re-read the items it cares about.
  • On /api/v1/events/stream, reconcile what you can and accept the rest is gone. Watch-matched notifications describe item state and can be re-derived by re-reading those items. One-shot PUSHES cannot: they are not stored as recoverable state, there is no backfill endpoint for them, and a push missed during a hole is missed permanently. This endpoint is best-effort for pushes by design, and sync_required on it means "your position is untrustworthy", not "re-fetch and you will be whole again". The pad CLI monitor does exactly this: it clears its cursor and keeps listening. There IS a separate metric — see pad_event_midstream_resyncs_total below — so the two populations stay distinguishable to an operator without changing what any existing alert means.

A connection gets at most one MID-STREAM announcement every 5 seconds (the resume-time signal is not rate-limited and never needed to be — it happens once per connection, at the start), and nothing is lost to that bound: a gap arriving inside the window is remembered and announced when the window closes. The bound exists because the subscriber most likely to be signalled is a slow one, and answering "you could not keep up" with "now fetch a delta" can feed back into more drops.

Redis health and metrics

/api/v1/health/ready reports Redis in its payload but does not gate readiness on it — the REST API, the web UI and every item-writing path work with Redis down, so failing readiness over a Redis blip would pull healthy replicas out of the load balancer and turn a degraded feature into an outage. When Redis is unreachable the payload carries redis.reachable: false, the probe error, and a degrades list naming what is lost. Note what that list says about activity events: they stop for all clients, not only across instances — the activity bus does not fall back to a local fan-out when its publish fails. The block is absent entirely when no Redis is configured.

Alert on these instead:

Metric Meaning
pad_redis_up 0 when the last probe (every 15s) failed. Exported only when Redis is configured — absence means "no Redis", not "down"
pad_stream_connections_active Held streaming connections on this instance, across both SSE endpoints — the population the limits bound
pad_watchevents_sequence_gaps_total This instance missed notifications — a delivery fault
pad_watchevents_resume_gaps_total Resumes this instance could not serve — from a hole, a cold start, an epoch change, or a shared-counter disagreement. Each sends a client sync_required. RESUME-TIME ONLY; a subscriber told mid-stream is counted separately, so an alert on this keeps the meaning it had
pad_watchevents_midstream_resyncs_total Watch-stream subscribers told MID-STREAM that they missed notifications, on a connection that stayed open. New in BUG-2730
pad_watchevents_notifications_missed_total How many notifications those gaps spanned
pad_watchevents_notifications_dropped_total Received but not delivered to a local subscriber — that connection's buffer was full. Since BUG-2730 that subscriber is told (sync_required, mid-stream) rather than silently under-served, so a rise here produces a rise in pad_watchevents_midstream_resyncs_total, one client at a time
pad_watchevents_sequence_resets_total Watch replay coverage dropped, by reason. epoch_change — the watch epoch token changed, so the IDs now come from a different sequence; the token is an opaque UUID here, not a numeric generation. counter_backward — an ID arrived at or below the high-water mark with the epoch unchanged. (This label was spelled counter_backwards while BUG-2739 was in development. If you are reading a dashboard that uses the plural, it was built against an unreleased build — see the note below.) subscription_resumed — a pub/sub connection dropped and re-subscribed, so whatever was published during the outage never arrived; expect these during a Redis failover and expect them to stop afterwards. undecodable_message — a message on the watch channel could not be parsed. The instance cannot tell whether that was a notification it should have had or something foreign, and it stops vouching because it cannot tell; expect zero, and suspect a namespace collision. The first two mean the ID space changed under this instance. subscription_resumed means it did not and something demonstrably went missing. undecodable_message means neither is established — only that coverage can no longer be proved. Each also announces to the watch subscribers connected at that moment, so each moves pad_watchevents_midstream_resyncs_total by AT MOST one per such subscriber — at most, because the signal is capacity-1 and coalescing, so a second cause firing before a client has acted on the first adds no announcement. For the same reason the announcement counter is not a ratio against this one in aggregate: it also counts gaps and slow-subscriber drops, and only a reset observed in isolation, against idle clients, lets you read the fan-out off these two counters
pad_watchevents_receive_loop_exits_total Non-zero outside shutdown means an instance publishes but receives nothing
pad_event_resume_gaps_total The ACTIVITY stream's (/api/v1/events) twin of the watch resume counter above. Expect a step around a deploy, with the RATE settling back to baseline (the counter itself only ever increases) — each instance starts with no replay coverage, so an early resume against a workspace it has not seen yet is a warranted resync. It counts RESUMES, not clients: a deploy with no reconnects does not move it at all, and a client that reconnects several times is counted several times. A rate that does not settle is the thing to alert on
pad_event_midstream_resyncs_total Activity-stream subscribers told MID-STREAM that they missed events, on a connection that stayed open. New in BUG-2730, and the counter to watch when judging whether that fix is costing more resyncs than it is worth. It counts ANNOUNCEMENTS, not causes and not distinct clients: a reset that drops buffers moves it once per live subscriber (and that ratio against pad_event_sequence_resets_total is the fan-out); a burst of drops on ONE connection moves it once, because signals coalesce and are rate-limited per connection; and a coverage loss on a workspace with no buffer yet moves it while every cause counter stays flat, because there was no coverage to end but the subscribers still have a hole
pad_watchevents_midstream_resyncs_total (see also, listed above) Same meaning for the watch stream. Its causes are a slow-subscriber drop and a received sequence gap or reset; a gap announces to EVERY subscriber on the instance, so it can exceed all of its cause counters
pad_event_sequence_resets_total Activity replay coverage dropped, by reason. subscription_resumed — a pub/sub connection dropped and resubscribed, dropping that workspace's buffer; expect it during a Redis failover and expect it to stop afterwards. epoch_change — the shared counter's ID space changed generation, dropping every buffer; expect a handful per cutover. counter_backward — an ID arrived at or below a buffer's high-water mark with no generation change; see Event ID-space migration for what to expect per phase. epoch_regressed — a LOWER generation was seen, so this instance stopped vouching for its buffers. One alongside an epoch_change is a message that was in flight when the generation rotated; a RUN of them means the counter itself went backwards — usually Redis lost writes, and since BUG-2740 possibly a repaired generation key (see A repaired generation counter). undecodable_message — a message on these channels could not be parsed, so that workspace's coverage ended; expect zero, and suspect a namespace collision. subscription_unconfirmed — a subscription was admitted before Redis acknowledged the SUBSCRIBE and the acknowledgement then arrived, so the span in between is one that stream cannot account for; it reaches THIS counter only when a buffer existed to drop, so read pad_event_subscription_unconfirmed_total for the dependable count. idle_timeout — a subscription received nothing at all (no event, no heartbeat, no acknowledgement) for longer than the idle timeout, so this instance stopped vouching for its buffer. It means coverage ended, not that the connection was replaced: the replacement is attempted afterwards and installs nothing if the instance is shutting down or the workspace loses its last subscriber, so only pad_event_subscription_cycled_total proves a replacement. Unlike subscription_resumed it does NOT establish that events went missing, only that the socket stopped proving it works, and like subscription_unconfirmed it reaches this counter only when a buffer existed to drop
pad_event_events_dropped_total Activity events not delivered to a live subscriber, by reason — today only slow_subscriber (that connection's 64-deep channel was full). Per-SUBSCRIBER: every subscriber that was keeping up received the event. Pairs with pad_event_midstream_resyncs_total, though not one-for-one in either direction — see that row. New in BUG-2730, along with the fix that stops the drop being silent, so a deploy that starts reporting these is not necessarily a regression — it may be the first time they were countable
pad_event_subscription_cycled_total Activity-stream workspace subscriptions torn down and replaced because nothing arrived on them — no event, no heartbeat, no acknowledgement — within the idle timeout. It counts replacements, not teardowns: a cycle that installed nothing because the instance was shutting down or the workspace lost its last subscriber does not increment it, so a restart cannot manufacture this signal. Detects a half-open connection: no FIN, no RST, just a route that stopped working, which go-redis cannot see because its pub/sub health check writes a PING and never reads the reply. Expect zero. Read this rather than pad_event_sequence_resets_total{reason="idle_timeout"}, which moves only when a buffer existed to drop and so under-reports exactly the early-wedge case this detector exists for. A non-zero rate means connections to Redis are being silently blackholed — a NAT idle timeout, a stateful firewall, an overlay network dropping long-lived flows; check TCP keepalive on the path before changing the interval. On heartbeat phase 1 this counter is structurally zero — detection is part of phase 2, so a zero there says nothing at all about whether any route has wedged. Read heartbeat_phase off the startup log before drawing any conclusion from it
pad_event_subscription_unconfirmed_total Activity-stream subscriptions admitted before Redis acknowledged the SUBSCRIBE, because the wait for it timed out (BUG-2747). Expect zero. Counts ESTABLISHMENTS, not clients — one workspace subscription that timed out increments it once however many subscribers were waiting on it. Nothing is known to have been lost; what it says is that a stream was admitted whose coverage this instance cannot describe, and that every subscriber waiting on it will be told to reconcile when the acknowledgement lands. A non-zero rate means the SUBSCRIBE round trip is slow or stalling — read it alongside SSE connect latency rather than alongside pad_event_sequence_resets_total
pad_event_receive_loop_exits_total A workspace's activity subscription loop stopped. Unlike the watch stream's twin this does not stay at zero — it is expected at shutdown and whenever a workspace's last local subscriber leaves. Read it as a rate against a stable subscriber count
pad_session_presence_failures_total Presence operations failing — read the op label, the risks differ and run in opposite directions: register/renew may under-report (a live session unlisted and untargetable), deregister may over-report (a dead session left listed, and a push aimed at it reaches nobody), list returns a 503, prune is benign. A failure means the operation reported an error — Redis can fail a pipeline after applying it, so the write may have landed anyway

A repaired generation counter

event_epoch_gen is a shared Redis key, and the same things that corrupt any shared key can corrupt it: a namespace collision with another installation, a hand-edit during an incident, a restore that mixed keyspaces. Since BUG-2740 a corrupted one is REPAIRED rather than fatal — before that, every phase-2 publish consumed a sequence ID and then failed, permanently, because the branch that would have rotated the generation was the branch that could not run.

Two operator-visible consequences, neither of which had documentation:

  • A repair reseeds the generation from wall-clock SECONDS. That is above any counted history, so it normally reads as an ordinary epoch_change. It is not guaranteed to be above a counter that a collision or a hand-edit had pushed higher, so it can instead surface as epoch_regressed — which otherwise means a failover to a replica that lost writes. The tell is the value: read the key, and a repaired generation looks like a unix timestamp (ten digits, around 1.7e9) rather than a small count of ID-space resets. There is no repair-specific counter or log line, because the repair happens inside a Lua script.
  • Clients reconcile, and normally once. A repair is an ID-space change like any other, so receivers stop vouching for their buffers. It does not loop, because the repaired key is valid and the next rotation increments it normally. The exception is a repaired generation that lands BELOW the one a receiver already holds: that instance discards the lower epoch as a straggler for its 30-second window rather than adopting it, so the same space can be disclaimed again when it is finally adopted. Bounded by that window, and visible as epoch_regressed rather than epoch_change.

Two repairs CAN collide, and what catches it is not the epoch. The seed is above any COUNTED history; it is not a monotonicity guarantee. Corrupt the key twice inside one second and both repairs seed the same value, so two genuinely different ID spaces carry the identical epoch — and an equal epoch means "same space" by design, so neither epoch_change nor epoch_regressed fires.

The detection chain that does hold, stated so nobody has to rediscover it:

A merge requires IDs to be REUSED at a receiver. Reuse requires the sequence counter to go BACKWARDS. A backwards counter is detected regardless of what the epoch says — it is the counter_backward reason, which drops the affected buffers and refuses cursors below the discarded high-water mark.

So the guarantee is carried by a different detector than the epoch mechanism suggests. That is deliberate and it is tested end to end (TestACollidingRepairIsCaughtBySequenceRatherThanEpoch), because a future change that weakened counter_backward would remove a protection nothing else here advertises.

Two cases that look similar and are not. A sequence counter set FORWARD — say to 50, so the next ID is 51 — is a jump inside ONE space: IDs stay unique and increasing, nothing is reused, and per-workspace IDs are non-consecutive by construction anyway. And a receiver that never held the colliding range has nothing to merge; what it experiences is a gap, which is the pre-existing undetectable-loss case tracked as BUG-2735.

pad_watchevents_sequence_resets_total has no released contract yet, and that is why BUG-2739 could change it freely. The whole metric was introduced after v0.14.0 and no tagged release emits it, so nothing outside a development deployment can be alerting on it. Two things about it changed on that branch: the counter_backward label lost a trailing s, and the metric widened from "the ID space changed" to "replay coverage was dropped", which added the subscription_resumed and undecodable_message reasons. A reason-specific alert on epoch_change is unaffected; one on counter_backward must have its expression updated for the spelling, which is the whole reason this paragraph exists. An alert on the unlabelled total now counts more things, which is the metric doing what its name says rather than a regression. During a rolling deploy an instance on the older build reports neither new reason and keeps the old spelling — so a mixed fleet reports two shapes under one name for the rollout's length, which is acceptable precisely because no released version is in that fleet.

Re-derive that rather than trusting this paragraph, because it is a claim about release state and release state changes without anyone editing this file:

git describe --tags --abbrev=0 origin/main      # the latest tag
git log --reverse --format=%H -S pad_watchevents_sequence_resets_total \
  -- internal/metrics/metrics.go | head -1      # the commit that introduced it
git merge-base --is-ancestor <commit> <tag>     # non-zero exit => still unreleased

Once a release does ship this metric, the next change to it is a real contract break and needs versioned treatment instead of a note here.

Avoid an evicting maxmemory-policy for Pad's Redis. docker-compose.prod.yml sets noeviction for this reason; the plain docker-compose.yml keeps allkeys-lru on its 64 MB dev instance, where the consequence below is a momentary annoyance rather than a lost instruction — change it too if you run that file in anger.

Under an evicting policy Redis may drop live session-presence entries under memory pressure. Nothing can distinguish that from a TTL lapsing, so a connected agent session briefly disappears from the picker and a push targeted at it reports delivered_sessions: 0. It self-repairs on the session's next 30-second renewal, and Pad's keyspace is small — a few hundred bytes per connected session plus two counters — so there is nothing to gain by evicting it.

If push stops finding a session (on-call)

The most likely Redis-related symptom is a transient write failure while registering a session. The agent's event stream stays up — the connection is never refused over a registry problem — but the session is absent from the shared registry, so:

  • it does not appear in GET /api/v1/sessions or the web picker, and
  • a push targeted at it returns 200 pushed:true with delivered_sessions: 0 and skips publication, so the instruction is not delivered.

What you'll see: session presence: failed to register session or failed to renew session entry warnings (rate-limited to one per minute, carrying failures_since_last_log — a large count means the replica, a small one means a single session), and the session missing from the listing.

What to do: restore Redis connectivity, capacity, or ACLs. Registration self-heals — each session's renewal re-writes its full entry, so an affected session reappears within ~30 seconds without reconnecting. Confirm it is listed again before re-sending anything.

What NOT to do: do not blindly re-send. A targeted push reporting delivered_sessions: 0 is safe to resend, because the server skipped the publish. A broadcast is always published, and a 502 push_unconfirmed means the outcome is unknown — re-sending either can deliver a second instruction the agent acts on twice. Only re-send what the server told you it skipped.

Upgrading a multi-instance deployment

PAD_REDIS_URL now also backs the session-presence registry — the list of which agent sessions are connected, which pad push and the web UI's "Push to agent" picker read to decide where a push goes. Previously that registry was per-process even when Redis was configured, so a push aimed at a session held by another replica was silently dropped.

During a rolling upgrade, old and new replicas disagree about presence. An old replica has only its own connections in view, so a push it answers cannot see a session held on a new replica, and GET /api/v1/sessions returns a different list depending on which replica answers. A TARGETED push reports this honestly — delivered_sessions: 0, and the publish is skipped, so nothing was sent — but the instruction is not delivered.

This is the same behaviour every replica had before this build, so the rollout is not a regression; it is a window in which the fix is only partly in effect. Two ways to avoid the window:

  • Blue/green — bring up the new replicas, cut traffic over, retire the old ones. No mixed period.
  • Drain first — scale old replicas out of the load balancer and let agent monitors reconnect (pad watch --stream reconnects on its own) before serving pushes from the new set.

If neither is practical, a rolling upgrade is still safe: nothing is corrupted and no migration is needed. Targeted pushes may report delivered_sessions: 0 and go undelivered until every replica runs the new build; those are safe to re-send once the rollout completes, because a targeted miss skips the publish entirely.

That safety does not extend to broadcasts. A broadcast push is always published, on old and new replicas alike, and the shared notification bus carries it across instances regardless of which registry the answering replica used — so a broadcast reporting 0 during the rollout may well have been delivered. Re-sending one is a second instruction the receiving agent will act on twice. Only re-send a push the server told you it skipped. There is no Redis or database migration; the registry's keys are transient and expire on their own TTL.

Event ID-space migration (PAD_EVENTS_PUBLISH_EPOCH)

Events on the workspace activity stream (GET /api/v1/events) carry a Last-Event-ID so a reconnecting client can be replayed what it missed. With Redis, every instance shares one counter, so those IDs are meaningful across replicas.

The problem this migration fixes. If that shared counter is ever reset — the key evicted under maxmemory, deleted by hand, a fresh Redis after a restore — IDs start again from 1. A replica that was buffering the old sequence cannot tell the new 101 from the old 101, so it can merge two ID spaces into one replay buffer and answer a resume across the boundary as though nothing was missed. Numeric detection alone cannot see it: by the time the new sequence passes the replica's high-water mark, it looks like ordinary progress.

What the fix does and does not close, stated before the procedure. It stops a REPLICA from mixing two ID spaces in one replay buffer, which is what turns a counter reset into a silently wrong replay. It does NOT make a CLIENT'S CURSOR say which space it came from — that would change the wire format every deployed browser speaks. So this is a substantial mitigation and not a closure; the residual case and why it is deferred are at the end of this section.

The fix gives each ID space an epoch — a generation number (monotonic in normal operation; see A repaired generation counter below for the one case that is not), minted by Redis when the space is created and carried as a <epoch>|<id>|<json> prefix on every message published by a phase-2 instance. Phase-1 instances publish the historical bare JSON and carry no epoch at all, which is what the two phases are about. A replica that sees a HIGHER generation drops its replay buffers and answers resumes across the change with sync_required, which is honest rather than silent. A message carrying a LOWER generation is a straggler from a space that has been abandoned, and is discarded rather than delivered.

The generation is a number rather than an opaque token so the two spaces can be ORDERED. Workspaces have independent subscriptions and Redis does not order messages across channels, so a pre-rotation message on one channel can arrive after a post-rotation message on another; with an unordered token that is indistinguishable from a second rotation.

It rolls out in two phases, and the order is not optional.

Phase What you do What instances publish What they accept
1 Roll the new binary everywhere. Leave PAD_EVENTS_PUBLISH_EPOCH unset. The historical bare JSON Both forms
2 Set PAD_EVENTS_PUBLISH_EPOCH=true and roll again. <epoch>|<id>|<json> Both forms

The asymmetry that makes two phases necessary: an instance running a pre-phase-1 binary cannot parse a prefixed payload at all. It fails to unmarshal the message and drops the event for its own clients. So flipping before every instance is upgraded loses events on the ones that are not — not a resync, a silent loss.

Both rolls are zero-loss in the other direction, because accept-both is on from phase 1: during the phase-2 roll, flipped and un-flipped instances are publishing different forms at the same time and every instance reads both.

Rolling back to phase 1 is safe: make the effective value false and roll. Peers accept the bare form throughout, so there is no window where this direction loses events.

Two things about rolling back that are easy to get wrong:

  • Setting the value to false is not the same as unsetting the environment variable. events_publish_epoch can also be set in ~/.pad/config.toml, and the config file's value stands when the environment variable is absent. Clear both, or set the environment variable explicitly to false.
  • Downgrading past phase 1 is a SECOND step, and the order is the reverse of the upgrade. A pre-phase-1 binary cannot parse the prefixed form. So: first roll every instance to phase 1 (new binary, flip off) and let the roll finish, then downgrade the binary. Introducing an old binary while any flipped instance is still publishing drops events on the old one — the same asymmetry that makes the upgrade two phases, in reverse.

There is no Redis or database migration in either direction. The epoch key and its generation counter are created by the first flipped publisher; a phase-1 instance deletes it if it ever sees the sequence counter restart, so a counter that is reset while the deployment sits on phase 1 does not leave a stale epoch for a later phase 2 to adopt.

What you should see when phase 2 lands. A replica learns the epoch from the first prefixed message it RECEIVES — which means only replicas currently subscribed to a workspace see it, and only when that workspace next has traffic. If such a replica had already buffered un-prefixed events, it drops its buffers once, records pad_event_sequence_resets_total{reason="epoch_change"}, and clients resuming across that moment get sync_required and re-fetch. A replica whose buffers are EMPTY adopts the epoch without dropping anything and without a reset count, deliberately: otherwise every replica would report a reset at startup and the counter would grow a per-deploy baseline instead of meaning something.

Do not delete the generation counter (<namespace>event_epoch_gen) by hand, and keep it out of any eviction policy: it is what makes one ID space orderable against the next. Losing it lets a later reset reuse a generation that has already been seen, which makes two different ID spaces look identical — the one shape the epoch exists to prevent. It is a single small integer key; the events keyspace should not be under allkeys-lru (see Redis configuration notes). One drop per replica per roll — if the counter keeps climbing, something is deleting the epoch or sequence key repeatedly; check maxmemory-policy against the events keyspace (see Redis configuration notes).

pad_event_sequence_resets_total{reason="counter_backward"} is the other counter to watch. It fires when an ID arrives at or below what a buffer had already seen.

On phase 1 it can be non-zero at any time, not only during a roll. Phase 1 keeps the historical two-call publish — INCR, then PUBLISH — so two instances can interleave (INCR 5, INCR 6, PUBLISH 6, PUBLISH 5) and a receiver sees 5 arrive after 6. That window is older than this migration; phase 2 is what closes it, by moving ID assignment into a single atomic script so publish order equals ID order globally.

So the expectation depends on where you are:

  • Phase 1, before this replica has ever seen a prefixed message — expect ZERO. The check is deliberately not armed until an epoch has been adopted, because a phase-1 deployment's two-call publish interleaves as ordinary traffic and reacting to that would drop every replay buffer on a busy multi-instance deployment. The cost of that gate is that a counter reset on a never-flipped deployment goes undetected — which is exactly the behaviour before this migration existed, and precisely what phase 2 fixes.
  • During the phase-2 roll, once a replica has adopted the epoch — expect it to rise for the length of the roll: un-flipped publishers are still assigning and publishing in two calls, and this replica is now armed.
  • Phase 2, every publisher flipped — expect it at or near zero. A persistent rate here is an anomaly worth investigating rather than tuning away.

Which phase an instance is publishing in is in its startup log, as id_space_phase=1 or id_space_phase=2 on the "Event bus using Redis pub/sub" line — the counter above cannot be read without it. An unparseable PAD_EVENTS_PUBLISH_EPOCH is ignored (a typo must not flip a migration whose wrong direction loses events) and logs a warning naming the value.

One narrow window during the phase-2 roll. Once a replica has adopted the epoch, a message from an un-flipped instance carries no epoch and is treated as belonging to the current space — which it does, unless the sequence counter reset between that publisher assigning its ID and publishing it. An ID from the dead space can then land in a buffer describing the new one. There is no way to tell the two apart from the message alone, and the alternatives are worse: a replica that refused un-flipped messages would resync its clients on every one of them for the length of the roll. It usually ends loudly and quickly: the next event that workspace receives is lower than the straggler's ID, which trips counter_backward, drops the buffers and is reported. It is not guaranteed to — the sequence counter is shared across workspaces while that check is per workspace, so if other workspaces carry the counter past the straggler's value first, nothing fires and the dead-space ID stays in that workspace's buffer. Closing that needs the same thing the residual below needs.

What this migration does not fix. A client's Last-Event-ID is still a bare integer with no epoch in it, and that is deliberate — every deployed browser speaks that format, and EventSource echoes the header with no application code in the path to translate it. So an old ID and a new ID of the same numeric value remain indistinguishable to a resume, even though the replica's buffers can no longer mix them. The exposure is a client that reconnects with a cursor whose number the new sequence has already reached. Tracked on BUG-2736.

Single-process deployments (no PAD_REDIS_URL) need none of this and ignore the variable: that bus owns its counter, so it identifies its own ID space from its start time. Two runs' IDs can only collide if the earlier process published more than 2^20 events per millisecond of its own lifetime, or if a restart completed inside a single millisecond — both deterministic bounds rather than probabilities, and neither reachable by a process that has to bind a listener and open a database before it can publish anything. A clock stepped backwards across a restart degrades the other way, into extra sync_required responses rather than wrong replays.

Half-open connection detection (PAD_EVENTS_HEARTBEAT)

The problem this fixes. A TCP connection can stop carrying traffic without closing — no FIN, no RST, just a route that stopped working. A NAT table expiring, a stateful firewall dropping an idle flow, an overlay network silently rerouting. The instance behind it blocks on a read that will never return, receives nothing, and its replay buffer goes on looking complete. Every resume for that workspace is then answered "caught up" from a coverage window that ended when the route did — silent loss, with nothing in any metric.

Why go-redis does not cover it. Its pub/sub health check writes a PING and never reads a reply, so its error stays nil for as long as the socket accepts writes — which a half-open socket does until its send buffer fills. The channel path sets no read deadline either. Measured, not assumed: against a TCP proxy that silently stopped forwarding, with the health check running, there was no reconnect in 24 seconds.

What the fix does. Every subscription records when it last received anything — an event, a subscription acknowledgement, or a heartbeat. When that goes stale past the idle timeout, the instance ends the workspace's replay coverage (so the next resume answers sync_required rather than "caught up") and replaces the connection. Dropping coverage alone would not recover: the resync it demands is served from the same dead socket, and the detector fires again on the next pass — a loop metering the failure rather than fixing it.

Why a heartbeat, rather than just a threshold on real traffic. "Is this workspace quiet, or is the route dead?" cannot be answered from traffic — it depends on your publish rate, and no constant is right for every deployment. Publishing our own frame replaces it with "did our heartbeat arrive?", which is answerable everywhere. The instance publishes one frame per subscribed workspace every 30 seconds (T), and cycles a subscription that has received nothing for 90 seconds (3T). Three intervals rather than two so a single lost or late frame is not a cycle. Detection latency measured from the last frame that got through is 90120s — the scan runs on its own 30s cadence, which adds up to one interval on top of the threshold. Measured from the moment the route actually died it is wider, roughly 60120s: the publisher runs on an independent schedule, so the last frame through may have been sent anywhere in the interval before the fault.

Detection is part of phase 2, not phase 1. Publishing and detecting are one capability with one switch, because an instance detects off its own frames — it publishes to the workspace channels it subscribes to and receives them back, so it never depends on peers having flipped. A phase-1 instance therefore detects nothing; it only recognises the frame so that a phase-2 peer costs it nothing. Splitting them was tried and is wrong: with no heartbeat and no events, a perfectly healthy quiet workspace crosses the threshold every 90120s and gets cycled, which is a resync storm on the default configuration every deployment lands in first.

It rolls out in two phases, and the order is not optional.

Phase What you do What instances publish What they do with a frame
1 Roll the new binary everywhere. Leave PAD_EVENTS_HEARTBEAT unset. No heartbeats Recognise and ignore it. No idle detection.
2 Set PAD_EVENTS_HEARTBEAT=true and roll again. One frame per subscribed workspace per 30s Recognise and ignore it. Idle detection active.

What happens if you run them out of order. The frame has to travel on the workspace's event channel, because that channel's connection is the thing whose liveness is in question — a probe anywhere else proves the wrong thing. An instance running a pre-phase-1 binary cannot classify it: the frame falls through to the event decoder, fails to parse, and is treated as a hole in coverage. That instance drops the workspace's replay buffer and tells every one of its live subscribers to resync — every 30 seconds, for every workspace, for as long as the deployment is mixed. The blast radius is the instances you have not upgraded, which no amount of care in the new code can reach. This is noisier than the ID-space migration's equivalent mistake and it is the reason the default is off.

Both rolls are zero-loss in the other direction: phase-1 instances recognise the frame from the release that introduces it, so during the phase-2 roll a mix of publishing and non-publishing instances is exactly the case ignore-the-frame exists for.

Rolling back to phase 1 is safe and takes effect immediately: make the effective value false and roll. Peers ignore the frame throughout, and idle detection stops with it — you are back to the pre-BUG-2738 behaviour, which is a wedged route going unnoticed, not a worse one. The same two wrinkles as the ID-space migration apply, for the same reasons:

  • Setting the value to false is not the same as unsetting the environment variable. events_heartbeat can also be set in ~/.pad/config.toml, and the file's value stands when the environment variable is absent. Clear both, or set the environment variable explicitly to false.
  • Downgrading past phase 1 is a SECOND step, in the reverse order. A pre-phase-1 binary still cannot classify the frame. Roll every instance to phase 1 (new binary, flip off), let it finish, then downgrade the binary.

The frame is validated, not just prefix-matched. A liveness frame is hb|<version> plus optional short tokens, under a length cap. Anything else that happens to begin with hb| is treated exactly as any other unreadable payload: that workspace's coverage ends and pad_event_sequence_resets_total{reason="undecodable_message"} moves, which is the signal that says suspect a namespace collision. A forged frame cannot fake liveness in any case — liveness means "this socket carried traffic", and a frame that arrives demonstrates that whoever sent it.

There is no Redis or database migration in either direction, and the frames are never persisted: a heartbeat consumes no event ID, carries no epoch, is never buffered or replayed, never reaches a subscriber, and is never counted as an event. That last part is load-bearing rather than tidy — three of this bus's reset reasons (counter_backward, epoch_change, epoch_regressed) are derived from the shared ID counter, so a probe that consumed IDs would manufacture the resets it exists to avoid.

Which phase an instance publishes in is in its startup log, as heartbeat_phase=1 or heartbeat_phase=2 on the "Event bus using Redis pub/sub" line, alongside id_space_phase. The two migrations are independent — any combination is valid. An unparseable PAD_EVENTS_HEARTBEAT is ignored and logs a warning naming the value.

What this covers, and what it does not. It is a receive-side detector, not a round-trip health check. It measures whether frames arrive on a workspace's subscription, so:

  • A subscription whose outbound direction is broken but which still receives looks healthy — correctly, since nothing is being lost.
  • The PUBLISH path is not covered and cannot be. PUBLISH travels on the client's ordinary connection pool while a subscription holds a connection from a separate pub/sub pool; those are different sockets with different fates, and a reconnect of one repairs nothing about the other. An instance whose publish path is wedged loses its own events for every other instance, and this feature will not tell you.
  • The replacement is attempted, not guaranteed. If the path is still blackholed when the cycle re-dials, the new connection cannot receive either and the detector fires again on the next pass. Coverage stays ended throughout, so nothing is ever falsely claimed — but delivery resuming is a statement about your network, not about Pad. One case where the replacement can fail on a healthy path is tracked as BUG-2764: go-redis discards the error from the initial SUBSCRIBE, so a failed subscribe yields a connection that looks live and is subscribed to nothing. The detector cycles it again on the next pass, which is why this self-heals on phase 2 and does not on phase 1.

What to watch. pad_event_subscription_cycled_total — expect zero. Read it rather than the idle_timeout reset label, which only moves when there was a buffer to drop and therefore misses the early-wedge case. A non-zero rate is a network fact about the path between your instances and Redis, not a Pad condition: compare it against TCP keepalive settings on that path before changing the interval, because a shorter interval treats the symptom and a longer one widens the window the detector exists to bound.

A residual an operator should know about, not fixed here. When many workspaces are cycled at once — a NAT table flush, a firewall rule change, an overlay network dropping every long-lived flow — every connected subscriber of every affected workspace is told to resync in the same instant. The SSE connections stay open, so this is not a reconnect storm and the admission limits are not involved; what it produces is a burst of /changes requests against the database, coalesced per browser tab but with no jitter and no global budget. This is not new with the heartbeat: a Redis failover already signals every workspace at once through subscription_resumed. What is new is a second trigger of the same class. Tracked separately; if you run a large fleet, watch database load alongside pad_event_sequence_resets_total after any network event that could wedge many routes simultaneously. Tracked as BUG-2761.

Cost. Each workspace has its own Redis subscription — and therefore its own connection — so liveness is genuinely per-workspace and there is no cheaper shared probe. An instance subscribed to N workspaces publishes N frames every 30s; at N=1000 that is roughly 33 publishes/sec, which is noise for Redis. If fleet workspace counts ever make it matter, the fix is connection consolidation, not a longer interval.

Security

Variable Default Description
PAD_SECURE_COOKIES false Set Secure flag on session cookies (requires TLS)
PAD_CORS_ORIGINS Comma-separated allowed CORS origins

Email (Optional)

Email enables sending workspace invitation links. Without it, users can still join via CLI invite codes.

Variable Default Description
PAD_MAILEROO_API_KEY Maileroo sending API key
PAD_EMAIL_FROM noreply@getpad.dev Sender email address
PAD_EMAIL_FROM_NAME Pad Sender display name

Password recovery (when email is not configured)

Without an email provider, the web "Forgot password" flow can't send a reset link — the page says so and points users at the host-side recovery below. Recover a locked-out account from the server host (the same trust model as pad auth setup — shell access to the box):

# Print a single-use reset link (open it in a browser to choose a new password)
pad auth reset-password admin@example.com

# Or set a random temporary password, printed to the terminal (headless boxes).
# Log in with it, then change it immediately — all existing sessions are signed out.
pad auth reset-password admin@example.com --temp-password

This calls a loopback-only endpoint (POST /api/v1/auth/local-reset): it needs no login (you're locked out, after all), but it only works for a direct request from the server itself — proxied or remote requests are refused, and it's disabled entirely in cloud mode.

Alternatively, if a user submits the web reset form, the server logs the reset path on a non-cloud instance with no email configured:

password reset generated (email not configured) ... reset_path=/reset-password/<token>

Open <base-url>/reset-password/<token> to finish the reset by hand.

Deployment Options

Single Binary (SQLite)

The simplest deployment — one binary, one file for the database.

# Download or build
make build

# Run directly
PAD_HOST=0.0.0.0 ./pad server start

# Or install as a systemd service (see below)

Best for: single-user, small teams, evaluations.

Docker Compose (PostgreSQL + Redis)

See Quick Start above. This is the recommended setup for teams.

Kubernetes

Manifests are in deploy/k8s/. Apply them in order:

# Create namespace
kubectl apply -f deploy/k8s/namespace.yaml

# Configure secrets (edit first!)
kubectl apply -f deploy/k8s/secret.yaml

# Deploy
kubectl apply -f deploy/k8s/configmap.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml
kubectl apply -f deploy/k8s/hpa.yaml

Prerequisites:

  • External PostgreSQL (e.g., AWS RDS, Cloud SQL, managed PG)
  • External Redis (e.g., ElastiCache, Memorystore)
  • Ingress controller (nginx-ingress or similar)
  • TLS certificates (cert-manager recommended)

Systemd Service

# /etc/systemd/system/pad.service
[Unit]
Description=Pad
After=network.target postgresql.service redis.service

[Service]
Type=simple
User=pad
Group=pad
ExecStart=/usr/local/bin/pad server start
Environment=PAD_HOST=0.0.0.0
Environment=PAD_DATA_DIR=/var/lib/pad
Environment=PAD_DB_DRIVER=postgres
Environment=PAD_DATABASE_URL=postgres://pad:secret@localhost:5432/pad
Environment=PAD_REDIS_URL=redis://localhost:6379
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now pad

Reverse Proxy

Pad needs a reverse proxy for TLS termination. SSE connections require specific proxy settings to avoid buffering.

Caddy handles TLS automatically. See deploy/Caddyfile:

pad.example.com {
    reverse_proxy pad:7777 {
        flush_interval -1
    }
}

nginx

See deploy/nginx.conf. Critical settings for SSE:

location /api/v1/events {
    proxy_buffering off;
    proxy_cache off;
    proxy_read_timeout 86400s;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}

Monitoring

Pad exposes Prometheus metrics at /metrics (unauthenticated). Key metrics:

Metric Type Description
pad_http_requests_total counter Total HTTP requests by method, path, status
pad_http_request_duration_seconds histogram Request latency
pad_http_response_size_bytes histogram Response body sizes
pad_sse_connections_active gauge Connections on the workspace activity stream (/api/v1/events) only
pad_stream_connections_active gauge Held connections across both SSE endpoints — the population the limits bound
pad_eventbus_publish_total counter Events HANDED to the bus — attempts, not confirmed publishes. A failed Redis publish is logged and still counted (BUG-2732)
pad_eventbus_subscribers gauge Active event subscribers
pad_db_open_connections gauge Database connection pool stats

Redis-specific metrics are listed under Redis health and metrics.

Health Check

Three endpoints, and they answer different questions:

# Liveness — is the process up? Kubernetes restarts the pod when this fails.
curl http://localhost:7777/api/v1/health/live
# {"status":"ok"}

# Readiness — can it serve traffic? Gated on the DATABASE only.
# Kubernetes should point its readinessProbe here.
curl -s http://localhost:7777/api/v1/health/ready
# {
#   "status": "ready",
#   "db": {"open_connections": 2, "in_use": 0, "idle": 2, "driver": "sqlite"},
#   "redis": {"reachable": true, "probed": true, "last_check": "2026-08-22T01:00:00Z"}
# }

# Build info.
curl http://localhost:7777/api/v1/health
# {"status":"ok","version":"...","commit":"..."}

With Redis configured but unreachable, readiness stays 200 and the redis block carries the failure. Readiness deliberately does not fail: Pad still serves the API, the web UI and every item write. What it cannot do is cross-instance delivery, and the paths whose job that is say so — POST .../push answers 503 for a session-targeted push it cannot resolve and 502 push_unconfirmed when the publish fails:

{
  "status": "ready",
  "redis": {
    "reachable": false,
    "probed": true,
    "error": "dial tcp ...: connect: connection refused",
    "degrades": [
      "all activity events, including to clients on this instance",
      "watch notifications",
      "session presence and session-targeted push"
    ]
  }
}

The redis block is absent entirely when no Redis is configured — "not applicable" rather than "down".

Upgrading

Pad releases a new binary roughly weekly. Migrations run automatically at startup — only the ones your database is missing are applied, and each one commits atomically, so a failed migration rolls back cleanly and is retried on the next boot.

Only ever move forward. A newer binary can migrate an older database; an older binary cannot understand a newer schema. Pad enforces this with a schema-ahead guard: if the binary finds a database that carries migrations it doesn't ship (the signature of a downgrade — a rolled-back brew formula, an older Docker tag, a redeployed prior binary), it refuses to start instead of silently running old code against a newer schema and corrupting data.

database schema is newer than this pad binary: the database has N migration(s)
this binary doesn't ship (...) ... This almost always means the binary was
DOWNGRADED (e.g. brew/docker rollback) ... Upgrade pad back to a build that
includes those migrations, or ... re-run with `pad start --force`.
  • Recover by reinstalling the newer binary (brew upgrade pad, pull the newer Docker tag, redeploy the newer image).
  • Override — only if you have intentionally downgraded and accept the data-corruption risk — with pad start --force or PAD_ALLOW_SCHEMA_AHEAD=1.

Pre-migration snapshot (SQLite)

When a SQLite-backed instance has pending migrations, Pad copies the database file to pad.db.pre-<version> (next to the DB) before applying them. If an upgrade goes wrong, stop the server and copy that file back over pad.db. It is a convenience net, not a substitute for backups — take a real backup first (see backup.md). The copy is best-effort: if it can't be written (read-only volume, full disk) the server logs a warning and proceeds, so keep your own backups regardless.

PostgreSQL is not snapshotted this way — take a pg_dump or provider snapshot before upgrading (see backup.md).

# 1. Back up (SQLite shown; pg_dump for Postgres — see backup.md)
pad db backup -o pad-backup-$(date +%Y%m%d).db

# 2. Stop, install the new binary, restart. Migrations + the pre-migration
#    snapshot run automatically on start.
brew upgrade pad     # or: docker pull, binary download, systemctl restart pad

# 3. Verify
pad --version
curl -s http://localhost:7777/api/v1/health   # {"status":"ok"}

Production Checklist

  • Database: PostgreSQL configured with PAD_DB_DRIVER=postgres
  • Redis: Connected for multi-instance events, notifications, and session presence (PAD_REDIS_URL), on a non-evicting maxmemory-policy, single node (not a cluster)
  • Redis namespace: PAD_REDIS_NAMESPACE set if this endpoint is shared with another Pad installation
  • Streaming limits: PAD_SSE_MAX_CONNECTIONS / PAD_SSE_MAX_PER_USER sized for your fleet (both cover both SSE endpoints)
  • Redis alerting: pad_redis_up and pad_watchevents_sequence_gaps_total wired to alerts
  • Stream-honesty alerting: pad_event_resume_gaps_total alerting on a rate that does NOT settle after a deploy (a step around one is expected — cold replay buffers), and pad_event_receive_loop_exits_total read as a rate against a stable subscriber count. See the metrics table above for what each label means
  • TLS: Reverse proxy with valid certificates
  • Secure cookies: PAD_SECURE_COOKIES=true (requires TLS)
  • Public URL: PAD_URL set to your public-facing domain
  • CORS: PAD_CORS_ORIGINS set if serving from a different domain
  • Backups: PostgreSQL backup strategy in place (see docs/backup.md)
  • Monitoring: Prometheus scraping /metrics
  • Admin account: Created via pad auth setup or web UI on first visit
  • Email (optional): Maileroo configured for invitation emails
  • Resource limits: Set in Docker Compose or K8s manifests
  • Log level: PAD_LOG_LEVEL=info (use debug only for troubleshooting)