Skip to content

Pulse Platform Patterns

Shared design patterns for monitoring/control "Pulse" products in the homelab. Codifies the way of doing things across Remote-Pulse (fleet) and WP-Pulse (WordPress sites). Future Pulse-X products should follow this as default; deviations should be motivated.

Status: Living document. Last revised 2026-05-31.

Scope: patterns that are stable enough to port. Out of scope: product-specific business logic (RP canary, WP db-push safety), one-off integrations, and anything still being prototyped in a single product.

Audience: future agents and operators building or porting Pulse-X products. Not a sales document.


Table of contents

  1. Stack baseline
  2. Enrollment
  3. Capability-gated UI
  4. Audit + retention
  5. Webhooks
  6. Bulk operations
  7. Saved views
  8. Live updates (SSE)
  9. Stats dashboard
  10. QoL standard
  11. Push-to-prod workflow
  12. Releases + deploy
  13. Appendix A: Module port matrix
  14. Appendix B: Glossary

1. Stack baseline

Layer Choice Why
Server FastAPI + SQLAlchemy async + Alembic + Pydantic v2 Async-native, typed, ergonomic for sub-agent codegen.
DB Postgres 16 (+ TimescaleDB for time-series products) Hypertables solve heartbeat scale without a second DB.
Web SvelteKit (RP) or Next.js (WP-Pulse) Local choice; the patterns are framework-agnostic.
Web styling Tailwind v4 + shadcn-svelte/ui + radix-colors tokens Copy-paste components, no CDN, CSP-friendly.
Agent Outbound polling, Ed25519 signed No inbound surface on customer machines by default.
Auth PocketID OIDC session cookie One IdP across the homelab. See [[oidc_pocketid_apps]].
Transport Tailscale (control) + Tailscale Funnel (public) Funnel where public exposure is needed; tailnet default.
Secrets SOPS + age in repo, env at runtime See [[sops_age_setup]].

Defaults:

  • Outbound polling beats inbound webhooks for agent → server. Hosts behind NAT/CGNAT just work.
  • Session cookies beat bearer tokens for the web app. Cookies are httpOnly, samesite=lax; the SPA reads /auth/me at boot.
  • Sync engines for migrations (alembic), async engines for runtime.

When NOT to use this stack: anything that needs <100ms p99 (use a pull-based scraper, not Postgres roundtrips), anything stateless and embarrassingly parallel (Cloudflare Worker beats a FastAPI LXC), anything where the user already has a SaaS that solves it (don't rebuild Sentry).

See ADR-0008, ADR-0009 (web dashboard redesign, en el repo remote-pulse), ADR-0010.


2. Enrollment

When to use this: any product where a human-with-CLI enrolls a new node (host, site, plugin) into the control plane and needs to type a code over a phone call, screenshot, or chat.

When NOT to use this: machine-to-machine enrollment with no human in the loop. Use a long-lived bearer token instead.

Anatomy:

  • 6 chars from a confusion-free alphabetABCDEFGHJKLMNPQRTUVWXY2346789 (29 chars, no 0/O, no 1/I/L, no S/5, no Z/2 collision). Ref: server/src/rp_server/auth.py:36 (SHORT_CODE_ALPHABET).
  • ~29 bits of entropy (29^6 ≈ 5.9e8). Brute force over a 5-minute TTL is unfeasible.
  • Display format XXX-XXX. Normalise to uppercase, strip hyphens, on the server (format_short_code / normalize_short_code in auth.py).
  • Partial unique index on enrollment_links(short_code) WHERE expires_at > NOW() AND used_at IS NULL — only live codes are unique; expired ones can collide.
  • TTL default 5 min, max 24h. Operator-chosen expires_in_minutes wins; otherwise default. Ref: server/src/rp_server/routers/dash_enroll.py:59 (_DEFAULT_TTL_MINUTES).
  • XOR redemption check — never SELECT … WHERE short_code = ? directly; constant-time compare avoids timing oracles. (Light protection, not a substitute for rate-limiting.)
  • Install scripts take --code XXX-XXX — agent's installer reads the code, hits /v1/enroll/redeem, gets a long-lived token + tailnet authkey + group assignment.

Server endpoints (RP reference):

POST /v1/dash/enroll-links           → create (admin)
GET  /v1/dash/enroll-links           → list active+expired (admin)
DELETE /v1/dash/enroll-links/{id}    → revoke
POST /v1/enroll/redeem {code,…}      → agent-side redemption

Alembic migration: server/alembic/versions/011_enrollment_short_code.py is the reference shape.

Anti-patterns observed:

  • Raw UUID links (/enroll?token=01J…) — operators can't type them, end up emailing the URL. Defeats the in-person flow.
  • No TTL — abandoned codes become permanent attack surface.
  • Reusing short codes across products with different scopes — namespace them per-product or scope them by URL prefix.

3. Capability-gated UI

Pattern: /auth/me ships permissions, client mirrors server helper

When to use this: any web UI where some affordances are role-dependent (delete host, approve command, run db-push). The server is always the source of truth — this pattern only prevents misleading UI.

When NOT to use this: simple admin-vs-user binary. Just check user.is_admin in the SPA and rely on server 403s.

Anatomy:

  • user_permissions table(user_id, action, scope). Action is a string from a frozen enum; scope is * or a concrete group name. Ref: server/alembic/versions/009_user_permissions.py.
  • ALLOWED_ACTIONS frozenset — single source of truth for valid action strings (host.delete, command.issue, webhook.write, …). Ref: server/src/rp_server/permissions.py:62.
  • user_has_permission(user, action, group) async helper — admin → True; else look up (action, scope) with scope = '*' OR scope = group. Ref: permissions.py:82.
  • /auth/me boot payload — returns {user, role, accessible_groups, permissions: [{action, scope}, …]}. Empty list for admins; the client treats admin specially. Ref: server/src/rp_server/routers/auth_oidc.py.
  • userStore.hasPermission(action, scope?) — mirrors the server check in the SPA. Ref: web/src/lib/stores/user.svelte.ts.

Use it in components:

{#if user.hasPermission('host.delete', host.group)}
  <Button variant="danger" onclick={openDeleteDialog}>Delete</Button>
{/if}

Anti-patterns:

  • Hiding the action and not gating it server-side. Always do both. The UI gate is UX; the server gate is security.
  • Stuffing fine-grained permissions into a single string column on users ("role=ops_lead_can_delete_hosts"). Use the (action, scope) rows.
  • Forgetting to update the SPA when adding a new action — feature ships invisible. Add data-testid="cap-{action}" to gated elements and make the QA Playwright test fail loudly.

4. Audit + retention

Pattern: audit_events table + retention config singleton + 24h scheduler

When to use this: every Pulse product. Audit trails are not optional; they're the only artifact when a customer asks "what happened on May 12?".

When NOT to use this: debug logs. Use stdout + Loki for that. Audit is the intentful subset — who did what to which entity.

Anatomy:

  • audit_events table(id pk, ts, actor_user_id, actor_kind, action, target_kind, target_id, payload jsonb, request_id). Indexes on (ts DESC), (actor_user_id, ts DESC), (action, ts DESC), (target_kind, target_id, ts DESC). Ref: server/alembic/versions/008_audit_events.py.
  • audit_retention_config singleton — one row, (retention_days, last_purge_at, last_purge_count). UI binds to /v1/dash/settings/retention. Ref: server/src/rp_server/audit_retention.py.
  • BoundsMIN_RETENTION_DAYS=1, MAX_RETENTION_DAYS=3650. Above the max we're effectively disabling retention; that's a separate decision.
  • retention_loop() asyncio task — wakes every 24h, calls purge_old_audit_events, updates book-keeping. Single DELETE … WHERE ts < cutoff query. No APScheduler — overkill for one tick. Ref: audit_retention.py:retention_loop.
  • purge_old_audit_events writes an audit.retention.purge_completed event with the count. The act of purging is itself audited.
  • /v1/dash/settings/retention endpoints:
GET    /v1/dash/settings/retention            → current config + stats
PUT    /v1/dash/settings/retention            → update retention_days
POST   /v1/dash/settings/retention/purge-now  → immediate purge (gated by "DELETE" typed confirm)
  • Destructive UI gate — the purge-now dialog requires typing DELETE verbatim. Ref: web/src/lib/components/.../RetentionCard.svelte, data-testid="retention-purge-confirm-input". Same convention as bulk-delete users/groups.

Why no pg_cron or TimescaleDB retention policy: keeping the scheduler in-process means it ships with the binary and uses the same auth/logging stack. One less moving part to set up on a fresh LXC.

Anti-patterns:

  • Logging audit through Python's logging module. You can't query it. Always go to the table.
  • Putting full request/response bodies in payload jsonb. Truncate and reference the request id; full traces belong in Loki.

5. Webhooks

Pattern: HMAC-signed outbound + retry/auto-disable

When to use this: any product that wants to fan out events to external systems (n8n, Slack, Healthchecks, the operator's own glue script).

When NOT to use this: an internal pipeline between two homelab components — use the event bus subscriber directly. Webhooks are for third parties who own the receiver.

Anatomy:

  • webhooks table(id, url, secret bytes(32), event_filter text[], group_filter text[] | null, enabled bool, created_at, failure_count, recent_deliveries jsonb). Ref: server/alembic/versions/012_webhooks.py.
  • generate_secret()secrets.token_hex(32), written once on create, displayed once. Ref: server/src/rp_server/webhooks.py:97.
  • compute_signature(body, secret)sha256=<hex> HMAC. Header: X-RP-Signature-256. Receivers verify with constant-time compare. Ref: webhooks.py:102.
  • WebhookDispatcher — long-running task subscribed to the event bus. On each event:
  • Filter by event_filter[] (exact-match strings; * allowed).
  • Filter by group_filter[] if the event payload has a group field.
  • POST with signed body.
  • On failure: retry with backoff [0, 1, 5, 30] seconds, max 4 attempts.
  • After 10 consecutive failures: auto-disable (enabled = false, write webhook.auto_disabled audit event).
  • recent_deliveries — sliding window of the last 20 attempts (timestamp, status, latency, attempt #, retry_of if manual). Renders in the dashboard delivery log. Ref: webhooks.py:171.
  • Manual retryPOST /v1/dash/webhooks/{id}/retry/{delivery_id} re-dispatches with retry_of set so the UI can show "retry of XXX".

group_filter UX: must be a multi-select pill picker reading from /v1/dash/settings/groups, never a comma-separated text input. Operators can't know group names by heart. (See QoL standard.)

Anti-patterns:

  • Storing the secret in plaintext display after creation. Show it once; if lost, rotate.
  • Retrying forever. Auto-disable is mandatory — a dead receiver doesn't deserve unbounded queue growth.
  • Signing the URL instead of the body. Always body.

6. Bulk operations

Pattern: createSelection<T> + BulkActionBar + fan-out dispatcher

When to use this: any list page (hosts, commands, users, groups, sites) where N>5 is a realistic operator workflow.

When NOT to use this: lists where the action is non-idempotent and the operator probably wants to confirm each one (e.g. db-push to multiple customer prods — that's a different workflow).

Anatomy (web):

  • createSelection<T>(opts) generic store backed by SvelteSet<T>. Returns { has, add, remove, toggle, clear, count, asArray, selectAllVisible, toggleAllVisible }. Ref: web/src/lib/components/app/selection.svelte.ts.
  • computeSelectAllState(visibleIds, store)'none' | 'some' | 'all' — drives the tri-state header checkbox. The 'some' state renders as an indeterminate checkbox.
  • BulkActionBar floating bar, visible when selection.count > 0. Shows count, clear button, and action verbs (Issue command, Set group, Delete…). Disabled actions render with aria-disabled and a tooltip explaining why.
  • Per-domain wrappershost-selection.svelte.ts, BulkGroupActionBar.svelte, BulkUserActionBar.svelte. Each is ~30 LOC around the generic.

Anatomy (dispatch):

  • bulk-dispatch.ts — fan-out using Promise.all over per-item promises. Each promise tracks {id, status: 'pending'|'ok'|'error', error?}. Ref: web/src/lib/commands/bulk-dispatch.ts.
  • Per-item progress — a writable rune $state array; the UI updates as each settles.
  • Abort signal — operator can cancel; the controller's signal propagates to every in-flight fetch.
  • Safety guards on destructive bulk — typed-DELETE confirm before the dispatch fires. Same convention as retention purge.

Server side: prefer a single bulk endpoint (POST /v1/dash/hosts/bulk/delete with {ids: [...]}) when the action is naturally atomic; otherwise fan out from the client. Atomic bulk endpoints simplify auditing — one event per bulk action with the full list in payload.

Anti-patterns:

  • "Select all" that selects all visible (paginated) and then quietly drops the off-page items. Either select-all means everything on the server, or it means the visible page — make it explicit in the button label (Select all 47 on this page).
  • No abort. Long-running fan-outs without cancel feel broken.

7. Saved views

Pattern: localStorage per scope + factory views + JSON import/export

When to use this: any URL-state-driven page (filters, sort, columns, time range) where the operator will revisit specific configurations.

Anatomy:

  • Scope keyrp.savedViews.{scope} in localStorage. Scope is the page route (hosts, commands, audit).
  • Factory views — built-in named presets shipped by the product (e.g. hosts:offline-last-24h, commands:my-pending-approvals). Cannot be deleted; can be pinned/unpinned.
  • User views — operator-saved named snapshots of the current URL query string. Pinned views render as quick-buttons; the rest live in a dropdown.
  • JSON import/exportrp-saved-views-{scope}-{date}.json. Lets operators share views across machines / with teammates. Ref: web/src/lib/components/app/SavedViewsSwitcher.svelte:145.

Implementation: the store reads the URL on mount, hydrates state, and writes back to the URL on every change. Saved views capture the URL string, not the parsed state — so reapplying a view is just goto(view.url).

Anti-patterns:

  • Server-side saved views. They're per-operator, per-browser; storing them server-side adds a sync problem nobody needs. JSON export covers cross-device.
  • Saving views without scopes — hosts filters bleed into the commands page. Always scope.

8. Live updates (SSE)

Pattern: monotonic event id + ring buffer + Last-Event-ID replay

When to use this: any page that shows state that changes from outside the operator's actions (heartbeats, command status, approvals, audit). Replaces polling.

When NOT to use this: rarely-changing config pages. Polling on focus + invalidate-on-mutate is simpler.

Anatomy (server):

  • EventBus — singleton with publish(event_type, payload) and subscribe_with_id(). Each subscriber gets a bounded asyncio.Queue (default 256). Slow subscribers drop events, not block publishers. Ref: server/src/rp_server/events.py:44.
  • Monotonic id — every publish increments a counter; the id is sent in the SSE id: field.
  • Replay buffer — the last 128 events are kept in a ring. On reconnect with Last-Event-ID: N, the server replays everything with id > N.
  • Synthetic gap event — if the requested Last-Event-ID is older than the oldest item in the ring, the server sends one event: gap then resumes. Client treats gap as "invalidate all caches and refetch".
  • EndpointGET /v1/dash/stream with Accept: text/event-stream. OIDC cookie auth.

Anatomy (client):

  • sse.svelte.ts — connection manager with exponential backoff 1s → 30s capped, max 10 retries → failed state surfaced in the UI. Ref: web/src/lib/queries/sse.svelte.ts.
  • live-context.ts — page-level provider; components subscribe via getLiveContext().on('host.heartbeat', cb).
  • RecentActivityWidget + LiveToasts + LiveBadge — the standard UX surface for live data. A pulsing dot when connected, a yellow chip when reconnecting, red when failed.

Anti-patterns:

  • WebSockets when SSE will do. WS adds bidirectional complexity and proxy headaches; the agent → server direction is HTTP-poll anyway.
  • Unbounded subscriber queues. One slow consumer takes the publisher down. Always bound and drop.
  • No replay. Reconnects after a 5s tunnel hiccup miss every event in that window.

9. Stats dashboard

Pattern: aggregated KPIs + TimescaleDB time_bucket + SQLite fallback

When to use this: every Pulse product gets a top-level /stats page eventually. Build it once with the dialect fallback so dev/test on SQLite stays in lockstep.

Anatomy:

  • /v1/dash/stats — single endpoint returning {fleet, commands, heartbeats, audit_summary, uptime_per_host}. Pydantic models in server/src/rp_server/routers/dash_stats.py:79+.
  • Range selector24h | 7d | 30d | 90d, URL-synced via ?range=7d. Default 7d.
  • time_bucket('1 day', issued_at) on Postgres+TimescaleDB. Falls back to strftime('%Y-%m-%d', issued_at) on SQLite. Dialect detection via db.bind.dialect.name. Ref: dash_stats.py:151 (_is_postgres).
  • Uptime calculation(heartbeat_count_in_range / max_expected_in_range) clamped to [0, 1]. Decommissioned hosts (no heartbeats ever in the range) get null, not 0, so they don't tank the fleet average.
  • Charts inline SVG — no extra dep beyond what the page already imports. uPlot for the host detail timeseries; small bar/sparkline charts hand-rolled. ~30 LOC per chart type.

Anti-patterns:

  • One endpoint per chart. Operators want the page to load atomically; one query, one render.
  • Computing uptime over rolling-window-with-gaps in JS. Do it in SQL where the index lives.
  • Hard-coding ranges in three places (server enum, client selector, query parser). Define once, import.

10. QoL standard

A feature is not done when its backend is correct. It's done when an operator who doesn't read docs can use it.

Apply [[feedback_qol_polish_standard]] as a gate before declaring any user-facing feature done.

The six rules (RP v1.0.13 audit findings, treat as checklist):

  1. Discovery > typing. Finite known set (plugin paths, group names, version IDs)? Show a selectable list. Free-text is fallback for edge cases.
  2. Progressive disclosure. Simple case = one click. Advanced (regex, custom URL, raw command) lives behind "Show advanced".
  3. Batch over loop. If the operator might do N items, the batch verb exists in the UI. See §6.
  4. Automation over manual repetition. Algorithmic sequence (bisect, step-up versions, retry-with-backoff)? Build the automation, don't make the operator click N times.
  5. Context in summaries. "3 matches" → also show which. "Status: red" → also show what's wrong. "60 options changed" → list them.
  6. Action buttons in rows. Status row showing a problem → the relevant verb is one click away.

Concrete examples that triggered the standard (RP v1.0.14 release notes):

  • Webhook group_filter was a comma-separated text input. Operators don't memorise group names. Fix: pill multi-select reading from /v1/dash/settings/groups.
  • Retention "Purge now" was a single button. Irreversible. Fix: type DELETE to arm.
  • Host detail "Delete" was visible to everyone, then 403'd. Fix: userStore.hasPermission('host.delete', host.group) gate.

Gate process for sub-agent work: when a sub-agent ships "F1.4 done", open the modal yourself. Ask: "would I use this if I forgot the syntax?" If no, send back.


11. Push-to-prod workflow

A non-negotiable contract for any Pulse product that pushes changes to a system the operator doesn't fully own (customer WP site, family Tailscale node, partner host).

Apply [[feedback_wp_pulse_push_workflow]] verbatim:

  1. Edit local worktree (/srv/wp-pulse/sites/{slug}/_stack/{slug}/www/wp-content/ for WP-Pulse; the equivalent staging area for other products).
  2. Capture preview — Playwright screenshot OR ask the operator to refresh https://{slug}-preview.monxas.casa (or the product's preview URL).
  3. Show the screenshot.
  4. Wait for explicit approval — "ok push", "a prod", "approved", or equivalent. Silence is not approval.
  5. Only then run the push verb (wpdev push <slug> --yes, rp deploy …, etc.).

Why: the preview environment exists precisely so prod doesn't get edited without human-in-loop validation. Skipping the confirm-after-preview step is a repeat offence pattern.

Exception: explicit single-stage operator commands like push X to prod are the approval — proceed without an extra round-trip. The rule binds chained iterations where the agent is fixing-something-it-just-edited.

Anti-patterns:

  • "It worked locally, pushing." Local is not preview. Preview is not prod.
  • Treating a green test run as confirmation. Tests validate code; the human validates intent.

12. Releases + deploy

The standard release loop, derived from 15 RP releases (v1.0.0 → v1.0.14) in under a week.

Bump:

  1. Update version in pyproject.toml (server) and __init__.py (__version__ = "1.0.X") and agent pyproject.toml. Keep lockstep.
  2. Write RELEASE_NOTES_vX.Y.Z.md in .github/. Sections: Why this release exists / Highlights / Changes (Server / Dashboard SPA / Agent) / Upgrade. Past tense, neutral tone, link to issues/PRs where relevant.
  3. Commit chore(release): v1.0.X.

Tag:

git tag -a v1.0.X -m "Release v1.0.X"
git push origin main --tags
gh release create v1.0.X -F .github/RELEASE_NOTES_v1.0.X.md

Deploy:

ssh rp-server   # or product-equivalent LXC
cd /opt/rp/source
git pull
.venv/bin/alembic upgrade head     # if there's a migration
scripts/build_dashboard.sh         # if web/ changed
systemctl restart rp-server
curl -fsS https://<host>/health    # smoke

Smoke contract: GET /health returns {status: "ok", version: "1.0.X", db: "ok"}. The version field must match the deployed tag. Mismatch = the unit didn't restart with the new code.

Anti-patterns:

  • git pull without tagging. You lose the rollback target.
  • Skipping alembic upgrade head and assuming the table will be there. Each release that touches schema bumps the head.
  • Writing release notes after the tag. They drift. Write first, tag with the file already committed.

Appendix A: Module port matrix

When porting a pattern from RP to WP-Pulse (or a future Pulse-X), this is the file-to-file map. Targets marked TBD are the port author's job to fill in during the port PR.

Pattern RP server RP web WP-Pulse target
Short-code enroll auth.py:36, routers/dash_enroll.py, alembic/011_* routes/enroll/+page.svelte api/routers/enroll.py TBD
Capability gating permissions.py, routers/auth_oidc.py lib/stores/user.svelte.ts api/permissions.py TBD
Audit + retention audit_retention.py, routers/dash_retention.py, alembic/008_*, alembic/013_* components/app/RetentionCard.svelte api/audit.py TBD
Webhooks webhooks.py, routers/dash_webhooks.py, alembic/012_* routes/webhooks/+page.svelte api/webhooks.py TBD
Bulk ops per-domain bulk endpoints lib/components/app/selection.svelte.ts, BulkActionBar.svelte, lib/commands/bulk-dispatch.ts mirror in Next.js TBD
Saved views n/a (client-only) lib/components/app/SavedViewsSwitcher.svelte mirror in Next.js TBD
Live SSE events.py, /v1/dash/stream lib/queries/sse.svelte.ts, live-context.ts api/events.py TBD
Stats routers/dash_stats.py routes/stats/+page.svelte api/stats.py TBD

Appendix B: Glossary

Consistent terminology across Pulse products. When porting, prefer the existing term over inventing a new one — operator muscle memory matters.

Term Means Used in
Host A managed machine (LXC, VM, RPi, laptop) running the RP agent. RP
Site A managed WordPress installation running the WP-Pulse plugin. WP-Pulse
Node Generic — host or site or future-product equivalent. This doc
Agent The outbound poller running on the node. RP: Python binary. WP: plugin. RP, WP-Pulse
Plugin Synonym for agent in WP-Pulse context. Avoid elsewhere. WP-Pulse only
Command An operator-initiated unit of work (exec, upgrade, db-dump). RP, WP-Pulse
Op Synonym for command. Prefer command in code; op is colloquial. n/a
Group Tag-like grouping of nodes for ACL + filtering. Default = default. RP, WP-Pulse
Approval A pending command awaiting human (web, Telegram) confirmation. RP, WP-Pulse F3
Audit event An intent-level row in audit_events. Not the same as a log line. RP, WP-Pulse
Enroll link One-shot, TTL'd short code redeemed by the agent installer. RP, WP-Pulse F2
Heartbeat Periodic agent → server liveness POST. Hypertable in Postgres+TS. RP
Drift Difference between expected and observed state on a node. WP-Pulse F5
Push Operator-initiated change from CP → node (code, db, config). WP-Pulse F3, F6
Preview The staged-but-not-live URL where pushes are validated before prod. WP-Pulse
Canary Pre-fleet rollout of an agent version to a small subset. RP F8
Tailnet A Tailscale network. Pulse products typically span 1-3 tailnets. All

Cross-references:

  • ADR-0008 Remote-Pulse — v1.0 GA. Source of most patterns here.
  • ADR-0009 RP web dashboard redesign (en el repo remote-pulse) — SvelteKit SPA, source of §3, §6, §7, §8.
  • ADR-0010 WP-Pulse — first port target.
  • [[feedback_qol_polish_standard]] — §10.
  • [[feedback_wp_pulse_push_workflow]] — §11.
  • [[homelab_infra_repo]] — ADR-0007 foundation (Ansible, SOPS, Caddy HA).
  • [[oidc_pocketid_apps]] — auth provider.