xarmian 2ed6e71ad3 feat(store): transactional event outbox — the SPEC-3 choke point (TASK-2658) (#1172)
* feat(store): transactional event outbox + events/1 item taxonomy (TASK-2658, SPEC-3)

Phase-0 unit 2 of PLAN-2656, store half. Events are now written to an
outbox in the SAME transaction as the mutation that produced them, so a
committed mutation cannot lose its event and a rolled-back one cannot
leak one. Nothing drains the outbox yet — behaviour is unchanged.

- migrations 081 / pgmigrations 059: event_outbox. Deliberately no FKs on
  workspace_id / subject_id: an outbox row must outlive its subject, or
  item.deleted cascades away exactly when it matters. Retention, not
  referential integrity, bounds the table.
- internal/kernelevents: the closed events/1 name set (SPEC-3 v1.3) with
  IsCanonical enforcing the closure rule at the choke point.
- store/event_outbox.go: writeOutboxTx (tx-scoped, hard-fails the
  mutation rather than degrading to best-effort), the item payload shape
  (snapshot EMBEDDED so query/1 predicates apply verbatim, prior_status
  alongside as the envelope pseudo-field), and the drain-side primitives.
- item.created / updated / status_changed / moved / deleted / restored
  emitted from inside their mutations' transactions, from in-tx snapshot
  read-backs rather than caller input.
- SPEC-3 v1.3 disjoint-delta rule: canonical events partition a
  mutation's delta and a mutation emits every event whose slice changed.
  The seam diffs slices rather than branching on "was this a status
  update" — branching drops the item.updated half of a mixed update.
- ImportWorkspace stays silent per the SPEC-3 ruling, commented at the
  INSERT so it reads as a decision. insertItemTx's "every creation side
  effect lives in this one place" comment corrected: it is API-path only,
  and import is the counterexample two units have now been misled by.

* feat(store): comment / attachment / member events on the outbox (TASK-2658)

Completes the store half of the choke point. Same rule throughout: the
event is written on the mutation's own transaction, from an in-tx
read-back rather than caller input.

- comment.created / comment.updated. GetComment gains a Queryer form so
  the emit reads through the tx: a pool read takes a different
  connection and cannot see the uncommitted write, so it would return
  the PRE-write row and the event would describe a state that is not the
  one committing (mutation-verified).
- attachment.added, gated to user-visible originals. Variants are
  attachment rows too — a thumbnail carries parent_id plus a variant tag
  — so an ungated emit announces three events per image upload, two for
  files no user added. Transform outputs stay admitted: no parent, and a
  user did add them.
- member.joined. AddWorkspaceMember becomes transactional to carry it; a
  self-committing INSERT plus a separate emit is the shape that loses
  events on a crash.

item.bulk_updated is NOT here, and not by omission: bulk is a handler
loop over per-item store mutations, each already emitting canonically
from its own transaction. There is no bulk transaction to write it in,
so the batch event is delivery-side aggregation — it belongs with the
drain in TASK-2714, where SPEC-3's per-member binding evaluation is
already satisfied by the per-item rows.

* fix(store): emit item.deleted for a cross-workspace move's source archive (TASK-2658)

Self-caught during the diff review. archiveItemForCopyTx deliberately
REPRODUCES DeleteItem's UPDATE inside the copy's transaction rather than
calling it, so it did not inherit DeleteItem's new emit: a cross-workspace
move archived the source silently while an ordinary archive of the same
item announced itself. Invisible until something drains the outbox, at
which point moves would just stop being observable.

Same ordering as DeleteItem — snapshot in-tx BEFORE the UPDATE, while the
row is still live, because SPEC-3 requires the final pre-archive state.

Also amends the file's DR-14 header. DR-14 says no fanout inside the
transaction because a rollback would leak the event; an outbox row written
on the SAME transaction rolls back WITH the copy, so that rationale does
not reach it. The three things DR-14 actually names — activity row, SSE
publish, webhook — still happen post-commit at the caller, unchanged. A
documented decision should not be silently contradicted by the code.

* fix(store): compare the move's event slices against an in-tx pre-move snapshot (TASK-2658)

Codex round 1, P2 — a defect in my own round-1 code. MoveItemWithPreCheck
refreshes `existing` in-tx only on the precheck path; on the no-precheck
path it stays the PRE-LOCK pool read. The emit block compared it against
the post-move in-tx snapshot, violating a precondition documented on
itemUpdatedSliceChanged itself (both snapshots must come from getItemTx,
or rendering differences read as changes), and a stale CollectionID makes
the item.moved decision wrong outright.

Adds a dedicated `preMove` in-tx snapshot and tightens the read: it used
to tolerate a failure by silently keeping the pre-lock value, which only
degraded from_status. It now also decides which events fire, so a
degraded read is no longer an acceptable outcome — under a held lock on a
row just resolved live, an error or missing row means something is wrong.

* feat(store): item.bulk_updated for store-side bulk mutations; purge the outbox (TASK-2658)

Codex rounds 1 and 2. Two more item-mutation write paths emitted nothing,
and both are single-transaction bulk mutations, so their emits are WRITES
and belong in this unit rather than with the drain:

- collections.go: renaming a select OPTION rewrites items.fields on every
  row carrying the old value.
- wiki_links.go: renaming an item rewrites the CONTENT of every item that
  links to it by title.

Each emits ONE in-tx item.bulk_updated rather than per-row item.updated:
the user performed one action, and per-row fan-out is the flood TASK-1668
already decided against. Per-member snapshots keep item-level bindings
evaluable, which is what makes batching safe (SPEC-3 v1.1). Payload size
is deliberately unbounded in v1 — capping members silently drops binding
evaluation for the tail, and dropping `content` would break exactly the
bindings the wiki cascade exists for.

Also from round 2:

- Workspace purge now deletes event_outbox. It has no FK by design (a row
  must outlive its subject), so nothing deleted it on the purge's behalf,
  and payloads hold full item content and comment bodies — a purged
  workspace's text would have stayed readable indefinitely. Added to
  wsChildTables so the exhaustive-purge test covers it.
- Documented that ListPendingOutboxEvents is deliberately cross-workspace
  and unauthorized, and must never be reachable from a request path.
- The two callers that discarded AddWorkspaceMember's error now log it.
  Not fatal (that is BUG-2715), but this unit made the call transactional
  and so gave it a new way to fail; widening a swallowed error without
  making it visible is how a failure mode goes unnoticed.

* fix(store): classification correctness + dialect-neutral payload validation (TASK-2658)

Codex round 3, five findings.

A REAL SILENT-EVENT BUG in the classifier. The done-key mask ran
unconditionally, but the status machinery (extractFieldValue) only reads a
done-key value when it is a JSON STRING. So on a collection whose done
field holds a number, `{"stage":1}` → `{"stage":2}` produced NO EVENT AT
ALL: status_changed could not see it, and the mask deleted the key from
both snapshots so item.updated could not either. Now the key is masked
only when both sides hold a string there — exactly the condition under
which status_changed will describe it. When it will not, the change falls
back to item.updated's slice, where something can.

Payload JSON is now validated in Go. The column types DISAGREED: Postgres
JSONB rejects malformed JSON at the INSERT, SQLite's TEXT accepts it, so
the same bad payload failed a mutation on one backend and silently
persisted an undeliverable event on the other.

Corrected an overclaim of my own: the exclusion-list comment said a new
column is compared by default. True only of columns that reach
models.Item's JSON — last_restore_seq and the content-flush watermarks are
invisible to the diff no matter what the list says. Unreachable today
(every caller that moves them also writes content or fields), but not
structurally guaranteed, and now written down as a constraint on adding
persisted columns.

Tests: a custom done-field key (every previous classification test used
"status", so a classifier hard-coded to that key would have passed them
all), non-string and non-object blobs, malformed payload rejection, and
the bulk test now asserts member IDENTITY and the delta rather than a
count and a substring.

* fix: comment-accuracy sweep + no-op comment gate + enumerate the remaining discards (TASK-2658)

Codex round 4, aimed at the claims my own comments make. Three of them
were false or overclaiming, which is the point of pointing a review round
at your own prose.

- taxonomy.go and migration 081 described the END STATE — a drain loop, a
  unified SSE/webhook vocabulary — as if it existed. Both now say plainly
  that nothing drains the table, that the legacy hand-calls still fire
  unchanged, and that the mapping and retirement are TASK-2714. A comment
  describing the intended end state in the present tense is how a reader
  concludes a feature is broken.
- The hop bound and the §L5 quota text read as running behaviour. Nothing
  propagates a hop yet (no binding kernel), so every production write
  leaves it 0 and the depth check is exercised only by tests. Said so,
  and recorded the surfacing obligation as an obligation.
- The re-delete comment was wrong TWICE. The zero-row return exits before
  the nil-snapshot guard, so that guard does not participate in re-delete
  at all — it is what keeps this correct if the order or predicate ever
  changes. My round-3 "correction" swapped one wrong mechanism for
  another because I reasoned from a mutation result instead of the code.

Real behaviour fixes in the same round:

- A no-op comment edit no longer emits. The UPDATE matches on id alone,
  so re-saving an identical body touched the row and emitted
  comment.updated; the row-count check never suppressed it. Comparing the
  body does, which also makes comment.updated consistent with the item
  events.
- applyFieldMigrationsTx returns 0, not totalAffected, when emission
  fails. Every error there rolls the caller's transaction back, so the
  count described writes that never committed.
- Two MORE callers still discarded AddWorkspaceMember's error (the JSON
  import and bundle import paths). Round 2 named two; I fixed those two
  and did not enumerate. All nine call sites checked this time; the two
  remaining discards now log.

Filed BUG-2716: the activity row commits before the comment and cannot be
reordered (the comment carries its id), so a failed comment write leaves
an orphan "commented" activity. Documented at the call site.

* fix(store): partition item.bulk_updated by the members' own workspace (TASK-2658)

Found in my own multi-tenancy probe while round 5 ran, not by the oracle.

emitBulkItemEventTx published every member under the workspace the CALLER
passed. For the collection-option rename that is right. The wiki-title
cascade is not so obviously safe: its source query selects on
target_item_id alone and carries each source row's workspace_id per-row
rather than assuming the renamed item's, so a member in another workspace
is not excluded by construction. That would have put one workspace's item
content on another workspace's webhook.

Whether it is reachable through today's queries is not the question worth
answering — "unreachable" is a property of the current query, not of this
function. Partitioning costs one map and makes it impossible.

Population, per CONVE-18: five emit helpers. Four derive the workspace
from the subject row itself (item, comment, attachment) or from the
membership being written (member.joined), so they are correct by
construction. One — bulk — took a caller-supplied id, and is fixed.

* fix(store): prior_status must be present on a transition FROM an empty status (TASK-2658)

Codex round 6, spec-conformance angle. SPEC-3 §Bindings makes prior_status
the envelope pseudo-field that lets a predicate filter "nonterminal →
terminal". An item can transition FROM no status at all — "" → "open" is a
real status change and item.status_changed fires for it — but `omitempty`
on a plain string dropped the key entirely, leaving a predicate unable to
tell "the prior status was empty" from "this event carries no prior
status".

Now a *string: nil on every event that has no prior status, and
present-and-possibly-empty on item.status_changed, where the empty value
is data. My original reasoning — that an empty string should never appear
"where a prior status is meaningless" — was right about the events where
it is meaningless and wrong about the one where it is not.

Also documents the bulk-snapshot read cost at itemSnapshotsTx rather than
leaving it to be discovered: N sequential joined reads under the caller's
lock, which roughly doubles an already-N-long hold (the migration loop it
serves already issues N sequential UPDATEs under that lock by design).
Batching it is BUG-2718; BUG-2717 covers the redundant post-commit re-read
on move and restore. Both spun off rather than folded, because each adds
an unreviewed path to a change that has been through six review rounds.

* fix(store): keep assignee name and email out of event payloads (TASK-2658)

Found in my own privacy-lifecycle probe while round 7 ran; round 7
independently reported the wider class.

An outbox payload is a frozen snapshot that outlives its subject by
design. Account deletion's de-identify pass (DeleteAccountAtomic) nulls
identity on LIVE rows so a departed user stops being legible — it cannot
reach a frozen payload. Every item event for an assigned item was
carrying the assignee's NAME AND EMAIL, and nothing drains or prunes the
table today, so those stayed readable indefinitely.

The rule applied, stated as a rule rather than a proxy: remove directly
identifying personal data, keep opaque identifiers and row state.
assigned_user_id stays — a predicate filters on it, and once the account
is gone it is a dangling reference to nobody.

Population enumerated rather than fixed one instance at a time (CONVE-18):
five payload shapes reach the outbox. Item-single and item-bulk carried
JOIN-populated name + email and are scrubbed. Comment (`author`),
attachment (`uploaded_by`) and member.joined (`user_id`) carry only their
own row's columns. Exactly one shape needed it, and what made it stand out
is that it was the only one carrying a join rather than the row.

* feat(store): comment.deleted + attachment.removed, ref-only (TASK-2658, SPEC-3 v1.4)

Round 7's privacy-lifecycle findings, resolved by adding the vocabulary
the conflict was missing rather than by deleting rows.

Without a delete marker, a hard-deleted subject's undispatched
created/updated rows were the ONLY record it ever existed — forcing a
false choice between dropping committed events (breaking the outbox
guarantee) and delivering deleted content forever. With one: the create
event still delivers, the deletion is announced REF-ONLY, and retention
prunes both. Privacy of a frozen payload is temporal, which makes the
drain load-bearing for privacy and not only for delivery (TASK-2714).

REF-ONLY is the contract, not a detail. A deletion event must not re-ship
what it deletes — the consumer needs to reconcile its model, not receive a
copy of what the user removed. Sharper for attachments, whose full
snapshot carries filename, content hash and STORAGE KEY: a locator for
bytes the system just reclaimed. Deliberately asymmetric with
item.deleted, whose subject is an archive and stays addressable.

- DeleteComment becomes transactional and emits comment.deleted. Refs are
  read before the DELETE, because afterwards there is no row to read.
- ClaimSoftDeletedAttachment emits attachment.removed. The transaction
  does not weaken the BUG-2415 claim protocol: the claim's conditionality
  lives in the DELETE's WHERE clause, unchanged.
- ClaimNeverAttachedAttachment stays SILENT, deliberately. It reclaims
  rows that were never attached to an item, and attachment.added fires
  only for attachments written against a live item — so those rows never
  announced their arrival, and announcing their removal would hand a
  consumer a deletion for an id it has never seen. Tested as an asymmetry,
  not left to inference.
- HardDeleteAttachment has no production caller; not wired.

No outbox row is ever deleted on subject death. That was my first
instinct and it was wrong: it trades a real durability guarantee for a
partial privacy one, through the privacy door.

* fix(store): make the attachment.removed gate symmetric with attachment.added (TASK-2658)

Codex round 8, and it falsified a claim I had written into the code as
verified one commit earlier.

I checked that never-attached implies never-announced — true, and the
verification stands: no path sets attachments.item_id back to NULL, and
every birth path producing a NULL item_id is non-emitting. Then I stated
the conclusion for BOTH directions, which does not follow. Rows reach
ClaimSoftDeletedAttachment having never emitted attachment.added by at
least three routes: VARIANTS (written silently because they carry a
parent, then tombstoned by their original's cascade), attachments cloned
by a cross-workspace copy, and attachments created by workspace import.
So the path announced removals for subjects no consumer had ever seen.

The emit now carries the SAME gate as attachment.added — a user-visible
original, attached to an item — so the two are symmetric by construction
rather than by argument. That closes the variant route, which is the
systematic one, and the test asserts the premise (the variant emitted
nothing on creation) before asserting the conclusion.

Residue, stated rather than papered over: an import- or copy-created
attachment still passes the gate while never having announced itself. The
failure mode is noise rather than harm — an unknown id in a delete is
ignorable, where announced-but-never-retracted would leave stale state —
and the cause is the deliberate silence of the import and copy paths.

Round 8 returned CLEAN on the ref-only payloads, the transaction wrapping
(contractually — it does broaden the SQLite writer-lock window, which is
inherent to making the delete and the emit atomic), scrubItemPII, and the
prior_status pointer.

* fix(store): derive subject_kind from the taxonomy instead of trusting the caller (TASK-2658)

Codex round 9, run explicitly as a convergence round — asked to find what
eight rounds would systematically miss rather than to re-check what they
covered. It found this, which is a fair answer to that question.

writeOutboxTx derived subject_kind only when the caller left it blank, so
a non-empty value was taken as given. subject_kind is a pure function of
the event name: a caller-supplied value can only agree with the taxonomy
or be wrong, and a wrong one persists silently and misroutes the event at
drain time — item.created stored as subject_kind "comment" would be routed
as a comment. Every existing test passed either the correct value or none,
which is exactly the blind spot that lets a defect survive review rounds
aimed elsewhere.

Now derived unconditionally. A caller that supplied a DIFFERENT kind
believes something false about the taxonomy, so that is an error rather
than a silent overwrite: correcting the row quietly would fix one write
and leave the belief in place.

* fix(store): stamp occurred_at rather than accepting it, and enumerate the rest of the class (TASK-2658)

Round 9 found that subject_kind was caller-trusted. Rather than fix the
named instance and wait for a review to name the next one (CONVE-18), I
enumerated the class: of the eight fields on OutboxEvent, event_type is
validated against the closed set, payload is validated as non-empty JSON,
hop is bounded, subject_kind is now derived, and id defaults but fails
LOUDLY on a duplicate. occurred_at was the remaining member with the same
shape of silent harm — SPEC-3 pins time-relative `within` predicates to
it, so a supplied value quietly changes how a predicate evaluates. It is
now stamped at write time; no caller sets it, and "the moment the event
was written" is the only honest value while the write is transactional
with the mutation.

That leaves workspace_id and subject_id as genuine caller inputs. Neither
is derivable, both are checked at their own call sites, and the bulk
emitter partitions by member workspace rather than trusting the one it is
handed. The enumeration is in the code so the next reader does not redo it.

* refactor(store): payload families, an honest helper name, proportionate comments (TASK-2658)

Codex round 10, run as a maintainability convergence round — read the diff
as someone who has to live with it for two years and did not write it.
Three findings, all fair.

PAYLOAD FAMILIES. The emitter helpers take an arbitrary event name and
writeOutboxTx validated only canonical MEMBERSHIP — so a caller could pair
item.created with a ref-only deletion payload and the write would be
accepted, having validated the half that was already obviously correct.
Each canonical event now declares its payload shape in the taxonomy, every
emit site declares what it marshalled, and the two are checked against each
other. The declaration is write-side only and never stored: the event name
already determines the shape, and persisting it would create a second
source of truth that could disagree with the first. A test walks the
canonical set so the two maps cannot drift.

HONEST NAME. itemSnapshotsTx is now outboxMemberSnapshotsTx, because it is
not a general "read these items" helper: it de-duplicates, silently skips
rows that no longer resolve, and scrubs assignee identity. Any of those
makes a general-purpose caller's result quietly incomplete rather than
wrong-looking, and the old name invited exactly that reuse.

PROPORTIONATE COMMENTS. Every canonical event now carries compact contract
documentation — comment.*, member.joined and pack.* had none, and pack.*
now says plainly that nothing emits it yet so a reader does not hunt for a
producer. In the other direction, three comments that had grown into
accounts of how I got something wrong are trimmed to the invariant and the
counterexample. The process belongs on the task trail and the identity
doc; the code should carry what is true.

* fix(kernelevents): one taxonomy table — round 10's family map could fail open (TASK-2658)

Codex round 11 BLOCKED on a defect round 10 introduced, which is the
review loop doing exactly what my own rule says it should: when a fix
introduces a mechanism, the mechanism needs the next round's attention
more than the original bug did.

The defect: writeOutboxTx discarded the ok from PayloadFamily. A canonical
event missing from the separate family map would resolve to the empty
family — which a caller declaring nothing then MATCHES. The check would
pass precisely when it had no idea what the answer should be, and the two
maps keyed on the same names were free to drift into that state.

Fixed structurally rather than by adding the missing ok test: subject kind
and payload family now live in ONE canonical table entry per event. A
second map is a second source of truth; co-locating makes the drift
unrepresentable instead of tested-for, and the compiler requires both
fields so a new event cannot arrive half-declared.

The fail-closed arm stays as a guard for a future table that separates
them again, and its comment says plainly that it is UNREACHABLE today —
verified by mutation: disabling it changes no test, because the mismatch
check catches every reachable case. A guard whose comment implies it is
the protection, when something else is doing the work, is the kind of
claim this unit has cost me several times.

The test now checks both directions: every canonical event resolves a
subject kind AND a family, and a non-canonical name resolves neither —
the second leg being the one that matters, since an unknown name must
report ok=false rather than an empty string a caller would match.
2026-08-20 19:07:59 -04:00
2026-03-26 01:52:36 +00:00

Pad

Project Management for the agent era.

CI Release Go Report Card Container image on GHCR License GitHub Sponsors

Website  ·  Docs  ·  Blog  ·  Changelog  ·  X  ·  Bluesky


One binary. Local-first. No accounts required. Pad gives you a CLI, a web UI, and an AI agent skill — all backed by SQLite, all running on your machine. Your project data stays on your laptop — unless you take it to Pad Cloud.

Pad dashboard showing collection summaries, active work, an active plan with progress, and a recent activity feed

Quick Start

brew install PerpetualSoftware/tap/pad
cd your-project
pad init                    # configure, auth, workspace, AI skill — all in one
pad server open             # opens the web UI at localhost:7777

pad init is the smart entry point — it auto-detects what's needed, walks you through each step, and is safe to re-run anytime (it skips finished steps and prints a status summary).

Then, in a fresh agent session in your project, say:

/pad onboard

Your new workspace ships with the canonical onboard playbook auto-activated. The agent walks an interview, inspects your codebase if it has shell access, and adapts your workspace's collections, conventions, roles, and playbooks to match the project. It's the fastest way to go from empty workspace to "okay, this is mine."

Why Pad?

Tools like Linear, Jira, and Notion are built for teams on the cloud. Pad is built for developers on their machine — and for the AI agents working alongside them. When you do want your projects on every device or a teammate on the board, Pad Cloud hosts the same product with sync, workspace invites, and role-based access.

Pad Linear / Jira Notion
Setup pad init Create account, invite team, configure Create account, pick template
AI agents Native /pad skill for 7+ tools Third-party integrations Third-party integrations
Data Local SQLite you own — or opt-in Pad Cloud Their cloud Their cloud
Offline Full functionality Read-only cache at best Limited
CLI First-class Afterthought None
Price Free, open source Per-seat pricing Per-seat pricing

Features

For Developers

CLI that doesn't get in your way. Create tasks, search items, check status — without leaving the terminal.

pad item create task "Fix OAuth redirect" --priority high
pad item create idea "Real-time collaboration" --category infrastructure
pad item list tasks --status in-progress
pad item search "authentication"
pad project dashboard                   # Project dashboard
pad project next                        # What should I work on?
pad server info                         # How this client is connected to Pad

Web UI that stays out of your way. A clean, dark-themed interface at localhost:7777 with:

  • Board, list, and table views — drag-and-drop between status columns
  • Keyboard navigationj/k to move, Enter to open, Esc to go back, Cmd+K to search
  • Rich text editor — Tiptap-based with markdown, formatting toolbar, and auto-save
  • Wiki-links — type [[Title]] to link between items
  • Real-time updates — agent creates a task in the terminal, it appears in the browser instantly (via SSE)
  • Dashboard — collection overview, active work, plan tracking, activity feed

Pad tasks board view: kanban columns for Open, In-Progress, Done, Cancelled with task cards in each

For AI Agents

Your agent becomes a project partner. Install the /pad skill once, and your AI coding tool can read, create, and update project items through natural language.

pad agent install        # Auto-detects your tools and installs the skill

Works with Claude Code, Cursor, Windsurf, Codex, OpenCode, GitHub Copilot, Amazon Q, and JetBrains Junie.

Then just talk to your project:

> /pad what should I work on next?
> /pad I finished the OAuth fix
> /pad create a task to add rate limiting
> /pad let's brainstorm about the API redesign

Conventions and playbooks teach agents how your project works:

  • Conventions — trigger-based rules like "run tests before marking a task done" or "use conventional commits"
  • Playbooks — multi-step workflows like "when implementing a feature: read the spec, create a branch, write tests first, then implement". Playbooks can declare a kebab-case invocation_slug so users can invoke them directly: /pad ship PLAN-42, /pad release 0.5.0. Fresh startup workspaces ship a generic ship playbook out of the box.
pad item create convention "Run tests before completing tasks" \
  --field trigger=on-task-complete \
  --field scope=all \
  --field priority=must

Agents load relevant conventions automatically. All agent actions are attributed in the activity feed, so you always know what the AI changed.

Onboard agents to a new codebase:

Open an agent session in the workspace directory and run /pad onboard. The agent walks an interview, detects your build/test/CI tooling, and adapts your workspace's collections, conventions, roles, and playbooks to match the project. Works for any agent that speaks Pad — Claude Code, MCP-only agents, etc.

Collections & Custom Fields

Pad organizes work into collections — typed containers with structured fields.

Built-in collections:

Collection Purpose
Tasks Work items with status, priority, assignee, effort, due date
Ideas Feature ideas with impact and category
Plans Project milestones with progress tracking
Docs Documentation, decisions, reference material
Conventions Project rules that guide agent behavior
Playbooks Multi-step workflows for agents to follow

Create your own with typed fields — select, text, date, number, url, relation, checkbox:

pad collection create "Bug Reports" \
  --fields "severity:select:low,medium,high,critical; browser:text; reproducible:checkbox"

Items get reference numbers automatically (TASK-5, BUG-12) and can be moved between collections with field migration.

Installation

Homebrew (macOS and Linux)

brew install PerpetualSoftware/tap/pad

Build from Source

git clone https://github.com/PerpetualSoftware/pad
cd pad
make build
cp pad ~/.local/bin/   # or /usr/local/bin/

Requires Go 1.26+ and Node.js 22+. Alternatively, nix develop provides a shell with the exact Go and Node versions pinned — see the Nix section below.

The go install github.com/PerpetualSoftware/pad/cmd/pad@latest path is not supported for the full Pad binary, because the web UI must be built and embedded during the source build.

Docker

docker run -p 127.0.0.1:7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad

This publishes Pad to localhost:7777 on the host machine, which is the recommended default for local use.

First run — create the first admin. Open http://localhost:7777 and you'll hit a setup page asking for a bootstrap token. On first start with no users, Pad logs a one-time setup URL to stderr (captured by docker logs) — grep it and open the printed link:

docker logs <container> 2>&1 | grep -A6 'Pad first-run setup'
# → http://<your-host>:7777/setup#token=<one-time-token>

Open that URL, create your admin account, and the token is consumed (the banner stops appearing). If you'd rather stay on the CLI, docker exec -it <container> pad auth setup works too — running inside the container counts as loopback, which the bootstrap gate allows. On a network you already trust, set PAD_BYPASS_SETUP_TOKEN=true to skip the token and create the admin straight from http://<your-host>:7777/setup (only safe when the port isn't reachable from the open internet).

Single user, more than one device? Publish to all interfaces so you can reach Pad from your phone, tablet, or another machine on the same LAN, Tailscale network, or home VPN:

docker run -p 7777:7777 -v pad-data:/data ghcr.io/perpetualsoftware/pad

For multi-instance deployments, Pad supports Postgres + Redis via docker-compose.yml — see docs/deployment.md for the full setup.

Nix

Run without installing:

nix run github:PerpetualSoftware/pad

Or install into your profile:

nix profile install github:PerpetualSoftware/pad

A flake devShell (Go, Node, and friends, pinned to the same versions CI uses) is also available for contributors:

nix develop

A nixpkgs package (nix-shell -p pad / environment.systemPackages) is planned but not yet merged upstream. Until then, use the github:PerpetualSoftware/pad flake reference above.

Binary Download

Pre-built binaries for macOS, Linux, and Windows are available on the releases page.

Pad Cloud (hosted)

Don't want to run anything? Pad Cloud is the managed option — same product, same CLI, same /pad skill, free during beta. Sign up on the web, then connect a project directory:

pad init --url https://app.getpad.dev --workspace my-workspace

Self-hosting stays first-class: the binary is unchanged and no features are Cloud-only.

Upgrading Pad

Pad ships a new binary on a roughly weekly cadence. Upgrades are designed to be boring: install the new binary and restart. Database migrations run automatically at startup, only the ones your database is missing are applied, and each migration commits atomically (a failed migration rolls back cleanly and is retried next boot).

The one rule: only ever move forward. Newer binaries know how to migrate an older database; older binaries do not understand a newer schema. Since Pad added its schema-ahead guard, a downgraded binary that finds a database newer than itself refuses to start rather than silently running old code against a newer schema (which can corrupt data):

database schema is newer than this pad binary: ... This almost always means the
binary was DOWNGRADED (e.g. brew/docker rollback) ... Upgrade pad back to a build
that includes those migrations, or re-run with `pad start --force`.

To recover, reinstall the newer binary (brew upgrade pad, pull the newer Docker tag, etc.). If you have intentionally downgraded and accept the risk, start with pad start --force (or set PAD_ALLOW_SCHEMA_AHEAD=1) to override the guard.

Automatic pre-migration snapshot (SQLite). Whenever a SQLite-backed instance has pending migrations to apply, Pad first copies the database file to pad.db.pre-<version> next to it. If an upgrade ever goes wrong, stop the server and copy that snapshot back over pad.db. This is a convenience net, not a backup strategy — keep your own backups (see docs/backup.md). PostgreSQL instances are skipped here; use pg_dump or a provider snapshot before upgrading.

Recommended upgrade flow:

# 1. Back up first (SQLite shown; see docs/backup.md for Postgres)
pad db backup -o pad-backup-$(date +%Y%m%d).db

# 2. Stop the server, install the new binary, restart
#    (migrations + the pre-migration snapshot run automatically on start)
brew upgrade pad        # or: docker pull, binary download, make install

# 3. Confirm it's healthy
pad --version
curl -s localhost:7777/api/v1/health

Getting Started

1. Set up Pad

cd ~/projects/myapp
pad init "My App"

pad init is the smart entry point that handles everything in one command:

  • Configures this client's connection (local server, remote, or Docker)
  • Auto-starts the local server
  • Creates the first admin account on a fresh local install (Docker / remote hosts run pad auth setup on the server instead)
  • Logs you in if needed
  • Creates or links a workspace for the current directory (writes .pad.toml)
  • Installs the /pad skill for any AI tools detected in the project

Run from your project root. Safe to re-run anytime — it skips finished steps and prints a status summary if nothing's needed.

Choose a template with --template, or omit it for an interactive picker grouped by category (Software / People / …):

pad workspace init --list-templates                   # See the full catalog grouped by category
pad init "My App" --template scrum                    # Scrum-style with sprints
pad init "My App" --template product                  # Product management focused
pad init "My Hiring" --template hiring                # Company-side: requisitions, candidates, interview loops, feedback
pad init "Job Search" --template interviewing         # Candidate-side: applications, interviews, companies, contacts
pad init "My App" --template blank                    # Custom: system collections only — let /pad onboard build the rest

Pad ships templates for software (startup / scrum / product), people workflows (hiring, interviewing), and a custom blank template — system collections (Conventions, Playbooks) only, with the /pad onboard playbook as its sole seeded content. blank is the entry point for the agent-driven /pad onboard flow: it walks you through shaping collections, conventions, and roles to match your actual project. Reserved categories for research, content, operations, and personal use await their first templates, so the same project-management primitives fit well beyond code projects. There's also a hidden demo template — the startup layout pre-loaded with realistic sample data — that's kept out of the picker but can be built explicitly with --template demo.

2. Start working

# From the CLI
pad item create task "Set up CI pipeline" --priority high
pad item create idea "Add WebSocket support" --category infrastructure
pad project dashboard

# From the web UI
pad server open              # Opens localhost:7777 in your browser

# From your AI agent
# Just use /pad in Claude Code, Cursor, etc.

3. Teach your agents the rules

In an agent session inside the workspace:

/pad onboard

The agent walks an interview, detects your tooling, and adapts the workspace's collections, conventions, roles, and playbooks. To browse the library directly:

pad library list --type conventions  # Pre-built conventions you can adopt
pad library list --type playbooks    # Pre-built multi-step workflows

4. Optional — connect a desktop AI app via MCP

Pad ships an MCP (Model Context Protocol) server so Claude Desktop, Cursor, Windsurf, Claude Code, or Codex can manage items, plans, ideas, and dependencies as native tools, read workspace state by URL, and load multi-step workflows as prompts.

pad mcp install claude-desktop   # or: cursor, windsurf, claude-code, codex, --all
# Restart the client; pad shows up as the "pad" MCP server.

pad mcp install writes each client's native config: JSON mcpServers for Claude Desktop / Cursor / Windsurf, a project-local .mcp.json in the current directory for claude-code, and an [mcp_servers.pad] table in ~/.codex/config.toml (TOML) for codex. Because Claude Code's config is project-scoped, it's install-on-request only — --all and pad mcp status cover the per-user clients (including Codex) and skip it.

Tool catalog (v0.25) — ten resource × action tools plus pad_set_workspace (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. pad_item create/update accept field values as a fields object (the same shape reads return) as an equivalent to the dedicated params / field: ["key=value"]. pad_item.list accepts unparented: true (mutually exclusive with parent) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (full: true opts into complete content bodies):

Tool Actions
pad_item create, update, delete, get, list, move, restore, link, unlink, deps, star, unstar, starred, comment, list-comments, backlinks, bulk-update, note, decide, export, import, history
pad_workspace list, members, invite, storage, audit-log, create, claim, deleted, restore
pad_collection list, create, update, delete
pad_project dashboard, next, ready, stale, standup, changelog, report, activity
pad_role list, create, update, delete
pad_search query
pad_playbook list, get, run
pad_library list, get, activate
pad_attachment list, show
pad_meta server-info, version, tool-surface, bootstrap
pad_set_workspace session-default workspace pinning (response embeds the bootstrap blob)

Plus resources at pad://workspaces, pad://workspace/{ws}/dashboard, pad://workspace/{ws}/items, pad://workspace/{ws}/items/{ref}, pad://workspace/{ws}/collections, pad://workspace/{ws}/attachments/{id} (bounded image bytes), pad://workspace/{ws}/bootstrap, and pad://_meta/version.

Stability contract — two version constants, both advertised in the initialize handshake under capabilities.experimental.padCmdhelp and capabilities.experimental.padToolSurface (and queryable at pad://_meta/version):

  • cmdhelp_version: "0.1" — CLI help-tree contract (used at dispatch time)
  • tool_surface_version: "0.25" — MCP tool catalog contract (v0.5 added pad_library; v0.6 pad_item.backlinks; v0.7 pad_item export/import; v0.8 pad_workspace deleted/restore; v0.9 made pad_item.list summary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side on pad_playbook.run with an allow_draft escape hatch; v0.11 added the read-only pad_attachment tool (list/show); v0.12 added pad_project.activity (agent-accessible non-streaming activity feed); v0.13 added pad_project ready/stale (agent-oriented backlog + attention queries); v0.14 added pad_item history + optimistic concurrency (TASK-2022); v0.15 added the pad_item.list unparented parameter (TASK-2096); v0.16 made an empty-string assigned_user_id / agent_role_id CLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 added clear_assigned_user / clear_agent_role booleans — the canonical, schema-discoverable way to unassign, backed by new --clear-assigned-user / --clear-agent-role flags on pad item update (IDEA-2584); v0.19 added a clear_parent boolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new --clear-parent flag on pad item update (BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalogs read-only knowledge — fully-read-only tools advertise readOnlyHint: true / destructiveHint: false, all-additive-write tools (pad_workspace, pad_library) drop destructiveHint, overwrite/delete-capable tools stay conservatively destructive, openWorldHint: false everywhere — replacing mcp-gos defaults that marked every tool destructive (BUG-2302), and made pad_item.list summary-shaped on the remote HTTP transport too, with a declared full boolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 bounded pad_item.history, which was unbounded on every surface — limit now covers it (default 50, max 300, the NEWEST N; no offset, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stopped pad_item.move destroying an items system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the moves activity entry rather than vanishing, and a field setter naming one of those reserved keys is refused with malformed_override instead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — a field setter naming implementation_notes, decision_log or convention is now refused on every transport at once (validation_error on HTTP, surfaced to MCP clients as validation_failed); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower a field setter into the same fields_patch; github_pr is deliberately exempt ON UPDATE (move and copy still refuse it), since pad github link cannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-fields payload is shared with Pads own writers. v0.23 also added the retry-hostile stored_state_unreadable error code so an agent told its target items stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the pad_item fields object a real write form on create/update — reads return fields as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped updated_at) — merging it into the same path as field/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made pad_library.activate resolve its DESTINATION collection from the targets declared artifact kind (SPEC-5 collection traits) rather than the literal conventions / playbooks slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); see internal/mcp/version.go for the full changelog)

External agents pin against these so a future rename doesn't break them silently. Errors come back as structured envelopes ({error: {code, message, hint, available_workspaces, ...}}) with a closed code taxonomy — 17 codes as of v0.23, enumerated in internal/mcp/errors.go. Branch on code, not on message text; a code you don't recognize is possible, and stored_state_unreadable in particular means STOP rather than retry.

Full guide at getpad.dev/mcp/local — install paths, action enums per tool, error taxonomy, troubleshooting.

On Pad Cloud? Skip the install: add https://mcp.getpad.dev as a remote MCP server in Claude Desktop, Claude.ai, Cursor, or Windsurf and sign in with OAuth — same tool surface, no local binary. Setup guide at getpad.dev/mcp/remote.

CLI Reference

pad auth configure                    Configure how this client connects to Pad
pad auth setup                        Initialize the first admin account
pad auth login                        Sign in
pad auth whoami                       Show current user

pad server start                      Start the Pad API server
pad server stop                       Stop the Pad server
pad server info                       Show client, connection, and local server status
pad server open                       Open web UI in browser

pad workspace init [name]             Initialize workspace in current directory
pad workspace link <workspace>        Link current directory to an existing workspace
pad workspace list                    List all workspaces
pad workspace switch <workspace>      Switch active workspace
pad workspace context                 Show structured workspace context
pad workspace context set --file X    Update structured workspace context from JSON
# Workspace onboarding: run `/pad onboard` from an agent session inside the workspace
pad workspace members                 List workspace members
pad workspace invite <email>          Invite a workspace member
pad workspace join <code>             Accept an invitation
pad workspace export                  Export workspace data
pad workspace import <file>           Import workspace data

pad project dashboard                 Project dashboard
pad project next                      Recommended next task
pad project ready                     Query actionable next items
pad project stale                     Query stalled or attention-worthy items
pad project standup [--days N]        Daily standup report
pad project changelog [--days N]      Release notes from completed items
pad project watch                     Real-time activity stream
pad project reconcile                 Reconcile item and PR state

pad item create <coll> "title"        Create item (task, idea, plan, doc, ...)
pad item list [collection]            List items (filters: --status, --priority, --all)
pad item show <ref>                   Show item detail
pad item open <ref>                   Open item in web UI
pad item update <ref>                 Update item fields
pad item delete <ref>                 Delete item
pad item move <ref> <collection>      Move item between collections
pad item edit <ref>                   Open item in $EDITOR
pad item search "query"               Full-text search across all items
pad item comment <ref> "text"         Add comment to an item
pad item comments <ref>               View item comments
pad item note <ref> "summary"         Append an implementation note to an item
pad item decide <ref> "decision"      Append a decision log entry to an item
pad item block <src> <target>         Create dependency
pad item blocked-by <item> <blk>      Mark item as blocked
pad item deps <ref>                   Show dependencies
pad item unblock <src> <target>       Remove dependency
pad item related <ref>                Show direct relationships for an item
pad item implemented-by <ref>         Show incoming implementers for an item
pad item bulk-update --status X       Batch update multiple items

pad collection list                   List collections with item counts
pad collection create <name>          Create a custom collection

pad library list                      Browse convention and playbook library
pad library activate <title>          Activate a convention or playbook

pad agent install [tool]              Install /pad skill for AI coding tools
pad agent status                      Show supported tools and installation status
pad agent update                      Update installed tool integrations

pad github link [item-ref]            Link current branch's PR to item
pad github status [item-ref]          Show PR status for linked items
pad github unlink <item-ref>          Remove PR link from item

pad webhook list             List workspace webhooks
pad webhook create <url>     Create webhook

All commands accept --format json for machine-readable output and --workspace to target a specific workspace.

Shell completion

pad ships completion scripts for bash, zsh, fish, and PowerShell:

# Bash — current session only
source <(pad completion bash)
# Bash — persistent
pad completion bash > /etc/bash_completion.d/pad                   # Linux
pad completion bash > $(brew --prefix)/etc/bash_completion.d/pad   # macOS (Homebrew)

# Zsh (make sure compinit runs in your ~/.zshrc)
pad completion zsh > "${fpath[1]}/_pad"

# Fish
pad completion fish > ~/.config/fish/completions/pad.fish

# PowerShell (append the output to your $PROFILE)
pad completion powershell | Out-String | Invoke-Expression

Beyond command and flag names, completion is context-aware: collection arguments (e.g. pad item list <TAB>) complete against your workspace's collections, --workspace completes configured workspace names, and --status / --priority complete their valid values.

Authentication

Pad runs without authentication by default for frictionless local use. For local installs, pad init creates the first admin account inline. The lower-level commands are useful when you're hosting a Pad server (Docker / remote) and need to set up auth on the server host directly:

pad auth setup         # Initialize the first admin account (server host, non-local mode)
pad auth login         # Sign in
pad auth whoami        # Show current user
pad auth logout        # Sign out

Once a user exists, all API requests and web UI access require authentication. Credentials are stored in ~/.pad/credentials.json. Multiple users can be invited to workspaces with role-based access control (owner, editor, viewer).

Authenticating with an environment token

Set PAD_TOKEN to a Pad API token (minted under Settings → API tokens in the web UI) to authenticate without pad auth login:

PAD_TOKEN=pad_xxxxxxxx pad item list

PAD_TOKEN takes precedence over credentials saved by pad auth login — the same convention as gh's GH_TOKEN. This is useful for CI, scripts, and machines where several AI agents share one CLI install but should act as different Pad users: give each agent its own token in its process environment, and the credential store is never touched. pad auth whoami reports the token's identity (with an Auth: PAD_TOKEN environment override line), and pad auth login/logout warn when the override is active — they manage the stored credentials, which the override bypasses. Deliberately, pad auth logout never invalidates the PAD_TOKEN session itself: it signs out the stored session only, and the env token's lifecycle belongs to wherever it was minted (revoke it under Settings → API tokens).

pad workspace members               # List workspace members
pad workspace invite user@example.com
pad workspace join <code>

Architecture

┌──────────────────────────────────────────────┐
│              pad (single binary)              │
│                                               │
│  ┌──────────┐  ┌──────────┐  ┌────────────┐  │
│  │   CLI    │  │  REST    │  │  Embedded  │  │
│  │ (Cobra)  │  │  API     │  │  Web UI    │  │
│  └────┬─────┘  └────┬─────┘  │ (SvelteKit)│  │
│       │    HTTP      │        └────────────┘  │
│       └──────────────┤                        │
│                ┌─────▼─────┐                  │
│                │  SQLite   │                  │
│                │  + FTS5   │                  │
│                └───────────┘                  │
└───────────────────────────────────────────────┘
  • Go backend — chi router, SQLite via modernc.org/sqlite (pure Go, no CGO), FTS5 full-text search, SSE for real-time updates
  • SvelteKit frontend — Svelte 5, Tiptap editor, drag-and-drop, adapter-static, embedded via go:embed
  • Single binary — serves the API and web UI, runs on macOS, Linux, and Windows
  • Workspace-per-project — each project gets its own workspace linked by a .pad.toml file

Self-hosted, all data lives in ~/.pad/pad.db. Your data. Your machine. No telemetry, no accounts required — cloud only if you opt in.

Contributing

See CONTRIBUTING.md for the development guide.

make build      # Build web UI + Go binary
make test       # Run Go tests
make dev-web    # SvelteKit dev server with hot reload
make install    # Build, install to ~/.local/bin, restart server

Security

See SECURITY.md for reporting vulnerabilities.

License

Apache License 2.0

Languages
Go 65.1%
TypeScript 21.5%
Svelte 12.8%
Shell 0.3%
CSS 0.1%