Files
pad/docs/architecture.md
xarmian a1716d8170 ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881) (#1247)
* ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881)

`npm audit` exits non-zero identically for "a HIGH/CRITICAL advisory
exists" and "the advisory service was unreachable". The Web job ran it
before Build / Type check / vitest under `bash -e`, so a registry
timeout (main, 03:50Z) and a 503 (#1246, 04:33Z) on 2026-09-04 each
produced a red row with every frontend verification step SKIPPED — a
lane that read like a failure and had asked nothing.

scripts/ci-audit.mjs runs the audit in --json mode and decides from the
report: metadata.vulnerabilities present → fail iff high+critical > 0,
naming the advisories; an error envelope or unparseable output → a
GitHub warning annotation saying the gate did not run, exit 0. The step
moves to the end of the job so the frontend's own verdict always exists
whatever the audit does.

Verified locally against five report shapes (transport timeout envelope,
E503 envelope, one high advisory, clean, garbage) and two live runs (the
real registry: clean; a dead registry: warning, exit 0). `--input <file>`
is the seam those checks use.

Fixes BUG-2881

* ci(web): the audit gate fails closed — retry an unreachable advisory service, then fail under its own title

Codex round 1 on #1247: the first draft warned and exited 0 when the
advisory service could not be asked, which made the only supply-chain
gate pass exactly when it had not run. A gate that passes when it cannot
run is not a gate.

Now: up to three attempts with backoff (registry blips are usually
seconds long), then `::error title=npm audit did not run` and exit 1.
The title is distinct from `::error title=npm audit` (a real advisory)
so the checks tab tells the two apart without opening the log; re-running
is the remedy for the first and never for the second. Because the step
runs last, Build / Type check / vitest have already produced their result
either way — the original blindness is gone regardless of which way this
step fails.

Verified against the same five saved shapes (transport and E503 envelopes
and garbage now exit 1 under the did-not-run title; a high advisory exits
1 under the advisory title; clean exits 0) and two live runs (real
registry: clean; dead registry: three attempts logged, exit 1).

Refs BUG-2881

* ci(web): the audit gate refuses counts it cannot read, and refuses bad tuning without crashing

Codex round 2 on #1247. (1) metadata.vulnerabilities was checked for
presence, not for shape: Number("x") + Number(null) > 0 is false, so a
malformed count read as a clean audit — a second fail-open, one layer
deeper than round 1's. high/critical must now be non-negative integers
or the report is unreadable, which is the fail-closed path. (2) The two
env knobs are operator-set, but CI_AUDIT_ATTEMPTS=NaN left the retry loop
unexecuted and threw a TypeError, and CI_AUDIT_BACKOFF_MS=Infinity parked
Atomics.wait forever; both now fall back to the default with a line
saying so.

Refs BUG-2881

* build: the local preflight runs the same audit gate CI does, and runs it last

Codex round 3 on #1247 (blast radius): `make web-check` still chained
bare `npm audit && npm run check`, so a registry blip stopped svelte-check
locally exactly as it had in CI, and CONTRIBUTING documented the bare
command as the way to reproduce the gate. New `web-audit` target runs
`npm run audit:ci`; `check` runs it after web-check and web-test, mirroring
the Web job's order. CONTRIBUTING and docs/architecture.md say so.

Refs BUG-2881

* build: web-audit stands alone — no `web` prerequisite, so `check` runs npm ci once and no new target reaches it

Codex round 4 on #1247: `web-audit: web` made `check` run `npm ci` twice
(`web` is .PHONY) and added a target CLAUDE.md's worktree rule did not
list as reaching `npm ci`. `npm audit` reads the lockfile and needs
neither node_modules nor a build — verified by running it with
node_modules removed — so the prerequisite goes; CLAUDE.md's safe list
gains `web-audit`.

Refs BUG-2881
2026-09-04 10:45:21 -04: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, svelte-check, vitest, npm audit (last).

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.