Webhook signing secrets were stored plaintext in the webhooks.secret column
and echoed back in every API response. Encrypt them at rest (reusing the
existing AES-256-GCM store helpers, same pattern as TOTP secrets) and return
the raw secret ONLY in the creation response; list responses now mask it and
expose a has_secret flag instead.
- store: encrypt on CreateWebhook, decrypt on Get/ListWebhooks so the
dispatcher still signs with the plaintext secret. Reuses the secret column
with the "enc:" prefix — no new column/migration. Keyless self-host stays a
no-op fallback (encrypt returns plaintext; decrypt passes legacy rows
through unchanged).
- BackfillEncryptWebhookSecrets encrypts pre-existing plaintext rows on
startup once a key is configured (idempotent), mirroring the TOTP backfill.
- model: add HasSecret so masked responses still signal presence.
- handlers: mask secret on list; document raw-only-on-create.
- tests: encrypt-at-rest round-trip + HMAC validity, list decrypt,
plaintext backfill/back-compat, and the API mask-except-on-create contract.
Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
The 2FA verify path accepted a valid TOTP code with no consumed-step
tracking, so within a code's ~30s window (plus skew) the same code was
replayable, and unlike the recovery-code branch the TOTP branch had no
per-challenge attempt cap.
Add a nullable users.totp_last_step column and an atomic compare-and-set
Store.ConsumeTOTPStep: a code's derived time-step must be strictly greater
than the stored watermark, and the winning UPDATE advances it in the same
statement so two concurrent requests can't both consume one step. The
handler derives the exact step a code matched (pinned within the ±1 skew
window, not the current step) and rejects a replay with the same
invalid-code response — no replay signal is leaked. Also caps TOTP attempts
per challenge token by reusing the existing RecoveryCode limiter.
Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
Share-link password verification had no dedicated brute-force limiter, so a
password-protected /s/{token} link could be ground offline-fast — the resolve
handler would bcrypt-compare an unbounded stream of guesses.
Add two limiters, both charged BEFORE the bcrypt compare:
- SharePasswordIP (5 / 10-per-hour, keyed on SHA-256(share ID)+client IP)
caps a single grinder and protects bcrypt CPU; per-IP so one caller can't
lock out other viewers, and it's checked first so a single address can't
drain the link-wide bucket.
- SharePasswordShare (60 / 60-per-hour, keyed on SHA-256(share ID)) caps the
aggregate guess rate across a botnet that rotates IPs. Charged pre-compare
like login's per-email AuthEmail gate, so an exhausted link blocks even a
would-be-correct guess (no password oracle). Its burst is sized so ordinary
multi-viewer traffic never trips it, and the per-IP gate ahead of it means
exhausting it needs a genuine botnet (self-healing) — the same bounded
tradeoff AuthEmail accepts for an unauthenticated shared secret.
Both keyed on SHA-256 so no secret hits the limiter map.
Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
Session IP/User-Agent binding was log-only by default, so a stolen
session token granted durable any-origin access. IP-change enforcement
already existed behind PAD_IP_CHANGE_ENFORCE=strict; this extends the
same single toggle to also enforce the User-Agent-hash binding.
When strict enforce is ON, a request whose client IP OR User-Agent hash
no longer matches the session's stored binding now revokes the session
(DeleteSessionIfExists) and rejects the request (401 for API,
revoked-passthrough for public/browser paths), killing the stolen token.
When enforce is OFF (default), behavior is unchanged: UA mismatch is
logged (slog only, no new audit row) and the request proceeds, so
existing self-host users see no behavior change and routine client churn
(browser/WebView updates, DevTools emulation, mobile-app rebuilds) is
tolerated.
The UA hash is stable within a real session, so UA-mismatch enforce
carries fewer false positives than IP enforce (mobile roaming, VPN
toggles, carrier NAT) — documented in the handler comment. Adds the
ActionSessionUAChanged audit action, emitted only in strict mode.
No DB migration: reuses the existing IPChangeEnforce config flag and the
existing session store primitives.
Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
Extend `pad mcp install/uninstall/status` beyond the three JSON desktop
clients to cover the two most prominent CLI agents:
- claude-code — writes a project-local `.mcp.json` in the current
directory (JSON, same mcpServers shape as the other clients). Because
the config is project-scoped, it's install-on-request only: excluded
from `--all` and `pad mcp status`, which cover the per-user clients.
- codex — writes an `[mcp_servers.pad]` table into `~/.codex/config.toml`
(TOML). New load/merge/write path (BurntSushi/toml) that preserves
unrelated top-level keys and other mcp_servers entries, is idempotent,
tightens perms to 0600, and refuses to clobber a non-table mcp_servers.
Generalizes the Agent struct with a Format discriminator (JSON/TOML) and
a CWDBased flag; Install/Uninstall/Status dispatch to the right
reader/writer and resolve cwd-vs-home per agent. Existing
claude-desktop/cursor/windsurf behavior is unchanged. FindAgent's error
string is now built from the agent list. Docs updated in README.
Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
The templates section listed startup/scrum/product/hiring/interviewing but
omitted `blank` — the custom, system-collections-only template that is the
designated entry point for the agent-driven `/pad onboard` flow (PLAN-1496).
Add a `blank` example + prose framing, and document the picker-hidden `demo`
template (startup layout + sample data, buildable via `--template demo`).
Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
Add a first-run subsection to the README Docker section: open :7777, grep
the 'Pad first-run setup' banner out of docker logs, and open the printed
/setup#token=<token> URL to create the first admin. Keep docker-exec
'pad auth setup' as the loopback fallback and note PAD_BYPASS_SETUP_TOKEN.
Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
The last hand-rolled modal after the #890/#891 migrations: it used a
<div class="backdrop" role="dialog"> with its own window Escape listener
and backdrop-click handler, so it missed the focus trap/restore and
top-layer stacking the shared native-<dialog> Modal provides.
Now the Modal primitive owns Escape, backdrop dismiss, focus trap, and
focus save/restore; the component keeps only its content markup. The
original centered look is preserved via placement="center" and the
--modal-shadow override (same technique as UserModal), and the body
becomes the scroll area since the Modal box is overflow:hidden.
Closes#899
Co-authored-by: xarmian <xarmian@gmail.com>
#896 raised the go.mod floor to `go 1.26.5`, but actions/setup-go's
`go-version: "1.26"` resolves to the newest patch in its manifest
(1.26.4) and unconditionally exports GOTOOLCHAIN=local, so every Go
command fails with "go.mod requires go >= 1.26.5 (running go 1.26.4)".
This broke `go vet`, the PostgreSQL test job, and the e2e "Build pad
binary" step on main and every PR since #896.
Override GOTOOLCHAIN=auto via a step that writes $GITHUB_ENV *after*
setup-go (last-write wins, since setup-go's export is unconditional),
so Go downloads the required toolchain on demand. Applied to all three
Go jobs in ci.yml plus release.yml.
Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
BUG-2084. Two parts.
RAM fix: `make vuln` and CI's Go job now run govulncheck in BINARY mode
(`-mode binary` against a freshly-built pad binary) instead of source mode
(`govulncheck ./...`). Source mode builds an SSA call-graph over the whole
dependency tree (BigQuery/OTel/gRPC/Cloud) and balloons to multiple GB of
RAM, which was locking up a memory-constrained host. Binary mode reads the
binary's symbol table — ~99 MB peak here — while staying call-graph-precise
and still detecting stdlib vulns from the Go version stamped in the binary.
The scan binary is written to the repo root (real disk, gitignored), never
/tmp, since some hosts mount /tmp as a small RAM-backed tmpfs where a large
embedded binary can hit "no space left" and consume the RAM we're sparing.
Vuln fix (govulncheck binary mode: 0 vulnerabilities after):
- go 1.26.4 -> 1.26.5: clears the only CALLED vuln GO-2026-5856 (crypto/tls)
plus not-called os GO-2026-4970.
- golang.org/x/crypto v0.51.0 -> v0.52.0: clears 13 not-called advisories.
- google.golang.org/grpc v1.59.0 -> v1.79.3: clears GO-2026-4762 (gRPC
authorization bypass). pad runs no gRPC server, but grpc.Server.Serve ships
transitively (OTel/ory/grpc-gateway) so binary mode flags the symbol.
Contained 12-line go.mod bump (genproto/protobuf/oauth2 family), no cascade.
Remaining not-called advisories deferred to a follow-up dependency sweep:
GO-2026-4985 (otel otlptracehttp) and GO-2026-5932 (x/crypto, Fixed in: N/A).
* fix(cli): silence usage on runtime errors, echo not-found input, validate --format
TASK-2031 + BUG-2032 (PLAN-1985). SilenceUsage + FlagErrorFunc keeps flag-error help; GetItem/UpdateItem/DeleteItem wrap not_found with ref+workspace; PersistentPreRunE rejects invalid --format; honest markdown advertising.
* fix(cli): return enriched *APIError for not_found to preserve concrete type
Team-lead P2: itemNotFoundError changed the concrete error type, so direct err.(*cli.APIError) assertions (which do not unwrap) stopped matching not_found — notably bulk-update's per-row code capture (cmd_item.go:2043), dropping code:"not_found" from the JSON envelope. Return a fresh *APIError (same Code/Details, enriched Message) instead of a wrapper type; APIError.Error() returns Message so the clean one-line message is unchanged. Both err.(*APIError) and errors.As now match. Test adds a direct-assertion + Details-passthrough lock-in.
TASK-2033 (PLAN-1985). Clarify that only playbooks and conventions have a portable-artifact form; point other item types at 'pad item show --format json'.
Parent command groups now return a non-zero exit on an unrecognized subcommand (e.g. `pad item bogus`, `pad role lst`) instead of printing help and exiting 0 — a silent failure for an agent-first CLI. Bare parents still show help; valid subcommands unaffected. Covers all 16 command groups plus a regression test that walks the real command tree so a future group can't silently regress.
Fixes#850
Co-authored-by: Dave <xarmian@gmail.com>
* feat(items): field-level PATCH + conflict envelope + version history
Adds three related item-update primitives (TASK-2022 / IDEA-1480):
- Field-level merge: PATCH `fields_patch` shallow-merges onto the item's
current fields INSIDE the write transaction (null deletes a key), so
concurrent single-field updates no longer clobber each other via the
full-blob read-modify-write. `pad item update` and the MCP `pad_item.update`
action now send only the changed keys.
- Optimistic concurrency: optional `expected_updated_at` on update; on
mismatch the store returns *UpdateConflictError and the handler emits the
pad-structured-error/v1 conflict envelope (HTTP 409, code=update_conflict).
Surfaced on CLI (`--expected-updated-at`) and MCP (`expected_updated_at`).
- Read-only version history: `pad item history <ref>` (alias `versions`) and
MCP `pad_item.history`, reusing the existing item_versions store + versions
endpoint (no new store, no schema change).
MCP ToolSurfaceVersion bumped 0.9 -> 1.0 (new action + param; update
behavior change). No migration required.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(items): address Codex review — dispatcher fields_patch, OCC ordering, date/required guards
Round 1+2 review fixes for TASK-2022:
- HTTP MCP dispatcher (dispatch_http_advanced.go) now sends fields_patch (only
changed keys) instead of a client-side merged full fields blob, and forwards
expected_updated_at — remote MCP callers get the same race-free merge +
optimistic concurrency the CLI/HTTP paths do.
- ValidatePartialFields rejects null-deleting a schema-declared REQUIRED field
(would otherwise persist a blob the full-update validator rejects).
- Open-children guard on the fields_patch path merges the patch onto the IN-TX
locked row inside the precheck (not a stale pre-lock preview), so a
priority-only patch can't false-fire the guard.
- Optimistic-concurrency check now runs BEFORE the open-children precheck in the
store, so a stale expected_updated_at yields update_conflict (not
open_children) — single in-tx re-read shared by both.
- Date auto-population on the patch path only fills an EMPTY current date; an
existing end_date the caller isn't touching is preserved.
Tests added for each fix.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
Add read-only `ready` and `stale` actions to the pad_project MCP tool,
mirroring the existing CLI `pad project ready` / `pad project stale`.
`ready` returns the actionable backlog (query-oriented counterpart to
`next`); `stale` lists items needing attention. Both HTTP dispatchers
already existed; this wires them onto the catalog surface.
`pad project reconcile` stays CLI-only (shells out to `gh` for live PR
state — a local-git dependency MCP agents lack).
Bumps ToolSurfaceVersion 0.12 -> 0.13 across version.go, instructions.md,
README, CLAUDE.md; adds readOnlyActions entries, drift-guard test entries,
and a SKILL.md routing line.
TASK-2019
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* feat(project): agent-accessible activity feed (pad project activity + MCP action)
Add a non-streaming, bounded activity query so agents can catch up on
what other agents/users changed since they last worked — the query
counterpart to the live `pad project watch` SSE stream.
- CLI: `pad project activity [--limit N] [--actor user|agent] [--since DATE]`
backed by the existing GET /workspaces/{ws}/activity feed.
- MCP: `pad_project.activity` action (passThrough) + cloud HTTP route.
- Extend the activity endpoint with a server-side `since` date filter
(handler parse + store SQL clause) so limit/actor/since behave
identically across CLI, stdio MCP, and cloud HTTP transports.
- Bump ToolSurfaceVersion 0.11 -> 0.12 (drift guard, README, CLAUDE.md,
instructions.md) and add a SKILL.md querying-guidance line.
Tests: store since-filter test, HTTP dispatch test, catalog action test.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(mcp): mark pad_project.activity read-only in tool surface
Add activity to readOnlyActions so the serialized MCP tool surface emits
read_only:true (missing entries default to write). Spot-check it in
tool_surface_test.go to guard against regression.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
Wire the existing attachment HTTP dispatchers onto the MCP catalog as a
new read-only pad_attachment tool with list/show actions, mirroring the
CLI `pad attachment list` / `pad attachment show`. Both dispatch paths
already existed (ExecDispatcher via passThrough, HTTPHandlerDispatcher
via dispatch_http_attachments.go) — this exposes them on the tool
surface.
- New tool rather than pad_item actions: an attachment is its own
workspace-scoped resource, not an item property; a dedicated tool
keeps pad_item's action enum focused.
- Read-only only: upload/download/view stay CLI-only (filesystem-bound),
matching the catalog's exclusion rules.
- Bumps ToolSurfaceVersion 0.10 -> 0.11; updates instructions.md,
README, CLAUDE.md, readOnlyActions, and the drift-guard fixtures.
- The base64 image RESOURCE for multimodal agents is deferred to
TASK-2076 (ResourceFetcher returns strings; no CLI base64-to-stdout
path exists — non-trivial, out of scope here).
TASK-2017
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(playbooks): enforce draft gate server-side + expose status (BUG-2020)
pad_playbook run / POST /playbooks/{ref}/run now refuse a playbook whose
status isn't "active" with a structured playbook_not_active error. Adds
an allow_draft escape hatch across all surfaces: JSON body field, CLI
--allow-draft flag, MCP boolean param, and an "allow-draft" bareword in
raw_args (stripped before strict parsing). status is now echoed on both
the run and get responses.
Bumps ToolSurfaceVersion 0.9 -> 0.10 and updates the drift-guarded docs
(instructions.md, README.md) plus CLAUDE.md.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(playbooks): forward allow_draft on WebMCP + refresh mcp serve help
Codex review follow-up for BUG-2020:
- WebMCP dispatcher + api client now forward allow_draft so the browser
surface can use the draft-gate escape hatch the catalog advertises.
- `pad mcp serve --help` refreshed from the stale "v0.4 / eight tools"
text to the current v0.10 / nine-tool surface (incl. pad_library).
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(playbooks): type playbooks.get() with top-level status (BUG-2020)
Codex P3 follow-up: the get response now returns Item & { status }.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
Part A: `pad bootstrap --format json` now emits compact (no-indent) JSON
via a new cli.PrintJSONCompact helper. Its canonical consumer is the /pad
agent skill; pretty-print indentation was ~29% of the payload (49696 -> 35118
bytes on this workspace, saving 14578 bytes). Humans keep --format markdown.
Part B (conservative): condense the Role Awareness section and the
playbook-authoring guidance in skills/pad/SKILL.md to on-demand pointers,
keeping the load-bearing core behavior + activation gotcha inline and ALL
routing behavior intact. Saves 2764 bytes of fixed per-session overhead.
No MCP tool-surface change; ToolSurfaceVersion unchanged.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
Rewrite the three multi-step MCP prompt bodies in the surface-neutral
style promptOnboardBody already uses: name the pad_* tool + action, with
the CLI form as a parenthetical, instead of instructing shell-less MCP
clients to run ```bash CLI commands they can't execute. Extend the
lockstep tests with TestPromptsLockstep_NoBashBlocks to guard against
regression.
Content-only: no prompt name/count/param changes, so ToolSurfaceVersion
is unchanged.
TASK-2016
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
BUG-2073 (PR #870) closed the 1-hop A<->B parent-cycle race by folding
the child key into a sorted per-endpoint lock batch and re-reading under
lock. But that batch only covers the two endpoints of the edge being
written, so a cycle closed via an edge on an item that NEITHER endpoint
locks still slips through the per-endpoint checkParentCycleQ ancestor
walk. Concrete N-hop reproduction (Postgres): with A->B and C->D already
committed, two concurrent adds SetParentLink(B,C) and SetParentLink(D,A)
lock the DISJOINT sets {B,C} and {D,A}, so both cycle walks pass on stale
snapshots and both inserts commit, forming A->B->C->D->A.
Fix (workspace-level cycle guard):
- New acquireWorkspaceParentLinkLock — a Postgres advisory xact lock keyed
on a DISTINCT namespace ('pad:parent-link-cycle:' || workspaceID) that
serializes ALL parent-edge-ADDING transactions in a workspace. With no
two adds running concurrently, checkParentCycleQ always walks a
consistent, non-racing ancestor snapshot and catches arbitrary N-hop
cycles.
- Acquired OUTERMOST (before the seq lock and the per-item parent-children
batch) in the parent-edge-adding paths: setParentLinkOnce (SetParentLink)
and updateItemWithParentLinkOnce (UpdateItemWithParentLink, only when it
actually adds a parent). Global lock order is therefore
cycle -> seq -> parent-children in every transaction that takes them, so
no AB/BA inversion can form.
- CreateItemLink was the SECOND parent-edge adder and a hole: with
link_type="parent" it appended a raw parent row with NO cycle check and
NO workspace lock — it could form cycles even single-threaded, could give
a child multiple parent rows (the schema only uniques
(source_id,target_id,link_type)), and checkParentCycleQ follows only ONE
arbitrary parent per source so the extra row could hide an N-hop cycle.
Now CreateItemLink routes link_type="parent" through SetParentLink, which
gives it the full guarded protocol: single-parent DELETE-then-INSERT, the
workspace cycle lock + checkParentCycleQ under lock, and the
errParentSetChanged retry wrapper. Non-parent link types (blocks /
supersedes / implements / related) keep the append-only INSERT — none are
followed by the cycle walk, so none can form a cycle.
- Scope is edge-ADDERS only: edge removals (clear/detach) and plain field
updates / status flips can't create a cycle, so they don't take the lock
— the common UpdateItem path stays un-serialized. The BUG-2073
per-endpoint locks remain (they still bound the re-read-under-lock
open-children invariant); the workspace lock is the outer guard that
closes the N-hop gap.
- Rejected alternative (locking the full ancestor chain per write):
complex, deadlock-prone under concurrent reparents, hard to keep in
canonical sorted order.
Postgres-only for the concurrency races (SQLite serializes all writers via
BEGIN IMMEDIATE). Tests:
- TestSetParentLink_ConcurrentNHopNoCycle and
TestCreateItemLink_ConcurrentNHopNoCycle build the disjoint-lock
A->B->C->D->A quad via parallel goroutines and assert no cycle forms;
both verified to reproduce the bug with the guard disabled (6/64 and
14/64 quads cycle) and pass with it.
- TestCreateItemLink_ParentSingleParentAndCycle (dialect-agnostic) asserts
CreateItemLink(parent) now enforces single-parent (latest wins) and
rejects a direct cycle.
golangci-lint, go test ./..., and the full Postgres suite all green.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
The public SetParentLink/ClearParentLink paths and the shared
acquireParentChildrenLocksForUpdate helper had two residual TOCTOU
races (pre-existing on main; the NEW atomic UpdateItemWithParentLink
path was already made cycle-safe in PR #868 / BUG-2013):
1. Cycle race: SetParentLink acquired only the old+new parent advisory
keys, never the CHILD's own (itemID) key. Concurrent
SetParentLink(A,B) and SetParentLink(B,A) locked disjoint keys
({B} vs {A}), so both cycle walks passed on stale snapshots and both
inserts committed — forming an A<->B cycle.
2. Stale-old-parent race: oldParent was read BEFORE the parent-children
locks were acquired and never re-read. A concurrent reparent of the
same child committing while this tx waited on locks let it DELETE the
newly-committed parent link without holding that real old parent's
lock, breaking the open-children guard serialization. The shared
read-then-lock helper (UpdateItem/RestoreItem/MoveItem) had the same
defect: it read the parent set before locking itemID.
Fix, consistent with the PR #868 pattern (sorted lock batches, tx-scoped
cycle walk), and deadlock-free:
- setParentLinkTx / clearParentLinkTx: fold itemID into the lock set and
acquire {itemID + old + new parent} in ONE sorted batch, then RE-READ
the old parent under the (now-held) child lock. New readParentLinkTarget
helper.
- acquireParentChildrenLocksForUpdate: after the sorted acquisition,
re-read the parent set under the itemID lock (keysNotIn detects any
parent that appeared during the acquisition window).
- When a re-read shows the parent set moved, signal the errParentSetChanged
sentinel instead of acquiring the moved key out of the canonical sorted
order (which could deadlock). The tx-owning callers — SetParentLink,
ClearParentLink, UpdateItemWithParentLink, RestoreItem,
MoveItemWithPreCheck — wrap their bodies in retryOnParentSetChanged,
which rolls back (releasing every advisory lock) and retries from a
fresh read. Every acquisition stays a single in-order sorted batch. The
signal fires before any commit, so a retry never leaves partial state;
bounded by maxParentLockRetries.
- RestoreItem: route through acquireParentChildrenLocksForUpdate so it
also holds the item's own lock and gets the re-read correction.
- CreateItemLink / DeleteItemLink: for child link types, lock the SOURCE
item's key in addition to the target's. Attaching/detaching sourceID as
a child mutates sourceID's parent set, so sourceID's own lock must be
held for the "the child lock freezes an item's parent set" invariant the
re-read/retry above depends on. Both keys go through the sorted helper,
so the two-key grab stays deadlock-free.
Postgres-only races (SQLite serializes writers via BEGIN IMMEDIATE), so
the new concurrency tests are gated on the Postgres dialect. They pass
with the fix and reproduce the A<->B cycle without it.
Out of scope (pre-existing, tracked separately): cycles closed via an
edge on an item that NEITHER endpoint locks (e.g. A->B->C->D->A) still
slip through the per-endpoint cycle walk — a documented limitation of the
per-endpoint lock scheme, not the direct A<->B race this bug names.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
BUG-2014 (PR #867) added Degraded + DegradedSections to DashboardResponse
so callers can tell a failed sub-query apart from a genuinely-empty
section. BUG-2072 reported that the slim BootstrapDashboard projection
omits them — but BootstrapDashboard embeds *DashboardResponse anonymously,
so encoding/json already promotes both fields into the bootstrap wire
shape. Verified empirically: the MCP pad_meta.action=bootstrap tool, the
pad://workspace/{ws}/bootstrap resource, and the pad_set_workspace embed
all serialize this same struct, so partial-failure state already reaches
every agent surface.
The promotion was untested and undocumented, so a future refactor to an
explicit slim projection (like BootstrapCollection / BootstrapRole) could
silently drop it. This pins the behavior:
- TestBootstrapDashboardCarriesDegraded asserts on the marshaled JSON
(not just promoted field access) that degraded=true + the failed section
names flow through, and that a healthy dashboard omits degraded_sections.
- BootstrapDashboard godoc now documents the promotion + the carry-across
requirement for any future explicit projection.
No payload change — the fields were already present.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(store): make item field + parent-link update atomic (BUG-2013)
handleUpdateItem committed the field write, then ran SetParentLink/
ClearParentLink as a SEPARATE store transaction. A failure there
(cycle discovered late, DB error) returned 500 with half the patch
already applied — a code-acknowledged partial-commit window.
Fold the parent-link mutation into the SAME transaction as the field
write:
- store: extract setParentLinkTx / clearParentLinkTx from the public
SetParentLink / ClearParentLink (which keep their own tx). Add
UpdateItemWithParentLink(id, input, precheck, *ParentLinkUpdate) —
UpdateItemWithPreCheck now delegates to it with a nil link. The
link write runs after the field UPDATE but before COMMIT, so a
failing link write rolls the field write back too.
- lock ordering: the NEW parent's advisory key is folded into the
update's initial sorted AcquireParentChildrenLocks batch (extraKeys
on acquireParentChildrenLocksForUpdate), so setParentLinkTx's later
re-lock is an idempotent no-op and the combined update stays
deadlock-free. checkParentCycle is parameterized over the queryer
so the cycle walk reads inside the tx.
- handler: restructured into validate / atomic-write / post-commit
stages. The parent-link directive is built once and threaded through
all three UpdateItemWithParentLink call sites; the post-commit
SetParentLink/ClearParentLink block is removed.
Works on both SQLite and Postgres (advisory locks are pg-only; SQLite
gets atomicity from BEGIN IMMEDIATE). Adds store tests proving a
failing parent-link write rolls back the field change (no partial
state) and that the happy path commits both together.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(store): check parent cycle under lock in setParentLinkTx (Codex #868)
Codex review flagged a cycle-check TOCTOU: setParentLinkTx walked the
ancestor chain BEFORE acquiring the parent-children advisory locks, so
two concurrent reparents could each pass on a stale snapshot, block on
the lock, then both insert — forming a cycle (A→B→C→A). Move the cycle
check to AFTER lock acquisition; under the lock the tx-scoped walk sees
the edge the just-unblocked peer committed and rejects the cycle.
Pre-existing behavior (the old SetParentLink checked cycles on s.db
before even beginning its tx), hardened here since this function was
already being refactored. Residual: cycles closed via an edge on an
item neither endpoint locks remain possible — a limitation of the
per-endpoint lock scheme, tracked separately.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(webhooks): track delivery goroutines + bounded retry (BUG-2012)
Webhook deliveries ran in untracked `go d.deliver(...)` goroutines that
write to the store — the BUG-842 shutdown-race class that goAsync was
built to prevent — and had no retry.
- Inject a `spawn func(func())` into Dispatcher (SetSpawn). Server wires
s.goAsync via SetWebhookDispatcher so deliveries are tracked on s.bg
(Stop() waits for in-flight deliveries) and inherit goAsync's panic
recovery (BUG-2011). Nil spawn falls back to a plain goroutine, so
standalone Dispatcher usage is unchanged.
- Add a bounded in-goroutine retry: up to 3 attempts with linear backoff
on transient failures (network error / timeout / 5xx). Permanent
failures (4xx, SSRF block, malformed URL) stop immediately. The final
outcome is recorded once via UpdateWebhookFailure.
- Tests: delivery runs on the injected spawn; transient 5xx retries to
the cap; permanent 4xx does not; a recovered transient records success.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(webhooks): classify redirect-block + non-5xx as permanent (Codex review)
Second Codex review of PR #864 found two retry-classification gaps:
- P2: an SSRF-blocked (or looping) redirect surfaces as an error from
client.Do (via CheckRedirect), which the retry loop treated as
transient — so a redirect to an internal target was retried 3x with
backoff. Wrap a sentinel (errRedirectRejected) in checkRedirect and
match it with errors.Is (url.Error unwraps to it) to classify these
as permanent — attempted once, no retries.
- P3: the status switch treated every non-2xx/non-4xx as transient.
Narrow transient to 5xx only; 4xx/3xx-no-Location/1xx are permanent,
matching the stated "network error / timeout / 5xx" retry policy.
Adds TestDispatcher_RedirectBlockIsPermanent.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
Mechanically split the 9,841-line cmd/pad/main.go god file into
cohesive per-resource files (all package main): cmd_item.go,
cmd_collection.go, cmd_workspace.go, cmd_auth.go, cmd_project.go,
cmd_playbook.go, cmd_role.go, cmd_tag.go, cmd_github.go,
cmd_webhook.go, cmd_agent.go, cmd_server.go, cmd_attachment.go,
cmd_db.go, cmd_library.go, cmd_bootstrap.go. main.go now holds only
main(), newRootCmd(), and shared config/client wiring (265 lines).
Zero behavior change — a pure move of command constructors + helpers.
All 169 top-level declarations preserved verbatim; the recursive
--help command/flag tree is byte-identical to main. cmdhelp and the
MCP catalog read command schemas at runtime, so they are unaffected.
Updates the CLAUDE.md "Add a new CLI command" recipe to point
contributors at the appropriate cmd_<resource>.go file and groups.go,
so the file stops being a merge-conflict magnet for parallel agents.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(server): surface dashboard sub-query failures instead of silent empty (BUG-2014)
buildDashboardResponse assembled several best-effort sections with
`if err == nil` / `if err != nil { continue }` and no logging, so a
failing sub-query rendered indistinguishably from a genuinely-empty
section — a completely silent degradation.
Add a `Degraded` bool + `DegradedSections []string` to DashboardResponse.
A new markDegraded helper logs each failure (slog.Error with workspace +
section) and records the affected section, so partial failures are both
diagnosable server-side and visible to the client without changing the
all-or-nothing contract for the queries whose failure genuinely
invalidates the whole dashboard (those still return an error). Wired into
active_plans, attention.stalled, attention.orphaned_tasks, recent_activity,
by_role, and starred_items. The has_agent_activity source fallback (which
has a valid default) logs a Warn but does not degrade.
Mirror the new fields in the TypeScript DashboardResponse type and add a
Go test asserting a failed sub-query flips Degraded, names the section,
keeps the endpoint at 200, and preserves the healthy sections.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* fix(server): skip orphan detection when GetParentMap fails (BUG-2014 review)
A GetParentMap failure previously fell back to an empty parent map and
still iterated allTasks, flagging every visible non-done task as an
orphaned_task (false positives). Skip orphan detection entirely on that
failure — the section is already marked degraded.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
* feat(web): show degraded-load banner on the dashboard (BUG-2014)
Consume the new DashboardResponse.degraded / degraded_sections signal on
the workspace dashboard page. When a best-effort sub-query fails
server-side, the affected sections could otherwise render as genuinely
empty; surface an amber "some data couldn't be loaded" banner (listing
the affected sections) so the partial-failure state is visible instead of
silent.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
The four long-running sweeper loops (orphan GC, op-log GC, token reaper,
workspace purge) spawn their own s.bg-tracked goroutine with a
stop-channel lifecycle, so they can't route through goAsync (a
fire-and-forget helper that owns the whole goroutine) without breaking
shutdown or double-counting s.bg. As a result they had NO recover(): a
panic in any sweeper body crashed the single-binary server for every
tenant.
Add a shared Server.recoverSweeper(name) firewall — mirroring goAsync's
recover + debug.Stack slog style — and defer it inside each sweeper
goroutine. A panic is now logged with a stack and the goroutine unwinds
cleanly; its own deferred s.bg.Done() still fires (recover stops the
unwind), so Stop() still drains. No change to any sweeper's loop cadence
or stop-signal shutdown.
Adds TestTokenReaper_RecoversPanic, which drives a real reaper tick to
panic (nil store → nil-pointer deref in the first cleaner) and asserts
the panic is logged+recovered and Stop() returns.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
goAsync wrapped fn in a bare goroutine with no recover(); chi's
Recoverer only covers request goroutines, not these detached ones.
A panic in a background task (e.g. deriveThumbnails hitting a Go
image-decoder panic on a crafted upload, or an email send) would
unwind past the goroutine and crash the whole single-binary server
for every tenant.
Add a single deferred recover() inside the goAsync goroutine that
logs the panic + stack via slog, covering all 15+ call sites at once.
The recover defer is registered after `defer s.bg.Done()`, so it runs
first on unwind and Done() still fires — Stop() continues to drain
the WaitGroup even when fn panics.
Adds TestServer_goAsync_RecoversPanic asserting the process survives
a panicking fn and Stop() returns.
Fixes BUG-2011.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
PR #860 put the attach affordance on the slash menu + mobile toolbar, but
the slash menu doesn't surface on touch — so the reliable mobile entry
point is the block context menu (⠿ drag-handle → tap → Turn into /
Duplicate / Delete). This adds an "Attach file" INSERT action there,
giving mobile parity with the desktop /attach slash command.
- Gated on the AttachmentUpload extension being present (canAttach).
- Positioned below the turn-into divider so it stays visible for atom
blocks (where the turn-into section is hidden).
- attachmentImage/attachmentChip are inline atoms. blockAtPos returns the
CONTAINER for list items / blockquotes, so the handler descends to the
last non-code textblock to find a valid inline position (paragraph,
heading, list-item paragraph, blockquote paragraph). Atoms / code blocks
have no inline home, so a host paragraph is created after the block.
- Common path sets the editor selection at click time so ProseMirror maps
it forward across intervening edits (collab peers, in-flight uploads) —
no stored raw position to go stale. The rare paragraph path is deferred
to file-pick and clamped, so cancelling the picker changes nothing.
- Reuses the uploadAttachments command (same paste/drop pipeline); no
ProseMirror/Y.Doc node-spec change, so no collab schema bump.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
The editor's attachment upload was paste/drop-only, which is unreachable
on touch devices — so the mobile shells' file-chooser plumbing (TASK-2049)
had nothing to trigger. Expose the existing startUpload pipeline as a
Tiptap `uploadAttachments(files)` command and wire an attach button +
hidden file <input> into two surfaces: the mobile toolbar (📎 button) and
a `/attach` slash command (the desktop insertion affordance, same pattern
as tables / HTML / import-from-URL).
Reuses the paste/drop flow verbatim — images become attachmentImage,
other files become attachmentChip, with the same upload/onError options.
Pure UI change: no ProseMirror/Y.Doc node-spec change, so no collab
schema-version bump.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
PLAN-1694 Phase 1 specced "hide the bar when the on-screen keyboard is
up" but it shipped unimplemented — the fixed BottomNav stayed pinned
above the keyboard, eating vertical space during text entry.
Detect the keyboard from its own geometry: track visualViewport.height
against the tallest height seen (the keyboard-closed baseline) and flag
keyboardVisible when it shrinks >150px. This works on iOS Safari and
Android Chrome, unlike `innerHeight - visualViewport.height`, which stays
~0 on browsers that shrink innerHeight in lockstep with the visual
viewport. Baseline grows as the URL bar collapses on scroll and
re-captures on orientationchange so rotation/chrome don't false-trigger;
gated on isTouch.
BottomNav gates the <nav> on !keyboardVisible (docked sheets stay mounted
so QuickCapture survives raising the keyboard itself), and drops the
has-bottom-nav content-reflow padding with it to avoid a dead gap.
Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
Points new contributors at the good first issue / help wanted labels
(now seeded with 8 real, scoped issues), explains the area:*/effort:*
labels, and states the claim + draft-PR triage rhythm so 87 stargazers
landing on the repo have a real entry point instead of a dead surface.
Part of TASK-2007.
The dashboard builder (also reused by the bootstrap endpoint and every
pad_set_workspace) ran ~1000 queries on a large workspace: one
GetItemLinks + per-link GetItem for every non-done item (blocked
attention + suggested_next filter), a GetChildItems per active plan
(progress + suggested_next), a GetItemIncludeDeleted per recent-activity
row, a GetCollection per visible collection, and a per-collection COUNT
via ListCollections whose result the dashboard never uses.
Replace every per-item/per-row loop with a set-based query:
- GetBlocksEdges: one workspace-wide JOIN of blocks-links -> blocker
essentials, ordered created_at DESC to preserve the old first-active-
blocker selection. Drives both blocked attention and the suggested_next
blocked-filter (retires itemBlockedByActive).
- GetChildItemsForParents: one IN query grouping all active-plan children
by parent (progress + suggested_next; no-content projection).
- GetItemsByIDsIncludeDeleted: one IN query batch-hydrating recent-activity
items (include-deleted).
- ListItemsParams.NoContent: skip loading full markdown bodies on the
count/summary scans (allItems, plans, stalled, orphaned).
- ListCollectionsMinimal now also selects slug; the dashboard uses it in
place of ListCollections, dropping the unused per-collection COUNT N+1
and the GetCollection-per-visible-id loop.
Per-item N+1s are gone; query count is now constant in workspace size.
Verified byte-identical dashboard + bootstrap JSON against three live
workspaces (docapp/claude/apm); dashboard latency ~376ms -> ~198ms on the
1907-item docapp workspace. New store methods are unit-tested.
enrichItemsWithParent loaded every parent link in the workspace and then
called full-row GetItem once per unique parent (151 in the live workspace),
despite a comment claiming a bulk fetch. A ?limit=1 list took 34-39ms vs
~2ms for a single GET — a ~20x tax to return one row, hit on every list
request including the /items-changes sync endpoint the local-first client
polls.
Scope the parent IDs to only the parents of the returned item slice, then
hydrate title/ref/slug/collection in one skinny WHERE id IN (...) query via
the new Store.GetItemLineageByIDs. Enrichment output shape and best-effort
(missing parent never fails the list) behavior are preserved; the visibility
filter now runs against the batched projection's collection_id.