Network · Source of truth
What 2plot.ai is in the network — the primary authenticator, the heartbeat, and (Phase 2) the ledger of record for every satellite app.
---
.. toc::
The shape of the network
The 2plot network is a constellation of purpose-specific apps around one hub. Each satellite does one job; the hub does three:
1. Primary authentication — one Clerk account works everywhere. Satellites redirect here to sign in and get sent back where they came from. See [Authentication](/docs/network-auth). 2. The heartbeat — a background pulse that watches every satellite, runs the network's scheduled work, and records everything to an append-only event log. See [Pulse](/docs/heartbeat-pulse). 3. The ledger of record *(Phase 2, in design)* — the wallet and cost ledger every satellite reports into, keyed by the shared Clerk email. See [Cockpit](/docs/activity-cockpit) for how this surfaces.
| App | Purpose | | --- | --- | | 2plot.ai | Auth · heartbeat · ledger of record (this app) | | piratesbargain.com | Shop + AI agent (physical products long-term) | | ai-agent.buzz | AI whiteboard canvas | | 2plot.xyz | The cell-evolution arena | | 2plot.media | Video/stream studio | | cast.2plot.net | Live browser casting | | 2plot.dev / 2plot.us / 2plot.store | Dev workbench · asset studio · storefront (rolling out) |
.. admonition::Why one source of truth :icon: tabler:target-arrow :color: teal
Every satellite accumulating its own opaque API costs, its own auth, and its own health story doesn't scale past a few apps. The hub inverts that: identity, observability, and (next) money live in one place, and each satellite stays free to be excellent at one thing.
Storage philosophy
Everything the pulse records is JSONL-first with a best-effort Postgres mirror: flat files are the always-on source of truth (zero configuration in dev), and when DATABASE_URL is set the same writes mirror into Postgres so the web container and the pulse worker — which share no filesystem in production — see one story. No code path differs between dev and prod.
```python
File: lib/db/models.py
"""ORM models for the network-pulse time-series tables.
Ported from the SailsBaord heartbeat substrate (services/db/models.py) — only the three tables the pulse needs: heartbeat_events, jobs, job_progress.
Schema philosophy: a small set of typed columns (the ones we query and index against) plus a JSON `extras` column for everything else. This keeps the schema stable as new event kinds appear — only adds need a migration if they become frequently-queried.
The `extras column uses JSON (not JSONB`) so the same models work against SQLite for local dev. PG silently upgrades JSON to JSONB on storage anyway.
The cost/token columns are kept even though the hub records no AI spend (no metered AI keys here, ever) — the schema stays wire-compatible with the rest of the network, and Phase 2+ satellites will report cost events into this same table shape. """ from __future__ import annotations
from datetime import datetime from typing import Any
from sqlalchemy import ( JSON, BigInteger, Boolean, DateTime, Index, Integer, Numeric, SmallInteger, String, Text, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase): pass
SQLite only auto-increments INTEGER PRIMARY KEY columns; BigInteger
would silently land NOT NULL with no default. Falling back to Integer
on SQLite gives us portable autoincrement for local dev while keeping
the larger range on Postgres.
_AutoPK = BigInteger().with_variant(Integer, "sqlite")
class HeartbeatEvent(Base): """One row per emitted heartbeat event (tick / task_* / api_call / error / note)."""
__tablename__ = "heartbeat_events"
id: Mapped[int] = mapped_column(_AutoPK, primary_key=True, autoincrement=True)
ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True, nullable=False) kind: Mapped[str] = mapped_column(String(32), index=True, nullable=False) phase: Mapped[str | None] = mapped_column(String(32)) task: Mapped[str | None] = mapped_column(String(64), index=True) task_id: Mapped[str | None] = mapped_column(String(64), index=True)
# Call accounting — populated for kind=task_end / kind=api_call. On the # hub an "api_call" is an outbound HTTP probe (satellite sweep, wallet # provision), so model/cost stay NULL; the columns exist for network-wide # schema compatibility. model: Mapped[str | None] = mapped_column(String(64), index=True) cost_usd: Mapped[float | None] = mapped_column(Numeric(12, 6)) tokens_input: Mapped[int | None] = mapped_column(Integer) tokens_output: Mapped[int | None] = mapped_column(Integer) duration_ms: Mapped[int | None] = mapped_column(Integer)
# Free-text fields used by note + error events. note: Mapped[str | None] = mapped_column(Text) reason: Mapped[str | None] = mapped_column(Text)
# Catch-all for anything not covered above (sweep target/status/latency, # rollup counts, etc). Keeps the schema stable as new fields appear. extras: Mapped[dict[str, Any] | None] = mapped_column(JSON)
__table_args__ = ( Index("ix_heartbeat_ts_kind", "ts", "kind"), Index("ix_heartbeat_ts_task", "ts", "task"), )
class Job(Base): """One unit of initiated heavy work, observed cross-process by reading rows — not thread-locals.
The heartbeat answers "what is the network pulse doing?" (global, owner-facing, /pulse). A Job answers "what did someone explicitly enqueue and what happened to it?" (the /pulse "run now" button). The web container never runs job bodies; it writes a `pending row and returns. The pulse-worker's dispatcher claims the row, runs the body inside an hb.task(...)`, and writes status back. Because state lives in rows, a web or worker restart is recoverable.
`id` is an app-generated uuid (NOT autoincrement): the enqueueing callback must know and return the id *before* the row is durably written.
Status machine (terminal states never transition):
pending → running → done | error | cancelled ↘ cancelling ↗ """
__tablename__ = "jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
kind: Mapped[str] = mapped_column(String(64), index=True, nullable=False) status: Mapped[str] = mapped_column( String(16), index=True, nullable=False, default="pending" )
# Provenance — who enqueued the work. actor: Mapped[str] = mapped_column( String(32), nullable=False, default="system" ) actor_clerk_id: Mapped[str | None] = mapped_column(String(64), index=True) actor_handle: Mapped[str | None] = mapped_column(String(64), index=True) title: Mapped[str | None] = mapped_column(String(160))
created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), index=True, nullable=False ) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Join key to the heartbeat — stamped when the body enters hb.task(...). task_id: Mapped[str | None] = mapped_column(String(64), index=True) cost_usd: Mapped[float | None] = mapped_column(Numeric(12, 6))
progress_pct: Mapped[int | None] = mapped_column(SmallInteger) message: Mapped[str | None] = mapped_column(String(256)) reason: Mapped[str | None] = mapped_column(Text)
params: Mapped[dict[str, Any] | None] = mapped_column(JSON) result: Mapped[dict[str, Any] | None] = mapped_column(JSON) extras: Mapped[dict[str, Any] | None] = mapped_column(JSON)
__table_args__ = ( # The dispatcher claim query: oldest pending first. Index("ix_jobs_status_created", "status", "created_at"), Index("ix_jobs_actor_created", "actor_clerk_id", "created_at"), Index("ix_jobs_kind_status", "kind", "status"), )
class ScheduleTask(Base): """One owner-created dynamic schedule task (the /admin/heartbeat/tasks page's rows).
The static `lib.pulse.schedule.SCHEDULE list stays code-reviewed and frozen; these rows are the runtime-extensible complement. In prod the web container writes them here and the pulse worker's 60s re-sync loop reads them (separate containers share no filesystem); zero-env dev runs off the JSONL twin in lib/pulse/dynamic_tasks.py`.
`template keys into lib.pulse.task_templates.TEMPLATES; params is the template's JSON parameter blob (resolved at fire time, so a param edit needs no worker restart). Soft delete via deleted_at` so historical heartbeat events keep a joinable row. """
__tablename__ = "schedule_tasks"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
title: Mapped[str] = mapped_column(String(160), nullable=False) template: Mapped[str] = mapped_column(String(32), nullable=False)
kind: Mapped[str] = mapped_column(String(16), nullable=False) # daily|weekly|hourly at: Mapped[str] = mapped_column(String(5), default="06:00") weekday: Mapped[int] = mapped_column(SmallInteger, default=0) every_minutes: Mapped[int] = mapped_column(Integer, default=60) duration_min: Mapped[int] = mapped_column(Integer, default=2)
color: Mapped[str] = mapped_column(String(16), default="blue") blurb: Mapped[str | None] = mapped_column(Text) params: Mapped[dict[str, Any] | None] = mapped_column(JSON)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
__table_args__ = ( Index("ix_schedule_tasks_enabled", "enabled", "deleted_at"), )
class JobProgress(Base): """Append-only progress trail for a Job.
One row per `jobs.progress(...) call. The *latest* pct/message is also denormalised onto jobs` for the cheap indicator read; this table is the full history for a per-job timeline view. """
__tablename__ = "job_progress"
id: Mapped[int] = mapped_column(_AutoPK, primary_key=True, autoincrement=True)
job_id: Mapped[str] = mapped_column(String(36), index=True, nullable=False) ts: Mapped[datetime] = mapped_column( DateTime(timezone=True), index=True, nullable=False ) pct: Mapped[int | None] = mapped_column(SmallInteger) message: Mapped[str | None] = mapped_column(String(256))
__table_args__ = ( Index("ix_job_progress_job_ts", "job_id", "ts"), ) ```
:defaultExpanded: false :withExpandedButton: true
Where things live
lib/pulse/— the heartbeat substrate (event log, jobs, schedule,
worker). Dash-free by design: the sidecar worker imports it without building the app.
lib/db/— the optional Postgres mirror.pages/heartbeat.py— the owner dashboard at/admin/heartbeat.HEARTBEAT-TASKS.md(repo root) — the operational playbook.
.. llms_copy::Network · Source of truth
---
*Source: /docs/network-overview*
Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs:
- /docs/network-overview/llms.txt — LLM-friendly documentation
- /sitemap.xml
- /robots.txt