Commit Graph

1556 Commits

Author SHA1 Message Date
xarmian effd0199cd fix(events): detect a half-open Redis connection with a bus heartbeat (BUG-2738) (#1195)
* fix(events): detect a half-open Redis connection with a bus heartbeat (BUG-2738)

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

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

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

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

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

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

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

Two decisions beyond the plan:

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

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

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

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

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

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

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

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

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

THREE INSTRUMENTS THAT WERE NOT MEASURING:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Three statements were wrong.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 16:57:52 -04:00
xarmian 381d4b0add Merge pull request #1194 from PerpetualSoftware/feat/TASK-2759-agent-name-surfacing
feat(web): surface agent display names wherever agent actors render (TASK-2759)
2026-08-24 14:45:29 -04:00
xarmian 927202a7a1 docs(web): put the leaf-not-fragment rule where the next edit will read it (TASK-2759)
Lead's one follow-up on the package: the round-8 reasoning had to live in the
code, not only in the evidence.

Two places. displayUser now says WHY the spoofing vector exists rather than
only what was done about it: this is ResolveAgentName's documented
attribution-honesty problem (agent_identity.go, "WHAT THIS IS NOT") arriving
through the renderer. The header records honesty rather than identity because
the actor authors it — and a surface that COMPOSES with an authored value
inherits that, handing the author influence over the parts they did not
write. The rule that falls out is stated for future edits: a self-declared
value is a leaf, never a fragment something else is built around. That covers
a new column, a tooltip, an export or a search summary, none of which exist
yet.

The same rule goes in agentActor.ts, since that is the file every surface
imports and the first place a maintainer looks. Stated there as what it is —
not a softening of the verbatim contract two paragraphs above it, because
isolation alters no characters and rejects no names; it refuses to let one
value redraw another.

Comments only; no behaviour change. It moves the tip, so the gate pointing at
501ba836 is void and I have re-asked rather than carrying the green across.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 18:22:02 +00:00
xarmian 501ba836f4 test(web): close the generic-id gap in the admin suite (TASK-2759)
The final mutation matrix, re-run on the tip, found one survivor in 26
(mutation, test-file) pairs: reinstating the retired GENERIC_AGENT_IDS filter
in the shared helper left the admin suite green, because every fixture there
used 'wren' — a value the filter would not have swallowed.

Exactly the hole I closed in the feed and audit-log suites earlier in this
run, repeated when I added this file eight commits later. The lesson landed
in two files and not in my habit, so it is now a case in all four.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 18:15:23 +00:00
xarmian 3a213d9918 test(web): cover the overview binding and agentNameOf's own contract (TASK-2759)
Codex round 14, reading the test files as a suite rather than one at a time.

The overview tab renders the same rows through its own markup and its own
writes-only filter, so it is a second BINDING and I had tested only the
first — my own CONVE-19 rule, missed on the surface I added two commits ago.

agentNameOf was pinned only by an equivalence assertion against the string
form. Four of the five surfaces call the parsed-object form, so its edges
were covered incidentally through component tests and not stated anywhere as
its contract. Direct cases now: named, generic id unfiltered, and every
not-a-name shape.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 18:07:59 +00:00
xarmian de3c9b818f feat(web): name the agent on the admin per-user activity views too (TASK-2759)
Codex round 12 — and it corrects MY exemption, not codex's reading of it.

I listed these two tabs as exempt because their local row type omitted
`metadata`. True, and the wrong reason: handleAdminGetUserActivity
serializes whole models.Activity rows, so the stamped name was on the wire
the entire time and only the client type dropped it. By this unit's own
discriminator — does the surface hold an Activity? — they were never exempt.
Verified against the handler before changing anything.

The consequence was the exact gap the audit log had, on the same rows: an
admin reading a user's activity saw "Updated an item via cli" with no way
to tell which agent acted. The lead ruled the audit log IN on this
discriminator; these belong in for the same reason.

Rendered with the same rules as every other surface — <bdi>, bounded at
24ch, title for the full value, nothing shown when no name was stamped.
Tests assert the binding at this surface (CONVE-19), including the empty
case, the non-agent case and the bidi one.

Docs updated: the surface list in the README and both SKILL.md copies now
names the admin console's audit AND per-user activity views. The precision
of that list is what round 2 was about, so it moves with the code.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:53:12 +00:00
xarmian 817bb0a5ce fix(web): widen the name bound off my own over-correction; assert bdi at every binding (TASK-2759)
Codex round 11.

P2 — the width bound was an over-correction, and it landed on people. The
activity page marks any actor_name `named`, so the 16ch rule I added for
hostile AGENT names was clipping ordinary human ones; the episode feed's 20ch
did the same. One value now, 24ch, which fits an ordinary full name
("Alexandra Whitfield" is 19) while still bounding the pathological case.
The number is written down as a judgement, not dressed up as a measurement,
next to what it does NOT cover: `title` is unreachable by touch, so a
clipped name is effectively unreadable on a phone, and a real disclosure
affordance rather than a wider bound is the actual fix.

P2 — only the audit cell asserted the <bdi> element; the other four bindings
checked text and classes, so swapping bdi back to span passed all of them.
Each now asserts the tag with a bidi-carrying name.

P2 on the casing tests, declined with the reason already in the code: they
assert the `named` class and not the CSS rule, because Svelte component
styles are not injected under this vitest setup (0 style elements, so
getComputedStyle resolves nothing). Covering the rule itself needs an e2e
with a real browser. The boundary is stated in the test comment rather than
implied away — a source-text assertion about the stylesheet would be an
instrument with an adversary, not coverage.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:43:52 +00:00
xarmian 46e3430f2b fix(web): restore the chip title, make the feed's fold-key test discriminating (TASK-2759)
Codex round 10, reading the assembled files rather than the diff — both
findings are the same class: a later round invalidated an earlier round's
premise, and nothing in either diff pointed at the other.

Round 5 removed the timeline chip's title because the chip never clipped.
Round 8 then bounded that label at 18ch to stop a hostile name widening the
card. So the label clips now and the reason for removing its title is gone;
a long name was being truncated with no way to read the rest. Title restored,
comment rewritten to say why it is there.

The EpisodeFeed test claiming to prove the fold key follows the agent name
put its two events on DIFFERENT items, which yields two cards no matter how
the actors are keyed — it only ever proved that labels render. Same item now,
same window, two names, and the card count is asserted, so a fold that
ignored the name produces one card and fails.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:33:25 +00:00
xarmian 0d08e7004c fix(web): isolate self-declared agent names so they cannot rewrite the audit around them (TASK-2759)
Codex round 8, probing adversarial names — the sharpest finding of the run
and a defect this unit introduced.

The agent name is text chosen by whoever is writing, and the admin audit log
built its cell as `${agent} (via ${human})`. A writer could therefore pick
a name that forges the construction (`admin (via root)` renders as nested
attribution), or one carrying U+202E, which reorders everything appended
after it — the audited party editing how the audit reads. Not an auth bypass:
the stored actor stays correct. It is an audit-integrity defect, on the one
surface whose job is to be trusted when trust is in question.

displayUser now returns the PARTS and the template renders them as separate
elements, each in its own <bdi>. That bounds a hostile name to its own
isolate: it still displays exactly as sent, but it cannot reorder the " (via
" literal or the account name, and the account half is structure rather than
string, so a name spelling "(via root)" is visibly text inside the agent's
element. The via span is styled distinctly for the same reason.

Swept the sibling renders rather than the reported one (CONVE-18): the two
badges, the episode label, the timeline chip and the human name beside it are
all <bdi> now, since every one of them sits inline next to other text.

P3 from the same round — the timeline chip and the audit User cell were the
two name surfaces still unbounded. Both bounded, ellipsis, full value on the
title where the element clips.

This does NOT weaken the verbatim contract, and the distinction is the whole
point: isolation changes no characters and rejects no names, it just renders
each value as its own unit. Storing raw and rendering safely are compatible;
an allow-list would not be.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:24:21 +00:00
xarmian 08b165af05 fix(web): restore the audit-log formatter guard I narrowed (TASK-2759)
Codex round 6, and it was my own regression from round 5. Hoisting the row
parse out of formatMetadata left its try/catch wrapped around only the parse
that had moved away, so the FORMATTERS below lost their guard. They can throw
on well-formed JSON — `String(data.keys)` cannot convert
`{"keys":{"toString":null}}` to a primitive — and what used to render an
em dash would now break the admin audit page.

The try now wraps the switch and the fallback, which is what it always
covered. Absent and unparseable metadata behave as before.

Also the test gap that let it through: this suite drove only action
`updated` and read only the User column, so nothing here could see
formatMetadata at all. Added two Details-column cases — the throwing one, and
a known action proving the hoisted object actually reaches the formatter
rather than only displayUser.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:12:10 +00:00
xarmian 454afe573a perf+a11y(web): one metadata parse per audit row, drop a redundant tooltip (TASK-2759)
Codex round 5.

P2 — the admin audit log parsed each row's metadata twice once this unit
added a second reader (displayUser alongside formatMetadata), on a table that
grows through "Load more". Hoisted to one `parseMetadata` per row, passed
to both. formatMetadata now takes the parsed object, which also removes the
try/catch it no longer needs.

Left alone, with the reason: the dashboard also reads metadata twice per row,
but it renders at most ten and its other reader (parseActivityChanges) has
callers outside this diff whose signature I am not changing for ten rows.

P3 — the title I put on the timeline actor chip duplicated text that is
always fully visible: that row wraps and the chip never clips, so it added no
information and gives assistive technology the same string twice. Removed.
The titles on the two badges and the episode label stay — those DO clip, and
there the attribute is the only way to the full value.

Codex reported clean on reachability (no code-reachable wrong-name case
beyond the documented self-declared limitation and the excluded BUG-2763) and
on history coherence.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:07:45 +00:00
xarmian e09216dfc4 fix(web): keep named actors out of the anonymous fold key; type the dashboard fixture (TASK-2759)
Codex round 4.

P2 — the fold key was built from the DISPLAY LABEL, so an agent that sends
`agent` in X-Pad-Agent folded together with every agent that sent no name at
all: two different claims ("this actor" and "we have no name for this
actor") sharing one key, which also contradicted the file's own comment
about a named agent getting its own key. Named and anonymous are now separate
namespaces. Swept the sibling rather than the reported half (CONVE-18): the
user branch had the identical defect for a person whose display name is
`cli` or `web`. Both fixed, both asserted.

P2 test gap — the dashboard route fixture was typed `Activity`, but
`recent_activity` is a REDUCED DTO with no id/workspace_id/document_id and
an OPTIONAL metadata. A fixture richer than the real payload cannot fail when
the payload changes, and it hid a reachable case: rows logged by the audit
helpers never call agentMeta, so absent metadata is a shape the server really
sends. Fixture now derives from DashboardResponse and both route suites cover
absent metadata.

P2 on activity debounce — real, verified against the store rather than taken
on report, and outside this unit's web-only boundary. CreateActivityDebounced
matches on (document, action, user) without actor, and its UPDATE leaves the
original `actor` in place while mergeActivityMeta overlays the newer
`agent` name, so a coalesced row can name the wrong writer in either
direction. Filed as BUG-2763 with both orderings worked through. This unit did
not cause it; it is the first thing to make it visible.

Codex also reported SSR/hydration CLEAN and verified this diff's claims about
the Go side against the Go code.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 17:01:33 +00:00
xarmian 6dc6499a6e refactor(web): drop the one-use helper wrapper, bound name width (TASK-2759)
Codex round 3.

P3 — `agentActorLabel` had a single caller. Every other site already holds
parsed metadata and reaches for `agentNameOf`, and each supplies its own
fallback anyway, so the wrapper saved one `?? 'agent'` and cost an
inconsistency in how five call sites looked. Removed; the reasoning stays in
the file so it is not re-added.

P2 — names are unbounded text in fixed-layout rows. Before this unit the
agent badge held one of four fixed words; it now holds whatever a client put
in X-Pad-Agent, while still being `flex-shrink: 0`, so one long name pushes
the timestamp off the row. Bounded with an ellipsis at the two badges and the
episode actor label (which has the same exposure for people's names, and had
it before this change), with the full value on the title attribute. The
timeline chip and the audit-log cell both wrap, so they take the title only.

P2 on presentation consistency — three surfaces show the (agent, human) pair
three ways, and this unit invented one of the three. Declined as scope rather
than as wrong, and filed as IDEA-2762 with what a decision has to cover.

Codex also independently confirmed the exempt set: comments, versions and
structured entries genuinely do not carry the name, and the linked comment
activity that does is skipped when the card renders.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:52:09 +00:00
xarmian fa22b6680e docs+test: correct two over-claims and pin name escaping (TASK-2759)
Codex round 2, fresh angles.

P1, accepted — my own docs over-claimed. The README and both SKILL.md
copies said the name appears wherever agent actors appear, including "item
timelines". Comments, version snapshots and note/decision entries carry the
actor KIND and no name (that is the exempt set the plan named, and TASK-2760
files the comment half), so on a timeline only ACTIVITY entries show it. Both
now say which entries carry it and which read "Agent".

P2, accepted — the README's fallback was wrong in a way that mattered. When
nothing resolves a name, the CLI omits X-Pad-Agent entirely (client.go:1884),
so actorFromRequest records the write as "user": it is attributed to the
PERSON, not to a generic "agent". Verified both call sites rather than
reasoning from the label. The generic "agent" rows that do exist come from
pre-naming writes and from audit events logged without agentMeta.

P2, accepted — the name is attacker-influenced text and every test used
benign values, so a rewrite to {@html} would have passed. Added a markup
payload at two surfaces that build their labels through different paths,
asserting no element is created and the text survives intact.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:45:35 +00:00
xarmian d1c5c3976e fix(web): stop the badge CSS upper-casing a stamped agent name (TASK-2759)
Codex round 1. `.actor-badge` sets text-transform: uppercase, so the
activity page's audit rows and the dashboard's recent activity rendered
`Wren` and `wren` as the same pixels — the verbatim contract broken in
CSS rather than in code, and invisible to every textContent assertion in
the suite.

The codebase already draws this line: `.actor-badge.user` opts out of the
transform, because a human's badge carries a NAME while "agent" / "cli" /
"web" are CATEGORY words that read as chips. A stamped agent name is a
name, so it follows the same rule via a `named` modifier; the generic
fallback stays a chip.

Swept the class rather than fixing the two reported sites (CONVE-18): the
other three surfaces are unaffected — Chip has no transform, EpisodeFeed's
uppercase rule is .section-label ("HAPPENING NOW"), and the audit log's
cell is untransformed. Two sites, both fixed.

Tests assert the class the markup applies, and say so: Svelte component
styles are not injected under this vitest setup (0 style elements, so
getComputedStyle resolves nothing), which leaves the adjacent CSS rule
outside what the suite can observe. The class is the half a refactor drops.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:40:05 +00:00
xarmian a3ba6eec6c docs: the "name your agents" story for agent attribution (TASK-2759)
The README's For AI Agents section promised that agent actions are
attributed, and said nothing about naming the agent — which was fair while
nothing rendered the name. Now that five surfaces do, the section carries
the precedence (.pad.toml agent_name -> $PAD_AGENT -> detected runtime),
where the name shows up, and that Pad renders it verbatim rather than
keeping a list of approved names.

The honesty framing is QUOTED from ResolveAgentName's own contract comment
rather than restated: the header is self-declared, an agent that omits it
is indistinguishable from the human whose credentials it uses, and a human
running `! pad ...` in an agent's terminal inherits that attribution. It
is a label an actor chose, not evidence about who acted — which is also why
the admin audit log shows both the agent and the account.

Both SKILL.md copies gain one clause: the name an agent sends is now
DISPLAYED, so a specific name beats a generic client id. Their existing
attribution principle was already accurate and is otherwise untouched.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:32:19 +00:00
xarmian 39844197ce test(web): close two coverage holes the mutation matrix found (TASK-2759)
Per-file negative controls showed EpisodeFeed and the console audit-log
tests staying GREEN when the retired GENERIC_AGENT_IDS filter was reinstated:
neither file used a value the filter would have swallowed, so both measured
'a name reaches the surface' without measuring 'an unfiltered name does'.
One claude-code fixture each. 7/7 mutations now detected per-file.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:26:41 +00:00
xarmian 0719286910 test(web): assert the agent-name binding from each consuming surface (TASK-2759)
Five render sites, five consuming-side assertions (CONVE-19). The helper has
its own unit tests, and a correct helper that a page never calls — or calls
with the wrong argument — passes every one of them; the Audit view's defect
was exactly that shape, with the metadata parsed three lines above the call
that ignored it.

Each file's load-bearing legs are the negative ones: the generic label a
pre-fix build produced is asserted absent where a name is stamped, and
asserted present for every stamp shape that carries no name (missing key,
empty string, non-string, unparseable). Two also pin that a non-agent row
never reads the stamp, since the metadata blob is shared and agentMeta
merges into it by string splice.

activityEpisodes.test.ts's shim case inverted with the shim it named.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:24:14 +00:00
xarmian 7e7d6e8efa feat(web): render agents' stamped names wherever agent actors display (TASK-2759)
The input half has existed since BUG-2542: the CLI resolves an agent name
(.pad.toml agent_name -> $PAD_AGENT -> detected runtime) and sends it as
X-Pad-Agent, and the server stamps it into activity metadata as `agent`.
Nothing rendered it. Every agent write displayed as an undifferentiated
"agent", and on the console audit log it displayed under the name of the
HUMAN whose credentials the write rode on.

Recon's discriminator: metadata.agent is stamped only by agentMeta(),
reached only from logActivityWithMetaReturningID, so workspace Activity
rows are the only carrier in the data model. Comments, versions, items,
structured note/decision entries and SSE events record the actor KIND and
no name. That makes render-vs-exempt mechanical rather than per-surface
judgement: does this surface hold an Activity?

Rendering (5 sites, each already holding the metadata):
  - the activity page's Live view fold (activityEpisodes.ts)
  - the activity page's Audit rows (getSourceLabel)
  - the dashboard's Recent Activity rows
  - TimelineActivityCard on the item timeline
  - the console audit log's user column

Exempt, name absent from the payload: comment authorship (TASK-2760 files
the server half), version cards, structured note/decision cards, the SSE
toast, ItemDetail's "Created by", and the console UserActivityTab (its row
type omits metadata).

Retires the GENERIC_AGENT_IDS shim on its own stated retirement condition
(CONVE-2757 rule 4, PR #1192): it filtered a hardcoded list of one team's
client ids out of the Live view, which made display quality depend on that
team's naming habits. Names now render verbatim -- no allow-list, no
normalization, no title-casing; any transform is a doorway for a
workspace's vocabulary to re-enter product logic. Historical claude-code
rows render as claude-code, which is honest: a reader learns every write
came from one undifferentiated client, which the filter concealed.

The audit log renders both facts rather than replacing one with the other
("wren (via Dave)") -- the agent acted, and that account is who it acted
as, and an ops surface needs both.

The shim's test inverted with it, and the file's header doc asserted that
"every current seat sends the generic client id claude-code" -- falsified
by this change, so rewritten rather than left (CONVE-23).

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 16:15:44 +00:00
dependabot[bot] 3f9daa5c7a chore(deps)(deps): bump the npm-minor-and-patch group (#1190)
Bumps the npm-minor-and-patch group in /web with 21 updates:

| Package | From | To |
| --- | --- | --- |
| [@tiptap/core](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/core) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-bubble-menu](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-bubble-menu) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-code-block-lowlight](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-code-block-lowlight) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-collaboration](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-collaboration-caret](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-collaboration-caret) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-link](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-link) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-placeholder](https://github.com/ueberdosis/tiptap/tree/HEAD/packages-deprecated/extension-placeholder) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-table](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-table) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-task-item](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-item) | `3.30.1` | `3.30.2` |
| [@tiptap/extension-task-list](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/extension-task-list) | `3.30.1` | `3.30.2` |
| [@tiptap/pm](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/pm) | `3.30.1` | `3.30.2` |
| [@tiptap/starter-kit](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/starter-kit) | `3.30.1` | `3.30.2` |
| [@tiptap/suggestion](https://github.com/ueberdosis/tiptap/tree/HEAD/packages/suggestion) | `3.30.1` | `3.30.2` |
| [@tiptap/y-tiptap](https://github.com/ueberdosis/y-tiptap) | `3.0.8` | `3.0.9` |
| [dompurify](https://github.com/cure53/DOMPurify) | `3.4.13` | `3.4.14` |
| [mermaid](https://github.com/mermaid-js/mermaid) | `11.16.1` | `11.17.0` |
| [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) | `2.70.2` | `2.70.3` |
| [marked](https://github.com/markedjs/marked) | `18.0.9` | `18.0.10` |
| [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) | `5.56.9` | `5.56.10` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.1` | `8.2.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.10` | `4.1.11` |


Updates `@tiptap/core` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/core/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/core)

Updates `@tiptap/extension-bubble-menu` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-bubble-menu/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-bubble-menu)

Updates `@tiptap/extension-code-block-lowlight` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-code-block-lowlight/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-code-block-lowlight)

Updates `@tiptap/extension-collaboration` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-collaboration)

Updates `@tiptap/extension-collaboration-caret` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-collaboration-caret/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-collaboration-caret)

Updates `@tiptap/extension-link` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-link/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-link)

Updates `@tiptap/extension-placeholder` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages-deprecated/extension-placeholder/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages-deprecated/extension-placeholder)

Updates `@tiptap/extension-table` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/extension-table/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-table)

Updates `@tiptap/extension-task-item` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-task-item)

Updates `@tiptap/extension-task-list` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/extension-task-list)

Updates `@tiptap/pm` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/pm/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/pm)

Updates `@tiptap/starter-kit` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/starter-kit/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/starter-kit)

Updates `@tiptap/suggestion` from 3.30.1 to 3.30.2
- [Release notes](https://github.com/ueberdosis/tiptap/releases)
- [Changelog](https://github.com/ueberdosis/tiptap/blob/main/packages/suggestion/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/tiptap/commits/v3.30.2/packages/suggestion)

Updates `@tiptap/y-tiptap` from 3.0.8 to 3.0.9
- [Changelog](https://github.com/ueberdosis/y-tiptap/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ueberdosis/y-tiptap/commits)

Updates `dompurify` from 3.4.13 to 3.4.14
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.14)

Updates `mermaid` from 11.16.1 to 11.17.0
- [Release notes](https://github.com/mermaid-js/mermaid/releases)
- [Commits](https://github.com/mermaid-js/mermaid/compare/mermaid@11.16.1...mermaid@11.17.0)

Updates `@sveltejs/kit` from 2.70.2 to 2.70.3
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/@sveltejs/kit@2.70.3/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.70.3/packages/kit)

Updates `marked` from 18.0.9 to 18.0.10
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v18.0.9...v18.0.10)

Updates `svelte` from 5.56.9 to 5.56.10
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.10/packages/svelte)

Updates `vite` from 8.2.1 to 8.2.2
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite)

Updates `vitest` from 4.1.10 to 4.1.11
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.11/packages/vitest)

---
updated-dependencies:
- dependency-name: "@tiptap/core"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-bubble-menu"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-code-block-lowlight"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-collaboration-caret"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-link"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-placeholder"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-table"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-item"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/extension-task-list"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/pm"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/starter-kit"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/suggestion"
  dependency-version: 3.30.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: "@tiptap/y-tiptap"
  dependency-version: 3.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: dompurify
  dependency-version: 3.4.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: mermaid
  dependency-version: 11.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-minor-and-patch
- dependency-name: "@sveltejs/kit"
  dependency-version: 2.70.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: marked
  dependency-version: 18.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: svelte
  dependency-version: 5.56.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vite
  dependency-version: 8.2.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 11:58:36 -04:00
xarmian 1af9fa255f test(mcp): guard the OAuth workspace allow-list population (TASK-2753) (#1193)
The sweep TASK-2753 asked for found NOTHING WRONG. Every workspace-global
MCP-reachable route already filters TokenAllowedWorkspaceSet, refuses
allow-listed tokens outright, or is structurally exempt.

The task's premise was wrong, and correcting it is half the value: the
hand-filtering in handlers_workspaces.go and handlers_audit.go is not
scattered evidence of an unswept class, it is the OUTPUT of BUG-2102
(PR #935, squash 9f6c1d8f), which closed five consent-scoping bypasses
and centralized the allow-set logic. So the obligation has now been
discharged by hand TWICE and is enforced by NOTHING — and nothing failed
when an unfiltered route landed, which is the exact condition that
produced BUG-2102. A sweep ending at "verified today" only schedules the
third hand-sweep.

Hence a guard rather than a report.

The population rests on one verified fact: MCP-dispatched requests
traverse the FULL middleware chain, so {slug} routes are gated by
RequireWorkspaceAccess. buildHTTPRequest strips chi.RouteCtxKey
specifically so routing runs from scratch against the root mux, and
explicitly preserves TokenAllowedWorkspaces. Without that the sweep
would have had to cover the whole catalog.

WHAT THE GUARD DOES. Every routed command is DRIVEN and the URLs it
issues are OBSERVED — routeTable mappers by calling them, specialRoutes
and the item-link family against a recording handler. A command whose
every URL carries a workspace segment needs no entry. One that issues a
workspace-global URL must be classified with a reason, so
allowlistCoverage holds exactly the cases where somebody had to decide
something.

The three classes now differ in what they ENFORCE, not just what they
say: workspaceScoped is verified against the observed URLs;
filtersAllowlist requires the named handler to contain a real allow-list
call; exempt is either mechanically checked (no store access) or must
open with JUDGMENT: so an argument cannot ride as a verified fact. Stale
entries fail, reasonless entries fail, and the /{slug} subrouter is
checked to still apply RequireWorkspaceAccess — the classifier's
foundation, which nothing had asserted.

NINE CODEX ROUNDS FOUND NINE DEFECTS, ALL IN THE GUARD. Recorded because
the pattern is the point: an instrument that asserts facts about source
is code with an adversary.

  - Two false greens. The recorder returned {}, so link drives died at
    their prefetch and the only URL observed was that (workspace-scoped)
    prefetch. Then the write assertion matched on path alone, so the
    un-* commands passed on the GET issued on the way to their DELETE.
  - A fail-open escalated across three rounds: URL shape is not proof of
    middleware coverage (workspace restore looks scoped and is a sibling
    of the subrouter), and the first fix's loose suffix match wrongly
    caught item restore, which IS inside.
  - braceBlock opened on a brace inside the "/{slug}" STRING LITERAL, so
    the subrouter block was zero-length and every gated route was
    reported as an unclassified sibling — beneath a comment asserting
    string-literal braces were not a problem here.
  - Comment blindness let the middleware assertion pass on a
    commented-out r.Use — the exact deletion it exists to catch — and
    was introduced inside a fix for comment blindness in the same file.

Every one was found by RUNNING a control, not by reading.

CONVERGENCE, at nine rounds and deliberately not CLEAN. Per the lead's
criterion, a source-scanning guard converges when it covers the
registration grammar the codebase actually uses AND fails closed outside
it. Both hold. The remaining findings concern styles nobody writes here
(r.With, r.Mount, paths in variables, block comments), and teaching the
scanner to TRUST more shapes would cost the fail-closed posture that
makes the rest sound. The recognized grammar and that posture are stated
in the file as a CONTRACT: routes registered via unrecognized forms FAIL
until the scanner is taught them, so a future style migration reads the
red as the guard asking to be taught rather than as the guard being
wrong.

ALSO CORRECTED: the workspace-create exemption originally claimed no
capability existed for creation and that a created workspace is
unreachable by its creator. Both false — may_create_workspaces exists,
and maybeAutoAddCreatorConnection adds the new workspace to the
allow-list when it is set (PLAN-1519/TASK-1521). IDEA-2756 was filed on
that wrong premise and has been corrected; the live question is whether
may_create_workspaces=false should REFUSE creation rather than merely
skip the auto-add.

Gates: make test 27 pkgs, make lint 0 issues, full Postgres suite 27
pkgs, CI 7/7. Rebased onto current main before gating so the green is
measured against the base it merges into.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 11:56:02 -04:00
xarmian 8a95d29a15 chore(web): name the GENERIC_AGENT_IDS shim's retirement condition (CONVE-2757) (#1192)
Product code temporarily encoding a convention-shaped assumption carries
the item whose completion deletes it: IDEA-2750 part 1.

Claude-Session: https://claude.ai/code/session_01HvAuiZ7JaWyCqqyV99LyWt
2026-08-24 11:26:44 -04:00
dependabot[bot] 019c335a87 chore(docker)(deps): bump golang in the docker-minor-and-patch group (#1188)
Bumps the docker-minor-and-patch group with 1 update: golang.


Updates `golang` from 1.26-alpine to 1.27-alpine

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.27-alpine
  dependency-type: direct:production
  dependency-group: docker-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 11:09:33 -04:00
dependabot[bot] 55fce493f1 chore(ci)(deps): bump docker/setup-buildx-action (#1189)
Bumps the actions-minor-and-patch group with 1 update: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action).


Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-24 11:09:26 -04:00
xarmian 1649adb6c1 Merge pull request #1191 from PerpetualSoftware/feat/activity-live-episodes
feat(web): Live view on the activity page — the feed folded into episodes (IDEA-2755)
2026-08-24 10:42:26 -04:00
xarmian d4e7be4a24 feat(web): Live view on the activity page — the feed folded into episodes (IDEA-2755)
An episode is a run of consecutive events by one actor on one item, split
on a 30m gap: audit-grain rows become work-grain cards. The Live/Audit
toggle persists per browser; server HTML and the hydration pass both
render the 'live' default and the stored choice applies in onMount,
strictly after hydration. Liveness is claimed only from event age.
Live cards enrich with the newest comment's first line (best-effort,
first four only, no polling) — the trail's checkpoint discipline is what
makes that line worth showing.

Seat identity: the fold reads metadata.agent (the X-Pad-Agent stamp);
generic client ids render as 'agent', and a seat that sends its own name
lights up its label with no further change — concept B's lanes want
exactly that.

Design canvas and decision record on IDEA-2755. Review loop: 4 rounds,
5 findings fixed (wire-contract phantom, agent metadata field, Node-25
localStorage guard, hydration mismatch, cross-type fixture bleed).

Claude-Session: https://claude.ai/code/session_01HvAuiZ7JaWyCqqyV99LyWt
2026-08-24 14:17:35 +00:00
xarmian ad0deacb43 fix(web): Activity's item_id was a phantom — the wire field is document_id
internal/models/activity.go serializes the referenced item's UUID as
document_id (the audit trail predates the document→item rename); the TS
Activity type declared item_id, which no server response ever carries.
Nothing read it until the episode fold tried to — its primary key never
fired and ref-less rows would have folded into one workspace episode.
The timeline test fixture carried the same phantom field, internally
consistent with the type and unlike any real payload.

Note the deliberate asymmetry: Comment's wire field IS item_id
(models/comment.go) — the two types genuinely differ, which is exactly
how the phantom survived review.

Claude-Session: https://claude.ai/code/session_01HvAuiZ7JaWyCqqyV99LyWt
2026-08-24 14:17:35 +00:00
xarmian 5003718802 fix(push): apply delivery's visibility gate to delivered_sessions (BUG-2725) (#1187)
deliveredSessionCount applied three of watchNotificationVisible's four
gates, missing the first thing delivery checks: vis.allows(CollectionID,
ItemID). Broadcast over-reported. Targeted was worse — the publish-skip
reads this count, so the gate passed, the push went out, the stream
dropped it on visibility, and the response said delivered_sessions: 1.
An instruction lost behind a success.

Per Dave's day-49 ruling, visibility is RE-RESOLVED at push time rather
than snapshotted: membership and grants are revocable, so a value cached
at connect goes wrong exactly when revocation is what makes it matter.

The one input that cannot be re-resolved is the target connection's auth
transport — computeWatchAccessVisibility consults isBearerAuth exactly
once, inside the admin bypass, and the pushing request only knows its
own. So SessionOrigin.BearerAuth is recorded at Add(). That is NOT the
snapshot the ruling rejected: auth transport is a property of the
connection, fixed when it opened and not revocable while held, so it
cannot go stale. Armed is the precedent. SessionOrigin is kept separate
from SessionIdentity because that type documents itself as self-declared
and never verified; folding a server-derived security fact in there
would silently retract the warning for one field. Both comments state
the rule for future extenders: connection properties are admissible,
derived authorization state never is.

computeWatchAccessVisibility now takes a bool instead of an
*http.Request, which makes the per-connection input visible in the
signature and lets the count answer for a connection it is not serving.

COST: "re-resolve per counted session" reads like N access checks per
push. It is at most TWO, and sessionVisibility's memo makes that true by
construction rather than by careful calling — every other input is
per-user and identical across the sessions counted, so one varying
boolean bounds the answers at two. Pinned by a test with 50 sessions.

Codex round 1 (P1): the first version swallowed store errors into "not
visible", reintroducing BUG-2698 through this fix — a targeted push
reporting 0 SKIPS the publish, so a DB blip would drop the instruction
and answer 200, in a function whose own doc comment says why 0 is
load-bearing. Round 2 (P1): the same class one layer down —
computeWatchAccessVisibility collapsed FOUR store failures into a
denial, two discarded into underscores. Fixed as a class per CONVE-18.
Resolution and policy are now separate: stream-side callers discard the
error explicitly with reasons, only the counting caller propagates.
Round 3 CLEAN.

CONVE-23 sweep found three consumer-facing artifacts still describing
the old mechanism, none on a line this diff touched: the plugin skill
doc, the web push dialog, and pad push --help. All three corrected to
name what actually remains rather than deleting the caveat. Plugin
0.3.1 -> 0.3.2, since installed plugins are version-pinned at install.

NOT fixed, deliberately: the UNDER-count. A stream past
maxSessionsPerUser receives broadcasts while never entering the
registry. delivered_sessions remains an estimate with error in both
directions, and every consumer-facing description now says so.

Two coverage gaps recorded rather than rounded off: mutation M11
survives (the reporting test reaches only the first of four store calls,
because closing the DB fails it first), and no test drives the whole
chain store-fault-to-503 (the DB-close instrument kills the request
earlier, so such a test would have gone green against the wrong 500 —
deleted rather than relaxed).

Also lands the BUG-2752 refutation sentinel: that item claimed the OAuth
workspace allow-list went unenforced on /api/v1/events/stream. Refuted —
no allow-list-bearing credential can authenticate to /api/v1/* at all.
The test guards that format gate, so if it ever widens, the refutation's
premise fails loudly instead of silently reopening a leak.

Gates on the merged tip: make test 27 pkgs, make lint 0 issues, full
Postgres suite 27 pkgs, govulncheck, codex CLEAN, CI 7/7.

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 09:45:48 -04:00
xarmian 72336aacb5 fix(events): release SSE admission slots when a client leaves mid-establishment (BUG-2749) (#1186)
`GET /api/v1/events` reserved its admission slot, then blocked in
`SubscribeAndReplaySince` while the workspace's Redis subscription was
dialled and — since BUG-2747 — acknowledged. Nothing propagated the
request's cancellation into that wait, so a client that disconnected
during establishment left a process-wide slot, a per-principal slot and a
per-workspace slot held for the whole of it. The connection was gone; the
capacity was not.

Cancellation is now DEREGISTRATION, and `wsCounts` — which already
answers "is anyone still here" — decides everything downstream. No
ownership hand-off and no reaper: the arbiter already existed. (One thing
IS handed off, and only one — the remainder of the confirmation wait; see
below.)

The two cancellation positions take different paths, and only one of them
owes the joiners anything:

- Before the install: the existing post-dial critical section already
  abandons and retires correctly when nobody is left. It needed one
  ordering rule — the departed establisher stops being counted IN THAT
  SAME SECTION, before the count is read. If joiners registered while we
  dialled, the count is still non-zero and they get the subscription;
  that is the hand-off the filing asked about, expressed as a count
  rather than a transfer of ownership.
- During the confirmation wait: the subscription is already installed
  with its receive loop running, so the connection is not at risk — but
  the WAIT is what releases the joiners, and dropping it would admit them
  into a subscription Redis has not acknowledged while telling them
  nothing. That is BUG-2747's defect re-created at the seam between the
  two designs. So the remainder of the wait moves to a goroutine that
  finishes exactly as the caller would have: same arms, same
  `markUnconfirmedAdmission` on the bound, same `finishPending`. Bounded
  by `confirmTimeout`; no reaper needed, because teardown stays
  count-driven.

A departure is not a refusal. `ok bool` is replaced by a
`SubscribeOutcome` enum across the three `EventBus` Subscribe methods, so
`SubscribeWorkspaceLimit` and `SubscribeCancelled` cannot be collapsed:
answering a departed client with 429 would have written a limit refusal
into the logs and counters that anyone would use to tune that limit. An
enum rather than a second bool or an error because the switch has to name
the case — by construction rather than by argument.

Caller population, with its search boundary: 3 production implementations
(events.MemoryBus, events.RedisBus, metrics.InstrumentedBus), 1 test
double (server.gapEventBus, which embeds the interface), 2 production call
sites (both in handlers_events.go). Searched this repo four ways — the
three method names, `.Subscribe(`, method declarations, and interface
embedding. collab.OpBus and watchevents.Bus are different interfaces and
are out of scope; no other repo links this package.

WHAT THIS DOES NOT FIX, verified in go-redis v9.22.0 rather than inferred
from its doc comment (which says Subscribe "does not wait on a response
from Redis" and so reads as though no dial happens on the request path —
it does; only the reply is unawaited). On plaintext, dialConn derives its
per-attempt deadline from the caller's context and the default dialer is
net.Dialer.DialContext, so cancellation aborts the dial. Under TLS the
same dialer calls tls.DialWithDialer, which takes no context, so the dial
stays bounded by DialTimeout alone. On a TLS deployment this shrinks the
held slot from (dial + confirm bound) to (dial), not to zero.

Review round 2 (codex) found a P1 in this unit's own first draft, of
exactly the shape the filing warned about. A cancellation check at the top
of the establish loop could return while the caller still OWNED an
unretired establishment record: section 1 had already named it the
establisher, so the record stayed in pendingSubs with nobody behind it,
its done channel never closed. The next subscriber for that workspace
would join it and wait forever — and its own registration keeps wsCounts
non-zero, so no later caller would establish either. A permanently dead
stream that looks alive, produced by a guard whose only purpose was to
save a dial. The guard is gone: a cancelled caller now goes THROUGH
establishSubscription, which is the only code that knows how to put the
record down. Regression test included, and reinstating the guard turns it
red.

Round 2 also found a P2 shutdown regression: routing the dial to the
caller's context alone took away Close()'s ability to interrupt a stalled
dial, which it had before. The dial now runs on a context ended by EITHER
the caller or the bus, and each half is pinned by its own test — dropping
either one is detected.

Review round 1 (codex): no P1. One nit fixed as a class — three comments
elsewhere in the file asserted the dial was "NOT bounded by the context we
pass", which this change falsified; the sweep found and corrected all
three (establishSubscription, defaultSubscribeConfirmTimeout, Subscribe).
The TLS half of its P2 is filed as BUG-2754: the fix belongs at client
construction, where it covers every Redis call rather than this one.

Class sweep filed separately as BUG-2751 (lead-ruled: one region, one
design per diff): internal/watchevents has no per-request establishment,
but its resume path blocks on a 250ms settle window bound to the bus's
context rather than the request's, while /api/v1/events/stream holds the
same admission slots across it.

Tests: five cancellation cases in internal/events (before install, during
the wait alone, during the wait with a joiner, a cancelled joiner, an
already-dead caller), a dial-binding assertion, and the handler-level
binding in internal/server asserting the admission slot itself is
released — the half of the bug that does not live in the bus.

Mutation matrix, 8 mutations: 7 detected, each by the test named for it.
The one survivor is the ctx term in the retry re-decide, and it survives
because it is an OPTIMISATION rather than a correctness guard — a departed
caller that mints a second record still establishes, deregisters and
retires correctly; the term only saves a pointless dial. The code says so
rather than implying the guard is load-bearing.

The earlier draft's entry guard and loop-top break formed a redundant pair
the matrix could only detect when both were removed. That redundancy was
the smell, and round 2 found the substance under it: one of the two was
not redundant, it was wrong. With it gone the entry guard is detected on
its own.
2026-08-24 08:24:34 -04:00
xarmian 692b3e1a84 fix(store): erase a deleted account's user id from frozen outbox payloads (TASK-2719) (#1185)
* fix(store): erase a deleted account's user id from frozen outbox payloads

DeleteAccountAtomic's de-identify posture reached only LIVE rows; outbox
payloads froze user ids at emit time, so a deleted user's id stayed legible
in undispatched and dispatched-retained rows — in workspaces they didn't
own — until TASK-2714's retention window closed on the row. Dave's ruling
on TASK-2719: 'delete my account' means prompt erasure, not a bounded
window.

ScrubOutboxUserRefsTx runs inside the deletion transaction: a key-scoped,
value-matched recursive rewrite (assigned_user_id / user_id / uploaded_by,
any depth — covers item_batch member nesting) plus a value-equality NULL of
the subject_id column, whose only user-valued rows are member events. Scrub
uniformly, delete nothing (lead ruling): erasure is this pass's job, row
lifecycle stays with retention. A scrubbed member row degrades to a
parseable resync signal — verified against the drain (opaque bytes) and
memberEventPayload (absent user_id unmarshals to ""), so SPEC-3's
tombstone branch is not needed.

Population per CONVE-18: five payload families enumerated at
outboxUserRefKeys; boundary stated (fields-blob interiors not entered,
matching the live-row posture). scrubItemPII's emit-time keep of
assigned_user_id is now SUPERSEDED at deletion time — both comments name
the winner.

Rewrite is Go, not SQL (dual-dialect: payload is JSONB on PG, TEXT on
SQLite; the row-finding LIKE casts for the same reason). Read-fully-then-
write on the one transaction (BUG-2409 shape). json.Number preserves
numeric literals across the rewrite.

TASK-2719

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

* fix(store): CAS the outbox scrub rewrite; pin key-scoping and number safety

Codex round 1 on TASK-2719. The rewrite is now a compare-and-swap
conditioned on the payload we read: two concurrent account deletions can
hold one bulk payload (naming both users) on Postgres READ COMMITTED, and
the later blind write would reintroduce the earlier deletion's id from its
stale copy. Zero rows matched means re-read and redo against the fresh
bytes; SQLite's single writer never takes the path; bounded loudly at 5.

Documented rather than fixed, with the mechanics: the residual
concurrent-emit window (FK KEY SHARE serializes every path that would
CREATE a reference to the dying user; what survives is a re-freeze of an
existing one, e.g. a title update on a still-assigned item, bounded by
TASK-2714 retention — closing it needs a table lock on a once-per-account
path), and the two prefilter invariants (newID() uuids carry no LIKE
metacharacters and nothing JSON escapes; writeOutboxTx's json.Valid gate
makes multi-value payloads unrepresentable).

New tests pin what the existing set couldn't fail on: a decoy field whose
VALUE is the deleted id under a non-target key survives (key-scoping), a
2^53+1 seq literal crosses the rewrite verbatim (json.Number), and the CAS
retry leg is driven deterministically with a stale payload copy, asserting
the stored bytes win and the stale copy's ids are not reintroduced.

TASK-2719

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

* docs(store): state the outbox-scrub residual window honestly

Codex round 2: the round-1 note overclaimed. On Postgres the escape is not
just the re-freeze case — a KEY SHARE acquired before the deletion reaches
DELETE FROM users means the DELETION waits and the emit commits first, and
attachments.uploaded_by has no FK at all (migrations 047/026), so post-
commit emits are not structurally clean either. All three paths stay
retention-bounded; the comment now enumerates them instead of asserting
cleanliness.

TASK-2719

Claude-Session: https://claude.ai/code/session_01Cpr3teiHHsgcTmg2xhHA86
2026-08-24 00:22:17 -04:00
xarmian 308ed994b0 Merge pull request #1184 from PerpetualSoftware/fix/subscribe-confirmation-window
fix(events): await Redis registration before admitting an SSE subscriber (BUG-2747, BUG-2748)
2026-08-23 22:50:35 -04:00
xarmian cbf2dd29c8 test(events,metrics): assert exactly-once, cover the new counter, drop an overclaim
Codex round 7.

The confirmation bound's comment said it bounds establishment. It does not: the
dial and go-redis's HELLO/AUTH handshake run inside client.Subscribe before the
timer starts and are bounded by the CLIENT's DialTimeout instead, so the worst
case composes to roughly DialTimeout plus this. Anyone reasoning about connect
latency needs both numbers.

The concurrency test asserted topology — subscriber count and pendingSubs —
while reading one event per channel, so a duplicate from a second establishment
could sit in the channel undetected. It now asserts exactly-once, which is the
behaviour the topology was standing in for.

The new Observer method, counter and deployment contract had no adapter test.
Added, including the half that matters: the count must NOT also land on the
reset series, since an adapter that merged them would pass a total-only
assertion while destroying the distinction an operator acts on.

And the abandonment test now says out loud that it fabricates its state, because
the establishing caller is blocked inside Subscribe and nobody can unsubscribe
it — the same reason the retry it exercises is defence in depth rather than a
reachable path.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 02:12:59 +00:00
xarmian 4d84453f41 fix(events): apply the registration bound to the buffer, before the cursor filter
Codex round 5. The P1 is the same two-spaces mistake the ID-valued ceiling made,
committed again inside the fix for it: asking since() for the events above the
cursor and then dropping the last (appends - mark) of them mixes a filtered list
with an unfiltered count. A post-registration straggler whose id falls at or
below the cursor is absent from the slice but still counted in the drop, so the
count eats a legitimate pre-registration event instead. Pre-mark [5 30 20],
post-mark [6 40], cursor 10 handed the caller [30] and lost 20.

replayBuffer.sinceBounded applies the window to the BUFFER and shares every
coverage rule with since(), which now delegates to it, so the two cannot
disagree about what cannot-vouch means.

This also closes the residual round 3 left accepted: if the wait's appends evict
everything the buffer held at registration, keep goes to zero and the span is
refused rather than partially served.

Three overclaiming comments corrected — the wait is bounded, and saying
Subscribe returns only once Redis has acknowledged is false on the timeout path.
And the ceiling test's own claim: it appends straight to the buffer, so it pins
the boundary arithmetic and says nothing about replay-XOR-channel, which is a
different test.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 02:04:45 +00:00
xarmian f5ca67cbca perf(events): shorten the subscribe-confirmation bound to a measured 1s
Lead ruling on codex round 3's second finding: file the ctx-plumbing as its own
unit (BUG-2749), bound the exposure here.

The bound is not a guess at how fast Redis is. Establishment either completes in
single-digit milliseconds or does not complete at all, so past the top of the
fast mode waiting longer buys nothing and only holds an SSE admission slot,
global and per-workspace, for a client that may already be gone.

Measured on a containerised Redis over loopback, 300 establishments, timing the
whole of Subscribe: p50 388us / p99 679us / max 1.73ms idle; p50 693us /
p90 5.1ms / p99 12.1ms / max 18.5ms under 24 busy loops on 8 cores. One second
is ~54x the loaded maximum.

Being too short costs an admission whose coverage this instance cannot describe
— counted, logged, and reconciled to the client when the acknowledgement lands.
Waiting is the silent direction.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 01:46:15 +00:00
xarmian 650d9df270 fix(events): bound the replay by append POSITION and buffer identity
Codex round 3. Both P1s real.

The ceiling used lastAppendedID as if it were a time boundary. This bus's ids
come from a counter shared across workspaces and a phase-1 publish assigns and
publishes in two calls, so arrival order and numeric order genuinely disagree.
Against an id-valued bound both directions break at once: a straggler arriving
after registration is replayed although it also went to the caller's channel,
and a pre-registration event carrying a higher id is filtered out and never
replayed at all. replayBuffer now counts its appends, and the bound is a
position — the entries to withhold are simply the final (appends - mark) of
whatever since() returned, which may trim from the front but never the back.

The mark also carries the BUFFER, not just a position in it. An ID-space reset
during the wait replaces the buffer wholesale; a position in the old one
describes nothing in the new one, and knownFrom may still accept an adjacent
cursor, so the mismatch does not announce itself.

Also corrected, all found by the same round and all mine: the Observer comment
claimed this counter never reaches SequenceReset, which the late-confirmation
path contradicts; the reason enumeration in metrics.go, its Help string and
docs/deployment.md were never updated for the sixth reason; and both the metric
and its comment said every increment is a client when it is one establishment
however many subscribers were waiting.

Accepted, not fixed: since() evaluates eviction over the whole buffer including
post-registration appends, so a flood inside the wait can evict a cursor that
missed nothing and force a sync_required. It costs a spurious resync, never
silent loss, which is the direction this family chooses every time.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 01:38:46 +00:00
xarmian cadf0fab33 docs(events): name the joiner retry as defence in depth, and make it loud
The mutation matrix could not reach it: no mutation of the surrounding code
makes a test take that path, because the same-lock retire in the abandon path
makes the strand unreachable rather than recoverable. A joiner increments
wsCounts under b.mu before the establisher's count check reads it, so a
registered joiner prevents the abandon; a joiner arriving after the check cannot
find the record, because it is gone in that same section.

That is an argument, not a measurement, so the retry stays — a permanently dead
stream that looks alive is worth one wasted pass in a case that should never
happen — but it now logs when it fires, so a wrong argument surfaces in
production instead of limping silently.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 01:22:06 +00:00
xarmian 1f4ab9b549 fix(events): abandon and teardown must not strand a joiner or leak a PubSub
Codex round 2, lifecycle angle. Both findings real, plus one my own fix
introduced.

P1 — Close cancels the context before it takes the lock and drains wsSubs, so
an establishment that locked afterwards installed into a map Close had already
emptied. Its receive loop exits on the cancelled context and neither subCancel
nor pubsub.Close ever runs: the PubSub and its health-check goroutine outlive
the bus. establishSubscription now refuses to install into a closing bus, and
Close clears wsCounts alongside the subscribers it counts, so the two
structures cannot disagree.

P1 — abandoning because the workspace emptied retired the establishment record
in a separate critical section from the decision. A subscriber arriving in
between registered, waited on a promise nobody would keep, and returned with a
channel wired to nothing — permanently, since its own registration keeps
wsCounts non-zero so no later caller establishes either. The record is now
retired under the same lock as the decision, and a joiner verifies a live
subscription afterwards rather than assuming one, taking the establishment over
once if there is none.

And one I introduced writing that: the re-check created a pending record on the
loop's final pass with nobody left to establish behind it, which is a worse
version of the same defect. Records are now created only at the top of an
iteration that will use them.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 01:20:20 +00:00
xarmian c719bf44b1 test(events): make the confirm-versus-timer race deterministic
The repetition version caught the mutation that removes the confirmClosed
re-check in 0 of 10 runs at 500 establishments each: a near-zero bound makes the
timer win outright far more often than it ties, and winning outright is the
ordinary timeout path, not the race. A one-in-ten detector reads as coverage
and is not.

beforeUnconfirmedMark holds the mark until the acknowledgement has landed,
reproducing the interleave every time. 10 of 10 against the same mutation.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 01:09:16 +00:00
xarmian 703e746922 fix(events): bound the replay at registration instead of withholding fan-out
Codex round 1, three findings, all real.

P1 — a subscriber arriving mid-establishment was admitted immediately.
establishSubscription installs wsSubs and only THEN waits for the
acknowledgement, so for that interval the workspace looks live and is not.
Reading wsSubs first let a second subscriber straight into the unconfirmed
window this change exists to close. pendingSubs is now checked first.

P1 — withholding fan-out from a not-yet-admitted subscriber dropped events for
the very population BUG-2747 is about. A fresh subscriber (sinceID == 0) reads
no replay at all, so an event skipped on the theory that the replay would carry
it was skipped and then never replayed. Replaced with a replay CEILING captured
at registration: the subscriber is live in fan-out from the moment it registers
and receives everything after that on its channel, while its replay is bounded
above by what the buffer held then. That is the same division the single
critical section gave for free, generalised to the case where a wait separates
the two halves.

P2 — the confirm timer could set unconfirmedAdmitted after the acknowledgement
had already cleared it, leaving a subscriber counted as unconfirmed and never
told to reconcile. markUnconfirmedAdmission now checks confirmClosed under the
lock that closes it.

Two tests added for the two P1s. The double-delivery test moved from the
establishing caller to a JOINER, because the establisher can never exercise it:
establishing implies no live subscription, losing the last subscriber deletes
the buffer, so its replay is always nil and there is nothing to duplicate.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 01:06:52 +00:00
xarmian 739f573b3b docs(events): the namespace helper's comment described a window this fix closed
It said the wait was covering up a production window with no remedy, pointing
at BUG-2747 as where it was tracked. That is now the fixed thing, so the
comment was teaching the next reader something false about the bus they were
looking at.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 00:44:54 +00:00
xarmian 4148278ef6 test(events): drive the double-delivery window through the gap it actually opens
The first version hung the publish off afterSubscribeRegister, which now runs
under b.mu in the second critical section — so the fan-out it was meant to race
could not happen until the lock was released and the subscriber was already
admitted. It passed against a mutation that delivers to unadmitted subscribers,
which means it was not testing the flag at all.

afterSubscriptionConfirmed opens at the real gap: acknowledged, so events
arrive, and before the establishing caller re-acquires b.mu to read its replay.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 00:40:21 +00:00
xarmian ad8853f0bc test(events): assert the widened-window premise after the defect assertion
Checked before the publish, it races the unfixed code — which returns from
Subscribe while the SUBSCRIBE is still on its way into the proxy — so the test
reported a broken instrument instead of the defect. Last, it can only fire on a
vacuous pass, which is the thing it is for.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 00:38:11 +00:00
xarmian 548b31588b fix(events): await Redis registration before admitting an SSE subscriber
RedisBus.startRedisSubscription wrote the SUBSCRIBE and returned. Events Redis
processed before it registered the subscription reached nobody — and since this
bus has no local fan-out, that includes events published by the very instance
serving the client's stream.

A resuming client was already protected: a first subscription has no replay
buffer, so eventsSinceLocked returns nil for sinceID > 0 and the handler emits
sync_required. The silent loss reached only sinceID == 0 — the SubscribeIfAllowed
path — whose buffer coverage begins at the first event that DOES arrive, leaving
the hole below anything the buffer ever claimed.

Subscription establishment now runs OUTSIDE b.mu (BUG-2748) and Subscribe does
not return until Redis has acknowledged it. All three entry points share one
body, subscribeAndReplay, so none can drift.

The confirmation is signalled from inside receiveMessages rather than by a
Receive placed ahead of it. That loop treats its first *redis.Subscription as
the initial acknowledgement and every later one as a RESUBSCRIPTION that ends
coverage (BUG-2739); consuming the first with an earlier Receive would leave it
swallowing the first genuine resubscription instead.

Splitting the register and the replay read into two critical sections put
SubscribeAndReplaySince's replay-XOR-channel guarantee at risk, so a subscriber
is now REGISTERED but not ADMITTED across the wait: fan-out appends to the
buffer and skips its channel, and the replay read that follows delivers it.
The flag's zero value is admitted, so MemoryBus and the already-live fast path
are unaffected by construction.

Failure path admits rather than refuses — every subscriber was admitted into an
unconfirmed subscription before this, so refusing would be new strictness that
turns a Redis blip into failed connects. Instead the span is reconciled to the
client through BUG-2730's mid-stream signal when the acknowledgement lands, and
counted for the operator via a new pad_event_subscription_unconfirmed_total.

BUG-2747, BUG-2748

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-24 00:37:35 +00:00
xarmian 4b0d41c17d Merge pull request #1182 from PerpetualSoftware/fix/generation-key-corruption-guard
fix(events): guard the generation counter the way the epoch key beside it is guarded (BUG-2740)
2026-08-23 19:58:54 -04:00
xarmian 86277915e1 Merge branch 'main' into fix/generation-key-corruption-guard 2026-08-23 19:27:46 -04:00
xarmian 5ff0cc9b59 Merge pull request #1183 from PerpetualSoftware/fix/events-load-fragile-tests
fix(events): stop the load-fragile tests failing for reasons that are not defects (BUG-2742)
2026-08-23 19:25:13 -04:00
xarmian 9a8e41f8ab test(events): keep invariants in the comments and process history in the trail (BUG-2742, codex round 12)
Asked to critique the change for scope and restraint.

Accepted: several comments carried trial-specific numbers and round labels —
catch rates, CI counts, which review round corrected whom. Those belong in
commit messages and on the item, which is where someone looking for the
history will go; a code comment that quotes a measurement acquires a
maintenance obligation nobody will honour. Trimmed to the invariant and the
reason, with a pointer to BUG-2742 for the figures and, where it matters, the
instruction not to trade rounds back for a bigger burst.

Accepted: subscriberChanDepth's comment described a test premise before it
described what the number means in production. Reversed.

Declined, three, with reasons since two of them argue against earlier rounds
of this same review:

- That the waits on the two key-only namespace tests are unnecessary and add a
  failure mode. They are unnecessary for those tests' own assertions, which is
  why round 10 asked for them: those are the shortest Redis-bus tests in the
  package and therefore the ones a newcomer copies. The failure mode added is
  "registration never happens", which is a defect rather than a flake.
- That this should be several units. It is one bug covering one family in one
  package, and the item asks for the family swept together rather than
  test-by-test. The steps are separate COMMITS, which is the separation that
  helps a reader.
- That the compile-time premise check is over-engineered next to a runtime
  one. Its whole value is failing EARLIER than a run: the failure it guards is
  the test going silently vacuous, which a runtime check inside the vacuous
  test cannot reliably announce.
2026-08-23 22:38:05 +00:00
xarmian bfaa1d88a9 test(events): make the nearest example safe to copy (BUG-2742, codex round 10)
Asked whether this package now makes the correct thing easy and the flaky
thing hard for someone adding a Redis-backed delivery test.

It did not. The two shortest Redis-bus tests — the ones a newcomer copies —
still read Subscribe, then Publish, with the registration wait afterwards or
absent. Neither NEEDS the wait, because both assert on Redis keys, which a
publish writes whether or not anyone is listening. But being correct for a
reason particular to themselves does not stop them teaching the race to
whoever copies their shape, so both now wait immediately after subscribing and
say why in one line.

The usage rule was also in the wrong place: waitForSubscribers' own comment
described what it polls, while the warning about WHEN to call it sat on
pollSubscriberCount, an unexported helper no caller has a reason to open. The
rule now leads the comment on the function people actually call, along with
the case that does not need it, so the exception is visible rather than
inferred from a test that omits it.
2026-08-23 22:29:42 +00:00
xarmian 7378707350 test(events): prove the replacement subscription is live by getting an event back (BUG-2742, codex round 9)
Round 8 left the post-reconnect publish protected only by the 2s poll above
it, documented as margin by accident. Asked whether anything in the package
could still redden CI, Codex went straight there: on a slow, -race or
single-core runner the reconnect may still be pending after 2s, the publish is
dropped, and drain times out. A real flake, and documenting it is not fixing
it.

The construction that works is the one the failure itself suggests. Counting
subscribers cannot distinguish the stale registration from the replacement,
and waiting for the count to fall can hang — both measured in round 8. But an
event ARRIVING is proof that cannot be satisfied by the dead connection. So
the test publishes until one comes back: instant when the subscription is
already live, and incapable of passing early when it is not.

The extra events some rounds publish are harmless. What the rest of the test
needs is a buffer with something in it, not a buffer with exactly one thing in
it, and the assertion that follows — that a reconnect WITH a buffer does
report a coverage break — is unchanged.

This also removes a vacuity I flagged in round 8 and had put out of scope: the
2s poll was the only thing establishing that a reconnect had happened at all,
so a slower reconnect would have made the first assertion pass without ever
reconnecting. The loop makes that premise explicit and fails loudly when it
does not hold.
2026-08-23 22:26:39 +00:00
xarmian 6ba8e8dd79 test(events): retract the reconnect wait, which guaranteed nothing (BUG-2742, codex round 8)
Two findings from reviewing the new test code as production code.

The phase-two collector read `msg := <-incoming` without checking whether the
channel was still open. A closed pubsub channel hands back a nil *Message, so
the next line panicked the test binary with a stack instead of naming the
failure — in the one goroutine whose entire job is to report what went wrong.
It reports the close now.

The second retracts my own round-4 change. I added waitForSubscribers before
the post-reconnect publish and claimed it turned safety by accident into
safety by construction. Measured, that claim is false: across the cut the
subscriber count goes 1 -> 0 -> 1 over a couple of milliseconds, because the
STALE registration still reads 1 until miniredis notices the closed socket. A
count-based wait placed after the cut returns immediately on the dead
subscription and guarantees nothing. Waiting for the 1 -> 0 transition instead
is not available either: in one trial of five the count never dropped inside
3s, so that wait can hang.

What actually protects that publish is the 2s poll above it, which runs to
completion whenever no reset is reported — the case this test asserts. That is
margin by accident, and the comment now says exactly that, with the
measurement, rather than carrying a wait that reads as a guarantee.

Worth its own note: that same 2s poll is how the test establishes a reconnect
happened at all, so if a reconnect ever took longer the test would pass
vacuously. Real, out of scope here, recorded on BUG-2742.
2026-08-23 22:20:57 +00:00