Commit Graph

156 Commits

Author SHA1 Message Date
xarmian 25c7cd20f5 feat(watchevents): Redis-backed bus so watch notifications cross instances (BUG-2651) (#1167)
* feat(watchevents): Redis-backed bus so watch notifications cross instances (BUG-2651)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

make vuln: 0 vulnerabilities in imported packages.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-20 02:54:53 -04:00
xarmian b5f0cd3963 feat(web,server): render embedded image attachments on share pages via a variants-only byte endpoint (BUG-2389 2b, TASK-2637) (#1153)
Merged by lead on accepted infra cancellation, Dave-approved in chat (day-44). Evidence basis: E2E tests demonstrably pass at pinned SHA d3c8234e — run 1: 191 passed (3.7m), job cancelled by CI/Nix twin-run concurrency race; run 2: 190 passed + 1 flaky (pane-controller, PLAN-2154's known flake, unrelated to this diff), job cancelled by the 10-minute cap after the flaky retry; run 3: rerun expired inside terminal-cancelled parent at 38s, no test signal. All six code-testing checks green (Go, Go-PG, Web, Nix, Smoke×2). The gate defect is filed as BUG-2645 (cap breachable by one flaky retry + twin-run race); the fix ships as its own reviewed workflow unit. Lead spot-check of the diff and full pin inventory: TASK-2637 trail.
2026-08-18 12:26:00 -04:00
xarmian 08dfbdb318 fix(server): rowless-blob GC sweep — reclaim blobs no attachments row references (BUG-2406) (#1137)
* fix(server): rowless-blob GC sweep — reclaim blobs no attachments row references (BUG-2406)

Every attachment write path calls AttachmentStore.Put BEFORE inserting
the attachments row, so a failure (or crash) between the two leaves a
blob on disk that nothing references — and the row-driven orphan sweep,
which walks Store.OrphanedAttachments, can never see it. Disk that is
never returned; the upload handler's failure comment even claimed the
GC would reclaim it.

Fix: a rowless-blob sweep that runs after the row sweep on the same GC
tick. attachments.Lister is a new OPTIONAL backend capability
(ListBlobs → key/hash/size/mtime); FSStore implements it via one
WalkDir of the sharded tree with a base-name validHash gate (excludes
Put's dot-prefixed temp files and anything the store didn't write).
Backends without the capability are skipped with a once-per-process
notice. Candidate = blob whose content hash has ZERO rows in ANY state
(soft-deleted rows still own their bytes under the row sweep's
row-before-bytes claim protocol, BUG-2415) AND whose mtime predates the
same operator-configured GC grace the row sweep uses — a young rowless
blob is just an upload whose insert hasn't happened yet. Delete-time
guards run under inFlightHashesMu: the in-flight fence plus a
single-hash row RE-CHECK that closes the subtraction-to-delete TOCTOU
(the writer that marked, inserted, and released entirely inside the
gap). Cost: O(blobs) per tick, 24h cadence, never on a request path.
Also retro-reclaims blobs stranded by past row-sweep delete failures.

The wrong claim in handleUploadAttachment's failure path is corrected
to point at this sweep.

Tests: FSStore.ListBlobs impostor coverage; five sweep legs
(aged-rowless reclaimed with a row-sweep-can't-see-it counterfactual,
young kept, live/soft-deleted-row kept, in-flight kept then reclaimed
after release, hook-injected delete-time row kept) — mutation-verified:
removing the re-check, the age gate, or the in-flight fence each fails
its leg; the store-level subtraction contract is pinned separately.

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

* docs(store): state the any-row rule's real rationale per Codex review (round 1)

Codex flagged the thumbnail refusal-cleanup's grace-window protection as
inconsistent with the sweep comment's claim that deleting bytes under any
existing row violates the claim protocol. The cleanup (and the row sweep
itself) deliberately end a row's hash-protection when its own grace
expires — CountProtectingAttachmentsForHash documents exactly that, and
the row machinery may do it because its claim protocol coordinates row
and blob fates within a sweep. The overstatement was mine: the rowless
sweep's any-row rule is chosen because it holds no claim on any row and
has no such coordination, not because past-grace stranding is forbidden
to the machinery that does. Comment corrected; no behavior change on
either path.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-17 09:03:44 -04:00
xarmian 31075d996a fix(store): route cross-workspace copy's lock-held reads through the copy transaction (BUG-2409) (#1136)
* fix(store): route cross-workspace copy's lock-held reads through the copy transaction (BUG-2409)

The copy transaction holds advisory locks on BOTH workspaces, but the
attachment planner (PlanAttachmentCopy) and the server's per-row
attachment authorizer read through the connection pool. Under enough
concurrent copies every pooled connection can be occupied by a
lock-waiter while the lock holder waits for a spare connection —
starvation presenting as a hang.

Fix: a store.Queryer interface (satisfied by *sql.DB and *sql.Tx)
threaded through the planner and the AttachmentAuthorizer callback, so
the mutating copy plans and authorizes on its own transaction's
connection while the preflight keeps planning through the pool — one
implementation, two executors, preserving TASK-2354's no-drift shape.
Mechanical *Q variants added for the store reads the authorizer
transitively needs (GetItem, GetUser, GetWorkspaceMember,
VisibleCollectionIDs, GetMemberCollectionAccess,
ListSystemCollectionIDs, GuestVisibleCollectionIDs,
GuestVisibleResources(+IncludeDeleted), ResolveBacklinksVisibility) and
Q-cores behind existing-signature server wrappers (checkItemVisible,
guestResourceFilterCore, resolveAttachmentParentItem,
attachmentCallerIsRestricted). No decision logic changed anywhere —
executor threading only. GetItem/getItemTx/GetItemIncludeDeleted's three
duplicate scan bodies collapse into one getItemScanQ.

Regression test: TestCopyItemAcrossWorkspaces_NoPoolIOUnderLocks pins
the invariant deterministically — with MaxOpenConns(1) the transaction
owns the only connection, so ANY pool read under the locks deadlocks.
Fails by timeout on the pre-fix executor (verified); passes in 0.16s
fixed. The test's authorizer performs a real read through the handed
Queryer, pinning the callback leg too.

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

* fix(store): quota check reads through the copy transaction too, per Codex review (round 2)

Codex's targeted round found the third lock-held pool-read leg: CheckLimitTx
routed only the feature COUNT through the caller's transaction while
checkLimitOn's owner lookup, GetUser, and resolveLimit's platform-setting
read stayed on the pool — the same starvation shape under the copy's
advisory locks. checkLimitOn is now parameterized over a single Queryer for
every read (CheckLimit passes the pool, CheckLimitTx the transaction), with
resolveLimitQ / GetPlatformSettingQ variants behind existing-signature
wrappers. The regression test now arms this leg deliberately: a FREE-plan
owner with EnforceItemLimit and no plan override drives the full quota read
chain under MaxOpenConns(1) — verified deadlocking before this commit,
0.16s after.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-17 08:16:02 -04:00
xarmian 79b3220c61 test(server): drive the reval-fault test off the fault seam instead of a sleep (BUG-2570) (#1102)
* test(server): drive the reval-fault test off the fault seam instead of a sleep (BUG-2570)

The reload-fault closure now narrows the member's access on faulting
tick 1 and lifts the fault on faulting tick 2, so consecutive reload
failures stop at exactly 2 — strictly below the clear-the-watch-set
bound — and the green path carries no timing bet at any load. The
300ms sleep is gone; readiness is signaled by the tick sequence itself.

Codex round 1 on this fix surfaced that regression DETECTION still has
a window (a successful tick 3 masks a hypothetical reset-skipped-on-
fault regression), so the interval is set to 500ms to give the revoked
PATCH ~10x headroom over measured loaded-runner request latency, and
the control-leg wait — the one that timed out in both CI instances —
is widened to 10s since it asserts delivery-at-all, not latency.

Verified: 5x -race green at both 50ms and 500ms; counterfactual mutant
(reset moved to the reload success path) leaks 3/3; full suite + lint
green; Postgres leg 2x -race green.

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

* test(server): drive reval ticks through a seam — deterministic in both directions (BUG-2570)

Codex rounds on the first fix found two regression-DETECTION windows
the interval-tuned shape could not close: a stray successful tick
before fault installation or after the tick-2 lift resets visCache /
reloads the watch list, masking the reset-skipped-on-fault regression
this test exists to catch. Interval tuning trades green-determinism
against detection-determinism; a free-running ticker cannot give both.

So the handler gains watchRevalTickOverride — a test seam mirroring
watchPredicatesLoadFault (atomic pointer, read once at stream setup)
that lets a test substitute the reval tick source. The test now drives
exactly ONE tick, after the access change, with the reload fault
active: no early tick can mask via a pre-fault reset, no late tick can
mask via a post-lift reload, and one faulting tick can never reach the
clear-the-watch-set bound. No sleeps, no interval mutation, no wall-
clock bets in either direction.

Claude-Session: https://claude.ai/code/session_01BhQoeaWXxJbvw86ezzK8dt
2026-08-15 09:33:29 -04:00
xarmian 599fdbd3f4 feat(watch): drop assignment from the addressed-to-you stream (IDEA-2544 Phase 2, TASK-2551) (#1092)
* feat(watch): drop assignment from the addressed-to-you stream (TASK-2551)

IDEA-2544 Phase 2. Assignment is bookkeeping (who owns this); push is
dispatch (where attention goes now). Conflating them meant one triage
session assigning N items sprayed N notifications into every open
session of the assignee, so Dave's product call (day-33) was to drop
assignment from addressed-to-you entirely — no opt-in flag, no config
key.

watchNotificationVisible loses its KindAssignment early-return; an
assignment notification now falls through to the watch-map check like
any other item-level fact, which is what an unconditional watch already
promises to deliver. Producers are untouched and AssignedUserID is still
populated, so a future opt-in re-addressing would be a consumer-side
change only. KindPush is now the only addressed kind.

Tests: six tests rode the deleted path and are reworked, not deleted.
The two mid-stream visibility tests needed new vehicles — the
persistent-reload-failure test uses a push (same watch-map-independent
property), and the reval-ordering test uses collection-access revocation
with a still-granted control item, since push is self-addressed only and
its subject is a user losing access. That test's reval interval goes
50ms -> 200ms: at 50ms the clear-the-watch-set bound landed ~30ms behind
the assertion and the control leg lost the race.

New coverage for the asymmetry the change creates: a push stays
exclusive of watch-matched delivery, an assignment does not — a watcher
is entitled to see who an item was assigned to.

Mutation-tested three ways (restore the old branch; make assignment
exclusive addressed-only; couple visCache.reset() to reload success);
each is caught by the intended test and each revert was grep-verified.

Live: assigning a fresh unwatched item to the connected user leaves the
plugin monitor silent, pushing the same item prints one line, and
assigning a WATCHED item still delivers — verified end to end against a
sandboxed server, not just in tests.

Refs TASK-2551, IDEA-2544

* docs(watch): note the deferred plugin wording per Codex review (round 1)

Codex's only finding: plugin/monitors/monitors.json and
plugin/skills/pad/SKILL.md still describe assignment as
addressed-to-you traffic. Correct observation, deliberately out of
scope — installed plugins are version-pinned at install, so
plugin-visible text reaches nobody without a version bump, and
TASK-2564 (PLAN-2558 S6) owns the wording and the bump together.

Recording it in code next to the deleted branch rather than leaving a
reader to discover the mismatch, and on TASK-2564 with the exact line
refs so the follow-up does not have to re-find them.
2026-08-14 17:16:10 -04:00
xarmian 21001bc4c3 feat(sessions): session-presence registry + GET /api/v1/sessions (PLAN-2558 S1) (#1091)
* feat(sessions): session-presence registry + GET /api/v1/sessions (PLAN-2558 S1)

Slice 1 of PLAN-2558 (IDEA-2544 Phase 3, web-UI push). The server can now
answer "is anything actually listening right now?" for the calling user.

WHY. `pad push` (Phase 1, da6ce642) is fire-and-forget with no
"no session connected" warning. That's a defensible contract for a CLI
verb typed by someone who knows whether their own session is running.
It is not a defensible contract for a web-UI button: "Push to Claude"
that silently goes nowhere is worse than the clipboard ferry it
replaces, because the user cannot tell the two outcomes apart. Presence
lets the UI answer the question before the click, and — once sessions
carry a label (S2) — turns the same data into the target picker S5 needs.

This also closes the substrate half of PLAN-2469 Phase 3 ("presence
surface: SessionStart hook -> live-sessions view", IDEA-2464). The two
Phase 3s were the same work; see PLAN-2558's opening section.

- internal/server/session_presence.go: SessionPresence interface +
  MemorySessionPresence. Registered from handleWatchEventsStream,
  bracketed to the SUBSCRIPTION's lifetime (defer pairs with
  Unsubscribe's on the adjacent line) so every exit path — ctx.Done, a
  failed SSE write, the replay-loop returns, the reval-tick paths —
  releases both or neither. A leaked entry is the failure that matters:
  it makes the UI promise a listener that is gone, i.e. the same silent
  nowhere-push with a confident label on it.
- internal/server/handlers_sessions.go: GET /api/v1/sessions, self-scoped.
  No ?user_id=, no admin bypass — who has an agent session open is a
  presence signal about a person, and the same reasoning that made push
  self-addressed only applies. 503 (not 200-with-empty-list) when no
  registry is wired: "I can't tell" and "nobody is listening" must not
  look the same to the UI, since collapsing them is exactly the
  dishonesty this slice exists to remove.

Interface from day one because MemorySessionPresence is per-process.
Its doc comment states the boundary precisely rather than hand-waving
it: watchevents.Bus is blind in the SAME direction (a push published on
instance A never reaches a stream on instance B), so per-instance
presence is as accurate as per-instance delivery and both stop being
trustworthy at the same boundary — except that a load balancer may
route a POST and a GET to different instances, at which point they
disagree. A Redis-backed presence must therefore land WITH the Redis
watchevents.Bus that package already anticipates, not separately.

No pad-cloud change required (checked, not assumed): /api/v1/sessions is
a plain JSON GET served by nginx-router.conf's default `location /`
pass-through — the special long-lived-connection blocks are for
/api/v1/events and /api/v1/collab/ only.

Verified: go test ./... (SQLite) clean; make test-pg clean (25 pkgs,
exit 0); make lint 0 issues; new tests pass under -race. Live, on the
installed binary: 0 sessions with nothing connected -> 1 with one
stream open -> 2 with two, oldest-first -> back to 0 after both
disconnect, with a second user's list staying empty throughout.

* fix(sessions): no-store the presence response; document two lifetime constraints (PLAN-2558 S1)

Codex round 2 findings, both verified against source before acting.

P2 — Cache-Control. GET /api/v1/sessions set no cache header: writeJSON
sets none and the jsonContentType middleware only sets Content-Type, so
the response was heuristically cacheable. Now `private, no-store`,
matching the house pattern for per-user sensitive responses
(handlers_attachments.go:585). Wrong two ways without it: a shared cache
could serve one user's presence to another (the same boundary this
endpoint's absent admin view exists to hold), and a cached liveness
answer is exactly the confident-but-wrong "1 session connected" the
slice exists to prevent. Pinned by a test.

P2 — Shutdown, REFINED rather than adopted as reported. Server.Shutdown
delegates to http.Server.Shutdown, which does not cancel an in-flight
handler's context; SSE handlers therefore hang until their own ctx.Done
or a failed write. True, but for MemorySessionPresence it is HARMLESS,
and that is the useful half: the registry lives in the process that is
going away, so its entries die with it. There is nothing to reap. A
Redis-backed implementation does not inherit that — its entries outlive
the writing process, so a crash strands them permanently rather than for
30 seconds. Recorded as a hard constraint on the interface: any
out-of-process implementation must carry its own reaping story (TTL plus
heartbeat renewal, or instance-keyed ownership swept at startup).

Also documents the staleness window neither codex round surfaced, found
in my own pass: a clean disconnect deregisters immediately, an ungraceful
one is invisible until the next keepalive write fails, and the keepalive
is 30s. So the list can name a dead listener for up to ~30 seconds. That
bound is fine for a fire-and-forget channel — a push to a session that
died 5 seconds ago loses a message that was lost anyway — but consumers
must not upgrade it into a delivery guarantee. Shortening it means
shortening the keepalive, which taxes every idle connection; the right
answer for a consumer that needs delivery confidence is an ack, not a
faster heartbeat.

Verified: go build, go vet, make lint 0 issues, presence tests green
under -race.
2026-08-14 17:16:07 -04:00
xarmian da6ce642da feat(push): pad push — user-authored instruction dispatch to agent sessions (IDEA-2544 Phase 1) (#1090)
* feat(push): add pad push <ref> -m vertical (IDEA-2544 Phase 1)

Self-addressed, human-to-harness dispatch over the existing watch-events
bus/stream: CLI -> POST .../items/{itemSlug}/push -> a new KindPush
Notification (carrying the generalized TargetUserID addressed-to field
KindAsk will later share) -> watchNotificationVisible delivers it back
to the pushing user's own connected monitor sessions. Transient,
fire-and-forget by design (no migration, no durable inbox) since
assignment already covers the durable-notification case and this is
meant to be the explicit, no-inference dispatch verb instead.

* docs(plugin): document the push notification contract (IDEA-2544 Phase 1)

Push is the one notification kind that IS an instruction rather than a
passive fact, so it gets its own lead bullet in the plugin skill's
notification-etiquette section (ahead of the read-only/park default,
which it explicitly lifts) and a mention in the monitor's description.
The embed-source skills/pad/SKILL.md has no notification section to
mirror this into (the two files diverge by design) and is left
untouched.

* fix(push): reject over-long push messages instead of unbounded Summary

Comments truncate their notification Summary to a preview (the full
body is still fetchable), but a push message IS the payload — silently
truncating it would corrupt the instruction with nothing to recover it
from. Add maxPushMessageLen (4096, measured post-collapse) and reject
anything over it with a 400 rather than truncating; state the same
bound in `pad push --help` so it's discoverable before a 400, not only
from one.

* fix(push): close the watch-fallthrough leak, disambiguate SKILL.md exceptions

Codex round 1 P1: watchNotificationVisible's push branch only returned
early on a MATCH — a non-target caller fell through to the watch-map
check below it, so anyone holding an unconditional (or predicated)
watch on the item received every push addressed to every OTHER user,
instruction text included. Push is addressed private dispatch, not an
item-level fact watchers have a legitimate claim on (unlike assignment,
which watchers are expected to see per `pad watch --help`) — the branch
now returns unconditionally for KindPush, gating strictly on
TargetUserID and never reaching the watch-map fallback either way.
Pinned explicitly since Phase 4's session targeting is expected to
inherit this same exclusivity.

Also (codex P2): reworded the SKILL.md notification-etiquette bullets —
the new push exception and the pre-existing assignment/ask exception
literally contradicted each other ("the ONE narrow exception" claimed
singularity after push had already claimed exception status). Now
explicitly enumerated as the first and second exceptions to the
never-write rule.

* test(cli): pin that PushItem inherits X-Pad-Agent (BUG-2542 rebase)

Verified, not assumed: PushItem builds its request via c.post ->
c.newRequest like every other mutating client method (CreateWatch
included), so the attribution fix's client.agentName wiring covers it
for free with zero code changes needed on this branch. Adds a live
httptest assertion rather than trusting the code-path read alone —
the same shape as TestClientSendsResolvedAgentHeader, scoped to
PushItem specifically since that's the one method this PR added.

* fix(push): disambiguate workspace in the monitor line and skill contract

Codex round 2 P1: the watch-events stream is user-scoped ACROSS every
workspace a caller has watches in, but formatMonitorLine printed only
ItemRef/Kind/Actor/Summary and dropped the Workspace field the wire
payload already carried — a session linked to workspace A receiving a
notification for workspace B would resolve the wrong item (or 404) with
no signal in the line that anything was off.

Fixed universally, not push-only: grepped plugin/ and skills/ for
anything parsing "PAD ..." lines and found none — the Claude Code
plugin host ingests the stdout line as free-text notification prose,
formatMonitorLine's only real consumer is its own fmt.Println, so there
is no wire-format consumer a workspace prefix could break. The
ambiguity predates push (any watched item across workspaces already had
it); push just makes the consequence sharper because it carries an
instruction rather than a passive fact.

SKILL.md's push bullet now tells the agent to resolve with
`pad --workspace <workspace> item show <ref>` using the slug read off
the notification line, not a bare `pad item show <ref>`.

* fix(push): respect --format json instead of hardcoding plain text

Codex round 2 P2: pushCmd's RunE ignored the global format flag and
always printed "Pushed <ref>", silently discarding --format json.

- server.pushResponse replaces the bare map the handler wrote before —
  a typed {ref, workspace, pushed, message} shape, with workspace
  resolved to the CANONICAL slug via s.getWorkspace (not merely echoed
  from whatever the URL contained), matching the same disambiguation
  need the round-2 P1 fix addressed for the monitor line.
- cli.PushItem now returns (*PushResult, error) instead of discarding
  the response body.
- pushCmd checks formatFlag == "json" and calls cli.PrintJSON, mirroring
  runCreateWatch's existing pattern.

internal/cli/agent_identity_test.go's TestPushItemSendsResolvedAgentHeader
needed a one-line update for PushItem's new two-value return — caught by
`go vet ./...`, not `go build ./...` (which doesn't compile test files);
folding vet into my own pre-flight going forward.
2026-08-13 18:41:46 -04:00
xarmian ec7fd027fc feat(server,cli): watches, user-scoped event stream, plugin monitor command — PLAN-2469 Phase 1 (TASK-2533) (#1082)
* feat(store): race-free status/assignment mutation signal (TASK-2533)

Adds models.Item.LastMutation (ItemMutationSignal), populated inside the
SAME transaction that already writes status_transitions / assigned_user_id
in UpdateItemWithParentLink and MoveItemWithPreCheck. This is the
foundation for TASK-2533's watch-notification pipeline: a before/after
snapshot taken in the HTTP handler layer would race concurrent writers of
the same item, so the signal is computed where the authoritative diff
already happens, in-transaction.

* feat(store): watches table migration, both drivers (TASK-2533)

watches(id, workspace_id, user_id, item_id, predicate, created_at) per
DOC-2479's subscription-table design: durable, server-side subscriptions
that survive both the plugin-monitor process and a padd restart.
uq_watches_user_item makes `pad watch <ref>` idempotent (re-watching
upserts the predicate). Wires watches into the workspace-purge child-delete
list, mirroring item_stars.

* feat(watchevents): add in-process notification bus (TASK-2533)

New package: a global (not per-workspace) in-process pub/sub bus carrying
watch-worthy Notifications (status-change / assignment / comment; ask
reserved in the enum with no producer yet — see the follow-up server
commit). Bus is an interface specifically so a Redis-backed implementation
can slot in later without touching any caller; only MemoryBus exists today.
Package doc comment states the single-process/multi-instance limitation
explicitly, mirroring internal/events' shape.

* feat(store): watches CRUD (TASK-2533)

models.Watch + Store.CreateWatch (upsert on user+item)/GetWatchByUserItem/
ListWatchesForUser (unscoped by workspace — a watch is personal, and the
event-stream handler needs every watch a caller holds across all their
workspaces)/DeleteWatch.

* feat(server): watch/nudge event stream + CRUD endpoints (TASK-2533)

GET /api/v1/events/stream (DOC-2479): a user-scoped, cross-workspace SSE
stream, filtered server-side to the caller's watches (with optional
--until field=value predicate) plus "addressed to you" — narrowed to
assignment-to-you only for Phase 1, confirmed with the dispatcher: this
codebase has neither a Collection.Kind field nor any user->active-role
binding to ground DOC-2479's "human-gate-shaped collection targets your
active role" half mechanically. watchevents.KindAsk stays in the wire
enum with no producer. `pad session register` is the natural future hook
for a session-carried role identity.

POST/DELETE .../items/{slug}/watch, GET /api/v1/watches (unscoped,
mirrors /auth/tokens' shape for a personal, not workspace, resource).

Producer wiring (TASK-2533 audit) publishes from every live mutation path
that can produce a LastMutation signal or a new comment: handleUpdateItem
(incl. its collab sub-paths and the comment-attached-to-update path, which
bypasses handleCreateComment entirely), handleMoveItem, handleCreateComment,
item creation with an initial assignee, and the bulk-items loop (covers
archive/restore/move/set-priority/tag/untag/assign uniformly via one call
site). Named, not silent, bypasses: import bundle, status_transitions
backfill, workspace restore/purge — none are live human-facing mutations.

Known Phase-1 tradeoff, flagged not fixed: bulk mutations are NOT batched
into one notification the way the existing SSE/webhook bulk path is — a
bulk-assign of N items surfaces N individual notifications. Each is still
correctly scoped by the recipient's own watches/addressed-to-you filter
(a narrower audience than the workspace-wide SSE firehose the existing
batching protects), so this is a noise-discipline tradeoff, not a leak.

* feat(cli): pad watch + pad session register (TASK-2533)

pad watch <ref> [--until field=value] creates/upserts a durable watch;
pad watch list / pad watch remove <ref> are the hygiene companions the
dispatcher asked to be included explicitly rather than silently added.
pad watch --stream --for-session is the plugin-monitor command: one
stdout line per matching event ("PAD TASK-214 -> kind (actor): summary"),
silent on startup with no .pad.toml (hourly retry) or an unreachable padd
(backoff retry) per DOC-2479's noise-discipline contract. The retry/
backoff math and line formatting are pure, unit-tested functions; the
actual sleep loop is not (per the dispatcher's ask).

pad session register writes ~/.pad/sessions/<pid>.json (pid, cwd,
CLAUDE_CODE_MESSAGING_SOCKET when set) -- forward-looking infra for
Phase 3's live-sessions/presence surface; nothing consumes it yet in
Phase 1/2.

* fix(server): comment replies never published a watch notification (TASK-2533)

Codex round 1 finding 2 (verified real, not a false positive):
handleCreateReply is a SEPARATE code path from handleCreateComment — it
calls store.CreateComment directly via POST .../comments/{id}/replies,
not POST .../comments — and was missing the watch-notification hook
entirely. A reply to a comment on a watched item produced zero
notification. Same kind=comment publish as the top-level path, plus a
regression test covering the reply route specifically.

* fix(server): re-check current access before serving/delivering watches (TASK-2533)

Codex round 1 finding 1: ListWatchesForUser filtered only by user_id — a
watch row survives a revoked workspace membership or grant (nothing
deletes it), so GET /api/v1/watches and the event-stream's notification
filter could keep leaking item title/ref, workspace slug, actor, and
summary for access the caller no longer has.

Adds Store.ListWatchesForUser's ItemCollectionID column (needed for the
visibility check) and server.filterWatchesByCurrentAccess, which mirrors
computeSSEVisibility's RBAC resolution (handlers_events.go) — admin
bypass, VisibleCollectionIDs for member/guest full-collection access,
GuestVisibleResources for item-level grants — grouped by workspace since
a caller's watches can span many, unlike a single SSE connection scoped
to one. Fails closed on any lookup error.

Wired into handleListWatches here; the event-stream's loadWatchPredicates
call site picks up the same filter in the next commit, which also
restructures that function's Subscribe/replay sequence and therefore
touches the same lines.

* fix(watchevents): atomic ID assignment + subscribe-and-replay (TASK-2533)

Codex round 1, findings 3 and 4 (same subsystem, fixed together):

Finding 4 — sequence assignment and replay-buffer insertion happened
under SEPARATE locks in MemoryBus.Publish. Two concurrent Publish calls
could append to the ring buffer out of ID order, corrupting since()'s
ordering assumptions (it walks the ring oldest→newest assuming monotonic
IDs). Fixed by unifying seq assignment, buffer append, and the
subscriber-list snapshot under one lock; the (already non-blocking)
fan-out send still happens after releasing it.

Finding 3 — GET /api/v1/events/stream called Subscribe() and, later
(when resuming via Last-Event-ID), EventsSince() as two separate calls.
A Notification published in the window between them landed in BOTH the
replay result and the live channel, double-delivering it. Bus gains
SubscribeAndReplaySince(sinceID), which atomically subscribes and reads
the replay buffer under the SAME lock; the stream handler now uses it
whenever a Last-Event-ID is present (this commit carries that call-site
change, plus the finding-1 loadWatchPredicates filter wiring from the
previous commit — both land in the same lines of this function).

Adds a concurrent-publish ID-ordering test and a subscribe-then-
concurrent-publish no-duplicate test, both run with -race.

* fix(cli): monitor silent-start ordering + sync_required handling (TASK-2533)

Codex round 1, findings 5 and 6:

Finding 5 (P1) — runWatchMonitor called getClient() once, before the
loop and before the .pad.toml check. getClient() -> getConfiguredConfig()
os.Exit(1)s when unconfigured with no TTY, or launches an INTERACTIVE
configuration wizard when one is attached — either way a direct violation
of DOC-2479's silent-start contract, which requires "not ready yet" to be
a silent retry, never a crash or a prompt. Adds monitorClient(), which
builds the client the same way but returns a plain error instead of
exiting or prompting; client construction now happens INSIDE the loop,
after the .pad.toml gate, on every iteration, and its failure folds into
the existing padd-unreachable backoff path.

Finding 6 (P2) — streamWatchEvents ignored "sync_required" (the server's
signal that the requested Last-Event-ID was evicted from its replay
buffer), so a stale cursor got resent on every reconnect forever. Now
clears the cursor on sync_required so the next reconnect is a fresh,
non-resuming subscription instead.

Both covered by tests that assert the goroutine returns promptly on
context cancellation (proving no os.Exit / no blocking prompt was hit,
since the test process itself is still running to observe the return)
and that streamWatchEvents clears/re-tracks the cursor correctly around
sync_required.

* fix(server): uniform current-access gate for watch AND addressed-to-you delivery (TASK-2533)

Codex round 2, findings 1 and 2 — same subsystem (watch/nudge delivery
access control), fixed together; finding 2 explicitly falsifies finding
1's fix's own admin-bypass argument, so this replaces that reasoning
rather than patching around it.

Finding 1 (confirmed real): VisibleCollectionIDs / GuestVisibleCollectionIDs
deliberately over-widen for navigation — a collection ID is included if the
caller has an item grant on ANY item inside it, explicitly leaving
item-level narrowing to the caller (their own doc comments say so).
computeWatchAccessVisibility used that over-wide set directly as the
"fully visible" gate, so a guest granted item A was treated as having full
access to A's WHOLE collection, including an ungranted sibling item B.
Fixed by building the "genuinely full access" set from
GuestVisibleResources' fullCollectionIDs (populated only from direct
collection_grants, never widened by an item grant) + GetMemberCollectionAccess
/ ListSystemCollectionIDs for an actual member — exactly computeSSEVisibility's
own fullCollSet construction, not an approximation of it.

Finding 2 (confirmed real): the addressed-to-you (KindAssignment) branch in
watchNotificationVisible returned true unconditionally, with NO access
check. validateAssignmentScope (internal/store/items.go) only checks
WORKSPACE membership, never collection access, so an item can be assigned
to a "specific"-access member whose granted collections don't include it
at all — an ordinary assignment, no revocation timing required. Fixed by
gating EVERY notification kind — watch-matched and addressed-to-you alike —
through the SAME watchAccessVisibility check before either branch runs.
watchevents.Notification gains CollectionID so the check has what it needs
without a second lookup; the stream handler resolves it lazily per
workspace via a small connection-scoped cache (workspaces aren't known in
advance for addressed-to-you the way watch workspaces are), cleared on the
same reval tick that reloads the watches map.

This also required replacing computeWatchAccessVisibility's admin-bypass
argument, not just its code: "every call site filters the caller's OWN
watches" stopped being a sufficient justification once addressed-to-you
(which is fundamentally about *this* caller's own assignment activity
across every workspace) shares the same gate — a bearer-borne admin token
unconditionally trusted for that is exactly BUG-1616's blast radius. Now
mirrors computeSSEVisibility's cookie-vs-bearer distinction exactly.

Tests: guest-with-item-grant no longer sees a sibling item's watch or
stream notification (filter-level and HTTP/SSE-level); an assignment
outside a restricted member's granted collections is denied at both
levels; addressed-to-you is proven still gated (denied with no access,
visible once granted) as a pure unit test.

* fix(store): always re-read existing under lock, not just for precheck/patch updates (TASK-2533)

Codex round 2 finding 4, verified real: updateItemWithParentLinkOnce's
`existing` snapshot was only refreshed under the write lock when precheck
!= nil, ExpectedUpdatedAt != "", or FieldsPatch != nil — any update
touching none of those (e.g. a plain title-only PATCH) kept the STALE
pre-tx `existing` for the rest of the function, including the
LastMutation assignment-delta comparison added in TASK-2533's first
round. A concurrent OTHER transaction's assignment change landing between
this transaction's pre-tx read and its lock acquisition would get
misattributed to THIS transaction: a title-only update could report a
spurious, wrongly-attributed AssignmentChanged for a transition it never
made, duplicating the one the other transaction already reported
correctly (or missing a real one, depending on interleaving).

The status-transition capture already defended against exactly this with
its own separate conditional re-read; the assignment-delta capture added
later did not replicate that guard. Fixed by making the re-read
unconditional — once, right after the locks are held, before any SET-
clause building or the UPDATE itself — so every existing.* comparison in
this function is race-free by construction, not by each caller
remembering to guard itself. Also removes the now-redundant duplicate
re-read the status code had of its own.

Reproduces the exact race deterministically using UpdateItemWithPreCheck's
precheck hook as a synchronization point (TX2's assignment change blocks
mid-transaction while TX1's title-only update races its own pre-tx read
against it) — the new test fails reliably against the pre-fix code and
passes reliably (including under -race, and in Postgres mode) against
the fix.

* fix(watchevents): send under the same lock Unsubscribe/Close use (TASK-2533)

Codex round 2 finding 3, confirmed real and high-severity: Publish
snapshotted subscriber channels under the lock, released it, and only
then sent to them. A concurrent Unsubscribe or Close could close one of
those channels in the window between the snapshot and the send — a send
on a closed channel PANICS in Go, which crashes the whole padd process,
not just one subscriber's connection. The reasoning for releasing the
lock before sending ("a slow subscriber would stall everyone else") didn't
hold up: the send is already non-blocking (select/default — a full
channel is dropped-and-logged, never awaited), so holding the lock
through it costs nothing and closes the window structurally.

Adds a hammer test (many iterations of concurrent Publish / Subscribe /
Unsubscribe / Close, short-lived churned channels, recover()-wrapped so a
regression fails cleanly instead of crashing the whole `go test` run) that
reproduces "send on closed channel" dozens of times per run against the
pre-fix code (plus an independent -race detection) and passes cleanly,
repeatedly, against the fix.

* fix(server): re-fetch the user, not just the vis map, on each reval tick (TASK-2533)

Codex round 3, confirmed real: watchVisCache captured *models.User ONCE
at connect time (newWatchVisCache) and never re-fetched it; reset()
cleared only the per-workspace visibility map. computeSSEVisibility's own
doc comment explains why it re-fetches the user fresh on every call —
"so mid-stream role changes (admin demotion, user.disabled flips) take
effect on the next tick" — and the round-2 commit claimed to mirror that
"exactly," but only carried over the collection/bearer logic, not the
re-fetch itself. Net effect: a demoted or disabled admin kept fullAccess
on an open stream (both watch-matched and addressed-to-you delivery,
since both go through this same cache) until reconnect.

Adds watchVisCache.refreshUser, called by both the constructor and
reset() so the cadence matches computeSSEVisibility's actual cadence in
handlers_events.go (that function is invoked once at connect and again
only on each membershipCheck tick — never per event — so "per cache
reset" here is the same cadence, not a narrower one). Deliberately fails
CLOSED (not open-to-stale like computeSSEVisibility's own transient-error
fallback) on a fetch error, a deleted user, or a disabled user — a nudge
stream's wrong failure mode is delivering a fact to someone who
shouldn't see it, not a dropped UI update, so this trades
computeSSEVisibility's availability-leaning fallback for a stricter one
and says so in the comment rather than repeating the "mirrors exactly"
claim the fix falsified.

Tests: a unit-level pair (mirroring handlers_events_revalidation_test.go's
existing admin-demotion/disable coverage of the analogous SSE gap
exactly) proves an admin loses fullAccess after a demotion + reset(),
and a disabled user is denied outright; an HTTP/SSE-level test proves a
live stream stops delivering entirely once its connected user is
disabled and a reval tick passes. All three reproduce the bug reliably
against the pre-fix code and pass cleanly against the fix.

The HTTP-level test deliberately runs serially (not t.Parallel()): it
mutates the package-level watchListRevalInterval var, which every other
parallel watch-stream test in this package also reads via its own
ticker — writing to it from a t.Parallel() test raced against those
reads under -race (misattributed by the race detector to a whole
cluster of unrelated concurrently-running tests before this was
diagnosed). Full server package -race pass is clean after the fix.

* fix(server): decouple vis-cache reset from watch-list reload success (TASK-2533)

Codex round 4, confirmed real: on a reval tick, if ListWatchesForUser
errored, the handler's `continue` skipped visCache.reset() entirely —
the two were coupled, with reset() only reachable on the reload's
success path. A demoted or disabled user's stale identity/visibility
(round 3's fix) stayed live for exactly as long as that UNRELATED query
kept failing, so the round-3 leak reopens for the duration of any
watch-list reload error.

Fixed by running visCache.reset() first, unconditionally, before
attempting the watch-list reload. On a reload failure, the stale watch
list is kept (its own staleness is already bounded by
watchListRevalInterval's "eventually consistent" contract) but is now
gated by the FRESH visCache regardless — a demoted/disabled user is
denied via visCache even while the watch list itself lags a tick.
Chose this over dropping all delivery for the tick (the other option the
finding offered) because tying stream availability to an unrelated
query's transient health seemed like the wrong tradeoff; the comment at
the call site states this choice explicitly.

Adds a watchPredicatesLoadFault test seam on *Server (mirrors the
existing restoreAckFault pattern) so the reload failure can be forced
deterministically without breaking the DB connection for the whole test.
Reproduces the exact bug: forces the reload to fail on every tick while
concurrently disabling the connected user, and asserts addressed-to-you
delivery (which depends only on visCache, never the watch list) is
denied anyway. Fails reliably against a reverted (pre-fix, coupled)
version of the reval branch and passes cleanly against the fix.

Full server package -race pass, full suite (SQLite + Postgres) pass,
lint clean — this is the pre-PR verification matrix; round 5 will be a
narrow re-verify of this fix only.

* fix(server): bound stale watch set under persistent reload failure; atomic test seam (TASK-2533)

Codex round 5, two P2s, both confirmed real:

Finding 1 — `watches = fresh` only ran on the reload's success path, so
under a PERSISTENT (not single-tick) reload failure the watch set stayed
live indefinitely: a dead watch (removed, item deleted) kept matching
forever, and a watch created during the outage was silently missed
forever — visCache (round 4) gates current ACCESS, not whether a watch
still legitimately exists, so it couldn't catch this on its own. Fixed
by tracking consecutive reload failures and clearing the watch set once
maxConsecutiveWatchReloadFailures (3 ticks) is crossed, failing closed
on watch-matched delivery specifically while addressed-to-you delivery
(visCache-only, unaffected either way) continues throughout. Updated the
tradeoff comment at the call site so the "eventually-consistent" claim
now matches the bounded, not unbounded, behavior it actually describes.

Finding 2 — the watchPredicatesLoadFault test seam was a plain `func()
error` field, written by a test AFTER the SSE stream's background
goroutine was already running and reading it on every reval tick:
genuinely racy, unlike restoreAckFault's own use of the identical field
shape, which is set once, synchronously, before the single HTTP request
that reads it — goroutine creation's happens-before edge makes THAT
usage safe without any extra synchronization. Verified restoreAckFault
does not share the flaw and left it untouched. Fixed the watch seam with
atomic.Pointer[func() error] instead.

Test for finding 1: forces maxConsecutiveWatchReloadFailures+1
consecutive reload failures via the (now-atomic) fault seam and asserts
watch-matched delivery is suppressed once the bound is crossed while
addressed-to-you keeps delivering, then clears the fault and confirms
watch-matched delivery resumes on the next successful reload — a bounded
outage response, not a one-way ratchet. Fails reliably against the
bound disabled, passes cleanly restored.

This is the (re-run) pre-PR verification matrix per the dispatcher:
SQLite + Postgres + full-suite -race + lint + gofmt, all clean. Round 6
is a narrow re-verify of these two fixes only.

* test(store): bound the concurrent mutation-signal test's wait (TASK-2533)

CI-triage follow-up: PR #1082's plain Postgres step hit go test's default
10-minute per-binary timeout. Investigated whether any store test added by
this branch scales with runner slowness (lock-wait defaults, sleep-based
polling, transaction-hold durations):

- Watches CRUD tests (8): 0.63-0.80s each under Postgres, isolated and in
  the full 741-test package run.
- Mutation-signal tests (6), including the precheck-hook two-transaction
  race test: 0.48-0.80s each; the race test held at 0.48-0.51s across 10
  consecutive runs (no variance) and across the full-package run.
- Full store package under Postgres: 279.17s and 277.12s across two runs
  on this branch, matching the ~275s/297s baseline team-lead measured
  locally and on PR #1081 — no reproducible slowdown from anything this
  branch adds.

No pathological test found locally. The one test with genuine
cross-goroutine DB lock contention (TestLastMutation_AssignmentDelta_
NotMisattributedUnderConcurrentWrite) had an unbounded wg.Wait() as its
only unbounded wait — TX2's release was already unconditional (fixed 50ms
sleep, not gated on TX1's progress), so there's no deadlock risk, but
there was no ceiling on how long legitimate lock contention could
stretch it under a slow/shared runner. Replaced with a bounded 10s wait
that fails fast with a diagnostic instead of silently consuming
test-binary budget if it's ever exceeded. Verified the regression test
still fails reliably (5/5) against a revert of the round-2 fix it guards.

Could not reproduce the CI timeout locally; likely the pre-existing
~297s CI baseline (already noted as close to the 10-minute ceiling)
plus environmental variance on the shared runner, not a specific test
this branch adds.
2026-08-12 15:50:41 -04:00
xarmian f8ff5742e5 feat(server): add cross-workspace copy endpoint with post-commit fanout (TASK-2365)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-31 00:59:25 +00:00
xarmian 01d640978c feat(server): add cross-workspace copy dry-run preflight endpoint (TASK-2364)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
2026-07-30 20:15:00 +00:00
xarmian 5804c80146 feat(server): add cross-workspace authorization helper (TASK-2358) 2026-07-30 12:20:38 +00:00
xarmian e601f2b368 fix(collab): reconcile Postgres commit-ack-loss on version restore instead of treating it as rollback (BUG-2276 residual 1)
On Postgres, a version-restore commit that durably lands but whose ack is lost surfaced as an error and wrongly resumed peers on a stale Y.Doc. ForceRefreshRoom now runs a Postgres-only reconcile after a commit error: two durable signals (content == restored version AND last_restore_seq advanced past a lock-captured baseline) must agree → LANDED (publish fences + reseed, return the restored item + SSE); both false → rolled back (unfreeze); disagree/read-error → UNCERTAIN (invalidate in-memory fences so durable state governs, then plain-close sockets so peers reconnect + re-evaluate). SQLite path unchanged.

Confirming Codex (high effort): 3 rounds — false-404, frozen-forever, archive-nil, stale-baseline, stale-in-memory-fence-clobber all closed; real Postgres end-to-end ack-loss + SSE test. make test-pg green. Residual 2 (applier-ack rollback race) follows separately. Go CI red only on the pre-existing govulncheck advisory (BUG-2278).

https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-21 18:00:49 -04:00
xarmian c8492db29f fix(e2e): disable rate limiting on the E2E server to stop 429 flakes (BUG-2089) (#922)
The E2E harness runs the real pad binary with the real rate limiter, and
every Playwright test shares one loopback IP (127.0.0.1). The auth limiter
(5 logins/min/IP, burst 5) trips as soon as a spec logs in a couple of
browser clients — collab-persistence.spec.ts logs in two per test — so
browserLogin fails with "in-page login failed with status 429". This was
deterministic, not flaky: it failed on TASK-2058's own PR and its push to
main, and on every downstream PR since.

Add a test-only env knob PAD_DISABLE_RATE_LIMITS: when truthy, New() leaves
Server.rateLimiters nil, which RateLimit() already treats as a pass-through
(Stop() and the MCP path are already nil-safe). Wire it into the Playwright
webServer.env; run-pad.mjs spawns the binary with inherited env so it
reaches the pad process. Limiters stay fully active in prod/self-host — the
knob is an explicit opt-in only the E2E server sets.

Verified: collab-persistence.spec.ts passes locally with the fix; the
existing limiter tests still pass (limiters on when the env is unset); new
TestRateLimit_DisabledByEnv pins the bypass.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-11 14:30:32 -04:00
xarmian 3f69b76b06 feat(security): enforce session UA binding under strict mode (TASK-2056) (#912)
Session IP/User-Agent binding was log-only by default, so a stolen
session token granted durable any-origin access. IP-change enforcement
already existed behind PAD_IP_CHANGE_ENFORCE=strict; this extends the
same single toggle to also enforce the User-Agent-hash binding.

When strict enforce is ON, a request whose client IP OR User-Agent hash
no longer matches the session's stored binding now revokes the session
(DeleteSessionIfExists) and rejects the request (401 for API,
revoked-passthrough for public/browser paths), killing the stolen token.
When enforce is OFF (default), behavior is unchanged: UA mismatch is
logged (slog only, no new audit row) and the request proceeds, so
existing self-host users see no behavior change and routine client churn
(browser/WebView updates, DevTools emulation, mobile-app rebuilds) is
tolerated.

The UA hash is stable within a real session, so UA-mismatch enforce
carries fewer false positives than IP enforce (mobile roaming, VPN
toggles, carrier NAT) — documented in the handler comment. Adds the
ActionSessionUAChanged audit action, emitted only in strict mode.

No DB migration: reuses the existing IPChangeEnforce config flag and the
existing session store primitives.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 23:32:22 -04:00
xarmian aeb80883f0 fix(webhooks): track delivery goroutines + bounded retry (BUG-2012) (#864)
* fix(webhooks): track delivery goroutines + bounded retry (BUG-2012)

Webhook deliveries ran in untracked `go d.deliver(...)` goroutines that
write to the store — the BUG-842 shutdown-race class that goAsync was
built to prevent — and had no retry.

- Inject a `spawn func(func())` into Dispatcher (SetSpawn). Server wires
  s.goAsync via SetWebhookDispatcher so deliveries are tracked on s.bg
  (Stop() waits for in-flight deliveries) and inherit goAsync's panic
  recovery (BUG-2011). Nil spawn falls back to a plain goroutine, so
  standalone Dispatcher usage is unchanged.
- Add a bounded in-goroutine retry: up to 3 attempts with linear backoff
  on transient failures (network error / timeout / 5xx). Permanent
  failures (4xx, SSRF block, malformed URL) stop immediately. The final
  outcome is recorded once via UpdateWebhookFailure.
- Tests: delivery runs on the injected spawn; transient 5xx retries to
  the cap; permanent 4xx does not; a recovered transient records success.

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

* fix(webhooks): classify redirect-block + non-5xx as permanent (Codex review)

Second Codex review of PR #864 found two retry-classification gaps:

- P2: an SSRF-blocked (or looping) redirect surfaces as an error from
  client.Do (via CheckRedirect), which the retry loop treated as
  transient — so a redirect to an internal target was retried 3x with
  backoff. Wrap a sentinel (errRedirectRejected) in checkRedirect and
  match it with errors.Is (url.Error unwraps to it) to classify these
  as permanent — attempted once, no retries.
- P3: the status switch treated every non-2xx/non-4xx as transient.
  Narrow transient to 5xx only; 4xx/3xx-no-Location/1xx are permanent,
  matching the stated "network error / timeout / 5xx" retry policy.

Adds TestDispatcher_RedirectBlockIsPermanent.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 12:02:49 -04:00
xarmian a04fd861dc fix(server): add panic recovery to background sweeper goroutines (BUG-2071) (#865)
The four long-running sweeper loops (orphan GC, op-log GC, token reaper,
workspace purge) spawn their own s.bg-tracked goroutine with a
stop-channel lifecycle, so they can't route through goAsync (a
fire-and-forget helper that owns the whole goroutine) without breaking
shutdown or double-counting s.bg. As a result they had NO recover(): a
panic in any sweeper body crashed the single-binary server for every
tenant.

Add a shared Server.recoverSweeper(name) firewall — mirroring goAsync's
recover + debug.Stack slog style — and defer it inside each sweeper
goroutine. A panic is now logged with a stack and the goroutine unwinds
cleanly; its own deferred s.bg.Done() still fires (recover stops the
unwind), so Stop() still drains. No change to any sweeper's loop cadence
or stop-signal shutdown.

Adds TestTokenReaper_RecoversPanic, which drives a real reaper tick to
panic (nil store → nil-pointer deref in the first cleaner) and asserts
the panic is logged+recovered and Stop() returns.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 11:58:41 -04:00
xarmian f7c4cb3287 fix(server): add panic recovery to goAsync background tasks (#863)
goAsync wrapped fn in a bare goroutine with no recover(); chi's
Recoverer only covers request goroutines, not these detached ones.
A panic in a background task (e.g. deriveThumbnails hitting a Go
image-decoder panic on a crafted upload, or an email send) would
unwind past the goroutine and crash the whole single-binary server
for every tenant.

Add a single deferred recover() inside the goAsync goroutine that
logs the panic + stack via slog, covering all 15+ call sites at once.
The recover defer is registered after `defer s.bg.Done()`, so it runs
first on unwind and Done() still fires — Stop() continues to drain
the WaitGroup even when fn panics.

Adds TestServer_goAsync_RecoversPanic asserting the process survives
a panicking fn and Stop() returns.

Fixes BUG-2011.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 11:49:01 -04:00
xarmian f5b437a65f feat(server): workspace restore + deleted-list endpoints (TASK-1970) (#827)
Foundation for PLAN-1969 (user-recoverable workspace soft-delete). A
workspace delete only stamps workspaces.deleted_at; items/collections/
members are untouched, hidden transitively. Restore clears deleted_at so
everything re-surfaces intact.

Store (internal/store/workspaces.go):
- RestoreWorkspace(slug): UPDATE ... SET deleted_at = NULL WHERE slug=?
  AND deleted_at IS NOT NULL. Returns sql.ErrNoRows (-> 404) when no
  soft-deleted row matched (already live or purged).
- ListDeletedWorkspaces(userID, cutoff): owner-scoped, deleted_at within
  the window, ordered deleted_at DESC. Account-deleted workspaces have no
  live owner, so they never leak.
- GetDeletedWorkspaceBySlug(slug): resolves a soft-deleted row (the normal
  resolvers filter deleted_at IS NULL) so the handler can tell 403 from 404.
- Dual-dialect via s.q/s.dialect; no migration (deleted_at already exists).

Handlers (internal/server/handlers_workspaces.go):
- POST /api/v1/workspaces/{slug}/restore: owner-only; 404 not-restorable,
  403 non-owner, 200 + restored workspace; logs a "restored" activity.
- GET /api/v1/workspaces/deleted: owner-scoped list with per-entry
  purge_at + days_left, both derived from workspacePurgeRetention so
  restore and the purge sweeper share ONE 30-day window (no drift).
- Both routed outside the /{slug} RequireWorkspaceAccess subrouter (which
  resolves only live workspaces); restore enforces owner authz inline.

CLI client (internal/cli/client.go): RestoreWorkspace + ListDeletedWorkspaces.
TS type (web/src/lib/types/index.ts): Workspace.deleted_at + DeletedWorkspace.

Tests: store (resurface-intact; double-restore/live -> ErrNoRows; window
boundary 29d IN / 31d OUT + owner-scoping) and handler (owner-only 403,
404 live/unknown, 200 restore, owner-scoped deleted-list). Green on
SQLite and Postgres (make test-pg); golangci-lint clean.

Closes TASK-1970

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-06 10:07:34 -04:00
xarmian b73ba63752 feat(store): hard-purge soft-deleted workspaces after 30 days (TASK-1966) (#825)
The /privacy policy promises owned workspaces are removed from live
systems within 30 days, but DeleteAccountAtomic and DeleteWorkspace only
SOFT-delete (workspaces.deleted_at) and nothing ever expunged them —
a right-to-erasure gap. Add a scheduled sweeper that hard-purges
workspaces soft-deleted longer than a named 30-day retention constant.

- Store: ListPurgeableWorkspaces (soft-deleted + past cutoff; never
  touches live rows), WorkspaceAttachmentBlobs, CountAttachmentsForHash-
  OutsideWorkspace (content-addressed dedupe guard), and PurgeWorkspace-
  Data — a transactional cascade that deletes every workspace-scoped
  child row in FK-dependency order (items/comments/versions/links/
  reactions/stars/yjs op-log/wiki-links/grants/transitions/moves/views/
  collections/documents+versions/agent_roles/webhooks/invitations/
  templates/share_links+views/oauth join rows/report layouts/members/
  member access/api tokens/attachments/activities), de-identifies
  mcp_audit_log, and refuses to touch a non-soft-deleted workspace.
- Server: a periodic sweeper modeled on the orphan GC — captures blob
  keys before the purge, cascades the DB rows, then reclaims blobs
  through the attachment store abstraction (FS + S3 safe) with the
  orphan GC's cross-workspace dedupe + in-flight-upload guards. Failure
  isolated per workspace; idempotent.
- Dual-dialect (SQLite + Postgres); partial index on
  workspaces(deleted_at) — migrations/073 + pgmigrations/051.

Both delete paths (account + manual workspace delete) purge on the same
30-day clock: identical deleted_at mechanism, both owner-initiated, and
the orphan GC already reclaims their attachment blobs at 30 days.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-05 22:23:12 -04:00
xarmian f34e66254b feat(admin): admin force-verify email override (TASK-1939) (#809)
Wave 4 of PLAN-1933 (DR-7). Adds a web-console-only admin override to
force-verify a locked-out unverified account. No CLI, no MCP (matches
the no-auth-mutation-on-MCP rule).

Server:
- POST /api/v1/admin/users/{userID}/verify-email — admin-only, mirrors
  handleAdminEnableUser. Reuses the existing SetUserEmailVerified store
  method (added in Wave 3b) and audits with the distinct
  ActionEmailVerifiedByAdmin action (separate from the self-serve
  ActionEmailVerified — a force-verify is an operator security action).
  Idempotent (already-verified returns 200 no-op).
- Surface email_verified_at in the admin list + get-user JSON so the
  console knows verified state (Wave 1 only added the store-level scan).

Web:
- adminVerifyEmail client method (api.admin.verifyEmail) confined to the
  admin section of client.ts.
- "Mark email verified" action in the admin user panel (UserSettingsForm),
  shown only when the target user is unverified.
- email_verified_at added to the AdminUser type.

Tests: admin force-verifies an unverified user (flips email_verified_at +
audits ActionEmailVerifiedByAdmin, not the self-serve action) and the
now-verified session is unblocked; non-admin -> 403 with no side-effect.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 04:15:09 -04:00
xarmian 4a7c054223 feat(server): cloud email self-registration + verify-email/resend endpoints (TASK-1938) (#808)
Wave 3b of PLAN-1933 turns ON Pad Cloud email/password self-registration
with mandatory email verification.

- DR-6: relax handleRegister to allow self-serve signup when
  cloudMode && emailConfigured. emailConfigured = s.email != nil AND a
  USABLE public base URL (non-empty, not a 0.0.0.0/:: bind-all host), so
  no unverifiable user is ever created. Self-serve is the ONLY path that
  writes email_verified_at = NULL (UserCreate.Unverified); admin-created
  and invited signups stay verified. Mints + sends a verification email.
- DR-5: POST /auth/verify-email (ConsumeEmailVerification → flips
  email_verified_at → returns fresh user) and POST /auth/resend-verification
  (always-200, enumeration-safe; minting a new token invalidates the prior
  one). Both wired into the rate-limiter path switch (PasswordReset bucket).
- DR-1: handleAcceptInvitation verifies an unverified account on accept
  (email-bound invite proves email control), via new store method
  SetUserEmailVerified.
- DR-11: keep the existing clear 409 on duplicate email at signup.

Session freshness: currentUser is re-read fresh from the DB per request
(ValidateSession → GetUser), so flipping email_verified_at unblocks the
same session's subsequent mutations immediately under RequireVerifiedEmail
(Wave 3a) — no session-row rewrite needed. Test covers verify → same-session
mutation succeeds.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 03:32:48 -04:00
xarmian 31053086ee feat(server): RequireVerifiedEmail enforcement across all mutation perimeters (TASK-1937) (#807)
PLAN-1933 DR-4 (Wave 3a). Enforce, on Pad Cloud only, that an
authenticated-but-unverified user cannot mutate content or mint
credentials. Cloud-only + unauthenticated + verified are all no-ops, so
this is inert in production until Wave 3b starts creating unverified
users; the tests drive the state directly via the store's explicit
UserCreate.Unverified control.

Core rule: block only when cloudMode && currentUser != nil &&
!IsEmailVerified(), on mutating methods (POST/PATCH/PUT/DELETE). Returns
403 email_not_verified. The middleware does NOT inherit CSRFProtect's /
RequireAuth's blanket /api/v1/auth/* exemption — it decides for itself.

Perimeters gated (systematic DR-4 audit — one test each):
- /api/v1 core writes (session AND PAT) — RequireVerifiedEmail method
  gate mounted after RequireAuth (server.go).
- Authenticated /auth/* mutations — token create/rotate/delete, PATCH
  /me, 2FA setup/disable, OAuth link/unlink, and cli-session approve —
  all fall through the method gate (no auth exemption); logout,
  verify-email, resend-verification, delete-account allowlisted.
- Collab WS GET-upgrade — authorizeCollabAccess (a GET the method gate
  can't catch; it persists Yjs edits).
- Remote MCP write path — dispatcher RequireVerifiedEmail hook fired in
  buildAuthedRequest (the single chokepoint every synthesized write
  passes through), wired in cmd/pad/main.go.
- OAuth-provider authorize + authorize/decide — emailUnverifiedBlocked
  checks (mounted outside /api/v1; decide mints the auth code).
- POST /api/v1/import/url — SSRF/abuse surface, method gate.

Carve-outs: POST /api/v1/invitations/{code}/accept stays open for
unverified invitees (DR-1); legacy no-user workspace PATs are
intentionally ungated (currentUser==nil). Self-host (!cloudMode) is a
full no-op.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 02:06:58 -04:00
xarmian b0eeef16ce feat(store): email_verification_tokens + SendEmailVerification + token reaper (TASK-1936) (#806)
Wave 2 of PLAN-1933 — verification-token infrastructure (pure infra; no
endpoint consumes it until Wave 3).

- Migration 071 (SQLite) / 049 (Postgres): email_verification_tokens table,
  cloning the password_resets shape (id/user_id FK/token_hash/expires_at/
  used_at/created_at + token_hash + user_id indexes), per-dialect created_at
  default.
- Store email_verification.go: 256-bit crypto/rand token, padver_ prefix,
  SHA-256-at-rest, non-destructive Lookup, atomic UPDATE...RETURNING Consume.
  Deltas from password_resets (DR-2): 24h TTL, keep invalidate-prior-on-mint
  (resend burns the old link), consume side-effect sets users.email_verified_at
  (RFC3339-with-Z, same format Wave 1's migration used) in one transaction —
  no password reset, no session mint.
- Email SendEmailVerification: clones SendPasswordReset, "1 hour" -> "24 hours".
- Token reaper (DR-5): lifecycle-safe background sweep (mirrors orphanGC/opLogGC
  — self-registers on Server.bg, context-cancellable via stop channel, started
  only from cmd/pad/main.go so unit tests don't leak goroutines) calling the
  four previously-unwired CleanExpired* methods (email verifications, password
  resets, sessions, CLI auth sessions) hourly. Adds CleanExpiredEmailVerifications.
- Audit consts ActionEmailVerified + ActionEmailVerifiedByAdmin.

Gates: make check + make test-pg green (store + migration on both dialects).

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-04 01:19:41 -04:00
xarmian 111e43d27f fix(server): invitation-preview endpoint + read-only email prefill on /join (BUG-1934) (#803)
On Pad Cloud the /join/[code] page never showed the invited email, so a
mistyped address hit a confusing 403 invitation_email_mismatch. Add a
non-consuming, public, always-200, rate-limited preview endpoint and wire
the join page to prefill the invited email read-only.

- GET /api/v1/invitations/{code}/preview returns {found,email,workspace_name,
  has_account}. Reuses store.GetInvitationByCode (never accepts/consumes the
  invite). Invalid/expired/missing codes and dangling-workspace codes all
  return 200 {found:false} — no 404 status signal (enumeration safety). A
  genuine DB fault still 500s (code-independent, leaks nothing).
- Public/pre-auth: added to isPublicAPIPath (matches only the trailing
  /preview segment, so /accept stays auth-gated).
- Dedicated per-IP rate limiter (20/min, burst 20) wired into the RateLimit
  switch so the endpoint can't be used to enumerate invite codes.
- TS client: api.members.previewInvitation + InvitationPreview type.
- /join page calls preview on mount, prefills + locks the invited email, and
  defaults register-vs-login by has_account. Keeps the mode-switch affordance
  and BUG-1930's register default when preview is unavailable.
- Tests: non-consumption, has_account, always-200 on unknown code, rate limit.

Composes with BUG-1930 (register default). Wave 0 of PLAN-1933 / IDEA-1927 §B5.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-03 23:47:06 -04:00
xarmian ae62c097ca refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916) (#802)
* refactor(mcp): consolidate project next/standup/changelog onto REST endpoints (TASK-1916)

dispatchProjectNext/Standup/Changelog were a second server-side copy of
the next/standup/changelog reshaping contract, written before TASK-1894
shipped dedicated REST endpoints for the same data. Replace the ~200
lines of duplicate reshaping with thin proxies that validate workspace
(preserving the pad_set_workspace hint) and forward to
GET /next|/standup|/changelog, relying on packageHTTPResponse's existing
array-wrap (BUG-985) and the REST handlers' own days-default and
per-status best-effort semantics rather than replicating them.

dispatch_http_slice4.go is deleted; its unrelated dispatchLibraryActivate
moves to dispatch_http_library.go now that the file's other three
methods are gone. KEEP IN SYNC comments across
handlers_project_intel.go/server.go/tests collapse from "three
reproductions" to "CLI + REST, MCP proxies to REST."

* fix(server): pass nav-lenient visibleIDs to changelog's parent enrichment (codex R1 P1, TASK-1916)

handleGetProjectChangelog passed guestResourceFilter's narrowed collIDs
into enrichItemsWithParent instead of the nav-lenient visibleCollectionIDs
set handleListItems uses for the same enrichment call. For a guest whose
granted item's parent lives in an item-grant-only collection (nav-visible
but excluded from the narrowed full-access set), this silently dropped
the parent link fields, causing itemMatchesParentFilter to exclude the
item from ?parent= results even though the guest can otherwise see it.

The root cause predates TASK-1916 (introduced alongside the REST endpoint
in TASK-1894), but this consolidation imports it into MCP wire behavior
via dispatchProjectChangelog's proxy, so it's in scope to fix here.

projectIntelVisibility now returns the unnarrowed visibleCollectionIDs
result (navVisibleIDs) alongside the existing (collIDs, itemIDs) pair;
handleGetProjectChangelog uses navVisibleIDs for enrichItemsWithParent
while keeping collIDs for the list query, mirroring handleListItems'
pattern exactly. handleGetProjectStandup and handleGetProjectNext have no
parallel enrichItemsWithParent call (verified by reading both, and
buildDashboardResponse) so neither needed the same treatment.

Added TestProjectChangelogEndpoint_GuestParentFilter_ItemGrantOnlyCollection,
confirmed to fail against the pre-fix code and pass against the fix.
2026-07-03 22:29:40 -04:00
xarmian f3335adea2 fix(server): filter handleListUserGrants through caller visibility (BUG-1928) (#799)
handleListUserGrants returned a target user's raw collection/item grants
(including collection_id/item_id) to any workspace owner unconditionally,
letting a restricted owner (collection_access="specific") enumerate
hidden-resource IDs — the disclosure half of the primitive BUG-1923's
handlers closed the action half of.

Filter the response through the caller's visibility when caller != target:
collection grants against guestResourceFilter's strict full-access set
(same set requireCollectionFullyVisible narrows to — item-grant-only
collections don't qualify), item grants via a bulk item_id->collection_id
lookup (GetItemCollectionRefs, state-agnostic so soft-deleted parents stay
listed) plus the existing isItemVisibleToGuest set-membership check.
Self-queries and unrestricted callers stay unfiltered, the latter via a
cheap short-circuit.

GetDeletedItemsWithCollection's query had no deleted_at filter despite its
name; renamed the shared implementation to GetItemCollectionRefs and kept
the old name as a wrapper for its existing delta-sync caller.
2026-07-03 20:18:29 -04:00
xarmian d9b9dd9499 fix(server): gate share-link and grant minting on item/collection visibility (BUG-1920) (#794)
* fix(server): gate share-link and grant minting/listing on item/collection visibility (BUG-1920)

A workspace-role "owner" can be independently restricted via
collection_access="specific" (handleSetMemberCollectionAccess has no
role exclusion), but handleCreateItemShareLink, handleListItemShareLinks,
handleListItemGrants, and handleCreateItemGrant gated only on
requireMinRole("owner") with no visibility check afterward — letting a
restricted owner (any auth class) mint a public share-link token or a
grant for an item in a collection hidden from them, an exfiltration path
since share links are public-read. The collection-level twins
(handleCreateCollectionShareLink, handleListCollectionShareLinks,
handleListCollectionGrants, handleCreateCollectionGrant) had the same gap.

Adds requireItemVisible (existing, bearer-aware post BUG-1917/1918) to
the four item-resolving handlers, and a new requireCollectionVisible
helper (mirroring handleGetCollection's visibleCollectionIDs +
isCollectionVisible idiom) to the four collection-resolving handlers.
Restricted owners (session or bearer) now get 404 minting/listing
share-links or grants for hidden items/collections; unrestricted owners
and non-owners (403 via the pre-existing requireMinRole gate) are
unaffected.

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

* fix(server): require full-collection access for share-link/grant minting (BUG-1920 R2)

Codex R2 caught a gap in the collection-level half of the previous
commit: VisibleCollectionIDs (used by requireCollectionVisible) folds in
collections that are visible ONLY via an item-level grant, "so the
collection appears in navigation" — intentional for handleGetCollection,
but it let a restricted owner holding nothing more than an item grant on
one item inside a hidden collection mint/list a share link or grant for
the ENTIRE collection.

Renames requireCollectionVisible to requireCollectionFullyVisible and,
mirroring reportVisibleCollections' fullCollIDs narrowing
(handlers_reports.go), restricts the acceptable set to full-collection-
access collections (collection grants + member_collection_access +
system collections) whenever the caller holds any item-level grants —
an item-grant-only collection no longer qualifies for collection-wide
minting/listing. handleGetCollection is untouched; its nav-lenient
check is intentional for metadata viewing. Item-level requireItemVisible
call sites are unchanged — an item grant legitimately entitles the
holder to act on that item.

Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
2026-07-03 16:25:52 -04:00
xarmian da21598f10 fix(server): bearer-gate checkItemVisible's admin bypass (BUG-1918) (#793)
Bearer-authed platform admins who are restricted workspace members can
no longer read, update, delete, or export a hidden collection's item by
direct ref — checkItemVisible's unconditional admin bypass previously
ignored isBearerAuth entirely, letting a bearer admin sidestep
BUG-1917's list-level scoping for anyone who could guess a ref. Cookie
session admins keep the existing unrestricted web-UI affordance.

checkItemVisible gains an isBearer parameter (mirroring the existing
authIsBearer idiom in resolverWorkspaceRole / guestResourceFilterCore)
and gates its admin bypass on !isBearer. requireItemVisible's own
signature is unchanged, so its ~20 call sites (comments, links, stars,
versions, timeline, playbooks, backlinks, storage, artifact-export)
inherit the fix for free; the three direct checkItemVisible callers
(writeItemResolveError, handleBulkItems, resolverItemVisible) are
updated explicitly.
2026-07-03 15:31:17 -04:00
xarmian a7f6fdf099 fix(server): bearer-gate visibleCollectionIDs to close BUG-1917 (#792)
Bearer-authed platform admins (PAT/CLI/OAuth) who are restricted
members of a workspace were unrestricted on dashboard, bootstrap,
items, and graph reads (plus item creation) — the last remaining
gap in the BUG-1616/1617 pattern, where RequireWorkspaceAccess
already suppresses the admin bypass for bearer auth everywhere
else. visibleCollectionIDs now applies the same `!isBearerAuth(r)`
gate as reportVisibleCollections, so a bearer admin who is only a
scoped member is correctly restricted to their membership; a
cookie-session admin keeps the existing unrestricted web UI
affordance. Because this is the shared helper, every consumer
(buildDashboardResponse -> /dashboard and /bootstrap, handleListItems,
handleGetWorkspaceGraph, handleCreateItem's collection check, and
~25 other call sites) is fixed at once.

This also completes TASK-1894's known asymmetry: standup's
blockers/suggested_next sections (sourced from buildDashboardResponse)
are now scoped for bearer admins just like its completed/in_progress
sections already were. Folds the now-redundant
bearerAwareVisibleCollectionIDs into the shared gate.

Claude-Session: https://claude.ai/code/session_01CL1pBjNpPUX6SWkuAuYXHS
2026-07-03 14:40:54 -04:00
xarmian 7c0b13767f feat(server): REST endpoints for project next/standup/changelog + WebMCP wiring (TASK-1894) (#791)
* feat(server): add REST endpoints for project next/standup/changelog

Adds GET /workspaces/{ws}/next, /standup, /changelog — session-authed
reads mirroring `pad project next|standup|changelog --format json`,
reusing buildDashboardResponse + store.ListItems so the browser
WebMCP surface stops returning "not available" for these catalog
actions (TASK-1894). Cross-references the MCP HTTP transport's
existing dispatchProjectNext/Standup/Changelog (dispatch_http_slice4.go)
with KEEP IN SYNC comments at both sites, since this is now a third
reproduction of the same reshaping contract pending a follow-up
consolidation.

* feat(web): wire next/standup/changelog into WebMCP dispatch + api client

Adds client.ts next()/standup()/changelog() methods and replaces the
three "not available in the browser" dispatch.ts stubs with real
handlers now that the backend endpoints exist (TASK-1894). Extracts
DashboardSuggestion as a shared type and adds StandupResponse /
ChangelogResponse types mirroring the Go response shapes.

* fix(server): make projectIntelVisibility bearer-aware (TASK-1894 codex R1)

standup/changelog's own item-list scoping used visibleCollectionIDs, which
has no bearer gate: a platform admin authenticated via a bearer token
(PAT/CLI/OAuth) who is only a restricted member of a workspace got the
unrestricted admin view instead of being scoped to their real membership.
Adds bearerAwareVisibleCollectionIDs, mirroring reportVisibleCollections'
existing BUG-1616/1617 gate, and switches projectIntelVisibility onto it
while preserving its item-level grant handling (which reportVisibleCollections
deliberately drops for aggregate reports).

buildDashboardResponse (and therefore /next, and standup's blockers/
suggested_next sections) is intentionally left ungated in this change —
gating it would break next's parity with dashboard.suggested_next and
diverge it from the CLI and MCP siblings. The resulting asymmetry is
documented inline pending a follow-up fix to buildDashboardResponse itself.

* docs(server): reference BUG-1917 in projectIntelVisibility comments

Replaces the textual placeholder ("the visibleCollectionIDs bearer-gate
bug filed from TASK-1894 review") with the actual bug number now that
it's been filed. Comment-only change, no behavior difference.
2026-07-03 13:51:04 -04:00
xarmian 7e5917056a fix(admin): don't persist masked Maileroo API key on email settings save (BUG-1890) (#778)
* fix(admin): don't persist masked Maileroo API key on email settings save (BUG-1890)

The admin settings "Save Email Settings" button PATCHed the whole
platformSettings object. GET /admin/settings returns the Maileroo key
masked (abcd...wxyz for >8 chars, **** otherwise), so saving without
re-typing the key persisted the mask over the real key — silently
breaking email until re-entered.

Two layers:

- Client (+page.svelte): track whether the API-key field was edited
  (apiKeyEdited flag) and scope the PATCH to the email fields this form
  owns (mirrors the TASK-1889 Integrations save). The key is included
  only when the admin actually edited it; an untouched save preserves
  the stored key, and clearing the field still sends "" to disable.

- Server (handlers_admin.go): extract maskAPIKey() as the single source
  of truth for the mask format and skip persisting maileroo_api_key when
  the incoming non-empty value equals the mask of the currently-stored
  key. Best-effort backstop for non-web/old clients; the client fix is
  authoritative.

Tests (handlers_admin_settings_test.go): maskAPIKey unit cases, the
masked-key-not-persisted regression (both long and **** short masks),
real-key-update-wins, and empty-key-clears.

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

* fix(admin): clear Maileroo key when disabling email provider (BUG-1890)

Codex review of the scoped email-save payload found a regression: when
an admin selects Provider "None" without touching the key field, the
scoped payload omitted maileroo_api_key, leaving the stored key. Because
reconfigureEmail keys email enablement off the presence of the API key
and ignores email_provider, "None" no longer disabled email.

Send an explicit empty key whenever the provider isn't Maileroo, so
disabling actually turns email off. The masked-key guard still applies
when the provider is Maileroo and the key was left untouched.

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

* fix(server): tear down live email sender when platform key is cleared (BUG-1890)

Codex review: clearing the Maileroo key (e.g. disabling via provider
"None") wrote the empty key to the DB, but reconfigureEmail's empty-key
branch returned early without clearing the in-memory s.email sender —
so the running process kept sending mail until restart, contradicting
the UI's "disabled" state.

Track whether email was wired from env vars (emailEnvConfigured, set in
SetEmailSender). When platform settings carry no key, reconfigureEmail
now tears down the live sender (s.email = nil, emailAPIKey = "") unless
env config exists — env is the deployment baseline the admin UI doesn't
disable. Tests pin both the teardown and the env-preserved paths.

Claude-Session: https://claude.ai/code/session_01HxBkAMiFBtCRJ2tKSCt3ST
2026-07-01 17:27:00 -04:00
xarmian e4caad2c64 feat(server): expose MCP tool-surface over authed REST endpoint (#764)
Add GET /api/v1/mcp/tool-surface, a session/token-authenticated
same-origin endpoint that serves the MCP catalog descriptor JSON
(the nine env.Catalog tools, their actions, and input schemas) with a
new per-action read_only bool. Backs the Phase 3 browser-side WebMCP
layer (PLAN-1888): the client fetches once and derives readOnlyHint
from the read_only flags without re-deriving the read set in TS.

Wired via the SetMCPTransport injection pattern to avoid the import
cycle: internal/mcp already imports internal/server (dispatch_http.go),
so internal/server cannot import internal/mcp. internal/mcp exports a
cycle-free ToolSurfaceJSON() that builds from the package-global
Catalog plus a co-located readOnlyActions allowlist; cmd/pad/main.go
(which imports both) injects it via Server.SetToolSurfaceHandler before
setupRouter. The route mounts in the authed API group so it inherits
TokenAuth/SessionAuth/CSRFProtect/RequireAuth — NOT the bearer-gated
/mcp infra path — and is available on both cloud and self-host.

The existing actionMetaToolSurface (pad_meta action=tool-surface) now
shares the same serializer, so MCP and REST can't drift; it gains the
additive read_only flag too. No ToolSurfaceVersion bump (DR-7): adding
read_only is additive metadata; names/actions/params are unchanged.

Refs TASK-1891 / PLAN-1888

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-24 22:45:32 -04:00
xarmian 616a6d2a0a feat(auth): localhost password recovery for locked-out self-host admins (#760)
Add a loopback-only account-recovery path so a self-hosted operator who
forgot their password (with no email provider configured) can recover
without editing the database by hand.

- POST /api/v1/auth/local-reset: loopback-gated, non-cloud, no auth
  required (same trust model as bootstrap). Returns a single-use reset
  link, or a temporary password with {"temp_password": true}.
- pad auth reset-password <email> [--temp-password]: talks to the local
  server over loopback directly (not the configured public URL), so the
  command works on the server host regardless of CLI config. Prints the
  server's shareable reset_url when a public base URL is known.
- Web /forgot-password reads email_configured from the session and shows
  host-recovery instructions instead of a dead "we emailed you a link"
  when no provider is configured.
- forgot-password server log emits the reset path on non-cloud instances
  so operators can also recover straight from the logs.
- Docs: CLAUDE.md + docs/deployment.md recovery sections.

Tests cover the loopback/cloud gates, the shareable reset_url, and both
output modes (reset link + temp password).
2026-06-22 20:58:50 -04:00
xarmian 4f0984bb15 feat(artifact): server export + import endpoints for playbooks & conventions (#755)
* feat(artifact): server export + import endpoints for playbooks & conventions

Phase 2 of PLAN-1867. Adds:
- GET  /workspaces/{ws}/items/{ref}/export  — item-visibility-gated; encodes a
  playbook/convention item to a Markdown+frontmatter artifact.
- POST /workspaces/{ws}/import-artifact      — editor-gated; byte-capped +
  YAML-bomb-guarded parse, forgiving preprocess (foreign selects blanked,
  invocation_slug de-collided, status forced draft), creates via the shared
  create path.
- Extracts createItemChecked from handleCreateItem so import inherits
  validation / uniqueness / edit-perm / side-effects (no direct store.CreateItem).
- PAD_IMPORT_ARTIFACT_MAX_BYTES env override.

Server validation, coercion, and YAML input limits land at the HTTP boundary
per DR-4/DR-7/DR-8 and the Codex P2 notes (collSlug via shared helper,
item-visibility export auth, byte-cap→node-walk→decode ordering).

Implements TASK-1871, TASK-1872, TASK-1873, TASK-1874.

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

* fix(artifact): enforce item quota + require title on artifact import

Addresses Codex Phase-2 review:
- P1: handleImportArtifact now calls enforcePlanLimit(items_per_workspace)
  before create, matching handleCreateItem — imports can't exceed the plan cap.
- P2: reject empty/whitespace-only artifact titles with 400 (Title is required),
  matching the normal create path.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-22 11:43:52 -04:00
xarmian 35cc26daaf fix(web): collection cards show real child-item progress; child-progress endpoint (BUG-1509) (#710)
* BUG-1509: show real child-item progress on non-plan collection cards

Backend: extract collectionChildrenProgress helper from handlePlansProgress
and expose it at GET /collections/{collSlug}/child-progress with identical
visibility/guest-grant filtering. handlePlansProgress refactored to delegate
to the shared helper (no duplication). Route registered in the existing
/{collSlug} subrouter alongside checkbox-progress.

Frontend: +page.svelte fetches child-progress + checkbox-progress in parallel
for non-plans collections; per-item merge prefers child-progress (label
"tasks") when total>0, falls back to checkbox counts (label "done"). ItemCard
extended to render progress.label when present. ChildItems.svelte render gate
fixed to include error state so a failed /children fetch surfaces instead of
silently vanishing.

Tests: TestCollectionChildProgress covers happy path (linked children counted
correctly), zero-children items (present with total=0), 404 for unknown
collection, and restricted-member visibility gate (empty response for hidden
collection, not a data leak).

* fix: include_archived on child-progress and progressLabel desync (codex r2)

P1: GetAllItemProgress now accepts includeArchived bool; the parent-row
filter (AND p.deleted_at IS NULL) is conditioned on it, mirroring
CollectionCheckboxProgress. handleCollectionChildrenProgress reads
?include_archived=true and threads it through. handlePlansProgress
hardcodes false — no contract change there. collectionChildProgress()
client method gains opts?: { includeArchived? } with qs() serialisation.
Both call sites in +page.svelte (loadCollection and refreshProgress) now
pass includeArchived to the child-progress fetch.

P2: refreshProgress plans branch now sets progressLabel = 'tasks' so a
sync-triggered refresh after a failed initial plans load renders with the
correct label. progressLabel = 'done' moved inside the non-plans try block
(symmetric with plans) so a thrown fetch leaves the label in whatever
state the previous collection set, not silently desync'd.

Tests: TestCollectionChildProgress extended — archives parentA, confirms
it drops from default response and reappears with include_archived=true.

* fix: thread includeArchived through childrenDoneFiltersForCollection (codex r3)

GetAllItemProgress conditionally drops the p.deleted_at IS NULL parent
filter when includeArchived=true, but the filter-discovery call at the
top of the function — childrenDoneFiltersForCollection — still had the
filter hardcoded. If a child collection's only parent links pointed to
archived parents, that collection was absent from the done-semantics map,
and those children fell back to default status terminals rather than the
collection's configured done field — producing wrong done counts.

Fix: childrenDoneFiltersForCollection gains an includeArchived bool param;
the JOIN on items p conditions p.deleted_at IS NULL on it, exactly mirroring
the main query. GetAllItemProgress passes includeArchived through. The only
other caller of this helper (GetItemProgress via childrenDoneFiltersForParent)
is unaffected — that path is a separate function and never surfaces archived
parents.

Test: TestCollectionChildProgress extended with a "Widgets" collection whose
done field is `state` (terminal: "shipped") — not the default `status` field.
An archived task parent links two widget children (one shipped, one open); no
live task parent links into widgets, so the filter-discovery bug would drop
the collection from the map and produce done=0. The test asserts done=1 and
was verified to fail on the pre-fix code.
2026-06-06 11:14:23 -04:00
xarmian 93220845a0 feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731) (#699)
* feat(server): workspace graph endpoint — nodes + typed edges (TASK-1731)

GET /api/v1/workspaces/{ws}/graph returns the whole workspace as
{nodes, edges} in one call, feeding the 3D graph view (PLAN-1730).
Nodes carry ref/title/collection/status/is_terminal/child_count/
updated_at; edges are typed (parent | blocks | implements | related |
wiki-link), with wiki-link edges sourced from the PLAN-1593 reverse
index, deduped per pair, self-links dropped.

Default response is active items only; ?include_terminal=true returns
the full history. Visibility follows the dashboard model (collection
visibility + guest item-level grants), and edges are filtered to the
visible node set so hidden items can't be inferred from dangling
endpoints.

Parent: PLAN-1730.

* fix(server): normalize graph edge types to advertised vocabulary per Codex review (round 1)

item_links can carry split_from / supersedes / wiki_link beyond the
documented enum. Map stored types to the hyphenated graph vocabulary
(wiki_link → wiki-link, split_from → split-from), dedupe (source,
target, type) so a stored wiki_link row and a parsed [[...]] mention
of the same pair emit once, and document the full edge enum. Unknown
future link types pass through rather than being dropped.

* fix(store): close graph edge enum against unknown link types per Codex review (round 2)

Route stored link types through models.NormalizeItemLinkType; values
it rejects (possible via the import path — no DB CHECK on
item_links.link_type) degrade to 'related' instead of leaking
undocumented edge types past the advertised vocabulary.
2026-06-05 18:12:56 -04:00
xarmian 72d8963c4c fix(timeline): resolve collab-snapshot diffs + collapse autosave bursts (BUG-1612) (#691)
Item timelines showed two collab-snapshot problems:

1. Artifacts: the timeline endpoint (ListItemVersionsBeforeTime) served
   diff versions unresolved, so TimelineVersionCard fed raw diff-match-patch
   patch text into DiffView. Add GET /items/{slug}/versions/{versionID}
   (handleGetItemVersion -> Store.GetItemVersionResolved) and have the card
   lazily fetch resolved content the first time a diff version is expanded.

2. Clutter: every ~5s web-editor autosave flushes a collab-snapshot version.
   buildTimeline now collapses uninterrupted collab-snapshot bursts (within
   10 min, no intervening event) to their newest entry, and the source badge
   renders as "Autosave" instead of the raw slug.

Adds TestCollapseAutosaveBursts. Known limitation (accepted): collapse is
page-local, so a 150+ cross-actor autosave chain can leak one row per
"Load more" page — gated by the 1h version throttle, degrades gracefully.
2026-06-01 21:25:42 -04:00
xarmian dfd3811eee feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668) (#669)
* feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668)

Add POST /workspaces/{ws}/items/bulk accepting item IDs + a verb
(archive, move, tag, untag, set-priority, assign). The lane-header
bulk actions operate on a whole filtered lane, so the endpoint emits
ONE items_bulk_updated SSE event and ONE item.bulk_updated webhook for
the batch instead of per-item fan-out.

Reuses the existing store paths (UpdateItemWithPreCheck / MoveItem /
DeleteItem) rather than re-implementing writes; the open-children
guard runs per status-bearing move exactly as the single PATCH path
does (force-overridable). Per-row failures are collected into the
response envelope (updated/failed/total) rather than aborting the
batch. Editor/owner gated.

Frontend client + TS types follow in TASK-1669; UI wiring in TASK-1672.

Parent: PLAN-1667.

* fix(api): per-item visibility + collection-move guard on bulk endpoint per Codex review (round 1)

- Enforce per-item collection visibility (checkItemVisible) in the bulk
  loop so a member with collection_access="specific" can't bulk-mutate
  items in hidden collections by guessing refs; report invisible rows as
  not-found. Also gate the move target collection on visibility.
- Route bulk collection moves through MoveItemWithPreCheck with the
  open-children guard (destination schema), closing the bypass where a
  collection move + terminal status could mark a parent terminal with
  open children. Status-only moves already ran the guard.
- Tests: status-move + collection-move guard coverage (reject + force
  override + mutation-safety).

* fix(web): consume items_bulk_updated SSE event per Codex review (round 2)

The bulk endpoint emits one items_bulk_updated event, but the SSE
service only listened for the fixed ITEM_EVENTS list — so a bulk
mutation left other tabs/sessions stale until an unrelated sync fired.
Route the batch event through the existing sync_required path: it
carries item_ids + a max seq but no per-item field payload, so an
incremental /items-changes delta reconciles every affected row by seq.
Broadcast so peer tabs reconcile too.

* fix(api): scope bulk SSE event per-collection, drop item_ids per Codex review (round 3)

The batch event published with an empty Collection, which the SSE
filter treats as workspace-level: restricted members received bulk
events for hidden collections (leaking item_ids/op/count) while guests
with grants were dropped entirely and stayed stale.

Emit one items_bulk_updated event per affected collection with
Collection set, so the existing visibility filter routes it like any
collection-scoped event. Drop per-item IDs from the SSE payload — a
batch can't be item-grant-filtered for guests on a broadcast bus, so
IDs would leak; recipients reconcile via the /items-changes delta,
which is visibility-filtered server-side (Seq carries the cursor). The
webhook (a trusted workspace integration) keeps the full id list.

Test asserts the event is collection-scoped and carries no item_ids.

* fix(api): bulk collection move notifies both source and target scopes per Codex review (round 4)

A cross-collection move only emitted a batch event for the target
collection, so a restricted member watching the source lane wouldn't
reconcile the item leaving it. Notify both the source and target
collection scopes for moves (still no per-item IDs). Test asserts both
events fire.

* fix(api): suppress itemless batch SSE events for item-grant-only subscribers per Codex review (round 5)

A guest/restricted member with only item-level grants in a collection
could still receive the collection-scoped items_bulk_updated event
(itemless), learning op/count/timing for items they can't see. Extract
the SSE visibility filter into sseEventVisibleFor and add a rule:
itemless collection-scoped events go only to subscribers with FULL
collection access; item-grant-only subscribers reconcile their granted
items via the next resume/reconnect /items-changes sync instead.

Adds a unit test covering the visibility matrix.

* fix(api): validate status override against target schema on bulk collection move per Codex review (round 6)

A status override on a collection move was applied after MigrateFields
but never validated against the target schema, so an out-of-options
value (e.g. status=bogus) could be written. Run ValidateFields on the
final field map before the move. Test asserts the invalid value is
rejected per-row and the item stays put.
2026-05-30 18:41:55 -04:00
xarmian 076fb9b2e7 feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663) (#665)
* feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663)

Foundation for comment editing (PLAN-1662). No migration — comments.user_id
already exists (012_users.sql) but was never written or exposed.

- Populate user_id on create/reply: CreateComment takes an explicit userID
  param (passed from currentUserID by the handlers, not via the request body
  so it can't be spoofed). Expose user_id on models.Comment + all comment
  SELECTs/scans. The workspace export path is left as-is — imported comments
  keep NULL user_id (admin-only edit), matching the pre-identity fallback.
- Store.UpdateComment(id, body): replaces body + bumps updated_at; the
  comments_fts_update trigger re-indexes.
- PATCH /workspaces/{ws}/comments/{commentID}: author-or-admin only
  (canEditComment), rejects empty body. Editing is an authorship op, distinct
  from delete (item editors). NULL user_id → admin-only.
- comment_updated SSE event: broadcast from the handler; added to the web
  sse allowlist + ItemTimeline refresh set.
- web: api.comments.update(), Comment.user_id type.

Tests: author edits own (200), non-author non-admin (403), admin edits
anyone (200), empty body (400), NULL-user_id comment is admin-only.

Parent: PLAN-1662.

* fix(account): detach authored comments on account deletion per Codex review (round 1)

Now that TASK-1663 populates comments.user_id (FK to users.id),
DeleteAccountAtomic would fail on the FK for any user who authored a
comment. Null comments.user_id for the user before deleting the row —
comments live on in soft-deleted/other workspaces; the display-name
author is preserved and the comment just becomes admin-only to edit.
Regression test added.
2026-05-30 12:38:57 -04:00
xarmian 1b1068537c feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653) (#658)
* feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653)

Foundation for the tags feature (PLAN-1652 / IDEA-1649). The write path and
per-collection ?tag= filter already existed; this adds tag enumeration and a
verified cross-collection read so a single tag can group items of any type.

- store: dialect.JSONArrayElements unnests a JSON text-array column
  (json_each on SQLite, jsonb_array_elements_text on Postgres);
  Store.ListWorkspaceTags returns distinct tags + item counts, ordered by
  count desc then tag asc, with the same collection/item ACL filters as
  ListItems so counts never leak hidden items.
- server: GET /workspaces/{ws}/tags (handleListTags), respecting collection
  visibility + guest item grants.
- models: TagCount{tag,count}.
- cli: client.ListTags + `pad tag list`.
- web: api.tags.list + TagCount type (items.list already forwards `tag`).
- tests: store-level (cross-collection aggregation, collection scoping,
  non-nil-empty = empty, archived excluded) and handler-level (a Task + an
  Idea sharing one tag; GET /tags counts + ordering).

Parent: PLAN-1652.

* fix(tags): count distinct items per tag, not tag occurrences per Codex review (round 1)

COUNT(DISTINCT i.id) so an item with duplicate tags (e.g. ["ux","ux"]) is
counted once — the write path doesn't enforce per-item tag uniqueness.
Adds a regression test.
2026-05-29 23:43:02 -04:00
xarmian eeff78118b feat(insights): per-user layout customization + persistence (TASK-1634) (#645)
* feat(insights): per-user layout customization + persistence (TASK-1634)

Let users personalize the Insights surface, persisted per-user per-workspace:
toggle which metric cards show, and remember the window + collection filter.

Backend:
- migrations 064/043: user_report_layouts (user_id, workspace_id, config JSON,
  PK(user_id,workspace_id), ON DELETE CASCADE) — dual-dialect.
- models.ReportLayout (hidden_cards/default_window/default_collections) +
  ReportCardIDs/ValidReportWindow validation.
- store.GetReportLayout / SaveReportLayout (ON CONFLICT upsert, both dialects).
- GET/PUT /workspaces/{ws}/report/layout — per-user; PUT sanitizes window +
  filters hidden_cards to the known card set. web client + TS type.

Frontend (Insights page):
- loads the saved layout, hydrates window/collections/hidden cards
- a "Customize" panel toggles each card (SvelteSet-backed); each section gated
  on !hiddenCards.has(id); Totals always shown
- debounced auto-save, gated on a per-workspace `hydrated` flag so it never
  saves during load or stomps another workspace's layout on switch

Single config per user (no named/multiple layouts — deliberate v1 scope).
Parent: PLAN-1628.

* fix(insights): save layout only on explicit user changes, not on load per Codex review (round 1)

The auto-save $effect ran once after hydration (loadLayout assigns reactive
state, then flips hydrated=true), firing a PUT /report/layout on mere page
view — which 401s on no-user/legacy-token sessions and bounces the user to
/login. Replace the effect with a scheduleSave() called only from explicit
handlers (toggleCard, selectWindow, toggleCollection, clearCollectionFilter);
hydration never saves. Also capture wsSlug at schedule time and drop the
pending save if the workspace changes mid-debounce, so A's edit can't land
on B.
2026-05-29 15:00:34 -04:00
xarmian a1d09c90df feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630) (#638)
* feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630)

GET /workspaces/{ws}/report?window=week&collections=tasks,bugs returns a
time-bucketed report: created-vs-completed throughput, net flow,
completed-by-collection, and a current status-distribution snapshot.

- Dialect.DateBucket(column, granularity) — day/hour bucketing via fixed-width
  substring on the UTC RFC3339 TEXT (identical + exact on SQLite + Postgres;
  avoids SQLite 'Z'-parsing fragility). Routes all report date math through it.
- store.GetReport: resolves per-collection done field + positive terminals
  (terminal options minus rejected/cancelled/etc.), counts completions from
  status_transitions and created from items.created_at, zero-fills buckets.
- HTTP handler + route; web ReportData type + api.report.get client.
- Tests: throughput/totals, negative-terminal exclusion, status distribution,
  collection filter, non-status done-field, out-of-window exclusion, hourly
  day-window, DateBucket per granularity. Dual-dialect via testStore.

Fixes the response contract that TASK-1632/1633/1635 consume (noted on them).
Parent: PLAN-1628.

* fix(report): scope report to caller's visible collections per Codex review (round 1)

The endpoint sits under RequireWorkspaceAccess (members, restricted members,
guests), but GetReport resolved ALL workspace collections — letting a caller
with access to one collection infer hidden collections' slugs, created/
completed counts, and status distribution. Mirror the dashboard: the handler
computes visibleCollectionIDs() and GetReport restricts to that set
(ScopeToVisible). Empty visible set → empty report. Aggregate reports are a
full-collection-visibility feature; item-level grants aren't surfaced in
workspace-wide counts.

* fix(report): correct visibility scoping for all-access + item-grant callers per Codex review (round 2)

Round 1's scoping had two bugs in how it read visibleCollectionIDs:
1. nil means "all-access" (admin / collection_access=all), but the handler
   treated nil as an empty visible set → all-access users got an EMPTY report.
   Now nil → ScopeToVisible stays false (full workspace report).
2. For guests, visibleCollectionIDs includes collections visible only via
   item-level grants; passing those to the aggregate report leaked the whole
   collection's counts. Now mirror the dashboard: when item-level grants are
   present, scope to fullCollIDs (full-access collections only).

Adds report handler tests (owner full report + default window) alongside the
store-level scoping test.

* fix(report): bearer-aware admin visibility scoping per Codex review (round 3)

visibleCollectionIDs grants ANY platform admin an unrestricted (nil) view, but
RequireWorkspaceAccess suppresses the platform-admin bypass for bearer auth and
falls through to membership (BUG-1616/1617). So a bearer admin (PAT/CLI/OAuth)
who is only a restricted workspace member could read the full workspace report.

Extract reportVisibleCollections(): gate the admin bypass on cookie auth; for
everyone else resolve actual member/guest visibility, and when item-level
grants exist scope to the full-access collection set only. Adds a cookie-vs-
bearer scoping test (cookie admin unrestricted, bearer restricted-member scoped
to the granted collection, end-to-end through GetReport).

* fix(report): exclude soft-deleted items from completion counts per Codex review (round 4)

status_transitions rows survive a soft delete (only a HARD delete cascades
them), so a completed-then-soft-deleted item still counted toward completed /
completed_by_collection while created and status_distribution (which filter
deleted_at IS NULL) excluded it — inconsistent totals. Join live items in both
completed queries. Adds a regression test.
2026-05-29 08:24:05 -04:00
xarmian 83716a65fc fix(backlinks): scope cross-workspace admin enumeration to membership for bearer auth (BUG-1617) (#633)
Companion to BUG-1616. The admin platform role granted unrestricted
cross-workspace visibility at the STORE layer too: `GetCrossWorkspaceBacklinks`
ran `ListWorkspaces()` for any user with `Role=admin`, and
`ResolveBacklinksVisibility` short-circuited to `(nil, nil)` for the
same role check. Both fired BEFORE the BUG-1616 middleware gate could
deny the request, so a bearer-borne admin (CLI / PAT / MCP) could
enumerate cross-workspace backlinks from every workspace on the server.

Policy: bearer-borne admin gets STRICT membership enumeration — no
guest-grants fallback. Matches RequireWorkspaceAccess's membership-only
stance from BUG-1616. Cookie-session admin keeps the global view
(preserved web-UI affordance).

Threads `authIsBearer bool` from the HTTP boundary (via the new
isBearerAuth helper) into the store layer:

- `Store.ResolveBacklinksVisibility` — admin bypass now gated on
  `!authIsBearer`; bearer-admin falls through to the regular
  member/grants pipeline. Also tightens the "no visibility" return
  shape from `(nil, nil)` to non-nil empty slices so callers can
  distinguish "unrestricted" from "explicit empty" — closes a
  latent ambiguity that doesn't fire in current callers but would
  if any future caller bypassed the upstream membership filter.
- `Store.GetCrossWorkspaceBacklinks` — new switch:
    - cookie admin       → ListWorkspaces (unchanged)
    - bearer admin       → GetUserMemberWorkspaces (NEW, strict
                            membership; no grants fallback)
    - non-admin          → GetUserWorkspaces (unchanged; memberships
                            ∪ guest-grant workspaces)
- `Server.guestResourceFilterCore` — admin short-circuit now gated
  on `!isBearerAuth(r)`; bearer-admin delegates to the store-side
  helper with the bearer signal threaded through.
- `handlers_backlinks.go` — pass `isBearerAuth(r)` to
  `GetCrossWorkspaceBacklinks`.

New `Store.GetUserMemberWorkspaces` helper — the first half of
`GetUserWorkspaces` without the UNION-with-grants block. Used by the
bearer-admin path; existing callers continue to use `GetUserWorkspaces`
unchanged.

Tests:

- `wiki_links_xws_test.go`:
  - Updated `TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces` to
    cover both `authIsBearer=false` (cookie, sees all) and
    `authIsBearer=true` (bearer, sees none) cases.
  - NEW `TestWikiLinks_CrossWorkspaceBearerAdminGrantOnlyWorkspaceFiltered`
    — bearer-admin with a guest grant on workspace C still gets ZERO
    cross-ws rows from C (Codex round-2 finding).
  - NEW `TestWikiLinks_CrossWorkspaceBearerAdminSeesMemberWorkspaces`
    — positive control: bearer-admin who IS a member sees the row.
  - Extended `TestResolveBacklinksVisibility_RoleMatrix` with two
    bearer-admin subtests (non-member workspace → empty; member
    workspace → unrestricted).
- NEW `handlers_backlinks_admin_bearer_test.go::TestCrossWorkspaceBacklinks_AdminBearer_OnlySeesMembershipWorkspaces`
  — full HTTP integration test, both cookie and PAT-bearer subtests.
- All existing callers updated to pass `false` for `authIsBearer`
  (preserves current cookie-session / non-admin behavior).

Verification: full `go test ./...` green; `make lint` clean;
Codex round 2 review CLEAN.

🤖 BUG-1617
2026-05-27 10:37:30 -04:00
xarmian 225fb4a53f Wire upgrade CTAs with Stripe-ready billing flow (TASK-800) (#629)
* feat(billing): add billing_available session flag gated on PAD_BILLING_AVAILABLE (TASK-800)

Add Server.billingAvailable field set by SetBillingAvailable(), called from
cmd/pad/main.go when PAD_BILLING_AVAILABLE=true|1. Expose the flag as
billing_available in both the setup-state and authenticated session payloads
(value: cloudMode && billingAvailable) so the web UI can gate Stripe CTAs
without a code change at deploy time. False by default.

* feat(billing): wire upgrade CTAs, checkout POST flow, plan section, clickable limit toasts (TASK-800)

Frontend prep work gated on authStore.billingAvailable (from billing_available
session field). When false, upgrade buttons remain hidden and the "coming soon"
note stays in place — flip PAD_BILLING_AVAILABLE=true at deploy time.

Changes:
- client.ts: add billing_available to AuthSession; add api.billing.createCheckoutSession()
  (POST /billing/checkout → parse {url} → caller does window.location.href)
- auth.svelte.ts: billingAvailable getter
- console/billing: replace STRIPE_AVAILABLE=false with $derived(authStore.billingAvailable);
  fix GET→POST on upgrade buttons; add ?checkout=cancelled banner; add cancelled style
- console/settings: new cloud-mode-gated "Plan" section with current plan + upgrade/manage link
- All 11 limit-hit sites: replace plain-text '/console/billing' appendage with
  toastStore.show(msg, 'error', 6000, '/console/billing') so the toast is clickable

* docs(billing): document pad-cloud CSRF and error-envelope contract divergences in createCheckoutSession (TASK-800)
2026-05-25 13:18:43 -04:00
xarmian 905876af04 feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b) (#622)
* feat(backlinks): cross-workspace wiki-links + request-independent ACL (Phase 2b)

Phase 2b of PLAN-1593 (TASK-1597). Completes the wiki-link reverse
index by indexing and surfacing `[[workspace::REF]]` cross-workspace
references. Builds on Phase 2a's title work (PR #621). Phase 3
(TASK-1596) owns the UI/MCP/CLI rendering changes.

What changed

- internal/store/backlinks_visibility.go (new): request-independent
  ACL helper `Store.ResolveBacklinksVisibility(userID, workspaceID,
  includeDeletedItems)`. Mirrors the role-determination + collection-
  merge logic from server.guestResourceFilterCore but doesn't depend
  on a request context, so cross-ws traversal can compute per-source-
  workspace ACLs without a `workspaceRole(r)` lookup. The Codex
  planning-round review caught the prior plan reusing the request-
  scoped helper as a hidden architectural cost; this is the resolution.

- internal/server/server.go: guestResourceFilterCore refactored to
  delegate to the new store helper. Keeps the request-scoped wrapper
  signature stable for all existing handler call sites; only the
  internals move.

- internal/links/extract.go: lift the Phase-2a workspace_ref emit
  gate. WikiLinkKindWorkspaceRef now flows through ExtractWikiLinks
  alongside ref and title kinds. parseBody recognition was already
  in place from earlier rounds.

- internal/store/wiki_links.go: WikiLinkKindWorkspaceRef branch in
  replaceWikiLinks stores (target_workspace_id, target_ref) verbatim,
  resolving the slug→ID via new resolveWorkspaceSlugTx (with per-call
  cache so repeated `[[ws::X]]` in one body don't re-query). Unknown
  slugs persist with target_workspace_id=NULL — broken-link
  semantics, identical to existing ref/title patterns.

- internal/store/wiki_links.go: new `Store.GetCrossWorkspaceBacklinks`
  enumerates accessible workspaces via Store.GetUserWorkspaces (which
  includes guest-only access — broader than membership query), then
  per-workspace computes visibility via ResolveBacklinksVisibility and
  runs the SQL backlinks query with the per-ws (FullCollectionIDs,
  GrantedItemIDs) predicate inline. Results sorted by updated_at DESC
  in Go, paginated globally. Per-workspace safety cap (offset+limit)
  prevents one workspace from dominating the global slice.

- internal/store/wiki_links.go: new `Store.CountBacklinks` for same-ws
  pagination boundary detection. Needed so the handler knows where
  the cross-ws tier begins for pages 2+.

- internal/models/backlink.go: new `SourceWorkspaceSlug string`
  (omitempty) field. Populated only by cross-ws rows; same-ws rows
  leave it empty so the existing wire shape is preserved.

- internal/server/handlers_backlinks.go: union pagination across
  same-ws and cross-ws tiers. Same-ws first (matches the renderer's
  UI mental model — your own workspace's links at the top of the
  panel). Count-based slice math handles pages 2+ correctly when
  same-ws is exhausted.

Tests

- internal/links/extract_test.go: workspace_ref forms emit correctly
  (bare, display alias, mixed case, invalid-slug fallback to title).
- internal/store/wiki_links_xws_test.go (new): six cross-ws scenarios
  plus a role-matrix test:
  - end-to-end cross-ws index + query
  - non-member sees nothing
  - guest with collection grant sees only that collection
  - guest with item grant sees only the granted item
  - unknown workspace slug → broken row, no query results
  - same-ws rows leave SourceWorkspaceSlug empty
  - ResolveBacklinksVisibility role matrix (admin/full member/guest
    with grants/non-member non-grant)

Out of scope (Phase 3 / TASK-1596)

UI rendering of cross-ws backlinks (workspace badge + workspace-
prefixed ref), MCP `pad_item.action: backlinks` cross-ws fields,
CLI display tweaks.

PLAN-1593 / TASK-1597.

* fix(backlinks): admin enumeration + cross-prefix ref fallback + unbounded perWsCap (Codex round 1)

Three P2 findings from Codex round 1 against PR #622:

Finding 1 — admin users miss cross-ws backlinks. `GetUserWorkspaces`
returns only memberships + grant-only guest workspaces, but
RequireWorkspaceAccess (middleware_auth.go:481) gives admins
implicit access to every workspace. An admin querying for backlinks
would silently miss links from workspaces they're not explicitly a
member of.

Fix: in GetCrossWorkspaceBacklinks, branch on user.Role:
  - admin → s.ListWorkspaces() (every non-deleted workspace)
  - non-admin → s.GetUserWorkspaces (memberships + grants)
Stale user IDs return empty result rather than erroring.

Finding 2 — cross-ws ref matching doesn't handle cross-prefix moves.
Same-ws is immune because target_item_id is resolved at parse time
and survives renames/moves; cross-ws resolves at query time, so a
`[[other-ws::OLD-42]]` row written before the target moved from
OLD→NEW collection wouldn't match a query under the NEW ref.

Fix: in queryCrossWorkspaceBacklinksForWorkspace, dual ref-match
clause: exact `LOWER(wl.target_ref) = LOWER(?)` OR
`LOWER(wl.target_ref) LIKE LOWER('%-N')` where N is the item_number
from the target ref. Pad prefixes are alphanumeric with no internal
`-`, so trailing `-N` uniquely identifies the number suffix — no
false positives like "TASK-142" matching "%-42" (LIKE anchors to
the trailing literal).

Finding 3 — per-workspace cap of 1000 silently broke pagination
beyond offset>=1000. The 1000 ceiling was defensive paranoia; the
correct math is offset+limit per workspace (worst case all rows
come from one workspace and the global slice still needs that
many).

Fix: drop the 1000 ceiling. perWsCap = offset+limit unconditionally.
For runaway offsets the per-workspace transfer cost is proportional;
documented as a known characteristic (callers shouldn't be paging
past offset=10000 anyway).

Regression tests:
- TestWikiLinks_CrossWorkspaceAdminSeesAllWorkspaces: admin sees
  cross-ws backlink without being a workspace member.
- TestWikiLinks_CrossWorkspaceRefNumberFallback: move target to new
  collection, query under new ref, old-ref-stored row still surfaces.

PLAN-1593 / TASK-1597.

* fix(backlinks): honor OAuth/MCP token workspace allow-list (Codex round 2)

Codex round 2 P1: cross-workspace backlinks bypassed the OAuth/MCP
token's workspace allow-list (TASK-952). A token consented for
workspace A but with the underlying user having access to B would
still surface source rows from B via the cross-ws query — leaking
data outside the token's consent scope.

Fix: thread `allowedWorkspaceSlugs []string` through
GetCrossWorkspaceBacklinks. Handler populates it from
TokenAllowedWorkspacesFromContext(r.Context()):

  - nil → no token gate (PAT or pre-TASK-952 token, allow all)
  - "*" wildcard → allow all
  - explicit list → strict slug membership

Workspace enumeration skips any source workspace whose slug isn't
in the allowlist. The same-ws path is unchanged because
RequireWorkspaceAccess already gated the target workspace against
the allow-list (so we only reach this handler when the target IS in
the list).

Regression test in wiki_links_xws_test.go covers four shapes: nil,
wildcard, target-only (blocks cross-ws), explicit source-workspace
(allows cross-ws).

PLAN-1593 / TASK-1597.

* fix(backlinks): normalize limit at handler boundary (Codex round 3)

Codex round 3 P2: the backlinks handler parsed ?limit=N but didn't
normalize it before computing the same-ws/cross-ws pagination
split. GetBacklinks and GetCrossWorkspaceBacklinks each clamp >300
internally, but the handler's 'remaining := limit - len(sameWs)'
used the original (potentially huge) value. With ?limit=301 and
more than 50 same-ws backlinks, the first page would mix cross-ws
in before same-ws was exhausted, violating the documented tier
order.

Fix: clamp 'limit' to <=300 at the handler boundary, before any
pagination math runs.

PLAN-1593 / TASK-1597.

* fix(backlinks): normalize same-workspace [[ws::REF]] to ref-kind (Codex round 4)

Codex round 4 P2: `[[<current-ws>::TASK-1]]` was being indexed as a
workspace_ref row with target_workspace_id = current workspace. But
the same-ws GetBacklinks query requires target_item_id (workspace_ref
rows leave it NULL), AND GetCrossWorkspaceBacklinks explicitly skips
the target workspace — so the link rendered and navigated correctly
in the UI but no backlink ever surfaced.

The renderer's L307 short-circuits same-workspace fully-qualified
form to behave identically to `[[REF]]`; the index must follow.

Fix: in replaceWikiLinks, normalize a workspace_ref link to ref-kind
when its slug resolves to the current workspace. The promotion
canonicalizes the ref (via new links.CanonicalizeRef exported alias)
so `[[ws::task-5]]` stores the same canonical shape as `[[TASK-5]]`.

Tests:
- TestWikiLinks_CrossWorkspaceSameWorkspaceQualifiedNormalized:
  same-ws fully-qualified `[[ws::REF]]` surfaces in same-ws backlinks
  and is absent from cross-ws backlinks.

PLAN-1593 / TASK-1597.

* fix(backlinks): same-ws qualified ref miss doesn't title-fallback (Codex round 5)

Codex round 5 P2: my round-4 normalization was too aggressive. It
promoted `[[<current-ws>::REF]]` to ref-kind and let the regular
ref branch handle it — including the title-fallback path that
runs on ref miss.

But the renderer's same-ws qualified branch (markdown.ts:472-481)
does NOT title-fallback: a ref miss in that path returns the
wiki-link verbatim (broken). Only the bare `[[REF]]` path
(markdown.ts:513) falls through to title lookup.

So my normalization could create ghost backlinks for source bodies
like `[[ws::ISO-9001]]` when an item titled "ISO-9001" exists but
no ISO collection — the renderer renders broken text, but the
index would point at the title-matching item.

Fix: handle same-ws qualified refs inline at the top of the loop,
BEFORE the switch dispatches. Insert as ref-kind row (resolved or
NULL) and `continue` past the switch. Bypasses the title-fallback
path entirely, mirroring the renderer's behavior.

Regression test in wiki_links_xws_test.go pairs same-ws qualified
miss (must NOT title-fallback) with bare ref miss (SHOULD
title-fallback) to lock the asymmetry in.

PLAN-1593 / TASK-1597.
2026-05-24 13:27:40 -04:00
xarmian 8e7d4040fd feat(backlinks): server-side reverse index for [[...]] (Phase 1) (#620)
* feat(backlinks): server-side reverse index for [[...]] wiki-links (Phase 1)

First phase of PLAN-1593. Today [[REF]] is parsed only at render time
on the client and there's no way to ask "who links to TASK-5?" without
a full-text scan. This change adds a materialized reverse index
(item_wiki_links) that's written every time an item's content changes
and exposes it via REST + CLI.

Phase 1 covers ref-form links only (`[[TASK-5]]` / `[[TASK-5|Display]]`).
Phase 2 (TASK-1595) will extend to titles + cross-workspace; Phase 3
(TASK-1596) adds the web UI panel + MCP action.

What lands here:

* Migrations 061 (SQLite) and 040 (Postgres) create item_wiki_links
  with partial indexes on target_item_id, (target_workspace_id, target_ref),
  and target_title — the schema accommodates all 5 wiki-link forms
  up-front so Phase 2 doesn't ALTER.

* internal/links/extract.go is the canonical parser. It strips fenced
  and inline code regions before extracting [[...]] occurrences, so
  example refs in docs / code blocks don't pollute the index. Phase 1
  emits only WikiLinkKindRef rows; title and workspace_ref kinds parse
  successfully but are gated out until Phase 2.

* internal/store/wiki_links.go (replaceWikiLinks + GetBacklinks +
  helpers) handles write-time bookkeeping and the read query. Resolution
  to target_item_id happens at parse time inside the same transaction
  as the items INSERT/UPDATE, so partial state never lands. Broken refs
  (target_item_id IS NULL) intentionally persist — they feed a future
  broken-links report.

* internal/store/wiki_links_backfill.go + cmd/pad/main.go hook the
  idempotent backfill into server startup. Existing items get indexed
  on first boot after the migration; subsequent boots are near-no-ops
  via an EXISTS short-circuit.

* internal/store/items.go is amended in two places: tryCreateItem
  always calls replaceWikiLinks (empty content → no-op DELETE), and
  UpdateItemWithPreCheck re-parses whenever input.Content was supplied.

* internal/server/handlers_backlinks.go serves
  `GET /api/v1/workspaces/{ws}/items/{itemSlug}/backlinks` with
  visibility + guest-grant filtering on the source items.

* internal/cli/client.go adds GetBacklinks; cmd/pad/main.go adds the
  `pad item backlinks <ref>` command (registered in groups.go).

Behavior decisions (per PLAN-1593):
- code blocks excluded (fenced + inline)
- self-links filtered at query time (kept in storage)
- repeated mentions stored as separate rows by position
- ordering: source updated_at DESC, position ASC

Tests:
- internal/links/extract_test.go: 26 sub-cases covering ref/title/
  workspace-ref discrimination, code-block exclusion (fenced + inline +
  unclosed fence), position-is-byte-offset (UTF-8 safety), and edge
  inputs.
- internal/store/wiki_links_test.go: 8 integration tests covering the
  create/update/delete/self-link/broken-ref/repeated/code-block
  scenarios plus backfill idempotence.

All pass. `make check` clean (lint + go test + web build).

Refs: TASK-1594, PLAN-1593, IDEA-1577

* fix(backlinks): visibility-aware pagination + case-insensitive refs per Codex review (round 1)

Two fixes from Codex code review:

P1 — GetBacklinks now takes a visibleCollectionIDs []string argument
that's applied INSIDE the SQL WHERE clause. Previously the handler
fetched LIMIT raw rows and filtered visible ones in Go, so a
restricted user asking for limit=50 could receive an empty page even
when later visible backlinks existed. Pushing visibility into SQL
makes LIMIT/OFFSET count visible rows.

  nil  → no restriction (owners, editors, root tokens)
  []   → see nothing (returns early, no SQL)
  [..] → AND s.collection_id IN (?, ?, ...)

Item-level guest grants still apply post-fetch — they're rare enough
that the residual page shrink is acceptable and pushing them into SQL
would balloon the query.

P2 — refPattern now accepts mixed/lowercase refs and parseBody
canonicalizes the prefix to uppercase at the single chokepoint.
Previously the renderer accepted `[[task-5]]` as a real link (its
REF_PATTERN is case-insensitive) but the indexer's ^[A-Z]... pattern
silently dropped it — divergent parsing on the same input. Storage
shape is canonical uppercase so the (workspace, prefix, number)
lookup against collections.prefix (also uppercase) has one shape.

New helper: canonicalizeRef("task-5") → "TASK-5".

Regressions:

  internal/links/extract_test.go
    + TestCanonicalizeRef                 — helper unit tests
    + TestExtractWikiLinks_RefVsTitleFallback updated to assert
      mixed/lowercase parses-as-ref-and-uppercases
    + edge-case test renamed from "lowercase ref" to "number-led
      not a ref" (lowercase IS a ref now per Codex P2)

  internal/store/wiki_links_test.go
    + TestWikiLinks_MixedCaseRefIndexed   — `[[task-5]]` produces a
      backlink row whose target_ref is "TASK-5"
    + TestWikiLinks_VisibilityAwarePagination — three sub-cases:
      nil → all 3, visible-only limit=2 → 2 visible rows (not 1 with
      hidden one consuming a slot), empty → 0

All call sites updated (8 in tests + 1 in handler).

`make check` clean (lint + tests + web build).

Refs: TASK-1594, PLAN-1593

* fix(backlinks): SQL-level item-grant filter per Codex review (round 2)

Round 1 fixed pagination for collection-level visibility but Codex
round 2 correctly flagged the same class of bug at the item-grant
layer: `visibleCollectionIDs` returns the UNION (full grants ∪
collections containing granted items), and the handler then
filtered each row's item-level visibility in Go AFTER fetching —
letting hidden rows in a granted-item's collection consume LIMIT
slots.

The refactor moves the precise predicate into SQL. New shape:

  type BacklinksVisibility struct {
      Unrestricted      bool      // admin / full-access member
      FullCollectionIDs []string  // direct collection grants
      GrantedItemIDs    []string  // item-level grants
  }

  // SQL predicate when Unrestricted=false:
  //   AND (s.collection_id IN (?...)  OR  s.id IN (?...))

This matches `guestResourceFilter` (which returns the precise
primitives), so the handler now passes them straight through and
drops the post-fetch filter loop entirely. Pagination is correct
for guests, restricted members, and unrestricted users alike.

New test:

  TestWikiLinks_ItemGrantPagination — guest with item-grant on ONE
  item in an otherwise-hidden collection sees exactly that one item;
  hidden siblings in the same collection do NOT leak in, and limit=2
  returns 1 row (not silently shrunken).

Other call sites updated:
- TestWikiLinks_VisibilityAwarePagination → uses
  BacklinksVisibility{FullCollectionIDs: ...} and
  BacklinksVisibility{} for the no-access case.
- 8 existing tests → BacklinksVisibility{Unrestricted: true}.
- handlers_backlinks.go → no longer calls visibleCollectionIDs;
  uses guestResourceFilter exclusively and skips the Go-side filter.

Verification:
- make check clean
- All TestWikiLinks_* pass

Refs: TASK-1594, PLAN-1593

* fix(backlinks): scan EXISTS into bool not int for Postgres parity (Codex round 3)

`SELECT EXISTS(...)` returns boolean on Postgres but integer 0/1 on
SQLite. Scanning into `int` happened to work on SQLite (the modernc.org
driver coerces) but would fail on Postgres — silently disabling the
backfill short-circuit there and meaning upgraded Postgres installs
wouldn't populate backlinks for pre-existing content until each item
got edited.

Fix: scan into bool. Both database/sql drivers in use (modernc.org/
sqlite and lib/pq) coerce their native representation into Go's bool,
so this single shape works on both engines.

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): allow CommonMark 0-3 space indented fences per Codex (round 5)

Round 5 flagged two edge cases in the code-stripping pass:

1. Multi-backtick inline code (``see [[X]]``) — traced through the
   parser; my permissive close-on-next-backtick logic already covers
   it correctly (range = [opener-start, after-closer-run]). Added
   a regression test to lock this in:
     TestExtractWikiLinks_CodeBlocksExcluded /
       "multi-backtick inline code excludes ref"

2. Indented fenced blocks — CommonMark allows 0-3 leading spaces of
   indentation before a fence opener (4+ spaces makes it an indented
   code block, a different construct). My fencedCodeRanges only
   matched fences at column 0, so `   ```\n[[X]]\n```` ` would
   render as code in the UI but leak a false backlink. Fixed both
   fencedCodeRanges (opener) and findFenceCloser (closer) to skip
   up to 3 leading spaces, with a hard cap at 4 (which would be
   indented-code, not a fence). Regression test:
     TestExtractWikiLinks_CodeBlocksExcluded /
       "indented fenced block (CommonMark 0-3 spaces)"

Not addressed:
- Round-4 escape-body parity finding. extract.go mirrors
  renderMarkdown's regex (web/src/lib/utils/markdown.ts:300), which
  is the actual render-time link parser; wikiLinksToMarkdown's more
  permissive escape grammar is editor-serializer-side and the
  renderer can't even consume its escaped output. Indexing what the
  user actually sees as a link is the correct invariant.

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): tilde fences + strict closer lines per Codex (round 6)

Two CommonMark conformance gaps in the code-block stripping pass:

1. Tilde-fenced code blocks (~~~) were ignored. marked() treats them
   the same as backtick fences, so a [[REF]] inside a tilde block
   would render as code in the UI but leak as a false backlink.
   Fixed by parameterizing fenceChar across fencedCodeRanges and
   findFenceCloser, with separate handling for the backtick-specific
   "no backtick in info string" rule (CommonMark §4.5).

2. Closer-line strictness — CommonMark requires the closing fence
   line to contain only the fence + optional trailing spaces. The
   previous accept-any-fence-prefixed-line check would terminate
   a still-open fence prematurely on a line like ```not-closed,
   leaking later refs in the still-rendered code block.

Refs reside in 4 new sub-tests under TestExtractWikiLinks_CodeBlocksExcluded:
- tilde fence excludes refs inside
- tilde fence with language tag
- mixed fence types don't pair
- closer-line strictness — backticks plus other text is not a closer
- closer-line strictness — trailing spaces OK

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): inline code closer must match opener length per Codex (round 7)

CommonMark §6.1 requires an inline-code span opened with N backticks
to close on a run of EXACTLY N backticks. The previous "close on next
backtick run of any length" logic would prematurely end the excluded
range on a stray single backtick inside a ``...`` span, leaking any
[[REF]] in the latter half of the code text as a false backlink.

Concrete failure case:
  ``has ` inside [[X-1]] and more``
  → old: range [0, 7], [[X-1]] indexed (bug)
  → new: range [0, end-of-closer], [[X-1]] excluded (correct)

Fix: track the opener-run length and scan only for matching-length
closer runs. Wrong-length runs in between are code text.

Two new regression sub-cases under TestExtractWikiLinks_CodeBlocksExcluded:
- inline code closer matches opener length — the main case
- single-backtick span unaffected by adjacent multi-backtick run —
  asserts the opposite direction (opener=1 doesn't close on ``)

Not addressed:
- Re-flagged round-4/round-7 escape-body parity finding. extract.go
  intentionally mirrors renderMarkdown's regex (markdown.ts:300), not
  wikiLinksToMarkdown's more permissive escape grammar (markdown.ts:461).
  renderMarkdown is the actual link parser at display time; its regex
  rejects escaped-`]` bodies, so any link with an escaped `]` in its
  body is NOT shown as a clickable link in the UI. Indexing it would
  produce phantom backlinks the user can't see. The wikiLinksToMarkdown
  permissive grammar is paranoid serialization that the renderer can't
  consume — that's a pre-existing inconsistency in the editor pipeline,
  not a backlinks bug.

make check clean (lint + tests + web build).

Refs: TASK-1594, PLAN-1593

* fix(backlinks): rune-align snippet end-edge to keep UTF-8 valid (Codex round 8)

The previous snippetAround() trimmed `start` to a rune boundary (so
the leading edge of the snippet was always at a valid codepoint) but
left `end` as a raw +40-byte clamp. When that landed in the middle of
a multi-byte rune — common around emoji or accented text — the
resulting slice was invalid UTF-8 and the JSON encoder would emit
replacement characters in backlink snippets.

Fix: same forward-advance pattern at the end as at the start.
Continuation bytes (10xxxxxx) get skipped until we land on a leading
byte. Going forward keeps the snippet anchored slightly past the
match rather than slightly before it, which is a small UX win
(emoji or accented text right after the link survives intact).

Regression test:
  TestWikiLinks_SnippetIsValidUTF8 — pads body with enough 4-byte
  emoji on each side that the ±40-byte window cuts through one;
  asserts utf8.ValidString on the resulting snippet.

make check clean (lint + tests + web build).

Refs: TASK-1594, PLAN-1593

* fix(backlinks): inline code spans cross newlines, break on blank lines (Codex round 9)

CommonMark §6.1: an inline-code span can cross single newlines but
terminates at a blank line (a line containing no chars or only
whitespace, which ends the enclosing paragraph). My previous scanner
broke at every newline, so multi-line spans like

    `pre
    [[INSIDE-1]]
    post`

would treat the opener as unclosed and leak [[INSIDE-1]] as a false
backlink. Fixed by:

  1. The newline branch in the closer scan now peeks ahead via the
     new isBlankLineAt() helper. Same-paragraph newlines are
     traversed; blank-line breaks terminate the span unmatched.
  2. isBlankLineAt() treats any line with only space/tab as blank
     (mirroring CommonMark's blank-line definition).

Three new regression sub-cases under TestExtractWikiLinks_CodeBlocksExcluded:
  - inline code spans single newline (CommonMark §6.1)
  - inline code breaks at blank line (paragraph boundary)
  - inline code breaks at whitespace-only blank line

Trade-off: a truly-unclosed inline backtick now consumes from the
opener up to the next blank line instead of just the rest of the
line. False-positive on wiki-links in that span, but the surface
area is small (unclosed backticks are rare in published prose) and
matches the renderer's behavior.

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): accept escaped wiki-link bodies per editor grammar (Codex round 10)

After 3 rounds of disagreement, capitulating on the escape-body parity
finding. My position was technically correct for the CURRENT
renderMarkdown behavior (which uses [^\]]+ and can't parse escaped-
bracket bodies), but the editor's wikiLinksToMarkdown grammar at
markdown.ts:461 explicitly produces such bodies — making the
renderer's regex the inconsistent half of the pipeline, not mine.

Mirroring the editor's grammar in the extractor makes the index
forward-compatible: when the renderer eventually gets fixed, no
change here is needed. The cost is a few "phantom" rows in the
interim (indexed links the renderer doesn't currently display as
clickable), but those are harmless and aligned with author intent.

Changes:
- wikiLinkPattern now uses `\[\[((?:\\.|[^\]\\])+)\]\]` — mirrors
  markdown.ts:461 verbatim.
- New splitOnUnescapedPipe() helper — scans for the first `|`
  that isn't preceded by `\`. Mirrors splitWikiBody at
  markdown.ts:664.
- New unescapeWikiBody() helper — undoes `\]`, `\|`, `\\` escapes
  in display text and key. Mirrors unescapeWikiBody at markdown.ts:657.
- parseBody() now uses both helpers — split on unescaped `|`,
  unescape both sides.

Regression coverage:
- TestExtractWikiLinks_EscapedBodyChars (5 sub-cases): escaped `]`,
  escaped `|`, escaped `\`, non-escape backslash passes through,
  Position still points at opening `[[` despite escapes.
- TestSplitOnUnescapedPipe + TestUnescapeWikiBody: direct unit
  tests for the helpers (round-trip safety vs the editor's
  escape/unescape pair).

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): preserve display text verbatim per Codex round 11 P3

The previous parseBody trimmed the display side of [[X|Display]] but
the WikiLinkRef.Display contract promises verbatim storage and the
renderer at markdown.ts doesn't trim either. Trimming would silently
diverge on padded display text like [[TASK-1|  spaces  ]] (renderer
keeps the spaces, extractor stripped them).

Fix: drop TrimSpace from the suffix half of the split. Keep trimming
the key/ref side because refPattern is anchored — a leading or
trailing space in the key would force the body to fall through to
the title kind even though the renderer resolves it as a ref.

Regression test:
  TestExtractWikiLinks_EscapedBodyChars / "display text preserved
  verbatim (no TrimSpace)"

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): distinguish empty display override from no-pipe per Codex round 12

[[REF|]] (explicit empty display) and [[REF]] (no display) are distinct
shapes in the editor: splitWikiBody returns displayOverride="" for the
former, null for the latter, and the renderer uses `displayOverride ??
title` (nullish coalescing, NOT empty-string fallback) so "" is
preserved. The previous extractor collapsed both into display_text=NULL,
violating verbatim-display preservation for the empty-string edge case.

Fix:
- WikiLinkRef gains a HasDisplay bool. parseBody sets HasDisplay=true
  iff splitOnUnescapedPipe found a pipe; downstream uses HasDisplay
  (not Display!="") to decide whether to persist the override.
- replaceWikiLinks in store: NullString.Valid is keyed off HasDisplay.
  display_text='' for explicit empty, NULL for no override.

Regression coverage:
- internal/links/extract_test.go:
    "explicit empty display override is distinguished from no pipe"
- internal/store/wiki_links_test.go:
    TestWikiLinks_EmptyDisplayDistinct (two-source assert: NOT NULL
    for [[REF|]], NULL for [[REF]])

make check clean.

Refs: TASK-1594, PLAN-1593

* fix(backlinks): pointer-typed DisplayText to preserve empty distinction over JSON (Codex round 13)

Round 12 added HasDisplay on the parser side and made the store
preserve display_text='' vs NULL on the DB row, but the wire model
collapsed the distinction at JSON-serialization time:

    DisplayText string `json:"display_text,omitempty"`

`omitempty` drops empty strings, so [[REF|]] (empty override) and
[[REF]] (no override) serialized identically on the API and CLI JSON
output. The end-to-end goal of round 12 wasn't reached.

Fix: change DisplayText to *string. nil → no override (field omitted
from JSON via omitempty), pointer to "" → explicit empty override
(field present with empty value). The store's NullString.Valid drives
the assignment, so the SQL round-trip matches the JSON shape.

Knock-on: the CLI's `pad item backlinks` now dereferences the pointer
and prints both populated and empty overrides ("displayed as: ").

Regression coverage:
- TestWikiLinks_EmptyDisplayDistinct extended to assert
  withBL.DisplayText is non-nil-pointing-at-"" and noBL.DisplayText
  is nil after a GetBacklinks round-trip.

make check clean.

Refs: TASK-1594, PLAN-1593
2026-05-23 23:09:44 -04:00
xarmian 2df6edeaab feat(server): library endpoints gain ?category, ?summary, /library/entry (TASK-1561) (#612)
Extends the convention + playbook library HTTP layer to support the shape
the upcoming `pad_library` MCP tool and the updated `pad library` CLI need:

- `GET /api/v1/convention-library?category=X` — server-side filter,
  case-sensitive exact match. Unknown categories return an empty slice,
  not 404.
- `GET /api/v1/playbook-library?category=X&summary=true` — same filter
  plus a new summary mode that strips Content and injects Summary
  (first non-heading paragraph, ~240 char cap). Web UI and existing
  consumers omit the flag and see the legacy full-body shape. Summary
  mode deep-copies category slices so a request never mutates the
  package-level library data — TestPlaybookLibrary_SummaryDoesNotMutate
  Global pins this.
- `GET /api/v1/library/entry?title=X` — NEW. Returns one matched entry
  in a `{type, convention|playbook}` envelope. Conventions-first
  precedence mirrors the dispatcher's `library activate` so a title
  resolves to the same kind in both surfaces. 400 on missing title,
  404 on no match.

Hoisted `playbookSummary` to `collections.PlaybookSummary` so the
bootstrap handler and the new library endpoints share one algorithm.
Bootstrap continues to call it for every playbook entry it returns.

Adds 12 handler tests + the existing bootstrap-summary test stays
green after the move. Lint clean on touched packages; `make check`
gate is blocked by a pre-existing gofmt issue in
internal/store/workspace_members.go captured as BUG-1565.

Parent: PLAN-1560. Unblocks TASK-1562 (CLI) and TASK-1563 (MCP catalog).
2026-05-21 13:08:02 -04:00
xarmian 48323e229e feat(admin): GET /admin/users/{id}/metrics windowed engagement metrics (TASK-1547) (#602)
Final backend task for PLAN-1542. Returns three engagement signals that
power the metric tiles on the admin user modal's Overview tab (T1553):

- days_since_write: derived from users.last_write_at (T1543). nil when
  the user has never had a write recorded.

- writes_7d: COUNT of write-class activities (created/updated/archived/
  restored/moved/commented) authored in the last 7 days.

- collections_touched_30d: COUNT(DISTINCT collection_id) of items the
  user has authored writes for in the last 30 days. Goes through
  activities.user_id (not items.last_modified_by, which is an attribution
  string — see T1543's architecture note).

api_requests_7d is intentionally NOT included; no per-request log exists.
Filed as a follow-up (IDEA-1556) that will add this as an additive,
non-breaking field once the request-log table lands.

Implementation:

- Store.GetUserMetrics in users.go runs three small queries: scalar
  SELECT for last_write_at, one COUNT(*) over activities, and a
  JOIN(activities, items) for the DISTINCT collection count. All
  three are index-backed (idx_activities_user from migration 022).

- No caching layer in this PR. The queries are cheap, and a per-user
  short cache fits more naturally at the handler boundary if needed —
  premature here.

- Handler handleAdminGetUserMetrics wired at GET /admin/users/{userID}/metrics.
  requireAdmin gate; 404 on missing user.

Tests: TestGetUserMetrics seeds a workspace with two collections, six
activities (five inside 7d, one ancient outside both windows), verifies
all three metrics. TestGetUserMetricsEmptyUser covers the no-activity
case (nil days_since_write, zero counts, no error).
2026-05-20 17:13:28 -04:00
xarmian a04e5217c6 feat(admin): GET /admin/users/{id}/activity paginated feed (TASK-1546) (#601)
* feat(admin): GET /admin/users/{id}/activity paginated feed (TASK-1546)

New endpoint returns activities originated by the user — item writes,
comments, account-level actions the user took themselves — in reverse-
chronological order with offset pagination.

Scope decision: feed shows activities where activities.user_id = userID
(events the user authored). Admin actions targeting this user as a
subject (role_changed where target_user_id is in metadata) are NOT
included; that "received" sub-feed needs a JSON predicate and is filed
as a follow-up. T1554 (modal Activity tab) consumes the current shape.

Implementation:

- Store.ListUserActivity mirrors the existing ListWorkspaceActivity /
  ListDocumentActivity helpers in activities.go. Same column projection,
  same LEFT JOIN users u for actor name. Hard cap at limit=50.

- Handler asks for limit+1 rows so it can flag "next_offset" without a
  separate COUNT query — trims the extra before responding. Returns
  next_offset=null when the page is the last.

- Route wired at GET /api/v1/admin/users/{userID}/activity. 404 on
  missing user; requireAdmin gate.

- Pagination is offset-based (matching the sibling endpoints) rather
  than cursor-based as the task body suggested. For per-user feeds the
  dataset is bounded and between-page drift is acceptable for an admin
  tool. Cursor can be added later if needed; the response shape is
  forward-compatible (next_offset → next_cursor would just rename).

Test: TestListUserActivity covers cross-user isolation, action filter,
offset pagination across pages without overlap, and the 50-row hard cap.

Part of PLAN-1542.

* fix: address Codex review on TASK-1546

Lift the store-side cap on ListUserActivity from 50 to 100. The handler
caps the public per-page at 50 and asks the store for limit+1 (51) to
flag "more available" without a separate COUNT. Previous store-side
cap of 50 silently truncated that probe, so next_offset would be null
even when row 51 existed — clients iterating at page-size=50 would stop
one page short of the actual end.

The HTTP layer remains the source of truth for the per-page maximum;
the inner cap is now just protection against pathological internal
callers. Regression test seeds 51 activities and verifies the store
returns all 51 when asked.
2026-05-20 17:07:57 -04:00