20 Commits

Author SHA1 Message Date
xarmian d83632e258 feat(attachments): extension trust — the audio/video split, the text family, the CFB office trio and RTF (TASK-2976 / BUG-2963 PR B) (#1309)
* test(attachments): record the Void-beyond-window limit, and correct three comments that claimed more than their evidence (TASK-2976)

The first commit of BUG-2963 PR B, before any extension-trust work: round
6's three items, deliberately kept out of PR A so its verified tip would
not move, plus the nix terminator-comment correction folded in from
BUG-2974's trail.

1. TestMatroskaDocTypeBeyondTheWindowIsWebM records a LIMITATION. A Void
   element is legal anywhere in an EBML header and may be any size; make
   one larger than the 512 bytes this door reads and the DocType behind
   it is not in the input at all, so the parse finds nothing and the
   stdlib's video/webm stands. Nothing got worse — the same file was
   video/webm before the DocType read existed, and video/webm's own
   allowlist entry permits inline serving — and no larger window fixes
   it, since Void may be larger still. The fixture is the ordinary
   FFmpeg Matroska with a 560-byte Void spliced into its header and the
   header size widened to match, the same construction as
   matroska-void-padded.head512; the complete file reads as
   matroska,webm under ffprobe, and what is committed is its first 512
   bytes, so the DocType is absent by construction rather than by
   truncation accident.

2. TestTarWinsAPrefixCollision no longer calls the collision
   "asymmetric". That was the round-4 premise round 5 refuted with
   flac-ustar-in-comment.head512 — a FLAC's Vorbis COMMENT tags are
   arbitrary UTF-8, so real audio carries "ustar" at offset 257 as
   readily as a real tar carries an audio marker at offset zero. The
   implementation and the fixtures already said so; the stale word
   survived in the place a reader looks first. The comment now gives the
   real reason tar leads the default order, which is weaker: its magic
   sits at a fixed offset rather than at a prefix.

3. The seven-byte textual-AAC input's comment says which of the two
   things it is. It is a valid ADTS sync and layer signature that the
   stdlib reads as text — not a decodable AAC, and nothing in the test
   establishes that it is. What review established is the case it stands
   in for, and the comment now separates the two.

4. The nix loop-terminator comments named the wrong mechanism
   (BUG-2974, observed day 62). The heal push cannot loop because a push
   made with the default GITHUB_TOKEN creates no workflow runs at all —
   `gh run list --branch main` spans cbfc073e -> 31d11e76 -> 0b16be49
   and skips the heal commit 249a8f87 entirely. The corrected-tree
   argument the comments gave is true and UNEXERCISED; it is now written
   as the backstop, with the tripwire that it becomes load-bearing the
   moment that push uses a PAT or app token. The per-commit query is not
   the instrument for this and the comment says so: it also returned
   nothing for 0b16be49, which has runs.

Also swept: the fixture README's "the two EBML entries" was already
false at four, and nix.yml's "THAT DISTINCTION" lost its referent once
paragraphs landed above it. The new fixture joins the FuzzSniffMIME
seeds.

go test ./internal/attachments/ green; nix/heal-vendor-hash_test.sh 20
passed, 0 failed.

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

* feat(attachments): the ISO-BMFF audio/video split — .mov and .m4a stop being refused (TASK-2976, BUG-2963 F4)

Both iPhone-default containers were refused at upload while their types
sat on the allowlist. Measured on 06ccabdd before the change:

  clip.mov  (qt   brand) -> sniff application/octet-stream -> mime_not_allowed
  clip.m4a  (isom brand) -> sniff video/mp4 -> mime_extension_mismatch
  clip.m4a  (M4A  brand) -> sniff video/mp4 -> mime_extension_mismatch

Three legs, per the day-62 per-family ruling on BUG-2963:

1. Major brand "qt  " -> video/quicktime. The stdlib's mp4 matcher wants
   a brand beginning "mp4" and a QuickTime file carries none, so it had
   no opinion at all. This ADDS a detection, exactly as the still-image
   brands do.

2. Major brand "M4A " -> audio/mp4. This is the one place this package
   OVERRIDES a type the standard library named: an .m4a's compatible
   brands routinely include "mp41", so the stdlib answers video/mp4 from
   a compatible brand, having no way to weigh it against the major one.
   The major brand is the file's own statement about itself. The
   override is bounded — both types are allowlisted, both render inline,
   and the only thing that moves is the CATEGORY, which decides whether
   the UI offers an audio player or a video one.

   It is read from the MAJOR brand alone and BEFORE the mp4-yield, which
   is the whole mechanism: the yield would otherwise return "" on that
   same mp41 brand before the check could speak. Both facts are stated
   in the code.

3. isom-branded bytes named .m4a -> audio/mp4, in ValidateUpload beside
   the zip+document branch and as the same kind of trust one family
   over: the BYTES establish the container, the FILENAME chooses only
   the spelling within it. No track reads, per the ruling.

sniffISOBMFFImage is now sniffISOBMFF. It answers for audio and video
now, and a name saying Image while it returns audio/mp4 is the exact
defect class this bug keeps producing.

The controls are the test. An accept-only test is satisfied by a rule
that trusts .m4a outright, which is a much larger claim than the one
granted, so each leg carries what the STDLIB said about the same bytes
(the thing separating "adds a detection" from "overrides one"), and the
extension leg carries four: the same bytes named .mp4 stay video, the
same bytes with no extension stay video, a WebM named .m4a is still
refused (the trust does not reach outside the MP4 family), and PNG bytes
named .m4a are still refused (the extension introduces no type). A
synthetic ftyp box with major isom and "M4A " among its COMPATIBLE
brands is refused too — reading a category out of the compatible list is
a wider rule than the one granted.

Fixtures: quicktime.head512, m4a-brand.head512, m4a-isom.head512 — real
FFmpeg n7.1 output, provenance in the fixture README. Swept prose that
said this pre-check is still-images-only (SniffMIME's doc comment, the
mime_isobmff.go header).

go test ./internal/attachments/ green. Mutation matrix next, then F5.

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

* refactor(attachments): the .m4a branch's category guard was dead — assert the invariant it stood for instead (TASK-2976)

A mutation run says the guard survives removal, and it is right: the
branch answers for exactly .m4a, so extEntry.Category == CategoryAudio
can never differ from the ext test beside it. (The zip branch's
identical-looking guard IS load-bearing, because that branch answers for
many extensions at once.)

What the branch actually depends on is that .m4a maps to an allowlisted
audio entry, since it returns that entry. That is now asserted directly
in TestBUG2963F4M4AExtensionTrust, so a remap of .m4a fails a test
instead of quietly retyping MP4 bytes. Dead code replaced by a tested
invariant, rather than kept as defence in depth it cannot provide.

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

* feat(attachments): the text family — the extension chooses which text, category unchanged (TASK-2976, BUG-2963 F5)

text/plain is the stdlib saying "these bytes are text" and nothing more;
it has no signature separating Markdown from YAML from JavaScript
because at the byte level there is none to have. So when the sniff is
text/plain and the extension maps to an allowlisted TEXT entry, that
entry is what gets stored. Per the day-62 ruling.

Category-preserving is the whole safety argument: nothing becomes an
image, an archive or a document by being renamed, and a mapping that is
not an allowlisted text entry does not fire — .svg still reaches
extension_blocked rather than being quietly stored as text.

TestTextFamilyStillStoresAsPlain is now TestBUG2963F5TextFamily. It
recorded this boundary and said in its own failure message that F5
landing should move it; it kept the same three inputs and changed the
expectation, which is what "move it" meant.

THE SERVING BUCKET MOVES FOR EVERY TYPE F5 TOUCHES, NOT ONLY .js.
text/plain is the only member of the text family in inlineSafe, so
choosing any other text spelling takes the file out of inline serving
and into Content-Disposition: attachment. The ruling weighed that for
.js, where the move to RenderForceDownload is the point. It applies
equally to .md, .csv, .json, .yaml and .toml, where it is a side effect
— and it turns out to be the honest one: the client's own DR-5 already
says every text/* subtype but plain text "is downloaded or rendered
inconsistently across browsers", and those types only reached the
browser-preview affordance because the server was storing them as
text/plain. Whether any of the five belongs in inlineSafe is an
allowlist decision and is not taken here.

The in-app markdown preview is unaffected: it fetch()es the bytes and
renders them itself, which an attachment disposition does not impede,
and it now matches on the MIME rather than falling back to the
extension. Its prose in web/src/lib/attachments/display.ts said an
uploaded .md "is stored as text/plain" as a present-tense measured
fact; that is now history, and the fallback it justifies is what
PRE-EXISTING rows still depend on, so the comment says that instead
(CONVE-23, and its mirror in display.test.ts).

Controls, since an accept-only test is satisfied by "trust text
extensions outright": .txt / an unmapped extension / no extension at all
stay text/plain; PNG bytes named .md are still a mismatch; text bytes
named .mp3 are still a mismatch (an allowlisted mapping in another
category is the dangerous shape and the one the category guard is for);
.svg still fails extension_blocked; and the .js leg asserts RenderMode
== RenderForceDownload against the same bytes named .txt, not merely
that the type changed.

go test ./internal/attachments/ green. Mutation matrix next.

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

* feat(attachments): the legacy Office trio and RTF — two more families the door refused (TASK-2976, BUG-2963)

CFB. The container legacy Office wrote .doc/.xls/.ppt into has no stdlib
signature, so every one of those files sniffed application/octet-stream
and was refused while all three types sat on the allowlist. The branch
reads eight fixed bytes and says only "this is a CFB container", because
that is all eight bytes can say: .msi installers and Visio files wear
the same header, and telling them apart is a directory-stream walk this
door does not do. So the extension chooses which of the three REVIEWED
types is stored, exactly as the zip+document branch does one container
family over.

The extension list is written out rather than derived from extMIMEMap.
The question is not "does this extension map to a document" — .vsd and
.msi are CFB too — it is "is this one of the three the allowlist
reviewed", and that is a list, not a predicate. .docx is the sharp
control: a document extension whose type is allowlisted but whose
container is zip, refused on a CFB header.

RTF. Printable ASCII, so the stdlib answers text/plain — an OPINION, not
the absence of one — and a .rtf was refused as a mismatch against its
own type. This one needs no extension: five fixed bytes at offset zero,
one of them a version digit the spec fixes, is a stronger signature than
the three-byte "ID3" the stdlib uses for audio/mpeg. So it is a
refinement in SniffMIME rather than extension trust in ValidateUpload,
and the tests assert it under three filenames including .bin, which is
what says so.

text/plain therefore joins the refinement switch, and the comment there
says what makes that case narrower than the octet-stream one: it admits
exactly one signature, and the bar for a second is the argument at
validRTFStream rather than the list's existence.

One behaviour change beyond the refusals: an EXTENSIONLESS RTF used to
store as text/plain and serve inline; it now stores as application/rtf
and serves as an attachment. More conservative, and named here because
it is the one leg that changes a file that was previously accepted.

CONVE-23 sweep, three sites, all of them prose this change made false:
SniffMIME's "two refinements", and the F6 comment claiming
text/javascript "is not reachable EITHER" — F5 made it reachable, which
is the whole point of including .js.

The CFB fixture is hand-built and the test says why that is sound here
and not elsewhere: the branch reads eight fixed bytes, so eight bytes
exercise all of it, unlike the ISO-BMFF and EBML fixtures where the code
walks structure an encoder produces.

go test ./internal/attachments/ green.

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

* fix(attachments): gate the CFB branch on the stdlib verdict, not the refined one (TASK-2976, BUG-2963)

A surviving mutant found this. Dropping the octet-stream gate changed
no test, which said the gate was either dead or untested; it is
untested, and it was also gating on the wrong value.

A CFB file's 512-byte header is sector data, so one can carry "ustar" at
offset 257 and be refined to application/x-tar by this package's own
magic table. Gated on the REFINED verdict, that file is refused under
its own .doc name — the false refusal BUG-2963 exists to remove. Gated
on the stdlib's, the question is whether anything IDENTIFIED the bytes,
and nothing did.

This is the reasoning the .aac branch already carries, in the same
function, for the same collision from the other side. The test is a CFB
header with ustar spliced into its sector data, and it asserts the
premise (these bytes really do refine to x-tar) before asserting the
outcome, so it cannot pass by the collision quietly failing to happen.

Claude-Session: https://claude.ai/code/session_01GqaEDuCtRiSJfa7eppWecn
2026-09-09 19:23:18 -04:00
github-actions[bot] 249a8f879f chore(nix): heal vendorHash for the module set in 31d11e76ea (TASK-2954) 2026-09-09 18:33:41 +00:00
xarmian 38d8803603 fix(nix): gate the vendorHash heal on state, not on the push range (BUG-2974) (#1303)
* fix(nix): gate the vendorHash heal on state, not on the push range (BUG-2974)

The heal job asked whether THIS push touched go.mod/go.sum. That is the right
question for loop prevention and the wrong one for recovery: after a lost push
race the tree needing the fix was written by an EARLIER push, so every later
merge that did not itself move the module set refused to heal, exited GREEN,
and left main carrying a hash a clean `nix build` rejects. It happened on
b7235a1a -> bc543a1d and main stayed stale until a human committed the value.

The gate is now a question about STATE — does what main committed differ from
what the build job recomputed — which is equally true for a hash this push
broke and one an earlier push broke and could not land. The loop still
terminates and never depended on the range: the heal commit corrects
package.nix, so the run it triggers finds the build green, never sets `bumped`,
and this job does not start.

An unhealed committed hash is now LOUD. A rejected push and a recomputed
artifact identical to the committed file both end the run RED with an
::error:: naming the state, instead of the silent exit 0 that hid this.

The step is a script rather than a `run:` block so it can be driven against a
real git remote: nix/heal-vendor-hash_test.sh, wired into the Go job beside the
parser suite. Its load-bearing case is the one that shipped, with a frozen
reproduction of the pre-fix gate as the negative control.

Folds in the two prose warts parked on TASK-2954's trail: the doubled
"one commit after the merge" clause, and the comment naming the push-range gate
as the loop guard when the terminator is a corrected tree computing no bump.

* fix(nix): the heal test must own the guard's environment, not inherit it (BUG-2974)

Codex round 1, P1, reproduced: GitHub Actions always sets GITHUB_ACTIONS=true,
which the test process inherits, so case 6 satisfied the run-location guard it
was written to trip. It passed locally and would have failed the new Go CI step
on every run — the suite's first act in CI would have been to report a defect it
had introduced itself.

Fixed at the class rather than the instance: the suite unsets both GITHUB_ACTIONS
and HEAL_ALLOW_LOCAL up front, so no case can be decided by the caller's
environment, and each opts in explicitly. Case 6b is new and covers the arm this
exposed as unasserted — in Actions, with no override, the script runs and heals.

Verified green in four environments: bare, GITHUB_ACTIONS=true, a stray
HEAL_ALLOW_LOCAL=1, and init.defaultBranch=master. Mutation matrix re-run and
extended to 7 (added: guard condition inverted); all killed.
2026-09-09 10:52:14 -04:00
xarmian 5ce1103981 chore(nix): heal vendorHash for the module set in b7235a1a (TASK-2954, BUG-2974)
The heal job's commit for #1301's module set (a515b03b on run 34296097000)
was rejected non-fast-forward when bc543a1d landed mid-build, and the next
run's push-range gate refused to retry because its own push touched no
go.mod — the recovery hole BUG-2974 records. Hash taken from both runs'
recompute output, which agree: sha256-uuSOBDhEo20QyqACh3fFjjTBatP9LKpquZdYBsJeGDI=.

Claude-Session: https://claude.ai/code/session_015A7n836r64Y9THC8UWsDFF
2026-09-09 00:53:54 +00:00
xarmian 49d1bfcd85 ci(nix): a Go bump's Nix check is green when the build passes, and main heals its own vendorHash (TASK-2954) (#1292)
`vendorHash` pins the Go module set by content hash. Dependabot updates go.mod
and go.sum and has no idea `nix/package.nix` exists, so every Go-dependency bump
PR failed `Nix build & check` on a fixed-output hash mismatch — structurally,
and forever. Both open Go bumps (#1274, #1275) are red that way today; the
npm-side bumps (#1276, #1277) are green, which is the control that isolates the
cause. A permanently-red check is not a check: a bump that genuinely breaks the
build looks identical, at a glance, to one that only moved the hash.

Two halves, and they are deliberately in different places.

THE CHECK IS MADE HONEST WITHOUT A TOKEN. Every Nix run recomputes the hash in
its working tree before the build steps judge it, so green means the build
passed with that ref's actual module set. This runs for every author, not just
Dependabot: a human's own go.mod change moves the hash the same way, and a check
that is honest for one author only is the shape this removes.

MAIN HEALS ITSELF ONE COMMIT AFTER A MERGE. The corrected file cannot be pushed
from a Dependabot PR run: such a run gets a read-only GITHUB_TOKEN — it runs as
if from a fork — and the `permissions` key does NOT lift that. Only a
repository-wide setting does, and that setting would hand fork PRs write tokens
on a public repo, which is the surface CONVE-2438 exists to keep closed. The
merge, however, is authored by a human, so the `push: main` run that follows is
ordinary. A separate job with the only `contents: write` in the file commits the
recomputed value there, gated on the JOB (a step-level `if` is not a boundary —
the job would still hold the token and checkout persists it), on the build
having passed, and on this push having touched go.mod or go.sum.

The loop guard is the commit's own contents: the bot commit touches
nix/package.nix and nothing else, so the run it triggers finds go.mod and go.sum
unchanged and stops at the first gate. Not a heuristic about who pushed — the
fix cannot invalidate the hash it just wrote.

Three ways that gate could have lost a heal, all closed. A cancelled main run's
heal is never retried — the next push's run recomputes correctly but its gate
sees only its OWN commits — so `cancel-in-progress` is now `pull_request`-only;
and that alone is not enough, because GitHub holds only ONE pending run per
concurrency group and a third push evicts the queued second, which looks exactly
like a run that found nothing to do — so push runs get a per-commit group that
nothing can evict. The gate also compares `before..after` on a full clone rather than `HEAD~1..HEAD`
on a two-commit one, because a direct push of several commits can carry the
go.sum change anywhere in the range. A concurrent merge makes the push a
non-fast-forward: the job goes red rather than overwriting, and that merge's own
run heals.

WHAT THE PARSER REFUSES is the whole correctness argument. This build has many
fixed-output derivations: every npm tarball `importNpmLock` fetches is one, and
a mismatch in any of them prints the same block with a `got:` line. Taking "the
got: hash" writes a tarball's hash into `vendorHash` and looks like it worked —
which is what package.nix's old comment told a human to do by eye. So each line
is stripped of its runner timestamp and indentation SEPARATELY and matched
whole, and a header counts only if it says `error:`, names a single store path
segment that STARTS with a 32-character store hash then `-pad-` and ends
`-go-modules.drv':` with nothing after it — identity, not resemblance, since
`-pad-` anywhere in the name also matches `…-other-pad-tool-…-go-modules.drv`;
the hash is then taken only from a canonical-length `got:` on the line
IMMEDIATELY after a canonical-length `specified:`. Anything else exits 1 having
written nothing, and the build stays red.

40 assertions in nix/bump-vendor-hash_test.sh, wired into the CI Go job and
`make test-nix-hash` — in ci.yml rather than nix.yml because otherwise nothing
on an ordinary PR would run it, and a break would surface on the next bump.
14 mutants, all killed — but five of them survived the first suite that claimed
to cover them, each because the case written for the rule was ALSO refused for a
second reason and so discriminated nothing about it: short hashes on both lines
never exercised either length rule on its own, and a nested path that also had a
malformed store hash never exercised the single-segment rule. A sixth, dropping
the canonical length from the `got:` condition, survived because the extractor
re-stated the rule; the fix was to state it once. Portability is checked, not assumed: `awk` is
gawk here and mawk on the runners, so the suite re-runs itself under mawk, gawk
and busybox and is green only if all agree. The parser also had a real defect —
it worked only on timestamped CI logs, not the local log package.nix tells a
human to produce — found by asserting an exit code rather than file contents,
because for a no-op input "did not write" and "could not parse" leave identical
files.

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: xarmian <xarmian@gmail.com>
2026-08-23 08:52:42 -04:00
xarmian 25c7cd20f5 feat(watchevents): Redis-backed bus so watch notifications cross instances (BUG-2651) (#1167)
* feat(watchevents): Redis-backed bus so watch notifications cross instances (BUG-2651)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

make vuln: 0 vulnerabilities in imported packages.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-20 02:54:53 -04:00
xarmian 94441b4eb2 chore(nix): bump package version to 0.14.0 ahead of the release tag
Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-16 20:26:51 +00:00
xarmian ad1e919290 chore(deps): bump otel exporters to v1.45.0, clearing GO-2026-4985 from the Nix baseline (#1097)
* chore(deps): bump otel exporter cluster to v1.45.0 (GO-2026-4985)

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

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

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

Refs BUG-2085, BUG-2567.

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

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

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

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

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

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

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

Refs BUG-2085, BUG-2567.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Verified: `make test` green, 25 packages.

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

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

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

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

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

CORRECTIONS to 496f521f's message:

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

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

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

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

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

Fixed in place:

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

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

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

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

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

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