* feat(watchevents): Redis-backed bus so watch notifications cross instances (BUG-2651) internal/watchevents shipped MemoryBus only, so in a multi-instance deployment a notification published on instance A never reached a stream held open on instance B — watches appeared to work and silently dropped. Bus was an interface from day one for exactly this; adding RedisBus changed no producer and no consumer. NOT A MECHANICAL PORT of internal/events.RedisBus. Three deliberate divergences, each documented at the point someone diffing the two files would call it a mistake: - ONE channel and ONE replay buffer, because this package has exactly one logical stream by contract (DOC-2479 DR-2: all per-caller filtering happens in the consumer). Most of the template's bookkeeping — per- workspace counts, subscriptions, buffers — has nothing to key on here. - EAGER subscription for the bus's lifetime, not lazily on first local subscriber. The replay buffer fills from the RECEIVE path, so a lazily torn-down subscription stops filling it at precisely the moment before a Last-Event-ID resume — for one harness monitor holding one stream, that makes resume structurally useless. The template can afford lazy because per-workspace means N idle subscriptions; here it is one. - ONE mutex across subscriber membership and the replay buffer, held through the whole local fan-out. The template uses two and offers only separate Subscribe + EventsSince, which cannot provide SubscribeAndReplaySince's guarantee. Copying its locking would have handed back the double-delivery window this package's interface exists to close. Publish fails CLOSED when INCR fails, where the template falls back to a local counter. Two instances falling back at once mint ids from independent counters into a shared stream, and replayBuffer.since() reasons on monotonicity — so the damage is silent replay corruption, not a visible error. INCR and PUBLISH share a connection anyway, so the fallback mostly lets a doomed publish proceed carrying a poisoned id. Both load-bearing tests were VACUOUS as first written; the mutation matrix is the only reason I know: - the concurrency test's producer finished before the subscriber joined, so the channel leg was never exercised and a split-lock mutant survived 50 iterations. Now paced, with a both-legs-non-empty precondition that fails a run which never approached the boundary, plus a dedicated detector (600 attempts, 8/8 kills, 0.02s after switching the drain to non-blocking — exact, because the duplicate is already buffered when the call returns). - the fail-closed test asserted nothing was delivered, which is true of the fallback too: Publish never delivers locally, so with Redis down neither policy delivers. Rewritten around a go-redis ProcessHook that records attempted commands, which is where the policies actually differ (INCR-then-stop vs INCR-then-PUBLISH). Also corrects session_presence.go, which told the next person these two had to be fixed together. Delivery is now cross-instance; the registry's under-report is unchanged, so the remaining defect is a picker that under-reports rather than a push that lies. The PLAN-2558 S3 gate stays, for that reason instead of the old one. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents): make id assignment and publish atomic; close the bus on shutdown (Codex round 1) P1 — INCR and PUBLISH as two client calls are not order-preserving, and the failure is concrete: A gets id 1 and is descheduled, B gets id 2 and publishes, A publishes 1. Every subscriber receives 2 before 1, the replay buffer appends in ARRIVAL order, and replayBuffer.since() reasons on monotonicity — so a resume from 2 hits the sinceID > newestID branch and answers 'gap too large', turning a healthy reconnect into a spurious sync_required, while a resume from 1 silently skips the late arrival. Fixed at the source with a Lua script: Redis runs it atomically on its single thread, so INCR and PUBLISH for one instance both complete before another's script begins, and publish order equals id order globally with no coordination on our side. The id rides as a '<id>|<json>' prefix rather than being edited into the JSON from Lua; the id is digits and the FIRST '|' separates, so a '|' in the body is unambiguous. A pleasant consequence: there is no longer a window where an id exists but the publish has not happened, so the fail-closed decision and the publish decision became the same decision. P2 — Stop() never closed the watch bus. That was survivable for MemoryBus, whose Close only drops channels; RedisBus holds a receive goroutine and a Redis subscription from construction, so it leaked both for the process's life. Closed after bg.Wait(), so a background producer cannot publish into a bus already tearing down. nits, all real, all in artifacts someone reads: - 'exactly-once delivery' was simply wrong. Redis pub/sub is at-most-once and the local send is deliberately non-blocking. The property the round trip actually buys is NO DOUBLE DELIVERY to the publishing instance; the comment now says that and names the replay buffer as the bounded recovery mechanism for the rest. - the Bus interface comment still said only MemoryBus existed. - cmd_server.go's session-presence note still claimed the same caveat as 'the watch bus directly above', which had just stopped applying. - session_presence.go now says delivery is fixed WHEN PAD_REDIS_URL is set, rather than unconditionally. Tests: the fail-closed assertion moved from 'nothing was delivered' — still true under the two-call version — to 'no bare INCR or PUBLISH was issued', which is what distinguishes atomic from not. Mutation-verified by splitting the script back into two calls. Added a decode round-trip test covering the new wire format, a '|' inside the body, and four malformed payloads, since that decoder consumes bytes from a channel any holder of the Redis credentials can publish to. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents,server): correct the targeted-push claim; close the bus before HTTP shutdown (Codex round 2) P2 — I claimed cross-instance DELIVERY was fixed. Half true, and the false half was mine to catch: handlers_push.go gates a session-targeted push on the LOCAL presence registry and skips the publish entirely when the id is not there, so a POST landing on A for a session held on B still delivers nothing. The bus would carry it; the gate means it never reaches the bus. Broadcast pushes and every other notification kind ARE fixed. I asserted that behaviour from reading the bus and session_presence.go without reading the push handler — the exact thing I hold myself to not doing. Corrected in all three places the claim was made (the package doc, session_presence.go, and the KindPush comment), with the correction recorded rather than quietly overwritten. The gate's own justification is now stale too, and worth more than a tweak: 'a target this instance cannot see is a guaranteed no-op' was TRUE under MemoryBus and is FALSE under RedisBus, where another instance may hold that session. Left in place deliberately — publishing unconditionally would fix delivery and immediately make delivered_sessions=0 a lie in the other direction, which is a question about what that field promises. It belongs with the shared-state SessionPresence that PLAN-2558 S3 already gates on: fixing the registry makes the snapshot right, and then the skip is correct again for its original reason. Both open halves collapse into that one implementation. P2 — the watch bus was closed only in Server.Stop(), which runs AFTER http.Server.Shutdown. The event bus is closed before Shutdown precisely so its SSE handlers unblock; the watch stream is the same shape, so an open one would have held Shutdown to its full 30s deadline. Now closed alongside eventBus, with the Stop() close kept as the path for other callers — both implementations are idempotent. nit — MemoryBus and RedisBus disagreed after Close: RedisBus handed a late Subscribe an already-closed channel, MemoryBus registered one nobody would ever close, so a consumer racing shutdown blocked forever. MemoryBus now matches, and its Close is idempotent, which the CLI's double close relies on. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents): report a missed notification as a replay gap (Codex round 3) P2 — a divergence MemoryBus structurally cannot have. It assigns every id itself, so its replay buffer is contiguous and the only gap it can report is eviction. RedisBus receives ids over at-most-once pub/sub, so a blipped subscription can miss 101 and receive 102: the buffer holds a hole, is nowhere near full, and replayBuffer.since() answers a resume from 100 with just [102]. The consumer loses a nudge and is never told. RedisBus now tracks the id at which the sequence resumed after the most recent hole, and answers nil — the same signal eviction already gives, which the SSE handler already turns into sync_required — for a resume that would have to span it. Resumes that do not span it still replay normally, and sinceID=0 is treated as a fresh subscriber rather than a resume, so a hole nobody spanned is not turned into a spurious resync. The atomic publish script is what makes this readable: publish order is id order globally, so a non-consecutive id means MISSED, not reordered. Mutation-verified by disabling the check; the test fails on both the spanning resumes and would have failed the over-broad version too (it asserts the non-spanning resumes still work). Two residuals documented rather than fixed, both because the fix is the same shared-state SessionPresence that PLAN-2558 S3 gates on: - delivered_sessions is now wrong in BOTH directions for a broadcast push — the count is local while delivery is global, so a replica can report 1 while two sessions receive it, or 0 while a remote one does. No local arithmetic fixes that; it is asking one replica what all of them are doing. - the Redis channel and counter names are not deployment-scoped, so two installations sharing a Redis endpoint cross-feed (and picking different logical DBs does not help — pub/sub ignores them). Left flat to match internal/events rather than giving one of the two buses a prefix the other lacks; the rule is one Redis endpoint per installation, and relaxing it should cover both buses at once. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents): a cold-started replica must report a gap too (Codex round 4) P1 — the round-3 hole check only fired BETWEEN two received messages, so it never fired for the first one. A replica restarting while Redis is already at 101 has an empty buffer; its first received message is 102, nothing looks like a hole, and a client reconnecting to that replica with Last-Event-ID 100 was handed [102] — skipping 101 exactly as silently as the case round 3 fixed, by a different route. Replaced contiguousFrom with knownFrom: the lowest id from which this instance's buffer is contiguous. SET on the first append (before which this instance knows nothing) and RESET on every hole (before which it no longer knows anything usable). One variable, both failures. The boundary is pinned in both directions, which is what stops this being an over-broad 'always gap after a restart': a resume from exactly the id before our first (101 when we started at 102) IS contiguous with our view and replays normally. Mutation-verified by disabling the cold-start arm. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents): idempotent publish, confirmed subscription, and real Redis tests (Codex round 5) P2 — go-redis retries a command whose reply is lost to a network error, and the publish script was not idempotent: the same notification would be published twice under two different ids. Both copies look valid — ordered, distinct — so nothing downstream could tell them apart, and on the push path a duplicate is a duplicate DISPATCH into an agent harness. The script now takes a caller-generated token and SET NX's it, so a retry carrying the same arguments returns 0 without publishing. TWO THINGS THIS UNIT OWES ITS TESTS, both found within minutes of each other and both invisible to the hermetic ones: 1. The idempotency script shipped indexing ARGV[3] while Publish passed two arguments. Caught by re-reading, which is not a control worth relying on for the next Lua edit. 2. NewRedisBus returned before go-redis had established the subscription, so notifications published in that window were lost to this instance, silently. Surfaced as a test flake; the production shape is a rolling deploy, where a replica takes traffic before its subscription is live. The constructor now waits for the confirmation (bounded, and a failure is logged rather than fatal since Channel() re-subscribes on reconnect). So miniredis is now a test dependency, and the round-trip tests it enables cover what fanOutLocally-driven tests structurally cannot: the channel name, the KEYS/ARGV mapping, the id prefix wire format, the shared counter across two buses, cross-instance delivery (the actual bug), the dedupe token, and Close tearing down the SERVER-side subscription rather than just local channels. Verified by restoring the ARGV[3] bug: the round-trip test fails on it. The two findings I am NOT fixing here are unchanged and documented where the reasoning is met — the targeted-push gate and delivered_sessions are both consequences of the per-process presence registry, and both are closed by the shared-state SessionPresence that PLAN-2558 S3 gates on, not by anything in this package. make vuln: 0 vulnerabilities in imported packages. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents): survive a Redis counter reset without replaying stale ids (Codex round 6) P2 — pad:watchevents_seq has no TTL but can still vanish: evicted under maxmemory, dropped by a FLUSHDB, or restored from an older snapshot. Ids then restart at 1 while this instance's ring still holds the hundreds. Keeping both is what corrupts replay — the two id spaces are not comparable, so a resume from 2 in the NEW space would be handed the stale 99/100/101 as though they were newer. A backwards id now drops the replay buffer and re-anchors knownFrom. Every resume from the old space then exceeds the newest id held and gets nil — the resync signal that is the only honest answer once the ids stopped meaning what the client thinks they mean — while clients in the new space keep working immediately. The test asserts BOTH halves, which is what makes it a detector rather than a description: a build that logged the reset and kept the buffer passes 'the old resume reports a gap' and fails 'the new resume never returns a pre-reset entry'. Mutation-verified on exactly that. Hardened while I was here: the epoch-reset path REBUILDS the buffer at runtime, so a bus constructed with a non-positive replay size would have turned a counter reset into a panic (newReplayBuffer(0)'s first append indexes a zero-length slice) rather than a resync. The constructor now normalizes. MemoryBus has the same trap for a caller passing 0; left alone as pre-existing and off this path, but named in the comment rather than silently fixed or silently ignored. nit — this file's header still claimed there was no miniredis dependency and no round-trip coverage, which the previous commit made false. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * docs(watchevents): actually correct the hermetic test header (Codex round 7) The previous commit's message claimed this fix. It did not contain it: the edit ran as one of two scripts in a single command, its assertion failed with a traceback, and the second script's success is what I read. The header kept saying there was no miniredis dependency and no round-trip coverage — both false since two commits ago, in the file a reader consults to find out what IS covered. That is the adjacent-success-signal failure exactly: a success line from the step next to the one I cared about. The tell was in the output and I walked past it, then asserted the change in a commit message. Recording it here rather than quietly fixing, because a commit that claims a change it does not make is worse than one that omits it. Verified this time by reading the file back and grepping for the stale phrases: zero. Round 7's other three findings are the documented residuals re-raised for the third time — the targeted-push gate, delivered_sessions, and the unnamespaced Redis keys. All three are dispositioned at the line a reader meets them, all three are consequences of the per-process SessionPresence registry or of matching internal/events' existing convention, and none is fixable inside this package. They stay open, on the record, and with the lead. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * docs(watchevents,cli): correct pad push --help; document the reset-window residual (Codex round 8) nit, and the one that stings — cmd_push.go's Long help still said pushes go over the 'in-memory watch-events bus'. That is the text a user reads when they run pad push --help, and it has been false since this branch's first commit. I have a standing pre-push step to grep the artifacts a CONSUMER reads for exactly this, and I ran it as a code search (watchevents.New) rather than a prose search, so --help never came up. The help now distinguishes broadcast (reaches every instance) from session-targeted (still resolved against the handling server) and names the bug. P2 — the counter-reset handling fires when the first post-reset notification ARRIVES, so there is a window between Redis losing the counter and the next publish in which this instance still replays old ids to a reconnecting client. Documented as accepted rather than closed: nothing local can detect the reset earlier (the counter is in Redis and we learn of it by receiving something), and the two shapes that would — a GET per resume, or a background poller — put network I/O on a latency-sensitive path or spend a goroutine and a round trip per tick forever against a condition measured in years. The exposure is redelivery of notifications the client already has, bounded by the window and self-healing on the next publish. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents): a replica that has received nothing must not answer 'caught up' (Codex round 9) P1 — the coverage check was skipped entirely while knownFrom was still 0, so a bus that had received NOTHING answered any cursor with an empty-but-non-nil replay, which the SSE handler reads as caught-up. The scenario is a restart, not an exotic one: replica B comes up while Redis is at 100, id 101 is published before B's subscription is live, and a client reconnects to B with Last-Event-ID 100 before 102 arrives. B says caught-up, then delivers 102 live, and 101 is gone with nothing to tell anyone. The principle the code now follows: having received nothing is strictly LESS knowledge than 'contiguous from X', so it must produce at least as strong a signal. A non-zero cursor against an empty bus is a gap. Both sides pinned, because the over-broad version is a real risk here — answering every fresh connection with a resync would be its own bug. A sinceID of 0 is not a resume and still gets an empty replay rather than a gap. Mutation-verified on the new arm. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * docs(watchevents,cli): name the trailing-gap and shutdown trades (Codex round 10) Two findings that are decisions rather than defects, so both are documented at the line where the reasoning is met and taken to the plan instead of being settled unilaterally after ten review rounds. P1 as reported — the TRAILING gap. Everything the coverage bookkeeping does reasons about what this instance HAS received; it cannot see a notification missed at the END of the sequence. Hold 100, miss 101 to a disconnect, and a client resuming from 100 before 102 arrives is told caught-up. The hole only becomes visible when 102 lands, which is too late for that connection. What would reveal it is a GET of the sequence key: a value above lastAppendedID means ids exist we never saw, and a value BELOW it reveals the counter reset documented last round — one mechanism, both open windows. It is not done here because it is product-visible in the other direction: INCR happens before the message propagates, so the counter legitimately runs ahead of every instance for microseconds after each publish, and a strict comparison turns ordinary in-flight traffic into spurious sync_required responses with no principled tolerance to pick. A resync is recoverable and a lost nudge is not, which is the argument for doing it — but that is a call about how chatty the resync path should be. P2 — closing the watch bus before Shutdown drains handlers means a push already in flight can publish into a closed bus and still return 200 with pushed:true. Closing after would instead hold every shutdown to its 30s deadline on any open stream. eventBus already makes the same trade the same way; naming it rather than inheriting it silently. The honest fix is Bus.Publish reporting the drop so the handler can, which is an interface change and a different unit. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * feat(watchevents): close the trailing gap with a settle-window authority check (lead ruling) Lead's ruling on BUG-2651: a silently lost nudge is unbounded staleness, a spurious resync costs one redundant fetch, so the gap must not survive — and don't pick a magnitude tolerance, because the reason the counter legitimately runs ahead is in-flight propagation, which is TIME-bounded while a genuinely missed message never arrives. So the discriminator is time. On a resume (and only on a resume), read the shared counter: if it disagrees with this instance's high-water mark, wait out one settle window and read again. In-flight ids land during the beat and the resume proceeds normally; missed ones never do and the resume is answered with a gap. That converts an unprincipled 'how many ids behind is too many' threshold into a principled propagation bound. The same read also catches the counter having gone BACKWARDS, so the counter-reset window documented last round is closed by the same mechanism rather than needing its own — the arrival-time reset handling stays, because it is what repairs the instance's own state and what covers a bus with no reconnecting clients. Ordering matters and is documented at the call: the check runs WITHOUT the mutex (it sleeps and does network I/O, neither of which may happen inside the lock fan-out needs) and BEFORE subscribing rather than between subscribe and replay, which would reopen the double-delivery window SubscribeAndReplaySince exists to close. Nothing is lost by waiting first — fanOutLocally buffers regardless of subscribers. An unreadable counter falls back to local knowledge rather than failing closed: turning a Redis hiccup into a resync for every reconnecting client at once is a worse failure than the one being guarded against. EventsSince deliberately does NOT do this and says so — it is the local primitive the Bus interface already describes as being for tests and non-resuming callers, and making it sleep and hit the network would surprise every one of them. Five tests, each pinning a different half: the missed tail reports a gap; a current instance does NOT (the control that stops this being 'always resync'); an id arriving mid-settle is tolerated; an unreadable counter falls back; a fresh subscriber neither waits nor gets a gap. Mutation-verified twice — disabling the check, and removing the settle beat — each killed by the test that names it. Also filed at the lead's direction, so the two remaining cross-instance defects have tracked homes rather than only comments: BUG-2698 (targeted push resolved against local presence, plus the delivered_sessions inaccuracy — one shared-state SessionPresence closes both) and BUG-2699 (push returns 200 pushed:true for a dropped publish; Bus.Publish reports nothing, and fixing it is an interface change). Every disposition comment now cites its item. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents): compare two FRESH reads, not one stale snapshot (Codex round 11) P1 — the settle beat re-read only the local side, so the comparison was against a counter SNAPSHOT taken before the wait. Id 2 arrives during the beat while id 3 is published and missed: the stale remote is still 2, the check declares convergence, and 3 is silently lost — the exact failure this whole mechanism exists to prevent, reintroduced inside it. P2 — the same staleness in the other direction. A GET can land just before a publish completes and report a value BELOW what this instance already holds; that never matches, so a client who had missed nothing got a full resync. Both are one defect: agreement between the authority and this instance has to be evaluated on two FRESH reads or it is not agreement. Now re-reads both sides after the beat, and treats any remaining disagreement as a gap in either direction — still behind means ids never reached us, still ahead means the counter was reset under us and our buffer belongs to a dead id space. Two tests, one per direction, each mutation-verified against the re-read-locally-only version: the second counter advance must produce a gap, and the raced read must NOT produce a resync. Without the second test the fix could have been 'always report a gap', which passes the first. Documented the cost side of the lead's ruling while I was in here: the condition is agreement, so a resume during CONTINUOUS publishing across the whole settle window can disagree every time and resync. Bounded by this stream being low-volume by design and resumes only happening on reconnect; if a workload makes it chatty, the answer is a longer window, not a magnitude threshold. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * fix(watchevents): an absent sequence key is zero, not unreadable (Codex round 12) P2 — the counter key can DISAPPEAR after this bus has seen ids (FLUSHDB, eviction). Reading redis.Nil as 'unreadable' meant falling back to local knowledge and cheerfully replaying an id space the authority no longer has — while the next publish starts again at 1 and collides with it. Absent is a VALUE. Returning zero-and-readable makes the case fall out of the ordinary comparison with no special branch: an instance holding 101 disagrees with an authority at 0, does not converge, and the resume is answered with a gap. A genuinely fresh deployment still agrees at zero and is not resynced — which is the control leg, and the reason 'absent means gap' would have been the wrong fix: it passes the first test while resyncing every first connection on a new install. P1 as reported — the equality fast path returning without settling — is not closed, and the comment now says why rather than leaving it to be re-found. A notification published AFTER that read and missed by this instance is invisible to any check made here, and settling anyway would not close it: the same race exists in the instant after the function returns. The check's honest scope is what was missed BEFORE the resume. A message missed after it is a property of at-most-once pub/sub with no per-connection ack, and the real answer is a durable stream (Redis Streams with consumer groups), not a longer wait. Mutation-verified: restoring redis.Nil to the unreadable branch fails the disappearing-counter test. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * feat(watchevents): epoch marker, so a reset that caught up is still a reset (Codex round 13) P2 — numeric detection is blind to a reset that has already climbed past this instance's high-water mark. Hold 100, lose the connection, the counter resets and ids 1-101 are published, and the only one that reaches us is 101 — the perfect contiguous successor of 100. Every arithmetic check passes, the buffer quietly mixes two id spaces, and a client resuming from OLD 100 is handed NEW 101 having silently missed the new space's 1-100. No amount of comparing numbers fixes that, because the question is not 'is this bigger' but 'is this the same sequence'. The publish script now mints an epoch once per id space (SET NX, so every publisher can offer one and the first wins) and carries it on every message; a change drops the buffer and re-anchors. The subtle half, and the one the first attempt got wrong: after an epoch change the cold-start rule must NOT admit its usual contiguous-with-our-view cursor. Within an epoch, a client at n.ID-1 is genuinely adjacent to our first id. Across one it is ambiguous — id spaces overlap, so that cursor may be the OLD sequence's n.ID-1, a different notification entirely — and admitting it hands them the new epoch's id as though it followed theirs, which is exactly the failure the epoch exists to prevent. Letting it back in one line later would have been a poor joke. The test caught it; the control leg (a cursor genuinely inside the new epoch is still served) is what stops the fix becoming 'resync everyone forever after any reset'. Wire format changed to <epoch>|<id>|<json>. Free of compat cost, checked rather than assumed: redis_bus.go does not exist on origin/main, so no released build produces or consumes the old shape. The numeric backward check stays — it covers a counter reset where the epoch key survived (eviction picks keys individually), and it is what repairs an instance with no reconnecting clients at all. Mutation-verified: ignoring the epoch change fails the new test. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * docs(watchevents): the wire format comments say <epoch>|<id>|<json> (Codex round 14) Three comments still described the pre-epoch format. Worth more than a tidy-up: a maintainer following them would conclude the epoch prefix is vestigial and remove it, which reintroduces exactly the cross-epoch replay corruption round 13 existed to fix. The publishScript comment now also says outright that the epoch is not decoration and points at redisWatchEpochKey before anyone considers it removable. Verified by grepping for the old shape rather than by trusting the edits — zero remaining, which is the check I owed after getting this wrong in round 7. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V * chore(nix): update vendorHash for the miniredis test dependency (BUG-2651) CI's Nix job failed on a fixed-output hash mismatch, and it is neither a flake nor a surprise once seen: nix/package.nix pins the vendored module set, and adding miniredis (plus gopher-lua, its Lua interpreter) to go.mod changed it. Regenerated per the procedure the file itself documents — build and read the 'got:' line. Run on CI rather than locally because this box has no nix; the hash is a content hash of the module set determined by go.mod/go.sum, so the same inputs produce it in either place. Worth naming as a gate lesson rather than just fixing: my pre-merge matrix had build, lint, test, test-pg, vuln and Codex, and none of them can see this. A dependency change has a SEVENTH consumer — the Nix packaging — and the only thing that checks it is the CI job that just did. Adding a dependency means checking the packaging, not only the security scan. Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
Pad
Project Management for the agent era.
Website · Docs · Blog · Changelog · X · Bluesky
One binary. Local-first. No accounts required. Pad gives you a CLI, a web UI, and an AI agent skill — all backed by SQLite, all running on your machine. Your project data stays on your laptop — unless you take it to Pad Cloud.
Quick Start
brew install PerpetualSoftware/tap/pad
cd your-project
pad init # configure, auth, workspace, AI skill — all in one
pad server open # opens the web UI at localhost:7777
pad init is the smart entry point — it auto-detects what's needed, walks you through each step, and is safe to re-run anytime (it skips finished steps and prints a status summary).
Then, in a fresh agent session in your project, say:
/pad onboard
Your new workspace ships with the canonical onboard playbook auto-activated. The agent walks an interview, inspects your codebase if it has shell access, and adapts your workspace's collections, conventions, roles, and playbooks to match the project. It's the fastest way to go from empty workspace to "okay, this is mine."
Why Pad?
Tools like Linear, Jira, and Notion are built for teams on the cloud. Pad is built for developers on their machine — and for the AI agents working alongside them. When you do want your projects on every device or a teammate on the board, Pad Cloud hosts the same product with sync, workspace invites, and role-based access.
| Pad | Linear / Jira | Notion | |
|---|---|---|---|
| Setup | pad init |
Create account, invite team, configure | Create account, pick template |
| AI agents | Native /pad skill for 7+ tools |
Third-party integrations | Third-party integrations |
| Data | Local SQLite you own — or opt-in Pad Cloud | Their cloud | Their cloud |
| Offline | Full functionality | Read-only cache at best | Limited |
| CLI | First-class | Afterthought | None |
| Price | Free, open source | Per-seat pricing | Per-seat pricing |
Features
For Developers
CLI that doesn't get in your way. Create tasks, search items, check status — without leaving the terminal.
pad item create task "Fix OAuth redirect" --priority high
pad item create idea "Real-time collaboration" --category infrastructure
pad item list tasks --status in-progress
pad item search "authentication"
pad project dashboard # Project dashboard
pad project next # What should I work on?
pad server info # How this client is connected to Pad
Web UI that stays out of your way. A clean, dark-themed interface at localhost:7777 with:
- Board, list, and table views — drag-and-drop between status columns
- Keyboard navigation —
j/kto move,Enterto open,Escto go back,Cmd+Kto search - Rich text editor — Tiptap-based with markdown, formatting toolbar, and auto-save
- Wiki-links — type
[[Title]]to link between items - Real-time updates — agent creates a task in the terminal, it appears in the browser instantly (via SSE)
- Dashboard — collection overview, active work, plan tracking, activity feed
For AI Agents
Your agent becomes a project partner. Install the /pad skill once, and your AI coding tool can read, create, and update project items through natural language.
pad agent install # Auto-detects your tools and installs the skill
Works with Claude Code, Cursor, Windsurf, Codex, OpenCode, GitHub Copilot, Amazon Q, and JetBrains Junie.
Then just talk to your project:
> /pad what should I work on next?
> /pad I finished the OAuth fix
> /pad create a task to add rate limiting
> /pad let's brainstorm about the API redesign
Conventions and playbooks teach agents how your project works:
- Conventions — trigger-based rules like "run tests before marking a task done" or "use conventional commits"
- Playbooks — multi-step workflows like "when implementing a feature: read the spec, create a branch, write tests first, then implement". Playbooks can declare a kebab-case
invocation_slugso users can invoke them directly:/pad ship PLAN-42,/pad release 0.5.0. Freshstartupworkspaces ship a genericshipplaybook out of the box.
pad item create convention "Run tests before completing tasks" \
--field trigger=on-task-complete \
--field scope=all \
--field priority=must
Agents load relevant conventions automatically. All agent actions are attributed in the activity feed, so you always know what the AI changed.
Onboard agents to a new codebase:
Open an agent session in the workspace directory and run /pad onboard. The agent walks an interview, detects your build/test/CI tooling, and adapts your workspace's collections, conventions, roles, and playbooks to match the project. Works for any agent that speaks Pad — Claude Code, MCP-only agents, etc.
Collections & Custom Fields
Pad organizes work into collections — typed containers with structured fields.
Built-in collections:
| Collection | Purpose |
|---|---|
| Tasks | Work items with status, priority, assignee, effort, due date |
| Ideas | Feature ideas with impact and category |
| Plans | Project milestones with progress tracking |
| Docs | Documentation, decisions, reference material |
| Conventions | Project rules that guide agent behavior |
| Playbooks | Multi-step workflows for agents to follow |
Create your own with typed fields — select, text, date, number, url, relation, checkbox:
pad collection create "Bug Reports" \
--fields "severity:select:low,medium,high,critical; browser:text; reproducible:checkbox"
Items get reference numbers automatically (TASK-5, BUG-12) and can be moved between collections with field migration.
Installation
Homebrew (macOS and Linux)
brew install PerpetualSoftware/tap/pad
Build from Source
git clone https://github.com/PerpetualSoftware/pad
cd pad
make build
cp pad ~/.local/bin/ # or /usr/local/bin/
Requires Go 1.26+ and Node.js 22+. Alternatively, nix develop provides a shell with the exact Go and Node versions pinned — see the Nix section below.
The go install github.com/PerpetualSoftware/pad/cmd/pad@latest path is not supported for the full Pad binary, because the web UI must be built and embedded during the source build.
Docker
docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad
This publishes Pad to localhost:7777 on the host machine, which is the recommended default for local use.
First run — create the first admin. Open http://localhost:7777 and you'll hit a setup page asking for a bootstrap token. On first start with no users, Pad logs a one-time setup URL to stderr (captured by docker logs) — grep it and open the printed link:
docker logs <container> 2>&1 | grep -A6 'Pad first-run setup'
# → http://<your-host>:7777/setup#token=<one-time-token>
Open that URL, create your admin account, and the token is consumed (the banner stops appearing). If you'd rather stay on the CLI, docker exec -it <container> pad auth setup works too — running inside the container counts as loopback, which the bootstrap gate allows. On a network you already trust, set PAD_BYPASS_SETUP_TOKEN=true to skip the token and create the admin straight from http://<your-host>:7777/setup (only safe when the port isn't reachable from the open internet).
Single user, more than one device? Publish to all interfaces so you can reach Pad from your phone, tablet, or another machine on the same LAN, Tailscale network, or home VPN:
docker run -p 7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad
For multi-instance deployments, Pad supports Postgres + Redis via docker-compose.yml — see docs/deployment.md for the full setup.
Nix
Run without installing:
nix run github:PerpetualSoftware/pad
Or install into your profile:
nix profile install github:PerpetualSoftware/pad
A flake devShell (Go, Node, and friends, pinned to the same versions CI uses) is also available for contributors:
nix develop
A
nixpkgspackage (nix-shell -p pad/environment.systemPackages) is planned but not yet merged upstream. Until then, use thegithub:PerpetualSoftware/padflake reference above.
Binary Download
Pre-built binaries for macOS, Linux, and Windows are available on the releases page.
Pad Cloud (hosted)
Don't want to run anything? Pad Cloud is the managed option — same product, same CLI, same /pad skill, free during beta. Sign up on the web, then connect a project directory:
pad init --url https://app.getpad.dev --workspace my-workspace
Self-hosting stays first-class: the binary is unchanged and no features are Cloud-only.
Upgrading Pad
Pad ships a new binary on a roughly weekly cadence. Upgrades are designed to be boring: install the new binary and restart. Database migrations run automatically at startup, only the ones your database is missing are applied, and each migration commits atomically (a failed migration rolls back cleanly and is retried next boot).
The one rule: only ever move forward. Newer binaries know how to migrate an older database; older binaries do not understand a newer schema. Since Pad added its schema-ahead guard, a downgraded binary that finds a database newer than itself refuses to start rather than silently running old code against a newer schema (which can corrupt data):
database schema is newer than this pad binary: ... 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`.
To recover, reinstall the newer binary (brew upgrade pad, pull the newer Docker tag, etc.). If you have intentionally downgraded and accept the risk, start with pad start --force (or set PAD_ALLOW_SCHEMA_AHEAD=1) to override the guard.
Automatic pre-migration snapshot (SQLite). Whenever a SQLite-backed instance has pending migrations to apply, Pad first copies the database file to pad.db.pre-<version> next to it. If an upgrade ever goes wrong, stop the server and copy that snapshot back over pad.db. This is a convenience net, not a backup strategy — keep your own backups (see docs/backup.md). PostgreSQL instances are skipped here; use pg_dump or a provider snapshot before upgrading.
Recommended upgrade flow:
# 1. Back up first (SQLite shown; see docs/backup.md for Postgres)
pad db backup -o pad-backup-$(date +%Y%m%d).db
# 2. Stop the server, install the new binary, restart
# (migrations + the pre-migration snapshot run automatically on start)
brew upgrade pad # or: docker pull, binary download, make install
# 3. Confirm it's healthy
pad --version
curl -s localhost:7777/api/v1/health
Getting Started
1. Set up Pad
cd ~/projects/myapp
pad init "My App"
pad init is the smart entry point that handles everything in one command:
- Configures this client's connection (local server, remote, or Docker)
- Auto-starts the local server
- Creates the first admin account on a fresh local install (Docker / remote hosts run
pad auth setupon the server instead) - Logs you in if needed
- Creates or links a workspace for the current directory (writes
.pad.toml) - Installs the
/padskill for any AI tools detected in the project
Run from your project root. Safe to re-run anytime — it skips finished steps and prints a status summary if nothing's needed.
Choose a template with --template, or omit it for an interactive picker grouped by category (Software / People / …):
pad workspace init --list-templates # See the full catalog grouped by category
pad init "My App" --template scrum # Scrum-style with sprints
pad init "My App" --template product # Product management focused
pad init "My Hiring" --template hiring # Company-side: requisitions, candidates, interview loops, feedback
pad init "Job Search" --template interviewing # Candidate-side: applications, interviews, companies, contacts
pad init "My App" --template blank # Custom: system collections only — let /pad onboard build the rest
Pad ships templates for software (startup / scrum / product), people workflows (hiring, interviewing), and a custom blank template — system collections (Conventions, Playbooks) only, with the /pad onboard playbook as its sole seeded content. blank is the entry point for the agent-driven /pad onboard flow: it walks you through shaping collections, conventions, and roles to match your actual project. Reserved categories for research, content, operations, and personal use await their first templates, so the same project-management primitives fit well beyond code projects. There's also a hidden demo template — the startup layout pre-loaded with realistic sample data — that's kept out of the picker but can be built explicitly with --template demo.
2. Start working
# From the CLI
pad item create task "Set up CI pipeline" --priority high
pad item create idea "Add WebSocket support" --category infrastructure
pad project dashboard
# From the web UI
pad server open # Opens localhost:7777 in your browser
# From your AI agent
# Just use /pad in Claude Code, Cursor, etc.
3. Teach your agents the rules
In an agent session inside the workspace:
/pad onboard
The agent walks an interview, detects your tooling, and adapts the workspace's collections, conventions, roles, and playbooks. To browse the library directly:
pad library list --type conventions # Pre-built conventions you can adopt
pad library list --type playbooks # Pre-built multi-step workflows
4. Optional — connect a desktop AI app via MCP
Pad ships an MCP (Model Context Protocol) server so Claude Desktop, Cursor, Windsurf, Claude Code, or Codex can manage items, plans, ideas, and dependencies as native tools, read workspace state by URL, and load multi-step workflows as prompts.
pad mcp install claude-desktop # or: cursor, windsurf, claude-code, codex, --all
# Restart the client; pad shows up as the "pad" MCP server.
pad mcp install writes each client's native config: JSON mcpServers for
Claude Desktop / Cursor / Windsurf, a project-local .mcp.json in the current
directory for claude-code, and an [mcp_servers.pad] table in
~/.codex/config.toml (TOML) for codex. Because Claude Code's config is
project-scoped, it's install-on-request only — --all and pad mcp status cover
the per-user clients (including Codex) and skip it.
Tool catalog (v0.23) — ten resource × action tools plus pad_set_workspace (eleven total), no flat verb explosion. pad_item.list accepts unparented: true (mutually exclusive with parent) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (full: true opts into complete content bodies):
| Tool | Actions |
|---|---|
pad_item |
create, update, delete, get, list, move, restore, link, unlink, deps, star, unstar, starred, comment, list-comments, backlinks, bulk-update, note, decide, export, import, history |
pad_workspace |
list, members, invite, storage, audit-log, create, claim, deleted, restore |
pad_collection |
list, create, update, delete |
pad_project |
dashboard, next, ready, stale, standup, changelog, report, activity |
pad_role |
list, create, update, delete |
pad_search |
query |
pad_playbook |
list, get, run |
pad_library |
list, get, activate |
pad_attachment |
list, show |
pad_meta |
server-info, version, tool-surface, bootstrap |
pad_set_workspace |
session-default workspace pinning (response embeds the bootstrap blob) |
Plus resources at pad://workspaces, pad://workspace/{ws}/dashboard,
pad://workspace/{ws}/items, pad://workspace/{ws}/items/{ref},
pad://workspace/{ws}/collections,
pad://workspace/{ws}/attachments/{id} (bounded image bytes),
pad://workspace/{ws}/bootstrap,
and pad://_meta/version.
Stability contract — two version constants, both advertised in the
initialize handshake under capabilities.experimental.padCmdhelp and
capabilities.experimental.padToolSurface (and queryable at
pad://_meta/version):
cmdhelp_version: "0.1"— CLI help-tree contract (used at dispatch time)tool_surface_version: "0.23"— MCP tool catalog contract (v0.5 addedpad_library; v0.6pad_item.backlinks; v0.7pad_itemexport/import; v0.8pad_workspacedeleted/restore; v0.9 madepad_item.listsummary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side onpad_playbook.runwith anallow_draftescape hatch; v0.11 added the read-onlypad_attachmenttool (list/show); v0.12 addedpad_project.activity(agent-accessible non-streaming activity feed); v0.13 addedpad_projectready/stale(agent-oriented backlog + attention queries); v0.14 addedpad_itemhistory+ optimistic concurrency (TASK-2022); v0.15 added thepad_item.listunparentedparameter (TASK-2096); v0.16 made an empty-stringassigned_user_id/agent_role_idCLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 addedclear_assigned_user/clear_agent_rolebooleans — the canonical, schema-discoverable way to unassign, backed by new--clear-assigned-user/--clear-agent-roleflags onpad item update(IDEA-2584); v0.19 added aclear_parentboolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new--clear-parentflag onpad item update(BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalog’s read-only knowledge — fully-read-only tools advertisereadOnlyHint: true/destructiveHint: false, all-additive-write tools (pad_workspace,pad_library) dropdestructiveHint, overwrite/delete-capable tools stay conservatively destructive,openWorldHint: falseeverywhere — replacing mcp-go’s defaults that marked every tool destructive (BUG-2302), and madepad_item.listsummary-shaped on the remote HTTP transport too, with a declaredfullboolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 boundedpad_item.history, which was unbounded on every surface —limitnow covers it (default 50, max 300, the NEWEST N; nooffset, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stoppedpad_item.movedestroying an item’s system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the move’s activity entry rather than vanishing, and afieldsetter naming one of those reserved keys is refused withmalformed_overrideinstead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — afieldsetter namingimplementation_notes,decision_logorconventionis now refused on every transport at once (validation_erroron HTTP, surfaced to MCP clients asvalidation_failed); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower afieldsetter into the samefields_patch;github_pris deliberately exempt ON UPDATE (move and copy still refuse it), sincepad github linkcannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-fieldspayload is shared with Pad’s own writers. v0.23 also added the retry-hostilestored_state_unreadableerror code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); seeinternal/mcp/version.gofor the full changelog)
External agents pin against these so a future rename doesn't break them
silently. Errors come back as structured envelopes ({error: {code, message, hint, available_workspaces, ...}}) with a closed code
taxonomy — 17 codes as of v0.23, enumerated in
internal/mcp/errors.go. Branch on code, not on message text; a code
you don't recognize is possible, and stored_state_unreadable in
particular means STOP rather than retry.
Full guide at getpad.dev/mcp/local — install paths, action enums per tool, error taxonomy, troubleshooting.
On Pad Cloud? Skip the install: add https://mcp.getpad.dev as a remote
MCP server in Claude Desktop, Claude.ai, Cursor, or Windsurf and sign in with
OAuth — same tool surface, no local binary. Setup guide at
getpad.dev/mcp/remote.
CLI Reference
pad auth configure Configure how this client connects to Pad
pad auth setup Initialize the first admin account
pad auth login Sign in
pad auth whoami Show current user
pad server start Start the Pad API server
pad server stop Stop the Pad server
pad server info Show client, connection, and local server status
pad server open Open web UI in browser
pad workspace init [name] Initialize workspace in current directory
pad workspace link <workspace> Link current directory to an existing workspace
pad workspace list List all workspaces
pad workspace switch <workspace> Switch active workspace
pad workspace context Show structured workspace context
pad workspace context set --file X Update structured workspace context from JSON
# Workspace onboarding: run `/pad onboard` from an agent session inside the workspace
pad workspace members List workspace members
pad workspace invite <email> Invite a workspace member
pad workspace join <code> Accept an invitation
pad workspace export Export workspace data
pad workspace import <file> Import workspace data
pad project dashboard Project dashboard
pad project next Recommended next task
pad project ready Query actionable next items
pad project stale Query stalled or attention-worthy items
pad project standup [--days N] Daily standup report
pad project changelog [--days N] Release notes from completed items
pad project watch Real-time activity stream
pad project reconcile Reconcile item and PR state
pad item create <coll> "title" Create item (task, idea, plan, doc, ...)
pad item list [collection] List items (filters: --status, --priority, --all)
pad item show <ref> Show item detail
pad item open <ref> Open item in web UI
pad item update <ref> Update item fields
pad item delete <ref> Delete item
pad item move <ref> <collection> Move item between collections
pad item edit <ref> Open item in $EDITOR
pad item search "query" Full-text search across all items
pad item comment <ref> "text" Add comment to an item
pad item comments <ref> View item comments
pad item note <ref> "summary" Append an implementation note to an item
pad item decide <ref> "decision" Append a decision log entry to an item
pad item block <src> <target> Create dependency
pad item blocked-by <item> <blk> Mark item as blocked
pad item deps <ref> Show dependencies
pad item unblock <src> <target> Remove dependency
pad item related <ref> Show direct relationships for an item
pad item implemented-by <ref> Show incoming implementers for an item
pad item bulk-update --status X Batch update multiple items
pad collection list List collections with item counts
pad collection create <name> Create a custom collection
pad library list Browse convention and playbook library
pad library activate <title> Activate a convention or playbook
pad agent install [tool] Install /pad skill for AI coding tools
pad agent status Show supported tools and installation status
pad agent update Update installed tool integrations
pad github link [item-ref] Link current branch's PR to item
pad github status [item-ref] Show PR status for linked items
pad github unlink <item-ref> Remove PR link from item
pad webhook list List workspace webhooks
pad webhook create <url> Create webhook
All commands accept --format json for machine-readable output and --workspace to target a specific workspace.
Shell completion
pad ships completion scripts for bash, zsh, fish, and PowerShell:
# Bash — current session only
source <(pad completion bash)
# Bash — persistent
pad completion bash > /etc/bash_completion.d/pad # Linux
pad completion bash > $(brew --prefix)/etc/bash_completion.d/pad # macOS (Homebrew)
# Zsh (make sure compinit runs in your ~/.zshrc)
pad completion zsh > "${fpath[1]}/_pad"
# Fish
pad completion fish > ~/.config/fish/completions/pad.fish
# PowerShell (append the output to your $PROFILE)
pad completion powershell | Out-String | Invoke-Expression
Beyond command and flag names, completion is context-aware: collection arguments (e.g. pad item list <TAB>) complete against your workspace's collections, --workspace completes configured workspace names, and --status / --priority complete their valid values.
Authentication
Pad runs without authentication by default for frictionless local use. For local installs, pad init creates the first admin account inline. The lower-level commands are useful when you're hosting a Pad server (Docker / remote) and need to set up auth on the server host directly:
pad auth setup # Initialize the first admin account (server host, non-local mode)
pad auth login # Sign in
pad auth whoami # Show current user
pad auth logout # Sign out
Once a user exists, all API requests and web UI access require authentication. Credentials are stored in ~/.pad/credentials.json. Multiple users can be invited to workspaces with role-based access control (owner, editor, viewer).
pad workspace members # List workspace members
pad workspace invite user@example.com
pad workspace join <code>
Architecture
┌──────────────────────────────────────────────┐
│ pad (single binary) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ CLI │ │ REST │ │ Embedded │ │
│ │ (Cobra) │ │ API │ │ Web UI │ │
│ └────┬─────┘ └────┬─────┘ │ (SvelteKit)│ │
│ │ HTTP │ └────────────┘ │
│ └──────────────┤ │
│ ┌─────▼─────┐ │
│ │ SQLite │ │
│ │ + FTS5 │ │
│ └───────────┘ │
└───────────────────────────────────────────────┘
- Go backend — chi router, SQLite via modernc.org/sqlite (pure Go, no CGO), FTS5 full-text search, SSE for real-time updates
- SvelteKit frontend — Svelte 5, Tiptap editor, drag-and-drop, adapter-static, embedded via
go:embed - Single binary — serves the API and web UI, runs on macOS, Linux, and Windows
- Workspace-per-project — each project gets its own workspace linked by a
.pad.tomlfile
Self-hosted, all data lives in ~/.pad/pad.db. Your data. Your machine. No telemetry, no accounts required — cloud only if you opt in.
Contributing
See CONTRIBUTING.md for the development guide.
make build # Build web UI + Go binary
make test # Run Go tests
make dev-web # SvelteKit dev server with hot reload
make install # Build, install to ~/.local/bin, restart server
Security
See SECURITY.md for reporting vulnerabilities.

