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¶
- Stack baseline
- Enrollment
- Capability-gated UI
- Audit + retention
- Webhooks
- Bulk operations
- Saved views
- Live updates (SSE)
- Stats dashboard
- QoL standard
- Push-to-prod workflow
- Releases + deploy
- Appendix A: Module port matrix
- 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/meat 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¶
Pattern: short-code enrollment links¶
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 alphabet —
ABCDEFGHJKLMNPQRTUVWXY2346789(29 chars, no0/O, no1/I/L, noS/5, noZ/2collision). 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_codeinauth.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_minuteswins; 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_permissionstable —(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_ACTIONSfrozenset — 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)withscope = '*' OR scope = group. Ref:permissions.py:82./auth/meboot 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_eventstable —(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_configsingleton — 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.- Bounds —
MIN_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, callspurge_old_audit_events, updates book-keeping. SingleDELETE … WHERE ts < cutoffquery. No APScheduler — overkill for one tick. Ref:audit_retention.py:retention_loop.purge_old_audit_eventswrites anaudit.retention.purge_completedevent with the count. The act of purging is itself audited./v1/dash/settings/retentionendpoints:
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
DELETEverbatim. 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
loggingmodule. 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:
webhookstable —(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 agroupfield. - 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, writewebhook.auto_disabledaudit event). recent_deliveries— sliding window of the last 20 attempts (timestamp, status, latency, attempt #,retry_ofif manual). Renders in the dashboard delivery log. Ref:webhooks.py:171.- Manual retry —
POST /v1/dash/webhooks/{id}/retry/{delivery_id}re-dispatches withretry_ofset 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 bySvelteSet<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.BulkActionBarfloating bar, visible whenselection.count > 0. Shows count, clear button, and action verbs (Issue command, Set group, Delete…). Disabled actions render witharia-disabledand a tooltip explaining why.- Per-domain wrappers —
host-selection.svelte.ts,BulkGroupActionBar.svelte,BulkUserActionBar.svelte. Each is ~30 LOC around the generic.
Anatomy (dispatch):
bulk-dispatch.ts— fan-out usingPromise.allover 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
$statearray; 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-
DELETEconfirm 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 key —
rp.savedViews.{scope}inlocalStorage. 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/export —
rp-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 —
hostsfilters bleed into thecommandspage. 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 withpublish(event_type, payload)andsubscribe_with_id(). Each subscriber gets a boundedasyncio.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 withid > N. - Synthetic
gapevent — if the requestedLast-Event-IDis older than the oldest item in the ring, the server sends oneevent: gapthen resumes. Client treatsgapas "invalidate all caches and refetch". - Endpoint —
GET /v1/dash/streamwithAccept: text/event-stream. OIDC cookie auth.
Anatomy (client):
sse.svelte.ts— connection manager with exponential backoff1s → 30scapped, max 10 retries →failedstate surfaced in the UI. Ref:web/src/lib/queries/sse.svelte.ts.live-context.ts— page-level provider; components subscribe viagetLiveContext().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 inserver/src/rp_server/routers/dash_stats.py:79+.- Range selector —
24h | 7d | 30d | 90d, URL-synced via?range=7d. Default 7d. time_bucket('1 day', issued_at)on Postgres+TimescaleDB. Falls back tostrftime('%Y-%m-%d', issued_at)on SQLite. Dialect detection viadb.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) getnull, not0, 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):
- Discovery > typing. Finite known set (plugin paths, group names, version IDs)? Show a selectable list. Free-text is fallback for edge cases.
- Progressive disclosure. Simple case = one click. Advanced (regex, custom URL, raw command) lives behind "Show advanced".
- Batch over loop. If the operator might do N items, the batch verb exists in the UI. See §6.
- Automation over manual repetition. Algorithmic sequence (bisect, step-up versions, retry-with-backoff)? Build the automation, don't make the operator click N times.
- Context in summaries. "3 matches" → also show which. "Status: red" → also show what's wrong. "60 options changed" → list them.
- 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_filterwas 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
DELETEto 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:
- Edit local worktree (
/srv/wp-pulse/sites/{slug}/_stack/{slug}/www/wp-content/for WP-Pulse; the equivalent staging area for other products). - Capture preview — Playwright screenshot OR ask the operator to refresh
https://{slug}-preview.monxas.casa(or the product's preview URL). - Show the screenshot.
- Wait for explicit approval — "ok push", "a prod", "approved", or equivalent. Silence is not approval.
- 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:
- Update version in
pyproject.toml(server) and__init__.py(__version__ = "1.0.X") and agentpyproject.toml. Keep lockstep. - Write
RELEASE_NOTES_vX.Y.Z.mdin.github/. Sections: Why this release exists / Highlights / Changes (Server / Dashboard SPA / Agent) / Upgrade. Past tense, neutral tone, link to issues/PRs where relevant. - 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 pullwithout tagging. You lose the rollback target.- Skipping
alembic upgrade headand 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.