mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
effd0199cd8a1dbbbfd2b83d76ab80fea6cf8cdd
1556 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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
|
||
|
|
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) |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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> |
||
|
|
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
|
||
|
|
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 |
||
|
|
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> |
||
|
|
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> |
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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) |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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) |
||
|
|
86277915e1 | Merge branch 'main' into fix/generation-key-corruption-guard | ||
|
|
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) |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |