Commit Graph

993 Commits

Author SHA1 Message Date
xarmian bfa32dde5a fix(security): encrypt webhook HMAC secrets at rest, mask in responses (BUG-2057) (#915)
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
2026-07-11 00:10:27 -04:00
xarmian 3bb50ab27f fix(security): make TOTP login codes single-use (BUG-2054) (#914)
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
2026-07-10 23:56:59 -04:00
xarmian 8f7b7d551f fix(security): rate-limit share-link password verification (TASK-2055) (#913)
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
2026-07-10 23:48:59 -04:00
xarmian 3f69b76b06 feat(security): enforce session UA binding under strict mode (TASK-2056) (#912)
Session IP/User-Agent binding was log-only by default, so a stolen
session token granted durable any-origin access. IP-change enforcement
already existed behind PAD_IP_CHANGE_ENFORCE=strict; this extends the
same single toggle to also enforce the User-Agent-hash binding.

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

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

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

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-10 23:32:22 -04:00
xarmian 43c31c826e feat(cli): add claude-code + codex targets to pad mcp install (TASK-2040) (#909)
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
2026-07-10 22:04:22 -04:00
xarmian 0ed0381f1f docs: document blank template, note hidden demo in templates list (TASK-2041) (#908)
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
2026-07-10 21:51:04 -04:00
xarmian 5a6659ea66 docs: document Docker first-run bootstrap-token flow (TASK-2038) (#907)
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
2026-07-10 21:50:58 -04:00
Dipak Chaudhari 0b80ffa36a refactor(web): migrate KeyboardShortcuts modal to shared Modal primitive (#900)
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>
2026-07-10 19:58:27 -04:00
xarmian 2ff8ac9c15 fix(ci): let Go fetch the 1.26.5 toolchain go.mod pins (unbreak CI) (#901)
#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
2026-07-10 19:40:27 -04:00
xarmian d6c6dfe682 build(deps): govulncheck binary mode + Go 1.26.5 / x-crypto / gRPC security bumps (#896)
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).
2026-07-10 12:02:20 -04:00
xarmian c62658a11b fix(cli): root usage/error/format hygiene (TASK-2031, BUG-2032) (#893)
* 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.
2026-07-09 21:50:36 -04:00
xarmian 4d1034a708 feat(cli): width-aware item-list table with STATUS/PRIORITY columns (#894)
TASK-2030 (PLAN-1985). Manual ANSI-safe renderer replaces tabwriter; terminal-width-aware title truncation; drops the modifier BY column.
2026-07-09 21:47:28 -04:00
xarmian 502d87b890 docs(cli): document playbooks+conventions-only export/import constraint (#892)
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'.
2026-07-09 21:28:20 -04:00
xarmian 13f6c28ff3 refactor(web): migrate roles page dialogs to shared Modal primitive (#891)
* refactor(web): migrate roles page dialogs to shared Modal primitive (TASK-2083 follow-up)

* fix(web): scroll new-item picker content within capped Modal (Codex P2)
2026-07-09 20:52:59 -04:00
xarmian 55c8eff0a6 refactor(web): migrate remaining route-inline modals to shared Modal primitive (TASK-2083) (#890) 2026-07-09 20:37:10 -04:00
xarmian be8fdf8d06 refactor(web): extract collab-flush path from item-detail monolith (TASK-2082) (#889)
* refactor(web): extract collab-flush path from item-detail monolith (TASK-2082)

* refactor(web): guard in-flight collab-flush dedupe record against reset (Codex P1)
2026-07-09 20:27:38 -04:00
xarmian 4eb72dc79f feat(web): surface rate_limited errors as a server-busy toast (TASK-2080) (#888) 2026-07-09 20:04:55 -04:00
xarmian 652584bcfa test(web): jsdom/Svelte vitest harness + Modal & breakpoint tests (TASK-2081) (#887)
* test(web): add jsdom/Svelte vitest project + Modal & breakpoint tests (TASK-2081)

* test(web): update package-lock for jsdom test deps (TASK-2081 review)
2026-07-09 19:53:55 -04:00
xarmian f49c14c86b refactor(web): unify mobile breakpoint into one shared isMobile store (TASK-2028) (#886) 2026-07-09 17:20:12 -04:00
xarmian 7d76fc2b8a refactor(web): extract contentSaver + shared progress-merge from item/collection monoliths (TASK-2029) (#885) 2026-07-09 16:51:55 -04:00
xarmian 576360eed9 fix(web): distinguish transient failures from not-found (BUG-2025) (#884)
* fix(web): distinguish transient failures from not-found on collection + dashboard (BUG-2025)

* fix(web): guard cross-navigation staleness, load races, and dashboard 404 (BUG-2025 Codex round 2)

* fix(web): dashboard load supersession guard + clear stale state on terminal 404 (BUG-2025 Codex round 3)

* fix(web): recheck load token after progress awaits on collection page (BUG-2025 Codex round 4)

* fix(web): guard superseded progress-fetch catch blocks (BUG-2025 Codex round 5)
2026-07-09 16:31:20 -04:00
xarmian 350617eb0c fix(web): flush pending raw-markdown on unload (BUG-2024) (#883)
* fix(web): flush pending raw-markdown on unload via keepalive PATCH + dirty prompt (BUG-2024)

* fix(web): cancel queued debounce + clear dirty on keepalive save (BUG-2024 review)
2026-07-09 16:09:51 -04:00
xarmian d58698a551 feat(web): 429/Retry-After handling in API client (TASK-2026) (#882)
* feat(web): 429/Retry-After handling in API client with backoff-aware bootstrap retry (TASK-2026)

* fix(web): global rate-limit cooldown so follow-up GETs honor last Retry-After (TASK-2026 Codex P1)

* fix(web): harden rate-limit cooldown against concurrency races (TASK-2026 Codex round 2)
2026-07-09 15:56:06 -04:00
xarmian 9664cd6ee5 feat(web): shared native-dialog Modal primitive + migrate form modals (TASK-2023) (#881)
* feat(web): shared native-dialog Modal primitive + migrate form modals (TASK-2023)

* fix(web): hide closed native-dialog Modal (specificity vs UA rule) — Codex P1
2026-07-09 15:33:37 -04:00
xarmian bcf5e6519e fix(web): render SSE connection-status indicator (TASK-2027) (#880)
* fix(web): render SSE connection-status indicator (TASK-2027)

* fix(web): initialize follower-tab SSE status via handshake (TASK-2027)
2026-07-09 15:05:34 -04:00
Zak Molloy 744e3791e9 fix: parent commands exit non-zero on unknown subcommand (#850)
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>
2026-07-09 09:48:11 -04:00
xarmian bed933d7fd feat(items): field-level PATCH + conflict envelope + read-only version history (TASK-2022) (#876)
* 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
2026-07-08 16:47:34 -04:00
xarmian 4bacea530f feat(mcp): expose pad_project ready + stale actions (#878)
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
2026-07-08 16:36:06 -04:00
xarmian c846cff4fd feat(project): agent-accessible activity feed (pad project activity + MCP action) (#877)
* 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
2026-07-08 16:26:41 -04:00
xarmian 2e6538ac34 feat(mcp): add read-only attachments surface (pad_attachment) (#875)
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
2026-07-08 15:50:33 -04:00
xarmian c127a5f965 fix(playbooks): enforce draft gate server-side + expose status (BUG-2020) (#874)
* 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
2026-07-08 15:28:21 -04:00
xarmian 7aa5cb98f3 perf(bootstrap): compact JSON for agents; trim SKILL.md reference sections (#873)
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
2026-07-08 15:08:13 -04:00
xarmian 30dd6cf585 refactor(mcp): make pad_plan/ideate/retro prompt bodies surface-neutral (#872)
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
2026-07-08 15:06:39 -04:00
xarmian e071b6273f fix(store): workspace-level guard for N-hop parent cycles (BUG-2074) (#871)
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
2026-07-08 14:46:15 -04:00
xarmian 55bfed543e fix(store): close parent-link cycle & stale-old-parent TOCTOU races (BUG-2073) (#870)
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
2026-07-08 13:55:03 -04:00
xarmian ff7e7d51cb fix(server): guard degraded/degraded_sections in bootstrap dashboard (BUG-2072) (#869)
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
2026-07-08 13:09:26 -04:00
xarmian a7994fc374 fix(store): make item field + parent-link update atomic (BUG-2013) (#868)
* 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
2026-07-08 12:18:01 -04:00
xarmian aeb80883f0 fix(webhooks): track delivery goroutines + bounded retry (BUG-2012) (#864)
* fix(webhooks): track delivery goroutines + bounded retry (BUG-2012)

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

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

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

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

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

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

Adds TestDispatcher_RedirectBlockIsPermanent.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 12:02:49 -04:00
xarmian ac6e05d1e3 refactor(cli): split cmd/pad/main.go by resource (TASK-2015) (#866)
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
2026-07-08 12:02:39 -04:00
xarmian 8be1ac67da fix(server): surface dashboard sub-query failures instead of silent empty sections (BUG-2014) (#867)
* 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
2026-07-08 12:02:12 -04:00
xarmian a04fd861dc fix(server): add panic recovery to background sweeper goroutines (BUG-2071) (#865)
The four long-running sweeper loops (orphan GC, op-log GC, token reaper,
workspace purge) spawn their own s.bg-tracked goroutine with a
stop-channel lifecycle, so they can't route through goAsync (a
fire-and-forget helper that owns the whole goroutine) without breaking
shutdown or double-counting s.bg. As a result they had NO recover(): a
panic in any sweeper body crashed the single-binary server for every
tenant.

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

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

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

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

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

Fixes BUG-2011.

Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
2026-07-08 11:49:01 -04:00
xarmian 555ee37f3e feat(editor): add "Attach file" to the block context menu (TASK-2067) (#862)
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
2026-07-08 08:57:28 -04:00
xarmian 79563386f0 feat(editor): add explicit attach-file button to tiptap menu (TASK-2067) (#860)
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
2026-07-07 21:42:36 -04:00
xarmian 14bbd4a390 fix(mobile): hide bottom nav when on-screen keyboard is up (BUG-2070) (#859)
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
2026-07-07 21:29:01 -04:00
xarmian 96c07c7981 docs(contributing): add 'Where to start' — good-first-issue labels + triage rhythm (#858)
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.
2026-07-07 20:24:34 -04:00
xarmian 1159fa7df4 docs: sync CLAUDE.md MCP narrative to v0.9 / nine tools (#849) 2026-07-07 17:36:44 -04:00
xarmian 15ad930d78 feat(bootstrap): add convention_index for triggered-convention discovery (TASK-2004) (#848) 2026-07-07 17:28:33 -04:00
xarmian 48104a5eff perf(server): collapse dashboard/bootstrap N+1 into set-based queries (BUG-2002) (#847)
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.
2026-07-07 17:18:54 -04:00
xarmian 375e3b5369 perf(store): batch parent-lineage enrichment into one scoped query (BUG-2003) (#846)
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.
2026-07-07 16:51:48 -04:00