Files
pad/docs/architecture.md
T
xarmian 7c8ed3c815 fix: nine false or overstated claims in this diff's own prose (codex round 15)
An angle worth naming, because it found more than several code-shaped
ones did: check the COMMENTS against the CODE. This diff is
comment-heavy and its comments make specific factual claims. Nine were
wrong.

The one that mattered most was a false argument for a correct rule.
redisns.Parse rejects colons, and justified it with a collision example
that does not happen: ns "a:events" builds pad:a:events:events:<ws>, not
pad:a:events:<ws>, because the suffix is appended too. The rule stands on
its own grounds (a colon spans segments and makes the keyspace ambiguous
to read back) — but a false example is worse than none, because the next
reader trusts it.

Chasing that turned up a REAL collision needing no colon: a namespace
equal to one of Pad's own first segments nests this installation inside
the default one's keyspace. Namespace "events" puts every key under
pad:events:*, which is the default installation's activity channel space
— the exact cross-feed the namespace exists to prevent, arriving through
the namespace. Now rejected, with a control leg asserting that names
merely CONTAINING a reserved word ("events-eu", "prod-session") stay
valid.

The other eight:

- "The three keyspaces cannot drift" — overstated. Each constructor takes
  its own Keys; a source-reading test is what enforces it, which is
  weaker than a compiler and now says so.
- Two docs claimed both SSE endpoints incur a presence registration. Only
  the watch stream registers.
- The Redis metrics section said they "stay at zero" without Redis, while
  pad_redis_up is deliberately unregistered — the section contradicted
  the field three lines below it.
- The presence-failure metric's HELP string still carried the blanket
  "leaves sessions unlisted and untargetable" that the field comment had
  already been corrected away from. Two of the four ops fail in the
  opposite direction.
- A nil from MGET was described as proof the process died. Eviction, a
  restart and a manual DEL produce the same nil, and this file's own doc
  says eviction is indistinguishable from expiry.
- A test comment claimed to cover both corrupt-entry shapes; the second
  is unreachable and the subtest is deliberately absent, as the note ten
  lines down already said.
- "Enumerates every refusal path" covered per-instance and per-workspace
  and not per-user — the same undercount as round 13's, one round later.
  Both per-user paths added.
- The Observer contract said a go-redis drop is reported as a sequence
  gap. Only if a LATER notification arrives to expose the hole: drop the
  newest message on a bus that then goes quiet and nothing is reported.

Claude-Session: https://claude.ai/code/session_01JVDBKbgn3Xt7ndW1YoYd8X
2026-08-22 04:35:57 +00:00

9.7 KiB

Architecture

High-level map of the Pad codebase for contributors. CLAUDE.md covers the same ground but is written for AI agents working in the repo — this doc is the human-readable companion.

Shape

Pad ships as one Go binary. The SvelteKit web UI is built into static assets and embedded into the binary at compile time via go:embed, so a deployed Pad has exactly one moving piece on the filesystem. SQLite is the default backing store; PostgreSQL + Redis is the alternate mode for multi-node production use.

┌────────────┐     ┌────────────┐     ┌───────────────┐
│  CLI (pad) │     │  Web UI    │     │  AI agents    │
│            │     │  (Svelte 5)│     │  via /pad     │
└─────┬──────┘     └──────┬─────┘     └───────┬───────┘
      │ HTTP              │ HTTP/SSE          │ HTTP
      ▼                   ▼                   ▼
              ┌─────────────────────────────┐
              │       Pad HTTP server       │
              │  (internal/server/*.go)     │
              └─┬────────┬───────────────┬──┘
                │        │               │
                ▼        ▼               ▼
          ┌──────────┐ ┌─────────┐ ┌────────────┐
          │ SQLite / │ │ EventBus│ │ Webhooks + │
          │ Postgres │ │ (SSE)   │ │ Email      │
          └──────────┘ └─────────┘ └────────────┘

Backend (Go)

  • cmd/pad/main.go — Cobra CLI entry point. Every pad <command> is registered here. The same binary also serves as the daemon (pad server start) and as the CLI client that talks to it.
  • internal/server/ — HTTP router (chi), middleware, handlers, SSE hub. server.go is the main router; handlers_*.go group endpoints by resource.
  • internal/store/ — SQL abstractions and migrations. Each resource (workspaces, items, users, webhooks, sessions, …) has a <resource>.go file; migrations/ is the golang-migrate style migration sources. Backed by SQLite by default, PostgreSQL when PAD_DB_DRIVER=postgres.
  • internal/models/ — shared Go structs that flow through the API response boundary (Collection, Item, User, View, etc.).
  • internal/items/ — field-schema validation: collection schemas declare typed fields (select, text, date, number, …) and this package validates item fields against them.
  • internal/collections/ — default collection definitions per template (startup, scrum, hiring, interviewing, product) and workspace bootstrap logic.
  • internal/cli/ — HTTP client used by the CLI to talk to the local daemon, plus formatting helpers for terminal output.
  • internal/events/ — in-process EventBus that fans SSE updates to connected clients. In Redis mode, a pub/sub bridge replaces the in-memory bus so multiple Pad replicas stay in sync.
  • internal/webhooks/ — outbound webhook dispatcher with HMAC signing, retries, and delivery log.
  • internal/email/ — transactional email via Maileroo. Used for workspace invitations and password resets; nil-safe if unconfigured.
  • internal/diff/ — per-item version history storage + diff rendering.
  • internal/links/ — resolver for [[wiki-link]] syntax across items.
  • internal/config/ — workspace detection, .pad.toml parsing, environment-variable loading.

Request flow

  1. CLI or web UI sends an HTTP request to /api/v1/….
  2. Middleware chain in internal/server/middleware_*.go handles auth, rate limiting, metrics, audit logging.
  3. The handler in internal/server/handlers_*.go parses the request, calls one or more internal/store/* methods, and writes the JSON response.
  4. If the mutation is observable (item change, comment, etc.), the handler publishes an event to internal/events which fans out to connected SSE clients at /api/v1/events.

Two SSE streams, one connection budget

Pad serves two Server-Sent Events endpoints over two separate buses, and the distinction matters for anything touching either:

/api/v1/events /api/v1/events/stream
Scope one workspace the caller's user, across every workspace
Bus internal/events internal/watchevents
Carries activity events (item changed, comment added) watch notifications and pushes addressed to the caller
Consumers the web UI pad watch --stream, the agent monitor
Auth a resolved user, a legacy workspace token, or the fresh-install window a resolved user, always

They cost the same process resources — a goroutine and a bus subscription each, plus (with Redis) a session-presence registration for the watch stream, which is the only one that registers — so they share ONE admission budget, enforced per instance by internal/server/stream_admission.go before either subscribes: PAD_SSE_MAX_CONNECTIONS and PAD_SSE_MAX_PER_USER cover both, PAD_SSE_MAX_PER_WORKSPACE covers only the workspace-scoped one. A refusal is 429 sse_limit_exceeded with Retry-After.

With PAD_REDIS_URL set, both buses and the presence registry cross instance boundaries together, under one key namespace (internal/redisns). Without it, all three are in-process and a multi-replica deployment would not work.

Frontend (SvelteKit + Svelte 5)

  • web/src/routes/ — page routes. File-based: a folder corresponds to a URL segment. [username]/[workspace]/... is the main workspace-scoped tree; console/ is the server-admin UI.
  • web/src/lib/components/ — reusable UI (BottomSheet, FieldEditor, ReactionPicker, NestedChildren, etc.).
  • web/src/lib/stores/ — Svelte 5 rune-based stores for cross-route state (current workspace, page title, current user).
  • web/src/lib/api/client.ts — typed HTTP client. Every REST endpoint the backend exposes has a method here; adding an endpoint means adding a client method too.
  • web/src/lib/types/index.ts — mirrors internal/models/ as TypeScript types.

The web UI is built with npm run build (static adapter) and the build/ output is embedded into the Go binary via //go:embed in internal/server/embed.go. npm run dev runs a Vite dev server on :5173 that proxies API requests to the running Pad daemon on :7777 — fast iteration without rebuilding the binary.

Data model

Workspaces
  └── Collections            (typed by a JSON schema)
        └── Items            (structured fields + optional markdown content)
              ├── parent/child links
              ├── blocks / blocked-by dependency links
              └── comments, reactions, tags
  • Collections have a fields JSON schema that declares field keys (e.g. status, priority, due_date) with types and options.
  • Items have structured fields JSON validated against the collection's schema, plus optional rich Markdown content.
  • Parent/child links power progress tracking and burndown; any item type can be a parent of any item type.
  • [[wiki-link]] syntax resolves across all items in a workspace and renders as clickable links in the UI.

CLI ↔ daemon model

There is only one binary, pad. Some commands run purely client-side (pad item show REF), but most go through the daemon:

  • pad server start — run the daemon foreground (normal dev mode).
  • pad auth configure — first-run credential setup, auto-starts the local daemon on first use.
  • All other pad <verb> commands are CLI → HTTP → daemon → SQLite.

The CLI discovers the daemon via ~/.pad/credentials.json (see internal/cli/client.go). In Remote or Cloud mode, the same client targets a network-served Pad instance instead of a local daemon.

Agent integration

skills/pad/SKILL.md ships inside the binary and gets installed into an AI agent's configuration by pad agent install. The skill is natural-language — it documents the CLI well enough that any Claude/Cursor/Copilot-style agent can drive Pad via terminal calls.

Testing

  • Go: go test ./... covers the backend; internal/store/ tests run against real SQLite by default and against PostgreSQL when PAD_TEST_POSTGRES_URL is set (see Makefile targets test and test-pg).
  • Web: cd web && npm run build to catch type / build errors; npm run check runs svelte-check.
  • CI: .github/workflows/ci.yml runs the full matrix — Go (SQLite + PostgreSQL + race), govulncheck, golangci-lint (new-issues mode), web build, npm audit, svelte-check.

Build and install

See CLAUDE.md for the day-to-day commands (make install is the one you'll run most). The short version:

  • make build — build web UI + Go binary to ./pad.
  • make install — build, kill running daemon, install to $HOME/.local/bin/pad, restart. Heads-up: make install runs killall -9 pad system-wide, so any other pad process on the host (including other users' daemons) gets killed.
  • make dev-web — SvelteKit hot-reload dev server.

Further reading

  • CLAUDE.md — agent-focused development guide (identical scope, different audience).
  • docs/deployment.md — full environment-variable reference, production deployment shapes.
  • docs/backup.md — backup and restore procedures.
  • SECURITY.md — reporting a vulnerability, threat model, hardening tips.