Commit Graph

2823 Commits

Author SHA1 Message Date
xarmian dac2c01b9f Redact credentials from request and application logs (audit 10-29) 2026-10-29 18:48:47 +00:00
xarmian 786406e569 fix(cli): print the op_key of a failed write; stop claiming a re-run is safe (BUG-3999) (#2346)
* fix(cli): print the op_key of a failed write; stop claiming a re-run is safe (BUG-3999)

Each CLI invocation sends a new idempotency key, so re-running a command
after an unclear outcome is a new request. Failed writes that sent an
op_key (create, comment, update/note/decide, patch, undo, push, copy) now
wrap the error in cli.OpKeyError, and the root error writer prints the key
on stderr with the --op-key re-run hint. Chat is excluded (caller-owned
key). SKILL.md no longer says a re-run never applies a patch twice.

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

* fix(cli): read APIError through the op-key wrapper at every call site (BUG-3999)

A write that sent an op_key comes back wrapped in OpKeyError, which a direct
err.(*cli.APIError) assertion does not see through: pad item create lost its
plan-limit structured marker. Add cli.AsAPIError (errors.As) and use it at all
17 command-side sites. Add the plan-limit, --format json, decide and push tests.

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

* fix(cli): keep the op-key hint when create replaces a plan-limit refusal (BUG-3999)

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

* fix(skill): drop the false retry claim from the plugin's SKILL.md copy too (BUG-3999)

plugin/skills/pad/SKILL.md is maintained separately from the embedded
skills/pad/SKILL.md (it is not synced from it), and it still said a plain
re-run "never applies it twice". It now carries the same corrected text,
and the prose pin checks both files. Adds a test that a failed write which
sent no op_key prints no hint (r1 H1, H2).

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-29 12:39:35 -04:00
xarmian a5e275795e fix(web): label the new-item dialogs' dismiss button Close, not Cancel (BUG-4004) (#2345)
Both new-item surfaces, the desktop quick-add dialog and the mobile
capture sheet, keep the dialog open after Add for the next item, so the
dismiss button ends the session rather than undoing anything. "Cancel"
read as if it would discard what was just added. The class names and the
button order (TASK-3972) are unchanged; the desktop hint already says
"Esc to close".

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-29 10:44:20 -04:00
xarmian 81248f33e8 feat(schedule): firing for item schedules, floating recipient zone, re-anchor and hooks (TASK-4102 PR1b) (#2344)
* feat(schedule): firing for item schedules, floating recipient zone, re-anchor and hooks (TASK-4102 PR1b)

fireOneReminder gains a managed branch (unmanaged path unchanged): on
Postgres one pin statement locks item, workspace and schedule before the
arbiter takes the reminder; after the win the schedule is loaded and a
floating occurrence is resolved in the recipient's zone, deferring (no
event) when the row was armed early at the UTC+14 bound. A stored rule
that no longer compiles leaves the row fired with no event and sets
error_code. The schedule start is re-anchored to the fired occurrence
under the plan's conditions. models.Reminder gains schedule_id and
occurrence_at; the notification dedupe key uses occurrence_at for managed
rows. The users.timezone writers and the assignee write reset the
affected user's armed floating rows to the bound after they commit.

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

* fix(schedule): UpdateSchedule locks the item first; hook-failure and lock-order tests (TASK-4102 PR1b)

UpdateSchedule takes the item FOR KEY SHARE (Postgres) before its schedule
FOR UPDATE, so every writer runs item, schedule, reminder. Adds a test-only
seam to fail the recipient hook, a test that a floating row fires in the new
zone when the hook fails, a PG test that UpdateSchedule waits on the item
before the schedule, and a comment on the fire pin's rowmark-order reliance.

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

* fix(store): compile a managed schedule's stored rule before the floating deferral (TASK-4102)

A broken floating rule was deferred once before error_code was recorded.
The compile now runs first, so it records error_code on the first fire
attempt with no deferral write and no event.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS
2026-10-29 09:23:46 -04:00
xarmian c4ce74a6c2 feat(web): per-user time zone setting, browser default and mismatch notice (TASK-4101 PR2a) (#2342)
* feat(web): timezone types, api helpers and an identity-fenced authStore.setTimezone (TASK-4101)

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

* fix(web): UI dismissals reset on identity change and fence late responses (TASK-4101)

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

* feat(web): browser time zone default (set-if-unset) and the mismatch notice (TASK-4101)

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

* feat(web): time zone setting in console settings (TASK-4101)

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

* fix(web): fence the settings profile load, merge PATCH answers into the profile, and let explicit zone writes win over a late default (TASK-4101)

PR2a r1, lead ruling ca031372: C-M1, H-L1 (both sites), H-L2, H-T.

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

* fix(web): reload settings for the current identity, gate Save on it, and count only successful explicit zone writes (TASK-4101)

PR2a r2, lead ruling bfc37793 (H2-M1, H2-M2, C2-L1) and the option-A ruling on the
size stop: the four profile-writer fences (H2-L3) move to TASK-4129. The identity
listener also clears the zone Save's busy and message state, which its fenced
finally no longer does.

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

* fix(web): a sign-out does not reload the settings profile (TASK-4101)

The identity listener reloaded on user -> '' too. On account delete those
/auth/me and /auth/tokens 401s fired the session-expiry redirect, which raced the
delete's own navigation to /login (#2342 E2E, account-delete.spec.ts:99).

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-29 07:47:23 -04:00
xarmian 36f88b89e3 feat(schedule): item_schedules storage, managed reminder rows and store CRUD (TASK-4102 PR1a) (#2341)
* feat(schedule): item_schedules storage, managed reminder rows and store CRUD (TASK-4102 PR1a)

Migrations 162/163 (SQLite) and 128 (Postgres): item_schedules,
schedule_occurrences and item_reminders.schedule_id, with item_schedules.error_code
(NULL = healthy; a schedule.Code or cap code). A schedule is the rule; its one
outstanding occurrence is a managed item_reminders row.

Store: CreateSchedule/GetSchedule/UpdateSchedule/DeleteSchedule behind one
compileScheduleSpec door, fireInstant, recipient-zone resolve, floating schedules
armed at the UTC+14 bound. RearmReminder/AckReminder/DeleteReminder refuse a managed
row with ErrReminderManaged. Export skips managed rows. NUL triggers, migrated
refusal list and purge census cover the new tables.

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

* fix(schedule): non-timing edits never recompile; lead edit cannot un-fire a row (TASK-4102 PR1a r1)

- A non-timing PATCH (kind, audience, lead_minutes) no longer compiles the
  stored rule, so a schedule whose rule no longer compiles can still be edited
  and keeps its error_code. error_code clears only when a timing edit's merged
  spec compiles.
- A lead change retimes an armed managed reminder from the stored next_wall and
  zone through fireInstant, with a conditional UPDATE (fired_at IS NULL) that
  never touches fired_at or acked_at, closing the tick/un-fire race.
- CreateSchedule maps a foreign-key refusal after the liveness check to
  ErrScheduleItemGone (test hook pins the race).
- The atomicity test now runs on Postgres via a plpgsql trigger.

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

* fix(schedule): dtend is not a timing edit; pin exactly-one-fire on the lead race (TASK-4102 PR1a r1)

- A dtend-only update validates and writes dtend without recompiling,
  re-arming, or clearing override, anchor_wall, fired_at or acked_at.
- The lead-vs-tick interleave test now drives FireDueReminders: one fire
  before the lead write, the row unchanged after it, and 0 on a second tick.

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

* fix(schedule): dtend is not a timing field; a dtend edit never recompiles or re-arms (TASK-4102 PR1a r1 H2)

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS
2026-10-29 04:47:16 -04:00
xarmian 7e5384ca07 feat(chat): refs in chat bodies, and the Chat entries in /console and the Sidebar (TASK-4044 PR 3c) (#2340)
* feat(chat): ref tokenising and the Go-parity trim for chat bodies (TASK-4044 PR 3c)

refs.ts and refs.test.ts from the parked 98247acb8, with one deliberate
divergence (D4): the three String.trim() calls are presence.ts trimSpace,
matching Go's strings.TrimSpace on U+FEFF and U+0085.

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

* feat(chat): the thread collects ref verdicts from every page and send (TASK-4044 PR 3c)

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

* feat(chat): render visible refs as links and restricted ones as a placeholder (TASK-4044 PR 3c)

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

* feat(chat): the Chat entry in the /console nav bar (TASK-4044 PR 3c)

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

* feat(chat): the Chat entry in the workspace sidebar, gated on sign-in (TASK-4044 PR 3c)

Option C, its own commit so it can be dropped alone.

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

* test(chat): e2e for refs in bodies and the Chat entries (TASK-4044 PR 3c)

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

* docs(chat): the refs disclosure names arrival-order merging (TASK-4044 PR 3c)

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-29 02:59:03 -04:00
xarmian e9cd8bcdc7 test(server): widen the OAuth-scope stream test's hang bound past context expiry (BUG-4126) (#2339)
The negative-control subtest opened the stream with a 3s context and
fataled at 4s, leaving the handler 1s to notice ctx.Done and unwind. A
slow scheduler tick in internal/server's ~370s CI run failed it at
2c97c2a4b. The bound now sits 30s past the deadline: it still catches a
handler that ignores its context, and the happy path still returns at 3s.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS
2026-10-28 21:07:31 -04:00
xarmian d7da14bed4 feat(chat): the web chat write path: composer, send/retry/delete, a serialized thread store (TASK-4044 PR 3b) (#2338)
* feat(web): chat write path: composer, send/retry/discard/delete, message menu, draft keeper (TASK-4044)

PR (3b) of the /messages/[n] thread. Optimistic sends keyed by op_key with
same-key retry, reply targets, a hover/focus/long-press message menu (Reply,
Copy, Delete), a visualViewport-aware composer, and a session-expiry draft
keeper stashed per user and thread. Sizes are checked against the server's
rules (Go TrimSpace, UTF-8 body bytes, the escaped 128 KiB request cap), and
a refresh is deferred while a send is in flight. The parked client.ts and
types hunks (H2, H3, H4, H7, H12) land byte-identical.

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

* test(web): each-key census entry for the thread's pending-aware list (TASK-4044)

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

* fix(web): re-anchor at the newest page on a send beyond a gap; cut Ctrl/Cmd+Enter (TASK-4044)

Pre-r1 ruling b3dd1e54: a confirmed own send while a gap is open used to be
discarded, so the sender's bubble vanished. The window now moves to the newest
page (store #reanchor, epoch-fenced against older loadNewer/loadOlder/refresh
answers) and the Q-d scroll applies. The Ctrl/Cmd+Enter send gesture is cut
(follow-up TASK-4099).

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

* fix(web): an out-of-order re-anchor keeps the window a later send moved (TASK-4044)

3b r1, hunter finding 2: two own sends confirming beyond a gap both
re-anchor. When the first one's newest-page fetch landed last, it replaced
the window that the second one's (newer) page had already set, and the
second sender's row vanished. #reanchor now captures the epoch before its
fetch; if another re-anchor landed meanwhile, it keeps that window and
merges its own row only where it is contiguous.

Also pins hunter finding 1 (refuted): a peer row committed while a send is
in flight is brought in by the deferred refresh's sweep leg.

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

* fix(web): an own send past unfetched peer rows no longer hides them from the next refresh (TASK-4044)

3b r1, hunter finding 1 (re-ruled FIX in a61ca213): merging a confirmed own
row in the no-gap branch moved newestSeq past peer rows committed before it,
so the follow-up refresh's step 1 started after them and only the newest-200
sweep could bring them in. More than 200 such rows left a silent hole, which
falsified the header's R1 invariant. The no-gap branch now sets a one-shot
cursor floor at the pre-send newest and marks the thread dirty; step 1 starts
from the floor and clears it. #reset and #reanchor clear it too.

Test: 201 peer rows committed during one send all arrive (mutant B18).

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

* fix(web): serialize the floor, the late re-anchor merge and the delete merge against the moving window (TASK-4044)

3b r2 (ruling a669a3a4), three confirmed findings:
- H1: a refresh in flight cleared a cursor floor that a send set meanwhile.
  The refresh now clears only the floor it started from.
- C1/H2: a late re-anchor checked contiguity at the window's oldest only, so
  a newer own row merged past unfetched peer rows. The no-gap branch and the
  late re-anchor now share #mergeOwn, which floors past skipped peer rows.
- H3: a delete answer that landed after a re-anchor inserted the tombstone
  below the new window. It merges only when the window holds that seq.

Tests: one '3b r2 ordering' row per finding (mutants B19-B21).

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

* refactor(web): serialize chat thread window ops and keep the published window contiguous (TASK-4044)

One promise chain runs every op that fetches then writes the window (open,
loadOlder, loadNewer, refresh, an own row's placement, a delete's tombstone).
An own row past unfetched peer rows is reached by #fill (bounded); on failure
or the bound it detaches and renders after "Show newer messages". Replaces
#epoch, #floor, #dirty/#settled's re-run and the late re-anchor branch; the
in-flight refresh deferral moves onto the queue. Adds a seeded property test.

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

* test(web): property test pins when an own row may detach and how far a fill pages (TASK-4044)

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

* test(web): pin that Show newer does not merge a page carrying an in-flight send's row (TASK-4044)

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

* test(web): default the property walk to 100 seeds to stay under the CI budget (TASK-4044)

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

* test(web): let the property test's settle page to the end of a 15k-row server (TASK-4044)

The settle loop capped at 30 Show-newer pages; seed 1171 ends with 7,626 rows
and the window at 804, which is the harness running out of pages, not a store
defect.

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

* fix(web): disable Show newer while a send is in flight (TASK-4044)

Derived from the pending items on the page, no store change. Also
corrects the property test title to I1-I10.

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

* test(web): pin the two in-flight-send guards against a merge beside a sending bubble (TASK-4044)

Two deterministic store tests for invariant I9 (ruling 2a03a442): N12, the
own apply must not fetch while another send is in flight, and N14, a
refresh page that lands during a send must not be merged.

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

* fix(web): keep the window when the re-anchor fetch fails and recheck sends in #fill (TASK-4044)

C1 (ruling ec6bbe31): a failed newest-page fetch detaches the row and keeps the
reader's window and hasNewer, instead of replacing the window with the sent row.
H1: #fill returns false after an await while another send is in flight, so the
row detaches rather than land beside the bubble of a retried committed send (I9).
Adds the red-first tests for both and four tests pinning the deferred-refresh
release after a confirm, a refusal, a failure and a Discard.

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

* test(web): make the property walk less vacuous (TASK-4044)

I10 causes are recorded per sent row, the walk discards failed bubbles, and
refusals are thrown as errors the real predicates (reply target, too_large)
recognise instead of mocking them to false (3b r4 C2/H4).

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

* test(web): e2e pin that a detached send shows below the Show newer marker (TASK-4044)

Q3 (ruling ec6bbe31): open past a gap, fail the re-anchor read, send, and assert
the sent row is below the marker while the marker stays.

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

* fix(web): a tap opens no message menu; focus, callout and live regions (TASK-4044)

r5 H1 + Codex M: the bubble opens its menu on :focus-visible only (a tap
focuses it but must open nothing), and a press inside the bubble but
outside the menu unpins. H2: E5c writes its screenshot under
testInfo.outputPath. H3: Reply focuses the composer; Copy and Delete hand
focus back to the bubble. H4: -webkit-touch-callout none and contextmenu
preventDefault while a touch press is armed. H5: the composer and pending
bubble status regions stay mounted and only change text. E6 gains a REAL
tap().

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

* fix(web): a replayed send does not overwrite a held row; pin the re-anchor mark (TASK-4044)

r5 A2: #applyOwn prefers a row already held (window or detached) over the
answer, and the re-anchored page over the answer, so a tombstone wins (I5).
A1: pin markRead(newest) after a re-anchor, the ruled behaviour.

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

* test(web): property walk attributes rejections, counts paths, checks the server marker (TASK-4044)

r5 A3: 'rejected' is recorded for the bubble whose own apply fetch failed;
the run counts detach, re-anchor, fill-resolved and fill-detach hits and
fails on any zero; I6 is checked against the server's recorded read marker.

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

* test(web): property walk counts a re-anchor from an empty window (TASK-4044)

The deep run (seed 2330) found the I6 skip rule blind to a re-anchor that
starts from an empty window: the window start goes 0 -> N, which the
moved-detector read as no move. A start above 1 from an empty window is
the re-anchor.

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

* fix(web): a replayed send below the moved window is history (R1, TASK-4044)

r5a2 R1: in #applyOwn a sent row whose seq is below the window's oldest
row is dropped with its bubble instead of merged across the hole (I1),
the rule the re-anchor branch already follows.

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

* test(web): property settle compares the whole window row for row (R4, TASK-4044)

r5a2 R4: I3 compared body and deleted for the newest 200 rows only. It now
compares every row, allowing only the disclosed residual (a live row older
than the newest 200 that the server has since retracted).

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

* fix(chat): trimSpace by index scan, linear on an interior whitespace run (B2-M1, TASK-4044)

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

* fix(chat): a detached own row keeps hasNewer after the apply (A-L1, TASK-4044)

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

* test(chat): a held detached tombstone beats a replayed answer (A-M1, TASK-4044)

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

* test(chat): the walk commits and loses an answer, then replays; overlap tagged at the op (A-M1, A2-L1, TASK-4044)

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

* fix(chat): the reply bar clears on a successful delete; Retry/Discard refocus the composer (L3, L4, TASK-4044)

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

* fix(chat): an unanswered send's body joins the session-expiry stash (B2-L1, TASK-4044)

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

* fix(chat): detached rows sit in a flex column so own rows right-align; e2e waits for pending=0 (L1, L2, TASK-4044)

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

* test(chat): exempt a lost-answer key from I9 only once its answer is rejected (r6fix F-L1, TASK-4044)

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

* perf(chat): hoist the White_Space regex out of trimSpace (r6fix R-L4, TASK-4044)

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

* fix(chat): a refused Retry puts its text back in the composer with the notice (r7 M1, TASK-4044)

A Retry refused because the quoted message was deleted (or is missing)
discards its bubble, and the page ignored the outcome, so the body was
lost with no notice. Composer exports the refusal tail send() already
had as restore(), and the page calls it for a refused Retry.

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-28 19:55:20 -04:00
xarmian e4a5c20dd0 feat(schedule): internal/schedule, RFC 5545 RRULE expansion with pinned/floating time and DST rules (TASK-4100) (#2336)
* feat(schedule): wall times, DST resolution, typed errors and a strict RRULE parser (TASK-4100 1/2)

Commit 1 of 2 of the internal/schedule package (pure, no store/HTTP).

- wall.go: Wall, ParseWall, String, Resolve. Resolve does not use
  time.Date's gap/overlap choice: a gap takes the offset before it
  (shifts forward), an overlap takes the earlier instant.
- errors.go: ErrMalformed, MalformedError with typed Codes, ErrViewRequired.
- rule.go: strict RRULE parser (ParseRule, canonical String, combination
  and satisfiability checks, Validate against Start). FREQ=SECONDLY is
  refused (CodeUnsupported); a 512-byte, INTERVAL 1e6, years 1970-2200
  limit set applies.
- doc.go: the DST and sub-daily policy, including the population table.

Size (wc -l): production 652 raw (doc 44, errors 82, wall 106, rule 420),
515 excluding comments and blank lines; tests 388 (resolve 107, rule 219,
wall 59, tzdata 3).

RED controls: Resolve overlap taking the later instant fails TestResolve
(ny-overlap-first-minute, ny-overlap-mid, london-overlap,
lord-howe-overlap); removing the SECONDLY refusal fails
TestMalformedRulesTable/FREQ=SECONDLY.

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

* feat(schedule): the expansion engine on rrule-go, caps, shorthands and RFC 5545 conformance (TASK-4100 2/2)

Adds github.com/teambition/rrule-go v1.8.2 (go.mod +1 line, go.sum +2) behind
a wrapper. The library is a naive wall-clock engine fed UTC-labelled walls;
Resolve maps each wall to an instant. Bare HOURLY/MINUTELY step in elapsed
time with a fast-forward; everything else is wall clock with collapse.

- spec.go: Spec, Compiled, Compile, Occurrence. Every refusal is a
  *MalformedError; no library text can surface.
- engine.go: NextAfter, Between, StartSynced. Resolve is not monotonic inside
  a DST gap, so the walk is bounded on the WALL reading (48 h margin), never on
  "first instant >= to"; results are sorted, de-duplicated by instant, strictly
  increasing, Between half-open.
- caps.go: Limits, DefaultLimits, CapError, ErrCapHit, the CapTime guard and
  the CapScan precheck. The library iterator is used, never All().
- shorthand.go: the eight shorthands and NormalizeRule.
- CLAUDE.md: one Key Directories line.

Tests: the 43 RFC 5545 3.8.5.3 rows and 30 DST/edge rows run through the real
package, the DST population table, caps, floating, Next/Between, shorthands,
TestOutputStrictlyIncreasingAcrossDST and TestCompileNeverReturnsLibraryError.

Erratum: the HOURLY example with UNTIL=19970902T170000Z is RFC 5545 erratum
3883 (170000Z is 13:00 EDT, so 15:00 EDT is past UNTIL). It stays in the suite
as an expected-different row asserting 09:00 and 12:00 EDT, never a skip.
Friday-made-on-Tuesday (ruling Q1): the Tuesday is not emitted and
StartSynced() is false.

Size (whole unit, commit 1 included), production only, code lines excluding
comments and blanks / raw: caps.go 81/109, doc.go 1/44, engine.go 225/265,
errors.go 60/82, rule.go 376/420, shorthand.go 41/53, spec.go 103/128,
wall.go 78/106; total 965 code, 1207 raw. Test files 1640 raw (row data
included).

govulncheck v1.2.0 (local binary, DB 2026-10-27) on ./internal/schedule/: No
vulnerabilities found.

RED controls, each applied then reverted: (a) no adjacent-instant dedupe fails
the gap-collision rows, TestBetweenDedupesByInstant and
TestOutputStrictlyIncreasingAcrossDST; (b) local-zone times fed to the library
fail dst-daily-ny-gap and the other gap rows; (c) emitting a non-matching
DTSTART fails TestNextAfterUnsyncedStartSkipsStart; (d) skipping the final
sort fails TestOutputStrictlyIncreasingAcrossDST (daily-rdate-inversion).

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

* chore(schedule): vendor teambition/rrule-go v1.8.2 verbatim into internal/schedule/internal/rrule (TASK-4100 H1, import)

Byte-for-byte copy of rrule.go, rruleset.go, str.go, util.go and LICENSE
(upstream 429348ca4477691b08edb0200b7f837edd32414c), plus PROVENANCE.md.
Not yet imported; no go.mod change. sha256, module cache vs vendored:

3af19c18...8fda  rrule.go    (cache == vendored)
89f1c9ca...ad8   rruleset.go (cache == vendored)
0dc2d364...7d96  str.go      (cache == vendored)
d95c565b...0173  util.go     (cache == vendored)
2c5aa800...470f  LICENSE     (cache == vendored)

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

* fix(schedule): bound rrule expansion with an in-loop step budget and drop the abandoned goroutine (TASK-4100 H1)

- internal/schedule now imports the vendored internal/rrule; go.mod and
  go.sum drop github.com/teambition/rrule-go (go mod tidy).
- Vendored generator: a step budget (RRule.SetBudget / Exhausted) counted
  on every period-loop and inner-skip iteration, matched or not. On
  exhaustion the iterator finishes and records it; no panic.
- Limits.MaxSteps (default 200000) feeds it; exhaustion is a CapScan,
  never a partial result. A 200k-step call costs about 13ms.
- guard no longer starts a goroutine: all work runs in the caller.
  CapTime is now a wall-clock check between yields.
- probeSynced treats an exhausted probe as unsynced.
- doc.go says why the library is vendored.
- Tests: TestBudgetFeb29Minutely, TestBudgetUnsatisfiable (goroutine
  count, wall time, CapScan); TestCapTimeGuard rewritten in-goroutine.

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

* test(schedule): prove no goroutine outlives an expansion call (TASK-4100 H1)

TestNoGoroutineOutlivesCall: MaxDuration 1ms, MaxSteps 2M on the
unsatisfiable rule (about 65 ns a step, so about 130 ms a call, under the
roughly 2.9M-step walk to year 9999, so CapScan still trips first). 20
calls, then NumGoroutine is read with no settle and must equal the count
before.

Mutant history: restoring the goroutine + select + time.After path in
guard stayed green alone at 58bc2a9b3, because the budget bounds the
abandoned goroutine. With this test it is RED:
  budget_test.go:116: goroutines 2 -> 22 right after 20 calls; none may outlive a call

go.mod and go.sum have zero net diff against origin/main 3e5cde148 (the
dependency was added in 08a09e807 and removed in 58bc2a9b3), so no nix
vendorHash heal is owed.

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

* fix(schedule): instant-bound UNTIL, a wall-margin instance stop, a saturating scan estimate and early exhaustion (TASK-4100 r1)

Fixes from review round 1, each with a test and a mutant that goes RED:

- M1 Between's constant +200 early stop is replaced by the wall-margin stop:
  once MaxInstances+1 distinct instants are held, the walk goes on until its
  key passes the bound of the (MaxInstances+1)-th smallest. Tests:
  TestBetweenGapPopulationKeepsEarlierRDate (the New York repro),
  TestBetweenEqualsChainedNextAfter at MaxInstances 5, 50, 500, 100000.
  Mutant (restore +200): "first 2025-03-09 07:30:00 +0000 UTC; want ... 07:10:00".
- M2+L1 a Z UNTIL is an instant bound on the wall-clock path: the library gets
  a generous wall, each resolved rule instant is filtered At <= UNTIL (RDATEs
  exempt), and probeSynced runs in the effective zone. Tests:
  TestUntilZIsAnInstantBound (gap, overlap, inclusive, two all-day rows),
  TestUntilZDoesNotBoundRDates, TestStartSyncedTokyoZUntil.
  Mutants (wall compare; UTC probe): "gap-emits-past: got [... 2025-03-09T07:50:00Z]";
  "StartSynced = false; the rule produces Start".
- L2 checkScan's period*INTERVAL saturates. Test: TestCapScanIntervalProductSaturates
  (213504 and 150000). Mutant (plain multiply): "INTERVAL=213504 NextAfter: cap hit: scan limit".
- L4 a rule whose UNTIL is spent before the query start is not estimated and
  not walked (the library rule is dropped, RDATEs still run); a COUNT rule skips
  the estimate and ends on its own inside MaxSteps. Test: TestExhaustedRuleIsNotACapScan.
  Mutant (no early check): "NextAfter: ok=false err=schedule: cap hit: scan limit".
- L3c the dangling fragment in rfc5545_rows_test.go's header comment.
- H1 reachability: Compile refuses an HOURLY/MINUTELY rule made only of
  INTERVAL, BYHOUR and BYMINUTE whose steps provably never land on them
  (CodeUnsatisfiable), unless an RDATE is present. Any other part, COUNT or
  UNTIL leaves it alone. TestReachabilityCheckIsExact: 5760 cells, 2626
  refusals, 0 disagreements with the vendored engine run directly.
  Mutant (drop the modulus): "Compile refused=false but the engine fires=false".

Size, production code (comments and blanks excluded) / raw, vendored files
excluded: 968 -> 1058 code, 1345 raw. caps 75/110, doc 1/52, engine 276/334,
errors 60/82, rule 421/477, shorthand 41/53, spec 106/131, wall 78/106.

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

* fix(schedule): bound only the no-match stretch (CapStall), keep prefetched answers, and prove the bounds over the conformance grid (TASK-4100 r2)

R2-H1: the vendored step counter restarts at every candidate the generator
appends, so MaxSteps bounds a no-match stretch only. Total work stays bounded
by checkScan (MaxScan) and the CapTime check between yields; candidates our
wrapper discards (window, Z UNTIL, EXDATE) cannot loop unboundedly because
each one is a yield. Tests: TestStallDenseRules (the four reported rules past
their former first-cap day, plus 3 years out, exact expected values computed
independently), TestStallFilteredCandidatesBounded. Mutant: remove the two
resets -> TestStallDenseRules RED ("cap hit: stall limit 200000; want
2025-05-11 00:15:00").

R2-L1: a stall that follows a held occurrence no longer turns it into an
error. RRule.Reached() reports where the expander stood; NextAfter and
Between keep their answer when that is past the bound of the window. Not with
RDATEs (the set drops the stalled rule and may reorder). Test:
TestStallPrefetchKeepsAnswer. Mutant: held=false -> RED ("cap hit: stall limit
2000; want 2104-02-29").

R2-L2: new Kind CapStall (Limit = MaxSteps); CapScan keeps one meaning (Limit =
MaxScan). Documented in caps.go and doc.go. Test: TestStallKinds. Mutant: map
stall to CapScan -> RED ("scan limit 200000; want a stall CapError").

R2-L3: budget_test.go asserts steps, kinds and goroutine counts, no wall
clock; the fabricated CapError closure is gone; TestNoGoroutineOutlivesCall
uses MaxSteps 200k (2 s under -race). m2 mutant (goroutine + select +
time.After in guard) still RED: "goroutines 2 -> 21 right after 20 calls".

Instrument: TestBoundsPropertyOverConformanceGrid. 73 conformance rows (43 RFC
+ 30 extra) x 2 query points, and 3,134 satisfiable grid cells, return what the
vendored engine returns with no budget (NextAfter and Between): 0
disagreements, 0 CapScan. 2,626 unsatisfiable cells: CapStall, Limit
MaxSteps. Default Limits but MaxDuration (a wall clock, lifted for -race).

Compile, slowest: 6.3 ms across the grid and the conformance rows (both noise
on a cheap rule); sparse YEARLY/MONTHLY at most 5.5 ms; the bound is one
MaxSteps probe, about 13 ms.

Sizes (internal/schedule, vendored excluded): code 1,068, raw 1,365. Vendored
delta vs 25ea7f68f: 43 lines.

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

* fix(schedule): merge rule, RDATE and EXDATE streams ourselves with a conservative stall position and one finality rule (TASK-4100 r3 design)

r3 found two Mediums in the stall logic (M1: the Reached() gate dropped an
occurrence at a small MaxSteps; M2: an RDATE starved behind a stalled rule at
default limits) and the stop rule fired: both came from our correctness resting
on the vendored Set's and generator's control flow. Design D1-D4, as ruled.

Order of work: the instrument first (TestNeverAPartialAnswer), shown RED on
ace5b29e6 for M1 and M2 (quoted in ~/.cache/rook/4100-f-red.log), then the
engine.

D1: the engine merges the rule generator (budgeted, alone), a sorted RDATE
slice and an EXDATE set (exact instant, in the candidates' own time space). An
RDATE equal to a rule occurrence yields once. A stalled rule is a stream state.
CapTime is checked on every pass of the merge loop, so a run of skipped EXDATEs
is bounded (engine.go:239). rrule.Set is the test oracle only.
D2: the generator reports StallPos, the start of the first period not fully
examined, set in one place (spend). Reached() and the held-answer machinery are
gone. Branches that can stall and the P each reports: period-loop top, by FREQ:
YEARLY Jan 1 00:00; MONTHLY day 1 00:00; WEEKLY and DAILY 00:00 of the day;
HOURLY hh:00:00; MINUTELY hh:mm:00; SECONDLY hh:mm:ss. Skip loops (HOURLY,
MINUTELY, SECONDLY): the current point plus INTERVAL at the FREQ unit.
D3: one finality rule. Stuck at P is final iff P is past the caller's bound
(the answer's bound for NextAfter, hi + wallMargin for Between); otherwise a
CapStall. merger.stuck is the only origin of CapStall.
D4: TestNeverAPartialAnswer: 73 rows plus sampled grid cells (every 23rd; every
149th under -race: coprime to every axis, since 24 and 150 aliased and dropped
values; the test asserts every axis value is run), M1 and M2 named cases (UTC, Tokyo, New York), MaxSteps in
{1,2,5,50,1000,default}, RDATE none/before/after/coincide, EXDATE
none/first/on-the-RDATE, three query points. Oracle: the vendored Set,
unbudgeted. Run: 330 cases, 27124 permitted CapStalls, 47192 answers equal to
the oracle. Rows run at +0, +400d and +3y. TestMergeYieldsEachInstantOnce holds the dedupe below the settle
that hides it from the public calls.

M2 measured: P lands at 2298-10-17, so the RDATE 2025-06-01T09:00 lies below P
and is returned by NextAfter and Between.

Mutants (each RED, reverted): f1 P inside the unexamined period (M1 shape); f2
stalled rule as an immediate call error (M2 shape); f3 no dedupe (merge test);
f4 EXDATE compared on wall (dst-hourly-ny-overlap); m2 goroutine guard restored
(goroutine count 2 -> 15).

Tests adjusted to the new semantics: budget_test (Between inside the examined
stretch is an empty final answer; a wider window is the CapStall),
TestNoGoroutineOutlivesCall (a final answer is not an error), bounds_test (walk
signature; refWindow moved to oracle_test.go on the vendored Set).

L2: caps.go comment corrected; a COUNT walk is bounded by MaxDuration and
MaxInstances, not by MaxSteps.

Size: production code 1116 lines (was 1068; over the 1,100 cap by 16), raw
1435. Vendored delta vs 25ea7f68f: 41 changed lines (was 43).

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

* docs(schedule): correct the bounds claims and the vendored-delta record (TASK-4100 r4)

Prose only; no Go code, test logic or behaviour changes.

- caps.go: three "bounded by" claims were false after the per-candidate
  step reset (r2). MaxDuration is the only bound on a whole call; MaxScan
  refuses a far wall-clock query except for COUNT and spent-UNTIL rules;
  MaxInstances counts only what Between returns; MaxSteps ends a no-match
  stretch. (r4 H-L2, plus caps.go:21 and the guard comment, found by the
  CONVE-18 grep.)
- PROVENANCE.md: list the whole vendored delta (budget, per-candidate
  steps reset, stall/StallPos/spend), and state that rruleset.go, str.go,
  util.go and LICENSE are byte-identical to upstream (sha256 matches the
  table). (r4 H-L3)
- TestNeverAPartialAnswer: say that its oracle shares the vendored
  generator, so recurrence correctness is the conformance table's job.
  (r4 C-L1)

Built with Codex (gpt-6.1-sol) from a spec; the PROVENANCE verification
line is mine.

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

* test(schedule): check the stall position against calendar arithmetic (TASK-4100 H')

The false-cap half of TestNeverAPartialAnswer judged a CapStall by the
generator's own StallPos, so a P that is too LOW was never caught: two
one-period-lower mutants of spend() left it green (r4, H step 1).

TestStallPosClosedForm checks P with RFC 5545 calendar arithmetic and no
generator call, on never-firing stretches from DTSTART, at budgets 1, 2, 3
and 5:
- YEARLY/MONTHLY/WEEKLY/DAILY (one charge per period, rrule.go:615):
  P == the period start S*INTERVAL periods on (WEEKLY aligned to WKST).
- HOURLY/MINUTELY with no day-level BY part (one charge per inner step,
  :774/:795): P == DTSTART + S*INTERVAL units. Includes New York cases
  across spring-forward and fall-back. These run on the wall-clock path,
  where the generator sees naive walls, so they confirm the zone never
  reaches it; they do not test DST handling inside the generator.
- Anything else (a filtered day jumps for free, :770/:791): only
  P >= DTSTART + floor(S/2)*INTERVAL units. This floor cannot see a P one
  INTERVAL low; the blind spot is stated in the test.
Equality catches a too-high P as well as a too-low one.

Acceptance (~/.cache/rook/4100-Hp/acc-*.log, line 1 carries the sha):
mutant A (period-loop top one period low) fails all 7 fixed cases at every
S; mutant B (inner loop one interval low) fails all 7 unit cases at every
S; floor mutant C (top-of-loop P = DTSTART) fails 3 of 4 floor cases at
S=2; the unmutated tree is green.

Built with Codex (gpt-6.1-sol) from a spec; reviewed and run here.
Test-only: production size unchanged (1,116 / 1,150).

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

* fix(schedule): refuse rules denser than 10,000 candidates per period, and parse integers by RFC 5545 grammar (TASK-4100 final round)

A 465-byte rule (YEARLY with every BYDAY, BYHOUR, BYMINUTE and BYSECOND
value) compiled in ~15 s and ~4.3 GiB, and NextAfter overran MaxDuration
~40x: the generator fills a whole period's candidates before its first
yield, and no cap is checked inside that (final-round H1, reproduced).

- periodDensity: an upper bound on one FREQ period's candidates from
  RFC 5545 3.3.10's expand/limit table (days per period x the expanding
  time parts). ParseRule refuses a rule over maxPeriodCandidates (10,000)
  with CodeRange before any expansion, so Compile's probe never runs.
- RFC 5545 integer grammar: a sign only where the grammar allows one
  (BYMONTHDAY, BYYEARDAY, BYWEEKNO, BYSETPOS, BYDAY ordinals) and the
  grammar's digit widths. INTERVAL=+2, COUNT=+3, BYHOUR=-0 and BYMONTH=001
  are now CodeSyntax; COUNT=-1 and INTERVAL=-2 move from CodeRange to
  CodeSyntax for the same reason.
- Prose made true: MaxDuration bounds a call to within one period's
  expansion, which the density cap limits; a far COUNT query ends in
  CapTime; the package reads the clock for that deadline; Wall is not an
  occurrence's identity on the elapsed path.
- Tests: the bound >= the generator's real per-period count for every
  FREQ x part (peak/bound 1.000 on the sampled cases); the cheap refusal
  (<50 ms, <1 MiB); a census of conformance rows, satisfiable grid cells
  and real-world dense shapes; the grammar; Pacific/Apia 2011-12-30.

Evidence (seat-run, not the builder's): ~/.cache/rook/4100-F1/tests.log
(full package ok), tests-v.log, threshold.log (worst admitted rule,
density 9,882: Compile 0.88 ms / 964 KiB; NextAfter equals the oracle at
default Limits with 42.9x-142x margin under MaxDuration), density-mut.log
(365-day and dropped-BYMINUTE mutants both red).

Size: production code 1,116 -> 1,150 (cap 1,150); raw 1,441 -> 1,499,
over the 1,450 raw cap; per-file numbers are on the trail for ruling.

Built with Codex (gpt-6.1-sol) from a code-only spec; tested here.

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

* docs(schedule): state the UNTIL type exception, the density refusal and MaxDuration's real overrun (TASK-4100 3a)

Comment-only, per the lead's 3a final-round ruling:
- doc.go: ParseRule refuses what RFC 5545 forbids EXCEPT an UNTIL whose
  value type differs from DTSTART's, which is accepted as Until defines it
  (C-M1, option B), and the refusal list names the 10,000-candidate density
  cap (H-L3).
- caps.go: MaxDuration is checked between yields, so a call can overrun it by
  one period expansion or one MaxSteps no-match stretch, and MaxSteps has no
  ceiling (H-L1).

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

* chore(lint): exclude upstream rrule-go's lno1wkst dead store, narrowly (TASK-4100)

golangci-lint's ineffassign flags `lno1wkst = 0` in the vendored
internal/schedule/internal/rrule/rrule.go. The line is upstream v1.8.2,
verbatim since the import commit. Exclude that one finding (path, linter
and text) rather than edit upstream code or unlint the whole package, so
every other linter still checks our budget/stall delta in the same file.
PROVENANCE.md records the exclusion.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS
2026-10-28 18:14:23 -04:00
xarmian b8b610812c feat: a per-user time zone for calendar days, backend (TASK-4101 PR1) (#2337)
* feat(users): users.timezone column, internal/usertz and the conditional set-if-unset write (TASK-4101)

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

* feat(auth): timezone on /auth/me and the session user, PATCH with if_unset, tz-mismatch dismissal keys (TASK-4101)

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

* fix(server): compute overdue, standup and stamped dates in the viewer's zone, UTC when unset (TASK-4101)

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

* test(server): regenerate the cloud contract goldens for the additive timezone field (TASK-4101)

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

* fix(store): add the Postgres twin of the users.timezone migration (TASK-4101)

pgmigrations has its own numbering; 160 alone left the column missing on
Postgres, and make test-pg failed on 'column "timezone" does not exist'.

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

* feat(store): NUL invariant triggers for users.timezone (TASK-4101)

The census exists so no single validator is the only guard (ruling
1431c980 item 1): protect the column with its own post-160 trigger file
instead of excluding it.

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

* fix(server): decode a dismissal key once, and test the viewer zone through /me/needs (TASK-4101)

A double-encoded %252F reached the handler already decoded once (net/url
keeps no RawPath for it), and the second PathUnescape read it as a slash.
Unescape only when chi routed on RawPath. Adds the unescape cases, the
too_many_dismissals code assertion, an HTTP-level test that a zone ahead
of UTC makes an item overdue on /me/needs, and the api.md notes for the
cap and the additive dashboard/standup fields (ruling 1431c980 items 2-5).

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

* fix(server): the standup test expects the UTC day, and the zone note names no PR2 surface (TASK-4101)

The standup date is the viewer's zone, UTC when unset; the test still
compared against the host's local day and failed on any non-UTC host
when the two dates differ. The note pointed at the web settings and
`pad auth timezone`, which land with PR2 (ruling 00877b60).

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-28 14:51:33 -04:00
xarmian d36f11cf40 fix(server): a workspace whose owner no longer resolves never yields a '//' path (BUG-4050) (#2334)
* fix(server): a notification whose workspace owner no longer resolves has no deep link, never a '//' path (BUG-4050)

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

* fix(needs): a needs row whose workspace owner no longer resolves has an empty path and no link, never '//' (BUG-4050)

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

* fix(needs): fail the request on an owner lookup error instead of reading it as no owner (BUG-4050)

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

* test(needs): an owner lookup error must answer 500, not merely not-200 (BUG-4050)

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

* test(needs): rebind the owner-corruption UPDATEs so they run on Postgres (BUG-4050)

The raw ? placeholders were a syntax error under make test-pg, so both
postgres subtests failed before reaching the handler.

Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
2026-10-28 10:02:06 -04:00
xarmian 875bbaa24e feat(attachments): sandboxed in-pane PDF viewer with page navigation (TASK-4075) (#2333)
* build(web): pin pdfjs-dist 6.4.299 for the in-pane PDF viewer (TASK-4075)

Exact pin, no overrides. The lockfile gains @napi-rs/canvas and its 11
platform packages: pdfjs-dist declares it as an optional dependency for its
Node canvas, npm records the ideal tree for every platform, and nothing in a
browser build reaches it.

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

* feat(web): 'pdf' surface renderer, exact MIME predicate, 25 MiB cap and bytes loader (TASK-4075)

canPreviewAsPdf admits exactly application/pdf. PDF_PREVIEW_MAX_BYTES is its
own 25 MiB constant with the measured receipt. createViewerBytesLoader is the
text loader's sibling: same phases, same two size gates, same staleness
fence, but it delivers bytes. Corrects the display.ts comment that called the
text set 'deliberately outside' the server mirror, which text/plain never was.

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

* feat(web): AttachmentPdfPreview, a canvas-only pdf.js page viewer (TASK-4075)

pdf.js is imported on mount (its own lazy chunk) with useWasm:false, no url,
no scripting and a copy of the bytes (getDocument detaches its buffer). The
worker is our own Vite asset. A Vite plugin copies only the two no-wasm
JPX/JBIG2 fallbacks to a fixed path. DPR is capped at 2 and each page at 16 MP.
No text layer, no annotation layer, no PDF-derived markup.

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

* feat(web): the Lightbox PDF arm (TASK-4075)

A third byte-loader arm beside text/table: bytes loader armed, the other two
disposed; loadKey carries the resolved size; the stage gives up its
touch-action claim; the wheel exclusion and arrow guard cover the PDF
scroller; PageUp/PageDown turn pages while Left/Right stay gallery paging.
Too-large and error reuse the text arm's copy and Retry.

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

* fix(web): leave the desktop toolbar room above the PDF arm (TASK-4075)

Found in the browser: the toolbar floats over the stage's top centre and
intercepted clicks on the PDF's own page controls.

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

* test(web): PDF viewer browser specs, emitted-bundle scan, fixtures (TASK-4075)

E1-E5 against the built binary, with the CSP instrument placed in the pdf.js
worker's own scope and a control leg that forces wasm back on. U8 scans the
emitted chunks for eval/Function call sites and @napi-rs. The two real JPX and
JBIG2 fixtures come from pdf.js's test corpus (Apache-2.0), origin beside them.
The fallback-arm specs that used a PDF as their no-renderer file now use a ZIP,
as the fixture's own note said they would.

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

* test(web): U3 per-arm loader disposal and the late-size re-decision (TASK-4075)

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

* test(web): settle the PDF e2e legs against the built binary (TASK-4075)

A PDF tile emits a single-entry set, so the gallery legs became inert-arrow and
no-sideways-movement legs (gallery paging stays a unit test). The over-cap leg
reports the size through the attachment list, since the 25 MiB upload cap equals
the 25 MiB preview cap. Dialog and popup instruments get control legs.

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

* test(web): pin the loader's id fence and silent AbortError (TASK-4075)

Two mutants survived the first run: dropping the id half of the staleness fence
(only a completion after dispose() distinguishes it from the token half) and
dropping the AbortError check (only an abort the loader did not issue reaches it).

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

* test(web): scope the E2 request check to other origins (TASK-4075)

The first gate run caught my own over-broad assertion: with the whole attachment
suite running in parallel, the app's background items-changes reads landed inside
the click window. What the leg pins is that nothing leaves the server's origin.

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

* fix(web): bound the PDF viewer's reads, images and canvas sides (TASK-4075 r1)

F1: one shared bounded byte reader (boundedBytes.ts) behind the PDF bytes
loader and the text loader: Content-Length precheck, no whole-buffer
fallback (a non-stream body is the error phase), residual one-chunk
overshoot disclosed. F2: pdf.js maxImageSize 64 MP. F3: pageRenderSize
returns null for non-finite or non-positive input. H1: PDF_MAX_SIDE 16384.

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

* docs(web): maxImageSize bounds every PDF image except JPX (TASK-4075 r2, H2)

Claim correction only, no runtime change. pdf.js checks the dictionary W/H
(pdf.worker.mjs:34283-34289); for JPXDecode the size is then re-read from the
codestream (:33055-33061, :33084). pdf.js requests a reduced decode sized to
about 2^30 bytes of output; the decoder's peak allocation is unmeasured. The
real bound is TASK-4106.

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

* test(web): make the U8 @napi-rs scan cheap and give it an explicit timeout (TASK-4075 r2, S1)

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

* test(e2e): an embedded TrueType font draws under the real CSP, with a stripped-program control (TASK-4075 r2)

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

* test(web): record the measured U8 scan duration (TASK-4075 r2, S1)

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

* test(attachments): E6 asserts the font-load-failure warning absent, with a rejecting-FontFace control; widen U8 scan (TASK-4075 r3)

H3: collect console warnings for E6 only (opt-in), assert no 'Warning: Failed to load font',
add a permanent control leg injecting a rejecting FontFace. Test name and comment claim only
what is proven; font-src vs ArrayBuffer FontFace is unmeasured.
H4: record the empirical >10% ink threshold (4375 vs 5204, 19%).
H5: U8 also flags eval?.(, Function?.( and tagged-template Function; tripwire wording, CSP named as the wall.

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

* test(e2e): E6 blank-stripped page must paint, claim only no font-load failure, control reads at the same point (TASK-4075)

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

* fix(web): page-number box takes 0/-/+/=; clear stale canvas on bad page size; test pins (TASK-4075 r5)

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

* test(web): L2 pin observes the canvas box reset (TASK-4075 r5)

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

* fix(web): pin the open-effect renderFailed reset and drop the redundant phase guard (TASK-4075 r5 survivors)

This applies the lead's day-40 ruling on the two fix5 mutant survivors. The analysis is on the TASK-4075 trail.

(a) l2-renderFailed-reset-removed: a reachable window exists, so the line stays and is pinned. A new document whose scroll region measures 0 wide never reaches the render effect's own reset. Without the open-effect reset, the old document's "couldn't be drawn" alert stays over the new document. The new unit test fails with that line removed (AssertionError on the stale alert).

(b) rekey-phase-guard-only-removed: no reachable state, so the clause is removed. The preview mounts only in the ready branch, and svelte's bind:this nulls pdfPreview in the same flush that unmounts it. There are no outro transitions, so a keydown can never see a non-null preview with the phase off ready. The two re-key tests stay as the pins on the null guard.

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

* fix(web): keep an over-cap read "too large" when the stream rejects the cancel, and make E2's link clicks land (TASK-4075 r6)

This applies the lead's day-40 r6 rulings. The triage and the rulings are on the TASK-4075 trail.

r6a2-L1: once the streaming count passed the cap, readBoundedBytes awaited reader.cancel() with no guard. An errored stream rejects that cancel, so the read threw and the PDF viewer showed the generic "couldn't be loaded" instead of "too large". The bytes were still refused either way. The cancel is now best effort, the same shape as the Content-Length precheck's body.cancel(). That makes it 3 cancel sites in the change: two stream cancels, both now guarded, and pdf.js's synchronous RenderTask.cancel, which has no rejection path. There are two new tests: one at the reader, and one at the loader with a real ReadableStream whose cancel rejects. Both fail with the guard removed (the loader reports 'error', not 'too-large').

r6a-L2: E2 clicked at 0.87 of the canvas height. The JavaScript link spans 0.747-0.874, and nothing checked that the point was in view. The clicks now go to each link rect's centre, scrolled to the middle of the scroll region, and the test first asserts that elementFromPoint at that point is the canvas. These clicks prove no more than the source pins do: the viewer has no annotation layer.

r6a-L3: fix the indentation of the zoom test's closing brace.

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

* test(web): say exactly what the rejected-cancel stream demonstrates (TASK-4075 r7-codex-L1)

Comment only. The test's stream has an underlying cancel that rejects; it is not an errored stream. An errored fetch body is one way a cancel rejects, which the comment now says instead of claiming the setup reproduces it.

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

* docs(attachments): state boundedBytes' real peak memory (TASK-4075 r8)

Comment only. The header said peak memory is "at most max plus one chunk";
that holds only on the refusal path. An accepted read holds the chunks and
the joined copy at once (about 2 x max), and the PDF caller's .slice()
handoff adds a third copy (about 3 x max) until the chunks are collected.
Lead-ruled fix-now on the r8 triage (H-L3), under the comment-only protocol.

Claude-Session: https://claude.ai/code/session_01Pa22hHpfXoZbQkEFvPtNeS
2026-10-28 09:04:35 -04:00
xarmian b0accdca9c chore(deps): give @tiptap/* its own dependabot group, out of the npm tooling group (TASK-4107) (#2330)
dependabot #2327 bumped the three Tiptap packages (3.31.4 to 3.32.0) inside npm-minor-and-patch, alongside unrelated tooling, and went red. CLAUDE.md requires @tiptap/core, @tiptap/extension-collaboration and @tiptap/y-tiptap to move together, exact-pinned, and never in a tooling group.

The new npm /web group `tiptap` (patterns @tiptap/*, no update-types, so majors stay coordinated too) is listed first, because dependabot assigns a dependency to the first group that matches it. npm-minor-and-patch also excludes @tiptap/*, so a later reorder cannot put Tiptap back in the tooling group.

Built by Codex (gpt-6.1-sol) from the seat's brief; reviewed and verified by the seat.

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL
2026-10-28 03:41:49 -04:00
xarmian 6dfc431402 fix(deps): bump golang.org/x/net to v0.62.0 for GO-2026-6866 and GO-2026-6860 (TASK-4115) (#2331)
* fix(deps): bump golang.org/x/net to v0.62.0 for GO-2026-6866 and GO-2026-6860 (TASK-4115)

govulncheck fails the Go and Nix CI gates on every PR. golang.org/x/net v0.61.0 (indirect) has two advisories, and pad's code reaches both: GO-2026-6866 (html.Render does not check every ancestor namespace transition when rendering raw text) and GO-2026-6860 (html.Parse/ParseWithOptions take quadratic time when coalescing text nodes). Both are fixed in v0.62.0, published 2026-10-27T19:00:16Z, after main last went green.

go get golang.org/x/net@v0.62.0, then go mod tidy. Only x/net moves: one go.mod line and two go.sum pairs. No other module changes.

govulncheck -mode binary on cmd/pad built from this tree reports "Your code is affected by 0 vulnerabilities", exit 0. The control, the same scan on a base build (x/net v0.61.0), reports both advisories, exit 3.

v0.62.0 is younger than the 7-day dependency-age threshold, so it is allow-listed in .github/dep-age-allow.txt as a security fix citing both advisories. The nix vendorHash follows from the PR's Nix job.

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

* chore(nix): vendorHash for golang.org/x/net v0.62.0 (TASK-4115)

Taken from the got: line of this PR's Nix job (job 122632750043, 04:34:55Z): specified sha256-uEIFUop7X4VHmbICrH6CW7SL1GB4tjmIIcHGKLkYmUU=, got sha256-ykiFPHwGu7Lr4NSJs45HuKj7rqfsBDUKtjiCTDfYkiA=. The job recomputes the hash before building, so its green result alone did not show the committed value was current (CONVE-3888).

Claude-Session: https://claude.ai/code/session_01Pa22hK3vXbxQYYyvvvRGYL
2026-10-28 02:02:09 -04:00
github-actions[bot] 3e5cde148e chore(nix): heal vendorHash for the module set in aaea719b3b (TASK-2954) 2026-10-27 10:59:21 +00:00
dependabot[bot] aaea719b3b chore(deps)(deps): bump github.com/go-chi/chi/v5 (#2326)
Bumps the go-minor-and-patch group with 1 update: [github.com/go-chi/chi/v5](https://github.com/go-chi/chi).


Updates `github.com/go-chi/chi/v5` from 5.4.0 to 5.5.0
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.4.0...v5.5.0)

---
updated-dependencies:
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-27 06:44:44 -04:00
xarmian 7cd91634dd feat(web): read-only chat thread at /messages/[n] (TASK-4044) (#2329)
* feat(web): chat message wire types, listMessages, markRead and the window model (TASK-4044)

Carries the parked chat hunks the read-only thread first uses: ChatMessage,
ChatQuote, ChatRef and ChatMessagesResponse, api.chat.listMessages and
markRead, and the whole chat/model.ts with its test. ChatRef lands here
because ChatMessagesResponse refers to it. ChatConversation.kind narrows to
'direct' | 'group' (migration 158 CHECK); a plain .test.ts pins it because
svelte-check excludes *.svelte.test.ts.

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

* fix(web): chat agent-name trim matches Go, and a production chatStore.stop() is inert (TASK-4044)

normalizeAgentName now trims the White_Space property like Go's TrimSpace
instead of JS trim(), which also trims U+FEFF (BUG-4092). A test-only Go row
pins the same five inputs. chatStore.stop() now outdates an in-flight answer
and makes onHint and resync no-ops until start() (rulings f05fd7c4, db5556a5).

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

* feat(web): the read-only chat thread at /messages/[n] (TASK-4044)

Ordered by seq, with an "N new" divider frozen at the read marker as of open,
Jump to unread, a quote chip that shows the excerpt only, a "sent from" label,
and a window that follows the server live: a refresh pages new rows from the
newest end (never missing one in a window of any size), takes tombstones from
the newest 200, and never appends past the gap of a long unread run. Marks read
only from the bottom sentinel on a visible tab, with the displayed seq.

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

* test(web): e2e for the read-only chat thread against the real server (TASK-4044)

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

* test(web): fix the e2e fixtures' mine expectation and the read-count bound (TASK-4044)

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

* test(web): the e2e opens after the read marker, so seq 1 is one Load earlier away (TASK-4044)

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

* test(server): write the trim-parity inputs as escapes, not literal characters (TASK-4044)

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

* test(web): census entry for the thread's seq-keyed each (TASK-4044)

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

* fix(web): close the chat thread on an identity change; drop E8's unfalsifiable 429 leg (TASK-4044)

r1 H1: #reset() now clears #n and sets status 'loading', so a version
bump after an identity change fetches nothing as the new identity. A
swap already reloads the tab and a sign-out navigates away; this is
defence in depth for a consumer that does neither. U14b pins it.

r1 C2: the e2e server runs with PAD_DISABLE_RATE_LIMITS, so E8's
"no 429" assertion could never fail. It is removed and the title says
what the spec pins (reads per message); the burst is checked against
real limits out of band.

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

* fix(web): re-mark rows that land during an in-flight mark-read; E8 counts both tabs (TASK-4044)

r2 hunter: the "window grew while in flight" re-mark was queued as a
microtask inside .then, so it ran before .finally cleared #marking and
returned early. It now runs in .finally, and only when the mark
succeeded and the window grew past the seq just marked. U17b-d pin the
retry and both gates.

r2 Codex: E8's per-tab bound was asserted on one tab only. Both tabs
are counted now, each at 10*4+4.

Claude-Session: https://claude.ai/code/session_01Y4xxtVpSpGmKykhWznQEdq
2026-10-26 16:59:51 -04:00
xarmian a8af168304 fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090) (#2328)
* fix(cli): session liveness reads unknown, not dead, across a PID namespace (BUG-4090)

The session registry's verdict probed the owner pid with kill(pid, 0), and
a pid means something only inside one PID namespace. From a sandbox or
container shell (bwrap --unshare-pid) every host pid is ESRCH, so
`pad session list` read every live host session dead, and `pad session
prune` - and the prune that follows every `pad session register` -
deleted their records.

Records now carry pid_ns, the namespace the owner pid was captured in,
stamped only when that pid is signalable there. The reader compares:
across a boundary the pid is not probed and a missing socket is not
evidence, so the verdict is unknown (kept by prune, untrusted by the arm
gate). Only a socket answering with a different identity stays dead. A
row without pid_ns read from a non-init namespace keeps a positive pid
probe and turns a negative one into unknown. Headless arm-state files
carry pid_ns too.

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

* fix(cli): judge a session's socket by its mount namespace (BUG-4090 r1)

The socket half of the liveness verdict now keys on the mount namespace
the socket was seen in (mnt_ns, stamped only beside a socket stat'ed with
its identity). A reader with another filesystem view gets unknown for a
missing or different node; a row without mnt_ns keeps the old rule, so a
container reading its own rows (or the Nix sandbox) behaves as before.
The unverifiable prose now says "from here (this platform, or another
namespace)", and the bwrap test gains a leg for a pre-fix, unstamped row.

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

* fix(cli): a foreign-namespace reader never manufactures dead; chat direction stamps mnt_ns (BUG-4090 r2)

An unstamped row read from a non-init PID namespace now reads alive only
on a start-token match: a mismatch, a vanished pid or a zombie at that
number describes the reader's namespace, not the owner's, and is unknown.
The chat-direction file, the third socket-identity writer, records its
socket's mount namespace like the registry and the arm file. The help and
CLAUDE.md no longer overclaim for rows written before this version.

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

* fix(cli): an unidentifiable namespace is a possible boundary on Linux (BUG-4090 r3)

A reader that cannot read /proc/self/ns/{pid,mnt} (a sandbox with no
procfs) no longer treats an unstamped row as same-namespace: its ESRCH or
missing socket is unknown, not dead. A Linux writer in that position
stamps pid_ns/mnt_ns with the sentinel 1, which no reader's namespace
matches, so every reader treats the row as crossed. Off Linux, where
namespaces do not exist, nothing changes.

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

* fix(cli): an empty start token is not a match for a foreign-namespace reader (BUG-4090 r4)

An unstamped row with no proc_start, read from a non-init or unreadable
PID namespace, read alive whenever any process answered at its number
there. That can honour a headless arm or disarm on an unrelated process.
With nothing to match, such a reader now says unknown; the bare-probe
residual stays for same-namespace and init readers.

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

* test: namespace-correct session fixtures for runs outside the init pid namespace (BUG-4090)

The Nix check runs go test in its build sandbox's PID namespace, where an
unstamped registry row is judged by BUG-4090's foreign-reader rule. Three
cmd/pad tests hand-wrote such rows and expected init-namespace verdicts.
The two dead-pid fixtures are now stamped with the test's own pid_ns, so
they mean "same namespace, dead pid" anywhere; the v1 legacy-row table
assertion follows the test's namespace. internal/cli gains a seam test
that drives the legacy row's verdict through init, non-init and
unreadable readers on every runner.

Claude-Session: https://claude.ai/code/session_01BpZwJcFxRtM9Wax4fHhDNC
2026-10-26 15:34:50 -04:00
xarmian 58c53d68eb feat(web): agent roster on /messages with Start chat, and a sign-out redirect (TASK-4044) (#2324)
* feat(web): ensureConversation client call, carried from the chat window work (TASK-4044)

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

* feat(web): chat roster, ensureDM and a reference-counted presence poll (TASK-4044)

Ruling 018faf64: counter clamped at 0, DM keys filtered to kind dm, ensure fenced by identity.

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

* feat(web): roster rows with Start chat on /messages, and a sign-out redirect (TASK-4044)

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

* test(web): census entry for the roster each (TASK-4044)

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

* fix(web): roster keeps DMs by the server's 'direct' kind; presence owners are balanced (TASK-4044)

The roster filtered on kind 'dm', which the server never writes
(migration 158 allows 'direct' and 'group'), so against a real server
every conversation dropped off the roster. Fixtures are corrected to
'direct', the Q3 row now uses a group conversation, and an e2e spec reads
the server's real value. The chatInbox afterEach asserts no presence
timer is left armed before one safety drain, so a leaked owner fails.

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

* fix(web): roster ensure does not navigate after the page is left (TASK-4044)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-26 08:35:18 -04:00
xarmian 36d6a27ca3 feat(web): agent presence in chat, with a presence dot and Try again on the thread placeholder (TASK-4044) (#2323)
* feat(web): carry the agent presence roll-up for chat (TASK-4044 2b-i)

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

* feat(web): chatInbox presence poll with startPresence/stopPresence (TASK-4044 2b-i)

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

* feat(web): PresenceDot component (TASK-4044 2b-i)

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

* feat(web): presence dot and Try again on the chat thread placeholder (TASK-4044 2b-i)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-26 04:12:48 -04:00
xarmian cd63dacae4 feat(web): Chat links in the user menu and You sheet, Back from chat, and a retry for a failed unread fetch (TASK-4044) (#2322)
* feat(web): chat Back link and last-workspace memory, parked for 2a-ii (TASK-4044)

Restores what 8c6cb0280 moved out of 2a-i, byte-identical to bb37ef0e2:
ChatBackLink, the lastWorkspace store, the workspace layout's write and
their tests, and the two Back cases in messagesRoutes.

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

* feat(web): retry a failed chat unread fetch after 15s (TASK-4044)

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

* feat(web): chat links in the sidebar, user menu and You sheet (TASK-4044)

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

* test(web): pin that a success cancels an overlapped retry and an identity change cancels it for the new account (TASK-4044)

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

* test(web): pin that a hint or resync near the retry's due time gives one fetch (TASK-4044)

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

* fix(web): a chat fetch in flight at stop() that then fails arms no retry (TASK-4044)

A stop epoch, captured with the seq at the start of each fetch, gates the
retry in the catch. Not a #started check: a never-started store driven by
resync()/onHint() is the supported test seam and must still retry.

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

* refactor(web): no Chat link in the sidebar footer row (TASK-4044)

With it, the desktop footer row overflowed its 235px (content 248px), the
bell was clipped and Settings wrapped, failing the BUG-2844 guard spec. Chat
stays in the TopBar user menu and the You sheet.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-26 02:35:33 -04:00
xarmian a0d6437ae6 test(testtmp): decide a template dir's owner by a held flock, not its pid (BUG-4088) (#2321)
testtmp.Sweep judged a template directory's owner dead with kill(pid,0)
on the pid in its name. A pid only means something inside one PID
namespace, and /tmp is shared across namespaces. A test binary run under
bwrap --unshare-pid got ESRCH for every host pid and deleted live
storetest templates mid-run ("copy template db: no such file"). In the
other direction, a pid recorded inside a sandbox can name an unrelated
host process and keep a dead owner's directory forever.

MkdirTemp now builds the directory under a staging name no sweep
matches, takes a flock on owner.lock, and renames it into place. The
lock follows the inode across the rename, and the fd is O_CLOEXEC and
held for the life of the process. Sweep removes a directory only when
it can take that lock. A directory with no owner.lock (made before this
change) keeps the old pid rule. Windows is unchanged.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-26 01:22:18 -04:00
xarmian b9b0aa784b test(cmd/pad): restore the root flag globals after tests that parse them (BUG-4086) (#2320)
workspaceFlag, formatFlag and urlFlag are bound to the root command's
persistent flags, so a test that parses --url/--format/--workspace
in-process writes process state that every later test reads. urlFlag
outranks PAD_URL, so a leaked URL sent a later test's CLI to an earlier
test's closed stub server. Default order hid it: building any root
re-binds the flags and zeroes the globals. Under go test -shuffle, 11 of
12 random seeds failed at ccb4ed4a2.

Four tests wrote the globals without restoring them:
TestHeadlessSetupMissingFlag, TestHeadlessInitPathSuccess,
TestSessionActivityParentNeverReports and
TestSessionActivityChildExplicitIdentity. Each now calls
preserveRootFlagGlobals(t) first. Every other writer in cmd/pad already
restores. Test-only.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-26 00:10:26 -04:00
xarmian 91b01fc4ee feat(web): /messages chat inbox list and placeholder thread (TASK-4044) (#2319)
* feat(web): /messages chat inbox list and placeholder thread (TASK-4044)

The inbox list at /messages (URL-only, no link points at it yet) and a
placeholder /messages/[n]. The root layout gives /messages no workspace
shell; chatStore gains a `version` bumped after each successful unread
fetch, and chatInbox reloads the list from one effect on it.

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

* test(web): pin the chat inbox identity fence on its own (TASK-4044)

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

* test(web): census the chat conversation list's keyed each (TASK-4044)

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

* docs(web): give chatStore.subscribe an accurate doc comment (TASK-4044)

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

* refactor(web): move the chat Back link to the next PR (TASK-4044)

The 2a-i size ruling on TASK-4044 moves Back (ChatBackLink, the
lastWorkspace store, the workspace layout's write into it and their
tests) to 2a-ii, so entering and leaving chat ship together. /messages
is reachable by URL only until then, and the browser's Back works.

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

* fix(web): let a failed chat inbox list be retried (TASK-4044)

The list reloads only when chatStore's unread fetch succeeds, so a failed
list fetch beside a good unread fetch had no way back until the next
focus or hint. The failure state now has a Try again button that calls
chatInbox.load() (r1 hunter Low #1, ruled FIX).

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

* fix(web): say when a chat list refresh fails, and fence the placeholder by identity (TASK-4044)

r2 rulings on TASK-4044:
- A failed reload kept the last list silently. The notice and Try again
  now show whenever the list fetch failed, above a kept list too.
- /messages/[n] fenced its answer by seq only. It now also drops an answer
  that outlives the identity it was asked for, like the inbox and the
  unread store.
- The root layout test asserted any aria-live node as the toast surface.
  It now asserts ToastContainer's own markup, and its name no longer
  claims dialogs it does not check.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 23:48:49 -04:00
xarmian 17bfe78389 feat(web): relay chat inbox hints to a chat unread store (TASK-4044) (#2318)
* feat(web): chat unread types and api.chat.unread client (TASK-4044)

ChatUnread / ChatUnreadResponse and api.chat.unread over
GET /me/chat/unread, the only chat surface the unread store needs.
Sliced by deletion only from the parked logic commit 98247acb8
(TASK-4044 packaging ruling S2); the other conversation and message
types and methods ship with the PRs that first use them.

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

* feat(web): relay chat inbox hints to a chat unread store (TASK-4044)

The access stream now asks for chat_inbox=true and hands kind 'chat' notices to a new chat store instead of refetching workspaces. The store keeps the unread total and per-conversation counts from GET /me/chat/unread, treating each hint as a refetch trigger only: 400ms trailing debounce, one fetch in flight, one dirty follow-up, immediate fetch on resync, and an identity fence plus seq so a previous account's answer never lands.

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

* fix(web): isolate chat hint subscribers; drop overclaiming chat comments (TASK-4044)

r1 rulings: a subscriber that throws no longer aborts the hint fan-out
(try/catch per call, logged), pinned by a test; and the api.chat
docblock and the types header's wire-shape lines, which described
surfaces this PR does not carry, are removed by deletion only.

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

* docs(web): drop the chat subscribe comment that names a surface this PR lacks (TASK-4044)

r2 ruling: deletion only.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 21:57:57 -04:00
xarmian 25153bcda5 fix(decision): a failure the store refuses is still counted, and an empty message is never a supersede (BUG-4022) (#2317)
* fix(decision): a failure the store refuses is still counted, and an empty message is never a supersede (BUG-4022)

FailDecisionJob's UPDATE carried the failure text. When the store refused
it, the attempt went unrecorded: attempts stayed 0, the backoff never grew,
the permanent drop never fired, and the job was re-run (a paid provider
call) every lease, forever. It now fails open: one fallback UPDATE with the
same WHERE and lease records the attempt with a fixed last_error, plus the
SQLSTATE when it has the fixed five-character shape. Both errors are
returned only if the fallback is refused too.

A failure whose cause has no text (nil, or Error() == "") was read as a
supersede, releasing the claim with no attempt and no last_error, so the
job was re-run on every tick. releaseDecisionJob is split into
releaseDecisionClaim (complete, release) and recordDecisionFailure (fail),
and an empty cause records a fixed message.

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

* fix(decision): the fallback counts an attempt only if the first write did not (BUG-4022 r1 H1)

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 19:51:26 -04:00
xarmian ccb4ed4a22 test(watch): run the identity-change monitor test on synctest's virtual clock (TASK-4067) (#2316)
* test(watch): run the identity-change monitor test on synctest's virtual clock (TASK-4067)

TestMonitorChatIdentityChangeResetsMemory drove runWatchMonitor over a real
loopback httptest.Server and paid real reconnect and identity backoffs (32s).
It now runs in a synctest bubble over an in-memory net.Pipe listener, with
http.DefaultTransport swapped for the bubble's life, and asserts exact
virtual elapsed time per case. Every existing assertion is unchanged; the
handler's 1ms ack poll is replaced by a channel so the times are exact.

TestMonitorChatServerFloorHunterLocalDelivered pins both sides of the
first-contact floor max(server floor, local delivered).

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

* test(watch): state and enforce the DefaultTransport swap's no-parallel precondition (TASK-4067 r1)

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 16:28:57 -04:00
xarmian 6402eb3055 fix(mcp): stdio's item patch gives line_hint remote's rule through one shared helper (BUG-4082) (#2315)
* fix(mcp): stdio's item patch gives line_hint remote's rule through one shared helper (BUG-4082)

stdio's pad_item patch was passThrough, so edits reached the CLI unread:
its JSON decode into bodypatch.Edit.LineHint (an int) refused a string
hint that remote coerces ("5"), and took a JSON number of 2^53 or more,
which mcp-go's float64 decode may already have rounded, as a line number.
Remote has validated each edit's line_hint since BUG-4065, inline in
mapItemPatch.

That loop is now editsWithLineHints, called unchanged by mapItemPatch and
by a new stdio action, actionItemPatch. On stdio:
- a valid hint is forwarded as its int64;
- a refusal is validation_failed with remote's message, naming the edit
  ("(in edits[1])");
- null is absent, and a non-object edit keeps its path.
Neither transport modifies the caller's map.

Tests (lead ruling 86931478): one table, bug4082Cases, runs every input
through both transports and requires the same forwarded edits or the same
refusal (C1). Remote's BUG-4065 rows stay green unchanged. A row on each
transport pins the caller's map (C2). Red on main: stdio forwards "010",
1.5, 2^53, true and {} raw while every remote half passes.

ToolSurfaceVersion 0.105 -> 0.106 (C3), with the changelog in version.go
and docs/mcp.md; the README and the instructions.md heading follow. The
wire goldens follow in the next commit.

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

* test(mcp): stdio's line_hint refusal checked on its own; actionItemPatch's input pinned directly (BUG-4082)

Mutants on 928bc363f: H2 (the helper drops the edit index) turned only
remote red, because stdio was compared with remote's message, which
changed alike. Stdio now also checks the expected text. S2 (the action
writes the new edits into its input) survived: the fan-out handler hands
actions a private copy, so the handler-level C2 row cannot see it. A row
calls actionItemPatch directly.

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

* test(mcp): wire goldens follow ToolSurfaceVersion 0.106 (BUG-4082)

Version string only: the capability version and the instructions heading
in the two initialize goldens. With 0.105 and 0.106 normalised, the old and
new lines are identical.

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

* docs(mcp): name the layer that rounds a 2^53 line_hint (BUG-4082 r1)

r1 hunter Low, ruled FIX (comment and docs only): the actionItemPatch
comment, the v0.106 changelog in version.go and docs/mcp.md said "the
decoder may already have rounded" a hint of 2^53 or more, which read as
the CLI's JSON decode, which is exact. They now name mcp-go's float64
decode of the request.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 14:08:59 -04:00
xarmian acc45070f6 feat(server): deliver chat inbox refs to the owner's own web stream (TASK-4044) (#2314)
A cookie-session stream that opts in with ?chat_inbox=true now receives
kind "chat" notices addressed to its own user, so the global web chat
view can refetch through /me/conversations instead of polling.

- Pref: honoured only for a non-bearer, non-app session; ignored (not
  refused) on any bearer/PAT stream. No armed required.
- Predicate: a notice with an empty target agent key delivers iff the
  stream has chat_inbox and the target user is the stream user. The
  existing agent clause is unchanged; the two cases are disjoint.
- Publish: one human-target notice per human member, plus a self-notice
  for a human author (the store's recipient list leaves the author out),
  deduplicated on the delete path. Human notices are ref-only: excerpt,
  reply_to_seq and target agent key are cleared at publish time. Agent
  notices keep excerpt and reply_to_seq for the monitor.
- docs/api.md: the param, the payload, the ignored-on-bearer rule, and
  the agent-path "never match" sentence scoped accordingly.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 12:00:28 -04:00
xarmian 729ae5f69c fix(mcp): stdio applies remote's number rule before a value becomes CLI text (BUG-4070) (#2313)
* fix(mcp): stdio applies remote's number rule before a value becomes CLI text (BUG-4070)

On stdio, a string for a "number" param reached the CLI verbatim. An int
flag parses it base 0 (pflag's ParseInt(s, 0, 64)), so "010" was 8, "0x10"
16, "1_000" 1000 and "+5" 5; pad_chat delete's positional seq parses base
10, so "010" was 10. Remote refuses all of them (decimalIntString,
BUG-4065). A JSON number was formatted and forwarded whatever its size,
where remote refuses one of magnitude 2^53 or more as possibly rounded
(wholeInt64, BUG-4061 / ba981cb4f).

makeFanOutHandler now runs rejectMalformedNumbers after
rejectUndeclaredKeys. For every top-level param the catalog declares
"number", a string or float value goes through numericInput, remote's
rule, and a failure is refused with validation_failed naming the param,
before any action runs. A value that passes is forwarded unchanged. Other
types keep their path, where the CLI's own parse refuses them.

Tests: rows at a flag param (chat show limit), a positional (chat delete
seq) and a custom handler (chat send reply_to), and a population walk
over every catalog number param (20 today). Red on main: the walk and the
door rows fail there, e.g. "010" dispatched as --limit 010.

ToolSurfaceVersion 0.104 -> 0.105 (lead ruling e522d1be), with the
changelog in version.go and docs/mcp.md; the README, the instructions.md
heading and the wire goldens follow. The goldens move by the version
string only. The nested edits[].line_hint is BUG-4082.

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

* test(mcp): a valid earlier number param does not end stdio's check (BUG-4070)

M5 (the loop stops after the first number param present) survived: every
row sent one number param. A since that passes, then a malformed limit,
must still be refused naming limit.

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

* test(mcp): pin where a non-string, non-float number value goes on stdio (BUG-4070 r1)

r1 row 1 (hunter Low, ruled FIX): nothing pinned the claim that other
types keep their path. Rows for true, false, null, [1] and {} at the flag
door (chat show limit) and the positional door (chat delete seq) assert
the new check passes each through unchanged, and pin the text it is
forwarded as, which pflag's ParseInt or cmd_chat.go's ParseInt refuses.

The claim's wording was narrow: pad_item list and history read limit
through intParam, which refuses these types before the CLI does. The
catalog.go comment, the version.go changelog and docs/mcp.md now name
intParam too.

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

* docs(mcp): null is absent at intParam, not refused (BUG-4070 r2)

Comment and changelog only (lead ruling on r2's Codex Low): the r1
rewording said intParam refuses a non-string, non-float number value, but
numericInput reads null as absent, so a null pad_item list or history
limit takes its default (v0.104). The rejectMalformedNumbers comment, the
v0.105 changelog in version.go and docs/mcp.md now say so; elsewhere the
CLI's own parse refuses all of these types.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 10:12:24 -04:00
xarmian ffecfc744b feat(chat): show chat delivered to an agent to the human at its terminal (TASK-4071) (#2312)
* feat(chat): tee monitor chat lines to a per-session chat-seen log (TASK-4071)

pad watch --stream --for-session appends every chat line it prints to
~/.pad/chat-seen/<harness-pid>.log (0600 in a 0700 dir, O_NOFOLLOW),
capped at 256 KiB with one .1 generation, so the human at the agent's
terminal can see it. No log when the pid is pad watch's own; pruning a
session record removes its log.

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

* test(chat): pin the chat-seen tee, perms, rotation, prune and C2 (TASK-4071)

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

* test(chat): refuse a FIFO that has a reader (TASK-4071 M13)

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

* feat(plugin): show chat delivered to the agent in a band and a toast (TASK-4071)

The pad mod tails ~/.pad/chat-seen/<harness pid>.log from the session's
first use of Pad, starting at the end of the file, and shows new lines in
a band above the prompt (the last three, until the person's next prompt)
and one toast per burst. Lines rotated away between polls are not shown.

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

* refactor(plugin): one rotation guard in the chat-seen reader (TASK-4071 P5)

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

* fix(chat): report a log that is off on its first line, and say what prune and the monitor do with it (TASK-4071)

The monitor prints nothing before it has something to deliver (DOC-2479),
so the no-harness-pid line waits for the first chat line. The pad watch
--stream --for-session and pad session prune help now describe the log.

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

* fix(chat): r1 findings on the chat-seen log and reader (TASK-4071)

- Refuse a symlinked chat-seen directory instead of chmodding its target.
- Reopen a log that was removed under the writer.
- Remove .log.1 before rotating: Rename does not replace on Windows.
- Reader: drop control characters and direction overrides before they
  reach the terminal; clip by code points; one toast per burst on the
  relay's clock; a stop during start leaves nothing running; a failed
  first read no longer replays the log.

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

* fix(plugin): r2 findings on the chat-seen reader (TASK-4071)

- The first look skips what the log held at its stat, so a line written
  between that stat and the read still shows.
- A stop during a poll no longer leaves the reader stuck after the next wake.
- An unreadable moment (failed stat, a symlink) changes nothing, so it
  cannot cause a replay.
- U+061C joins the stripped bidi controls.

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

* fix(plugin): r3 findings on the chat-seen reader (TASK-4071)

- Start a log at zero only when the listing shows it absent; a first look
  that merely failed leaves the start to the first good read (R3-1).
- Restore the byte-size rotation reset beside the character one: a log of
  multi-byte text can shrink in one while it grows in the other (R3-2).

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 07:17:51 -04:00
xarmian 0a32eff274 fix(scripts): install-refresh removes pad.pid only when it is provably not a live sibling's (BUG-4068) (#2311)
* fix(scripts): install-refresh removes pad.pid only when it is provably not a live sibling's (BUG-4068)

The refresh removed <data dir>/pad.pid unconditionally after the install,
whether or not it stopped anything and whoever the file named. A second
server sharing the data dir lost its record, so `pad server stop` and
`pad server status` could no longer find it.

Now the file is removed when it names the pid this refresh stopped, or a pid
that is provably dead (kill -0 fails and ps finds no such process, so EPERM
is not read as dead). It is kept when it names any live process, when its
liveness is unknown, and when it does not parse as a pid record (JSON or the
legacy bare integer). A kept stale file is the safe failure: server stop
refuses a record it cannot prove (BUG-2969). The BUG-4064 tests, which
clear a dead-pid file with nothing stopped, stand unchanged.

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

* test(buildtools): pin install-refresh's pad.pid removal rule (BUG-4068)

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

* fix(scripts,buildtools): pad.pid is read no more loosely than pad writes it, and only ESRCH is dead (BUG-4068 r1)

r1 items, as ruled:
1, 3: pid_file_pid accepts only MarshalIndent's record (one "pid", at the
   two-space top-level indent) or a bare integer with surrounding whitespace,
   at most 10 digits. Duplicate or nested pid keys, compact JSON, "1 2" and
   huge values no longer parse, so the file is kept.
2: pid_liveness reads the builtin kill's own message under LC_ALL=C. Only
   "No such process" is dead; EPERM and anything else are unknown. ps is not
   asked, so a missing, busybox or hidepid-restricted ps cannot call a live
   sibling dead.
4: STOPPED_PID is recorded only when kill -TERM is delivered.
5: the decision is pid_file_removable, tested on its own, so the stopped-pid
   leg (a zombie or a quick reuse) has a row of its own.
6: the dead-pid row uses a reaped pid, not 999999.

Also: the harness guard's testTempScope cuts the test name to 64 bytes, as
Go's makeTempDir does. A top-level test name of 64 bytes or more was refused
by its own temp dir; this unit's long test names found it.

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

* fix(scripts): pad.pid's reader checks the whole record's line shape and caps the pid (BUG-4068 r2)

r2 items, as ruled:
1: pid_file_pid now checks the record's line SHAPE end to end, not only the
   pid line: first non-blank line exactly "{", last exactly "}", every line
   between a two-space-indent key line, one of them the pid. A "\r" is
   dropped only at a line end. This is still a shape check, not full JSON
   validation. The r1 commit's "accepts only MarshalIndent's record" said
   more than r1 did; this commit is what makes the shape claim true.
2: a pid above 4194304 (Linux's largest pid_max) does not parse, so it can
   never wrap as a pid_t.
3: pid_liveness states that ESRCH is per pid namespace.
5: the stopped-pid test comment is corrected: the e2e pre-server is a zombie
   until cleanup reaps it, so the e2e stopped-server rows reach that leg too.

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

* docs(scripts,buildtools): pid_file_pid's comments state the inner-line check exactly (BUG-4068 r3)

Comment-only. pid_file_pid checks that each line between the braces STARTS
like a two-space-indent quoted key; the rest of such a line is unchecked.
The comments said "every line between a two-space-indent key line", and
"read no more loosely than the SHAPE", which claimed more than the pattern
checks (r3, ruled a decline: the code stays as it is after the close).

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 06:26:02 -04:00
xarmian e09b11cc2e feat(attachments): in-app previews for text, Markdown and CSV/TSV (TASK-4074) (#2310)
* feat(attachments): add bounded CSV and TSV parser (TASK-4074)

* feat(attachments): add capped table preview component (TASK-4074)

* feat(attachments): enforce exact preview types and renderer byte caps (TASK-4074)

* feat(attachments): mount table previews in bounded cell steps (TASK-4074)

* feat(attachments): add raw markdown and text wrap controls (TASK-4074)

* test(attachments): accommodate full-suite table coverage (TASK-4074)

* fix(attachments): allow native table touch scrolling (TASK-4074 r1)

* fix(attachments): focus and label the table scroll region (TASK-4074 r1)

* fix(attachments): preserve preview choices on metadata fills (TASK-4074 r1)

* fix(attachments): prove both navigation toggle resets (TASK-4074 r1)

* fix(attachments): skip blank delimited-file lines (TASK-4074 r1)

* fix(attachments): preserve literal quotes in delimited fields (TASK-4074 r1)

* fix(attachments): preserve wide table arrow scrolling (TASK-4074 r2)

* fix(attachments): preserve Wrap within its default class (TASK-4074 r2)

* fix(attachments): truncate CSV cells by code point (TASK-4074 r2)

* fix(attachments): cover table row resets on input changes (TASK-4074 r2)

* test(e2e): the text card pans on both axes (TASK-4074)

The card's touch-action is now pan-x pan-y, so a wide table or an
unwrapped line can pan sideways; the IDEA-2712 spec still pinned pan-y.
No viewer gesture collides: pan/pinch arm only over a decoded bitmap
and nothing swipes between attachments. The stage-is-not-none leg
stays.

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

* docs(attachments): the text card's touch-action comments say pan-x pan-y (TASK-4074)

Comment-only: two CSS comments still named the pre-TASK-4074 pan-y.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-25 04:29:31 -04:00
xarmian cba880ca17 fix(config): warn on a relative PAD_DATA_DIR / PAD_DB_PATH; anchor install-refresh's marker and config reads (BUG-4066 phase 1) (#2308)
* fix(config): warn once per process on a relative PAD_DATA_DIR / PAD_DB_PATH; install-refresh anchors its marker and config reads (BUG-4066 phase 1)

config.Load takes PAD_DATA_DIR verbatim, or PAD_DB_PATH's dirname, with no
filepath.Abs, so every process resolves a relative value against its own cwd:
a CLI and a server started in different directories silently use different
data (database, attachments, key). Phase 1 (lead ruling 4b4cb4ff) leaves the
resolution unchanged and makes it loud:

- One helper, envDataDir(), is the only reader of the two variables (Load and
  userConfigPath both use it). A relative value warns ONCE per process,
  naming the absolute path this process resolved, that a CLI and a server in
  different directories will use different data, and that a future release
  refuses it (TASK-4072). A literal '~' gets a hint. A CLI prints it to
  stderr; `pad server start` logs it through slog; `pad watch --stream
  --for-session` (the plugin monitor) suppresses it.
- install-refresh anchors the refresh marker and config_value the way 2a
  anchors the log (${SERVER_CWD:-$PWD}), so the marker is written and
  cleaned up in one place (it used to leak under the caller's cwd), and warns
  before anything is read or stopped.
- docs/deployment.md: both variables must be absolute; relative is
  deprecated and warns.

Tests: the warning rule (and Windows, GOOS-guarded), the marker and the
warning-before-stop end to end, config_value at function level, and
child-process tests for the monitor's silence and the server's slog line.

Harness guard (after this unit's draft test stopped the box's shared :7777
server twice): every install-refresh exec in internal/buildtools now passes
requireSafeScriptEnv first, which refuses an environment whose resolved port
is missing (the script would fall back to 7777) or is 7777, whose HOME or
data dir is the real user's or outside a test temp dir, or whose port answers
on a LAN address without this test's random stub token.

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

* fix(config,mcp,scripts): the relative data-dir warning names the server's path and stays out of agent-facing errors; harden the test guard (BUG-4066 r1)

r1 rulings (c0ef54e8):
- install-refresh's warning now runs once SERVER_CWD is captured (still
  before the marker and the stop) and names the anchored path
  (${SERVER_CWD:-$PWD}/<dir>, a leading ./ stripped); when the caller's cwd
  differs it also names the caller's reading. It used to name $PWD/<dir>,
  which the server does not use.
- `pad mcp serve` checks the data dir once on its own stderr; the stdio
  dispatcher's `pad` children get PAD_INTERNAL_NO_DATA_DIR_WARNING=1, so the
  warning is not folded into a failed tool call's agent-facing error. The
  variable is internal (code-documented only), so users cannot silence the
  deprecation. Shell completion no longer prints the warning mid-Tab.
- Test guard: refuses a relative data dir with a config-sourced port (the
  script reads that port from the server's cwd, which the guard cannot see);
  compares symlink-resolved temp paths (macOS); asks for the stub token on
  the LAN address that answered; compares 7777 numerically; and a source-scan
  test fails if any install-refresh exec in the package is unguarded.

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

* fix(config,mcp,scripts): the relative-dir warning names the data dir; completion and MCP fetchers stay quiet; harden the test guard (BUG-4066 r2)

- PAD_DB_PATH warning names the resolved data dir (Dir of the value), in Go and in install-refresh.sh; the script normalises . and .. in pure shell.
- Shell completion (__complete, __completeNoDesc) sets the silent sink in main.
- MCP resource and bootstrap fetchers exec with dispatchChildEnv; a population scan pins every pad exec in internal/mcp.
- Harness guard: numeric 7777 refusal before any dial (asserted by message), PAD_HOST must be empty or loopback, temp-dir check scoped to this test, hardened and self-tested source scan, marker-presence probe in the restarted stub, child timeout, sink reset.

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

* fixup! fix(config,mcp,scripts): the relative-dir warning names the data dir; completion and MCP fetchers stay quiet; harden the test guard (BUG-4066 r2)

test(buildtools): the config-file 7777 row asserts the numeric refusal by message too (BUG-4066 r2 item 12, same class)

* test(buildtools,cmd): close the m15, m24 and m28 survivors (BUG-4066 r2)

m24: a scanner self-test case whose variable never runs in its function,
with a guard in a later function, so the function boundary is what flags it.
m28: insideTestTempDir refuses another test's directory, and admits a
subtest's, which t.TempDir names <Parent><sub...> with no separator.
m15: the --workspace completion func no longer sets its own sink. main()
silences every completion request, which made that line dead code.

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

* fix(cli,buildtools): an auto-started server does not inherit the warning silencer; the guard reads the config port like the script (BUG-4066 r3)

Item 2: an MCP tool-call child carries PAD_INTERNAL_NO_DATA_DIR_WARNING=1
and is often the process that auto-starts the server, so that long-lived
server never logged the relative data-dir warning. serverStartCommand drops
that one variable from the spawn env, and keeps every other one. It is the
only `server start` spawn in internal/cli; a source test pins both facts.

Item 7: the harness guard took the leading digits of config.toml's port, so
"8080abc" read as 8080 where install-refresh rejects it and falls back to
7777. It now reads the first port line the way config_value does, and a
non-numeric value is refused as unreadable.

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

* fix(scripts): install-refresh's relative-dir warning is one line; the completion tests run main() (BUG-4066 r4)

Item 1: when the server's cwd differs from the caller's, the warning printed
a second "Warning:" line naming the shell's-directory reading, and a third
for a leading ~. Phase 1's contract is one warning per process, as
config.Load prints it, so both now ride in the one line. The test asserts
both paths sit in that line and that it prints once.

Item 6: the completion child cases called silenceWarningsForCompletion
themselves, so deleting main()'s call passed every test. They now set
os.Args and run main().

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-25 01:13:51 -04:00
xarmian bc2a32895e feat(chat): monitor reply quotes and a session-start DM ensure (TASK-4043) (#2309)
* feat(chat): render monitor reply references and excerpts (TASK-4043)

* feat(chat): ensure owner-agent DM once per monitor process (TASK-4043)

* docs(chat): describe monitor quotes and DM startup (TASK-4043)

* test(chat): accept startup DM ensure in recovery fixture (TASK-4043)

* fix(chat): wait a default when a 429 has no Retry-After (TASK-4043 r1)

* fix(chat): bound Retry-After before it can overflow the wait (TASK-4043 r2)

A Retry-After too large for a time.Duration (>= 9223372037s) wrapped
negative and made the ensure retry hot. Seconds are now bounded before
the multiply; zero, negative, absent and overflowing values all wait
the 120s default, and the largest value that fits is honoured.

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

* test(chat): keep the Retry-After table portable and hermetic (TASK-4043 r3)

The overflow rows are int64 and skip where int cannot hold them; the
rows run with a temp HOME.

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-24 22:55:32 -04:00
xarmian 82f81ee828 fix(mcp): refuse a non-number for a numeric parameter instead of reading it as absent (BUG-4065) (#2306)
* fix(mcp): refuse a non-number for a numeric parameter instead of reading it as absent (BUG-4065)

numericInput read any value outside float64, int, int64 and json.Number as
absent, so a string, a bool or an object for limit, offset or days was
silently replaced by the default. itemExpectedSeqParam had its own switch with
the same fall-through, so a string expected_seq sent the content write out
UNGUARDED.

Now only null is absent:
- A string is admitted only as a plain decimal integer (an optional '-' and
  digits), parsed exactly and bounded like a JSON number (|x| < 2^53), so "10"
  is 10 and "123" is a guarded write. That matches what stdio's CLI flags and
  the buildQuery doors already accept (lead ruling af4bff40, Q1). Every other
  string, and every other type, is refused with validation_failed naming the
  parameter.
- Every Go integer type is admitted exactly; an unsigned value above MaxInt64
  is refused.
- itemExpectedSeqParam, pr_number and chatSequence share that one rule
  (pr_number's bound becomes the exclusive 2^53; chatSequence's float64 gets
  the 2^53 bound and an exact json.Number). Their messages and hints are
  unchanged.
- item patch validates expected_seq and edits[].line_hint locally, and the
  collection and role update doors validate sort_order, so each answers
  validation_failed naming the field rather than the server decode's 400.
- buildQuery routes the native integers through the same check; strings are
  still forwarded verbatim.
- The item-list resource's limit flag is refused when malformed, not
  defaulted.

ToolSurfaceVersion 0.103 -> 0.104, with the changelog in version.go and
docs/mcp.md; the README, instructions.md heading and wire goldens follow.

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

* fix(mcp): numeric query keys and leading-zero strings are refused, not defaulted or re-read (BUG-4065 r1)

r1 found two Mediums (lead ruling 5a06782e):
- The buildQuery doors forwarded a bool, an object or a non-integer string
  for a numeric key (item backlinks, item search, item context's budget,
  project activity, project standup and changelog). The server Atoi'd it and
  silently kept its default. Each route now names its numeric query keys
  (routeSpec.numericQuery, and the explicit lists at the direct buildQuery
  callers), and those keys go through numericInput. TestBUG4065_NoRouteForwards
  ANonNumberForANumberParam walks every remote route against every parameter
  its tool declares "number", so a new numeric key cannot be left off.
- "010" was 10 here and 8 on stdio (pflag parses base 0). A plain decimal
  string is now ^-?(0|[1-9][0-9]*)$, so a leading zero is refused. The stdio
  side is BUG-4070.

Also: an end-to-end row through the pad_item tool handler, value rows at the
chat doors, an array value at every door, absent-means-default, the resource
flag's negative row, stale expected_seq comments, and the v0.104 changelog
corrected in version.go and docs/mcp.md.

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

* test(mcp): the numeric-param population walk cannot pass vacuously; uintptr admitted (BUG-4065 r2)

r2 found the r1 population walk could pass on broken code (lead ruling
b9025d7c): it looked for the raw bad value under the catalog name only, so a
rename, a path segment or a value dropped as absent was invisible, and its
"x"-filled inputs tripped unrelated validation, so most routes refused
before reading the number. With a route's numericQuery dropped, the walk
alone stayed green.

The walk now uses a differential oracle from a curated valid base: each
(route, number param) is dispatched with the param absent, as "7", and as a
bad value. If "7" changes what is forwarded, the route reads the param and a
bad value must be refused naming it with no write; otherwise a bad value must
forward exactly what the absent case does. An inconclusive case fails. The
derived read-set is pinned in bug4065ReadSet, so a route that stops reading a
param altogether cannot be reclassified and go green.

Also: uintptr is admitted like the other unsigned types; an empty string and
false are named as refused in the v0.104 changelog; rows for a missing key, a
null expected_seq (still unguarded), "" and a 20-digit string at every door.

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

* test(mcp): false, the catalog limit doors, and the chat null claim (BUG-4065 r3)

r3 rulings (9bd7fa2d), all Low:
- false is fed to the unit table, the door table and the population walk;
  the changelog says it is refused, and nothing pinned it.
- pad_item.list and pad_item.history read limit through intParam before the
  dispatcher; TestBUG4065_CatalogLimitNonNumbers refuses "abc", true, false,
  an object, "010" and "" there naming limit, and uses "7".
- The v0.104 changelog says pad_chat's numeric params still refuse a present
  null, as before, with the same message; "only null is absent" read as if
  they did not.
- A misnamed row; the refused pr_number rows assert no request went out.

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

* docs(mcp): reflow a joined comment; state the population walk's limit (BUG-4065 r4)

Comment-only (lead ruling 86de95f1): my r1 edit joined two comment lines in
dispatch_http_advanced.go into one; and the walk's doc comment now says
conditional reads are outside it, with edits[].line_hint covered by the door
table.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-24 20:04:54 -04:00
xarmian 8873978b1d test: close surviving-mutant gaps from the #2284-#2290 audits (TASK-4051) (#2307)
* test(push): verify configured gateway wiring (TASK-4051)

* test(push): verify server shutdown stops dispatch (TASK-4051)

* test(push): cover committed updates with lost row counts (TASK-4051)

* test(notifications): cover owner and actor lookup errors (TASK-4051)

* test(catch-up): cover caller scope and ceiling label (TASK-4051)

* test(push): fail shutdown regression without blocking cleanup (TASK-4051 r1)

* test(push): drive retry settlement across record chunks (TASK-4051 r1)

* test(push): pin a gateway batch inside one record chunk (TASK-4051 r1 D2)

Claude-Session: https://claude.ai/code/session_017ofcsFmgnoVDPxeHwuxCjg
2026-10-24 19:36:38 -04:00
xarmian 3e7f9e18cf fix(scripts): install-refresh restarts the server logging to cfg.LogFile() (BUG-4064) (#2304)
* fix(scripts): install-refresh restarts the server logging to cfg.LogFile() (BUG-4064)

The restart appended to $HOME/.pad/server.log, a file nothing else names:
`pad server info`, the daemonized start and every "Check <log>" message
use cfg.LogFile(), <data dir>/logs/server.log. Anyone following server
info read a log days stale. The old line also ignored PAD_DATA_DIR.

The log path now comes from the script's data_dir(), which mirrors
config.Load, and its directory is created before anything is stopped.
The refresh names the log in its report and on a failed probe. An
existing ~/.pad/server.log is left untouched and named in a one-line
note.

Tests: both restart branches, with and without PAD_DATA_DIR, find the
restarted process's own stdout line in the configured file and nothing
at the old path; the old file survives byte-for-byte. stubEnv now pins
PAD_DATA_DIR and clears PAD_DB_PATH so the developer's environment
cannot move the data dir out of the test HOME.

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

* fix(scripts): install-refresh proves the log writable before the stop; pid file in the data dir (BUG-4064 r1)

- The log is opened for append (create or append) in a new section 2a,
  before the marker and the stop. An unwritable log used to stop the old
  server and then fail the restart's redirect, leaving nothing on the port.
  Now the refresh refuses with the path and the running server untouched.
- The stale pid file is cleared at "$(data_dir)/pad.pid", where `pad server
  start` claims cfg.PIDFile() (cmd/pad/cmd_server.go, config.go), not at
  $HOME/.pad/pad.pid regardless of PAD_DATA_DIR.
- The "mkdir ~/.pad before the redirect" block, which this change made
  untrue, is rewritten. A PAD_DATA_DIR refresh no longer creates a stray
  ~/.pad.

Tests: a refusal on an unwritable log (a directory in its place) leaves
the pre-server answering as itself; a stale pad.pid in PAD_DATA_DIR is gone
after the refresh.

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

* fix(scripts): install-refresh anchors a relative data dir to the server's cwd for the log and pid file (BUG-4064 r2)

config.Load takes PAD_DATA_DIR verbatim, so the restarted server resolves a
relative data dir against its own cwd. The writability proof and the pid-file
removal now use that same anchored path (SERVER_CWD, else $PWD), so the file
checked before the stop is the file opened after it. Also corrects the
"root-owned" comment, and adds
TestInstallRefresh_AnchorsARelativeDataDirToTheServersCwd.

Mutation run (~/.cache/rook/4064-mut4.log, plus 4064-mut4-m8.log): baseline
passes, and m1-m11 are all DETECTED.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-24 16:16:54 -04:00
xarmian ba981cb4f2 fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061) (#2303)
* fix(mcp): refuse a numeric parameter that is not a whole int64 instead of changing it (BUG-4061)

numericInput converted a float64 with int64(x), so limit 1.5 read as 1, -2.7
as -2, and a value outside int64 as an implementation-defined number. A
json.Number that did not parse read as absent, so the parameter was dropped.

- numericInput returns an error for a fraction, a value outside int64 (2^63
  is out), and an unparseable json.Number. intParam names the parameter.
- All 10 caller-input sites refuse with validation_failed: the route mappers,
  item history, attachment list, and the pad_item.list and pad_item.history
  catalog actions. pad_item.list's documented truncation of a fractional
  limit to the default is reversed by ruling; zero still means the default.
- actionItemGet's read of the server's own seq stays lenient (pinned).
- itemExpectedSeqParam refuses a float64 outside int64 and gets the same
  json.Number refusal, rather than dropping the token (an unguarded write).
- Comments note the unreachable float32 paths in formatNumber and
  chatSequence.
- ToolSurfaceVersion 0.103: the parameters are declared "number", so the
  refused inputs were valid under the advertised shape. Goldens move only by
  the version string.

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

* fix(mcp): buildQuery refuses a number that is not a whole int64 (BUG-4061 r1)

Round 1 (Codex Medium, and the hunter's standup/changelog question): the
query-string doors built by buildQuery (item backlinks, item context budget,
project activity, item search, project standup and changelog) forwarded
"1.5", and the server parsers read it as absent and used their default.
buildQuery now refuses with the key named, and its four callers return
validation_failed (lead ruling on Q2, option (a)).

- TestBUG4056BuildQueryNumbersUnchanged: the 2.5 and 1e19 rows are now
  refusals, superseded by that ruling. The notation rows stand, and
  json.Number "1e6" forwards as "1000000".
- jsonNumberInt64: a whole json.Number in exponent form ("1e6") is
  accepted. Int64() alone refused it. Used by numericInput and
  itemExpectedSeqParam.
- The v0.103 changelog door list is corrected. The instructions.md and
  README version headings follow 0.103, which 54fc02855 missed. Goldens
  move only by the instructions heading.

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

* fix(mcp): read a json.Number exactly, and envelope a refused expected_seq (BUG-4061 r2)

- jsonNumberInt64 fell back to Float64, which rounds: 9007199254740993e0
  became ...992 and 1.0000000000000001e0 became 1, changing the caller's
  number again (Codex r2 Medium, hunter r2 Low). It now parses exactly with
  big.Rat. A length bound (64) and an exponent bound (|e| <= 64) keep a
  literal like 1e999999999 from costing a huge allocation.
- dispatchItemUpdate returned a refused expected_seq as a raw error, which
  the transport surfaces as a protocol failure. It is now a validation_failed
  envelope, as every other refused parameter is and as the v0.103 entry says
  (hunter r2 Medium). The fraction half predates this bug.

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

* fix(mcp): refuse a float64 integer above 2^53, which the decoder has already rounded (BUG-4061 r3)

Codex r3 Medium: the MCP JSON decoder turns expected_seq 9007199254740993
into float64 9007199254740992 before any check runs, so a whole, in-range
value was forwarded as the wrong number. wholeInt64 now refuses |x| > 2^53
("beyond the exact integer range of a JSON number"). This applies to the
float64 path only, per the lead ruling on Q3. A json.Number is parsed exactly
and keeps the full int64 range. chatSequence and formatNumber are unchanged;
the chat seq limit is tracked on BUG-4065.

Also corrects the chatSequence comment (hunter r3 L3): a json.Number in
exponent form is refused there, not parsed.

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

* fix(mcp): refuse a float64 of magnitude 2^53 or more, not only above it (BUG-4061 r4)

Hunter r4 Low, ruled a fix by the lead's correction of Q3: 2^53 is the
rounded image of 2^53+1, so a float64 2^53 cannot prove it is the number
sent either. wholeInt64 now refuses |x| >= 2^53, so the largest accepted
float64 is 2^53-1, the safe-integer bound. json.Number keeps the exact
int64 range. Test rows: 2^53-1 accepted, 2^53 and -2^53 refused.

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

* fix(mcp): read a json.Number only if it is a plain decimal literal (BUG-4061 r5)

Hunter r5 L1, ruled a fix: jsonNumberInt64 filtered big.Rat-only syntax
with a deny-list ("/xXpP"), which missed the 0b and 0o prefixes, so a
hand-built json.Number "0b101" was read as 5. An allow-list,
isDecimalLiteral, now admits a sign, digits with at most one '.', and at
most one e/E exponent with an optional sign. Refusal rows: 0b101, 0o17,
0x10, 1_000, 4/2. 12e+0 is accepted.

Hunter r5 L2: the standup and changelog refusal hint is neutral ("Pass
numeric parameters as whole numbers.") instead of naming days for any key.

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

* test(mcp): cover the remaining query doors and the lenient seq at 2^53 (BUG-4061 r7)

Test rows and one comment, no runtime change (lead ruling on Q7):
- Door rows through Dispatch: item context budget and project activity
  limit (the generic route mapper's other numeric query keys), and a
  json.Number limit on item list (h2 L1).
- actionItemGet's lenient server-seq read at float64 2^53 leaves Seq zero
  (h2 L2).
- chatSequence's comment says a float32 or exponent-form json.Number
  producer should be routed through numericInput's check, and that this
  path does not do that today (Codex r7 Low).

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

* test(mcp): the door table requires the refusal clause to start with the key (BUG-4061 r8 Q8)

The door table matched a bare "<param> " substring, which the hint text
also satisfies on the two doors whose hints repeat the key (expected_seq,
item history's limit). A door naming the wrong key in its message passed.
The check now requires ": <param> must be " or ": <param> is ", the
validationFailedResult rendering of "<cmdKey>: <msg>".

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

* test(mcp): pin expected_seq's 2^53 bound and the exact fraction refusal; narrow the float32 note (BUG-4061 r9)

- itemExpectedSeqParam's float64 branch had no 2^53 row of its own, so a
  looser conversion there passed. Rows for 2^53-1 (accepted) and ±2^53.
- The json.Number fraction-past-float64-precision row asserted a prefix the
  "in range" refusal also has; it now asserts the fraction refusal itself.
- formatNumber's float32 note: no non-test code passes a float32, but its
  own tests do.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-24 15:50:52 -04:00
xarmian d3f87d205a feat(chat): delivery, framing and the accept-instructions opt-in (TASK-4041) (#2305)
* WIP TASK-4041: add ref-only chat delivery and explicit acknowledgements

* WIP TASK-4041: implement local consent and fetched monitor delivery

* WIP TASK-4041: document chat delivery and session instruction consent

* WIP TASK-4041: rename chat arm files (_arm.go is a GOARCH build constraint)

* test(chat): fix delivery fixture and strengthen consent guards

* test(chat): pin replay publication guard after mutation audit

* test(chat): make review guards detect their named regressions

* docs(chat): clarify bounded excerpts and defer unwired command guidance

* test(chat): cover empty agent pairs and the Redis envelope

* fix(chat): seed gap fills and acknowledge contiguous output

* test(chat): restore gap regressions for amended ack rule

* fix(chat): advance contiguous delivery across own rows and tombstones

* test(chat): cover final tombstones, ack retries, and sync recovery

* fix(chat): acknowledge final contiguous tombstone events

* fix(chat): retry contiguous delivery ack on duplicate events

* fix(chat): reseed delivery baselines on sync required

* fix(chat): wire session consent commands and reuse chat client

Pin arm/disarm execution through the real root tree and restore the deferred skill and monitor guidance.

Wiring adds both local consent commands to the cmdhelp Document supplied to MCP. Preserve TestChatArmHasNoMCPPath's failure for the requested ruling; do not change MCP goldens or versions.

* test(mcp): pin chat direction dispatch across transports

* docs(cli): add chat direction command examples

* test(chat): expose delivery memory loss and unsafe acknowledgements

* fix(chat): retain process delivery memory independently of fill cursors

* docs(chat): describe local delivery memory and failed-summary recovery

* test(chat): pin both monitor adapter fetch limits

* docs(api): state chat owner-message framing in the present tense (TASK-4041)

The TASK-4039 provenance caveat described TASK-4041 behaviour as future
work. It is implemented now: say what is true and point at the framing
rules under GET /api/v1/events/stream.

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

* test(chat): expose failed resync, late retraction and identity collisions

* test(chat): keep push delivery when monitor identity is unavailable

* fix(chat): preserve closed recovery and retraction ownership per identity

* docs(chat): clarify recovery, page cap and identity memory limits

* test(chat): recover after a transient monitor identity lookup failure

* fix(chat): retry transient identity failures on chat references

* test(chat): require deferral until a safe server summary

* fix(chat): defer events until a server delivery floor is available

* test(chat): pin per-conversation server floors and caller boundaries

* feat(chat): expose caller delivery floor on conversation GET

* fix(chat): fetch per-conversation floors before monitor delivery

* test(chat): require bounded synchronous first-contact delivery and backoff

* fix(chat): fetch first-contact floors synchronously with bounded backoff

* test(chat): remove redundant break from synchronous stream fixture

* test(chat): cover stream deadlines, retry windows and cookie agent isolation

* fix(chat): bound stream HTTP calls and back off failed conversations

* docs(chat): clarify direction consent and stream delivery limits

* test(chat): preserve independent dedupe eviction and retry clocks

* fix(chat): require bearer authentication for stream agent addressing

* test(chat): cover r10 tombstone, summary and arm-generation regressions

* fix(chat): fill tombstone gaps and back off unread summaries

* fix(chat): bind direction consent to a random arm generation

* test: pin r11 retractions and session direction cleanup

* fix: retract owed chat deletions before network backoff

* fix: report session action when direction removal fails

* docs: clarify chat direction rearming and connect state

* test(chat): bound HTTP deadline failures and cover unresolved retractions

* docs(chat): clarify immediate retractions and session re-arm direction loss
2026-10-24 15:16:30 -04:00
xarmian 8f1221cf1d test(chat): name the 65-rune ensure test for the gate it pins (TASK-4059) (#2302)
TestChat_Ensure_65RuneBodyRefusedWithoutCreation read as pinning the 64-rune
agent_key bound, but a 65-rune human body key is refused first by the
normalizeAgentName(key) != key equality gate, and the handler's rune-count case
is shadowed (BUG-4055). Reverting the bound left it green.

It also did not pin the equality gate: with that gate disabled, the shadowed
rune case refused the key with the same 400 validation_error and the test still
passed. It now asserts the equality gate's own message, and it is renamed and
commented for what it pins. The bound stays pinned by the store table and the
64-rune non-ASCII round trip.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-24 13:33:56 -04:00
xarmian b1dce0eddb feat(server): log every unguarded content write the PLAN-3555 counter counts (TASK-4062) (#2301)
pad_content_writes_unguarded_total and pad_collab_append_failures_total are
in-process counters with no scraper, zeroed by every restart, so the week
PLAN-3555 phase 2 waits on cannot be read from them. Log each event instead,
so the count survives restarts in the server log.

- handlers_items.go: one slog.Info("content write unguarded") per Inc(),
  outside the metrics guard, carrying door, kind, item ref and id, workspace
  id, actor kind and actor user id. Never the body, the title or a field value.
- Collab needs no new line: "collab: append op-log" already sits in the same
  branch as the counter. Its test now pins one line per counted failure.

Claude-Session: https://claude.ai/code/session_01Hk4vxsaKDm3E1LvsoHtHeV
2026-10-24 10:32:10 -04:00
xarmian 0ed34ea9f7 fix(mcp): integral numbers keep decimal notation on both transports (BUG-4056) (#2300)
* fix(mcp): preserve decimal numeric arguments (BUG-4056)

Share numeric formatting across CLI dispatch and HTTP queries, and validate remote chat sequences directly with int64 bounds. Preserve the existing tool surface and wire goldens.

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

* fix(mcp): numeric array elements, playbook tokens and buildQuery's default share formatNumber (BUG-4056, r1)

Lead ruling on r1 (tier A from here): toStringSlice spelled each JSON number
element of an array with fmt.Sprint, so a repeatable flag or variadic
positional given 1000000 got '1e+06'; playbook raw_args tokens used %v, so a
string- or ref-typed argument would bind '1e+06'; buildQuery's default
branch kept fmt.Sprint for float32. All three now use formatNumber.

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-24 08:47:40 -04:00
xarmian e8e2a698b8 test(chat): sibling-DM coverage for inbox, unread and ensure (TASK-4060) (#2299)
* test(chat): sibling-DM coverage for inbox, unread and ensure (TASK-4060)

From the lead's post-merge rule-3a round on #2296. TestChat_AgentCannotReachSiblingAgentDM
now also covers the endpoints that could reveal bravo's DM without naming it:
alpha's inbox and unread summary carry no row for it and their totals exclude
it, with bravo's DM holding unread rows. Alpha ensuring agent_key "bravo" is
refused 400 with no DM bucket allocated and no conversation created, which is
where the DM bucket assertion can actually move. The delete case targets
bravo's own message so an author check cannot mask a leak, and the header
comment says which layer catches a single-layer leak and that the bucket
claim is allocation only.

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

* test(chat): make the ensure case's bucket and row checks able to fail (TASK-4060, r1)

Lead ruling on r1: the ensure case's DM-bucket and row-count assertions were
vacuous. The fixture's API ensures had already allocated the owner's ChatDM
bucket, and "bravo" already had a DM, so a charge or a create before the
refusal could not show. Both DMs are now seeded through the store, and alpha
is also refused naming "charlie", which has no DM yet. The seed comment now
says which unread row a leak would expose.

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

* test(chat): comments state what the sibling-DM cases prove (TASK-4060, r2)

Lead ruling on r2, comment-only: the delete case's rationale now says what
bravo's own message catches (alpha resolved as bravo would delete it), and
the header describes the single-layer leak behaviour instead of citing an
off-repo mutant id. The header also names the second ensure.

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-24 06:10:59 -04:00
xarmian 6e37c69934 test(cli): arm-state tests own the session pid env (BUG-4025) (#2298)
* test(cli): arm-state tests own the session pid env (BUG-4025, BUG-4057)

armStateTestEnv reset HOME and the messaging socket but not PAD_SESSION_PID
or CLAUDE_PID, which CaptureSessionOwner prefers over this process. A gate
inherits them from the harness that started it, and when that session is
relaunched mid-run the dead pid made TestArmState_HeadlessLivePid read
disarmed. The helper now clears both. TestArmStateTestEnv_IgnoresAmbientSessionPID
plants a dead pid first, so the guard holds in CI too, where CLAUDE_PID is
never set.

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

* test(cli): put HeadlessLivePid's doc comment back on it (BUG-4025, r1)

Lead ruling on r1: the new test had been inserted between
TestArmState_HeadlessLivePid and its doc comment. Comment-only.

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-24 04:22:21 -04:00
xarmian 5308535c02 fix(chat): one 64-rune bound for agent keys (BUG-4055) (#2297)
* fix(chat): share the 64-rune agent key bound (BUG-4055)

Use the attribution and presence name limit for chat headers and stored keys. Refuse overlong explicit body identifiers while normalizing self-declared headers, and cover Unicode round-trips and presence agreement.

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

* test(chat): an agent names itself by the normalised key on ensure and unread (BUG-4055, r1)

Lead ruling on r1: the agent-caller equality checks (ensure body agent_key,
unread ?agent=) were untested now that headers truncate. A 70-rune header's
agent must pass the 64-rune key and be refused the raw name. The shadowed
rune-count case in the ensure switch now says it is shadowed and by which
gate.

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-24 02:50:45 -04:00
xarmian 811154a4c7 test(chat): pin per-agent separation on every conversation endpoint (TASK-4054) (#2296)
* test(chat): pin per-agent separation on every conversation endpoint (TASK-4054)

One owner, two agents: alpha gets 404 on get, list, send, delete and
mark-read for the owner's DM with bravo, with a body identical to a missing
conversation's, no send or DM bucket allocated, and no change to bravo's
conversation (last_seq, member markers, tombstones). Each endpoint is
asserted on its own terms, so the director's mutant (the agent key dropped
from resolveChatConversation) no longer depends on the send bucket's side
effect to die.

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

* test(chat): baseline side effects per case so each leak names its own endpoint (TASK-4054)

The first mutant run showed one endpoint's leak (send's bucket) reported
again by the later delete and read cases, because the state and bucket
baselines were taken once before the loop. Each case now takes its own
baseline and checks side effects before issuing the missing-number
reference request.

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

* test(chat): check rows.Err in the snapshot and the reference 404's status (TASK-4054, r1)

Lead ruling on r1: an iteration error must not truncate the DM state
snapshot silently, and the missing-conversation reference response must
itself be a 404 before its body serves as the oracle.

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-24 00:00:04 -04:00
xarmian 51b0bb4de9 feat(cli,mcp): pad chat CLI and pad_chat MCP tool (TASK-4042) (#2295)
* feat(cli): add global pad chat commands (TASK-4042)

Add conversation targeting, inbox and message commands with caller-owned idempotency and precise read markers. Preserve API responses for JSON output and use cwd workspace links for reference context.

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

* fix(cli): add chat examples and isolate command tests (TASK-4042)

Satisfy the CLI help-tree example contract and restore package-level flags after command regression tests.

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

* feat(mcp): expose global pad_chat on both transports (TASK-4042)

Add six chat actions with shared target parsing, uniform authenticated dispatch, precise read markers and structured unnamed-agent errors. Exclude agent chat from ChatGPT and pin the direction guard. Bump the surface to 0.102, update documentation and goldens, and shorten duplicated prompt prose within the fixed budgets.

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

* test(mcp): label global chat scope judgments (TASK-4042)

Mark the membership-based global-chat exemption reasons explicitly, as required by the allowlist coverage guard.

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

* fix(chat): preserve unread gaps and successful outcomes (TASK-4042, seat r0)

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

* fix(chat): restore agent rules within prompt budgets (TASK-4042, seat r0)

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

* fix(chat): keep scope fixture contiguous with read marker (TASK-4042, seat r0)

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

* fix(chat): guard read boundaries and direction inputs (TASK-4042, r1)

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

* fix(chat): validate MCP bodies and report mark outcomes (TASK-4042, r1)

Claude-Session: https://claude.ai/code/session_01KYZ6vrKNro953q9kfdSGFd
2026-10-23 22:08:29 -04:00
xarmian f8756a0b5b feat(server,cli,plugin): agent presence — agent name, worker id and busy/idle activity (TASK-4040) (#2293)
* test: pin agent presence and worker-scoped session activity

* feat: carry agent presence and track activity by user and worker

* docs: describe worker presence and session activity states

* fix: include hook activity examples in CLI help

* fix(cli): keep session activity hooks silent on failures

* fix(server): tolerate activity read failures in session lists

* docs: place activity keys after presence registry details

* fix(plugin): wire silent Claude Code activity hooks

* docs: explain session busy-state hook limits

* fix: bound the entire session activity command

* fix: batch session activity reads across workers

* docs: describe detached activity reports and timestamp ordering

* fix: order worker activity atomically by client timestamp

* fix: detach hook activity reports with captured timestamps

* test: reproduce detached activity losing registered identity

* fix: capture activity identity before detaching the reporter

* test: isolate activity registry home on Windows

* docs: clarify activity ordering and hook failure behavior

* fix: bound activity reordering to ten seconds

* fix: replace activity records with corrupt client timestamps

* test: prove activity parent exits while child response is held

* fix(session): derive activity reorder window from report bound

* fix: replace non-string Redis session activity records

* test: cover cleared and expired session activity ordering

* test: cover older timestamp after legacy activity

* test: cover corrupt activity nanos and malformed JSON

* test: verify activity child deadline and process isolation

* docs: clarify read-only tokens cannot report hook activity

* test: strengthen session activity liveness and timing guarantees

* test: pin malformed null session activity timestamps

* test: harden session activity detach regression checks

* test: skip activity process-group check when ps is unavailable

* test: tie activity parent-exit bounds to the HTTP timeout

* test: bound activity liveness checks by the child HTTP deadline

* test: require ps for process-group checks in Go CI
2026-10-23 19:46:02 -04:00