Skip to content

Architecture — the 3 rings

The backend is organized as three rings with dependencies pointing inward only:

modules/  →  workspace/  →  core/
(features)    (the spine)    (infra)
RingPathHolds
corebackend/src/core/Universal infra only: anthropic, supabase, settings engine, notifications, dataforseo, perplexity, pdf, url-guard
spinebackend/src/workspace/Shared across ALL modules: accounts, collections (+ the CMS adapter layer), site-context, site-audit, readiness
modulesbackend/src/modules/<group>/<module>/The features: content/*, performance/*, market, orbit-pixel, lead-audit, web, mcp
platformmodules/agent (hot path), observability, documents, reports; cron runner at scheduler/Cross-cutting infra

Modules never import each other — they talk through the spine, the agent, and the data.

All data is tool-reachable (hard rule, CLAUDE.md, 2026-08-05): every piece of data a module stores or the frontend displays must be surfaceable through an agent tool (and therefore MCP — same registry). A new table, column, route field, or frontend panel ships with the owning module's tool extended in the same change — no frontend-only or route-only data. Motivating audit: plans/tool-coverage-audit-2026-08-05.md (~60 gaps + 6 silent-wrong-answer bugs where the agent saw less than the UI). The mechanical half is enforced by the tool surface verify gate below.

The module contract

A module is a folder with a manifest — modules/<group>/<module>/index.js exporting:

js
export default {
  id,                // also the settings module_id — FROZEN once it owns a settings row
  name,
  settingsSchema?,   // {} if it owns a (possibly empty) row; OMIT to namespace into another row
  schedule?(ctx),    // crons — registry calls it per workspace
  operatorCrons?,    // Run-now descriptors for the internal dashboard
  queries?,          // the module's OWN read functions
  agentTools?, agentSkills?, resources?,  // pushed to the agent automatically
  signals?,          // detectors the nightly sweep collects
  shares?,           // PUBLIC shareable views: [{ kind, label, load({workspaceId, resourceId}) }]
  routes?, routeAlias?, routeAliases?,    // mounts at /api/modules/<id> by default
}

Adding it to MODULES in modules/registry.js (same commit — the one-path invariant) auto-wires settings, crons, routes, and agent tools. Boot-time guards throw on duplicate ids, duplicate route paths, malformed operator crons, duplicate/malformed share kinds, and a module declaring the reserved enabled key.

scheduleModuleCrons isolates each schedule(ctx) call and reports the failures together at the end (2026-08-08). Before that a single throw aborted the loop, so registration order decided which modules survived a bug: one module calling ctx.cron(...) (it is the gated wrapper object, { schedule }, not a callable) left the four modules after it — autopilot included — with no crons at all, fleet-wide, until the next deploy. The aggregate still throws, so initWorkspace still alerts the admin; it just no longer takes the healthy modules with it.

shares is how a module offers a client-facing page without owning any of the plumbing: the spine holds the token and the agency's brand, the module only says what the page contains — see Shares.

Reference implementations: modules/performance/backlinks/ (fullest manifest) and modules/performance/gsc/ (route aliases + settings namespaced into another row).

No per-workspace enablement

The per-workspace module switch (a reserved enabled boolean injected per schema, defaultEnabled dark-ships, nav graying, cron fire-time checks) was removed 2026-08-14: every module is available to every workspace. It only ever gated crons and nav while the agent's tools, skills and module routes ignored it — proven live when the agent configured AI-visibility prompts on a "disabled" module. Future rollouts get one deliberate gate designed when needed. Old enabled keys still stored in workspace_settings rows are inert.

Frozen identifiers

  • DB identifiers: table names, RPC names, settings module_id strings ('1a-blog-publisher', 'orbit-pixel') — live keys for real workspaces. Code names rename freely; these never do (scripts/guards.mjs enforces).
  • Agent hot path: agent/chat.js, agent/runner.js — change last, behind tests. Since testing-v1 (2026-08-31) that is mechanical: the hot path, workspace/readiness.js and core/services/workspace-active.js accept no edit without their colocated *.test.js green in the same PR, and every bug fix ships the test that fails without it (backend/CLAUDE.md § The two test laws).

The verify gate

cd backend && npm run verify is the mechanical truth (there is no backend build). Since 2026-09-02 verify:pure executes through scripts/verify-pure.mjs — the parse gate first, then every other gate concurrently through a worker pool (~145s serial → well under a minute; boot-smoke and the route contract each boot on a random port, and every gate reads the committed .env.test, so nothing collides). The canonical gate list lives in ONE place, the verify:pure:serial script in backend/package.json, which the runner parses (expanding npm test / npm run indirections) — a gate added to the chain is picked up automatically, and the serial chain remains runnable as a fallback. The gates:

GateCatches
parsesyntax errors (node --check on every src file)
undefinedscripts/check-undefined.mjs (2026-08-08) — a called name that is never imported, declared or a known global. The gap between the two gates around it: parse only parses, and resolve only proves a module LOADS, so a name missing inside a function body first fails in production. That is not hypothetical — a missing cloudflareConnectingIp import destroyed 2h20m of pageviews fleet-wide, and the same run of this gate found a second one (readSitemap in a Signals detector, swallowed by a catch that meant "sitemap unreachable"). Scanner is pure and tested (scripts/lib/js-scan.mjs, scripts/test-js-scan.mjs)
resolvewrong import paths, missing named exports (dynamically imports every module)
frozen idsa table/RPC/module_id literal added or renamed vs the baseline
signal closersscripts/check-signal-closers.mjs (2026-08-11) — a closed-status property write (resolved / dismissed / gone) on agent_signals outside modules/agent/board/lifecycle.js, or a time-shaped closer name in the registry. Since 2026-08-16 a machine close writes gone (off every tab), never resolved — Done holds only human closes. Every close names WHO closed it (closed_by); before the gate, four independent paths wrote a bare resolved and 138 closed rows were indistinguishable from human completions
ringsscripts/check-rings.mjs (S10, 2026-08-12) — a NEW cross-ring import: deps point inward only (core ← workspace ← modules), feature modules never import each other, and modules/{agent, observability, documents, reports} are the platform ring (agent is the sanctioned broker). 19 pre-existing violations are grandfathered in scripts/baseline/ring-violations.json — shrink-only: fixing one requires --snapshot, growing the baseline is refused
embed hintsscripts/check-embed-hints.mjs (2026-08-31) — an un-hinted PostgREST embed between tables that carry more than one FK relationship (pinned in scripts/baseline/ambiguous-embed-pairs.json). The moment a second FK lands between two tables, PostgREST refuses every bare embed between them with PGRST201 — which is how the agency-site-slot FK (2026-08-29) turned isWorkspaceActive into a fleet-wide "inactive" verdict for two days: crons silently skipped, publish/chat routes refused, MCP listed zero tools. Fix pattern: name the FK (accounts!workspaces_account_id_fkey(...)) and record the pair in the baseline, same commit. Its live half (--probe, also inside check:data) runs every embed found in src as a zero-column read against the real schema, so a NEW ambiguity (or a hint naming a dropped FK) surfaces in the weekly sweep instead of in production behavior
detector coveragescripts/check-detector-coverage.mjs (S10, 2026-08-12) — a module that serves workspace data (queries or routes) with no signals detector on its manifest: the dashboard would be blind to its domain (§7 coverage guarantee mechanised). Own reasoned allowlist (pages until S11's detector, shells, mcp, lead-audit); stale entries fail
tool surfacescripts/check-tool-surface.mjs (2026-08-05) — a module exporting queries (it owns workspace data) with no agentTools and no allowlist reason; a malformed/undescribed tool (name, description, input_schema, handler); an MCP denylist entry naming a nonexistent tool. The judgment half of the tool-reachable rule ("does the tool return every column the UI shows?") stays in review — the gate makes the default for a data-owning module "ship tools or explain yourself"
CMS contractan adapter missing the CMSAdapter surface, a missing setup guide, or wire-shape drift
CMS dry-runa real publish() per adapter with HTTP stubbed — catches an arbitrary (non-blog) collection whose custom fields never reach the wire
bootthe real server fails to boot (waits on /api/health)
route contractscripts/check-route-contract.mjs (testing-v1 T2, 2026-08-31) — a manifest route mount missing vs scripts/baseline/route-mounts.json (the frontend/MCP hold URLs into those prefixes), or a mounted prefix whose first GET route answers 404 on a live boot ("route silently unmounted"). Mount set changed on purpose? --snapshot, same commit
unit testsscripts/test-unit.mjs (testing-v1 T1, 2026-08-31) — every colocated src/**/*.test.js under node:test, zero deps: the readiness gate's full spec, workspace-active's error≠silent regression (the 2026-08-29 swallow), collection publishing + publish prep, the outcome loop's pure laws
namespacesa settings key not assigned to exactly one namespace
module cronsscripts/test-module-crons.mjs (2026-08-08) — calls every manifest's schedule(ctx) with a fake cron and asserts: it does not throw, it registers through cron.schedule() (not cron(...)), every task lands in ctx.jobs (else a reload leaks a live job), no expression is scheduled twice, and the GSC sync + site refresh each exactly once. boot-smoke runs with ENABLE_CRON=false, so nothing else in verify ever executes a schedule() — the pixel rollup shipped calling ctx.cron(...) and took every later module's crons down with it at boot
docsa docs page whose mapped sources: code was committed after the page (both tiers)
vue hazards../docs/scripts/check-vue-hazards.mjs (2026-09-01) — a bare <placeholder> or a bare double-brace interpolation in dev-docs prose. VitePress compiles those pages as Vue templates, so the shape passes freshness, coverage and the pre-commit hook, then fails CI's vitepress build as an unclosed element; it broke the Gate twice in one day before this existed. Backslash-escaped and v-pre lines pass. A tripwire, not a compiler — CI's real build stays the truth

Deliberately NOT in verify, because it talks to live CMSs: npm run check:cms. A read-only sweep of every workspace's real CMS — do the credentials still authenticate, does the mapped collection still exist, does every mapped field still exist in the live schema, do relationship options still resolve. No writes, safe against production. Run it after touching the CMS layer, or when a customer reports that publishing stopped working; -- <name> narrows it to one workspace.

The third leg (2026-08-03, also not in verify — it reads the live DB): npm run check:data. The data-plane correctness sweep (scripts/check-data.mjs): verify proves the code, check:cms proves the integrations, check:data proves the DATA the product's judgments stand on. Read-only, fleet-wide, three families of checks — INVARIANTS (facts that must always hold; every data incident graduates into one: live rows carry real URLs, one counted lead per person per goal, exactly one primary row per article group, no post-fix slug-derived titles), CONSISTENCY (clicks ≤ impressions, 7d ≤ 28d windows, CTR arithmetic, health scores 0-100, signal fingerprints unique, storyline evidence references real rows), FRESHNESS (per-dataset heartbeats — a GSC-connected workspace with content must have a snapshot ≤3 days old; backlinks/health ≤10 days; stale data is flagged, never silently treated as current — but agent-paused workspaces are exempt, their staleness being the operator's deliberate call, and a workspace whose articles never earned a GSC row is honest absence, not staleness), and the SCHEMA probes (every PostgREST embed in src run live — the embed-hints gate's DB-truth half). --workspace <name> narrows. Its first fleet run (2026-08-03) caught 33 article groups with no primary row (invisible to every primary-scoped read), 2 URL-less live rows, and 10 duplicate-URL pairs — run it after data-pipeline changes and on a schedule. --reconcile adds TIER 2: our copy vs the SOURCE — a fresh Search Analytics query with the ingestion's own window math + property resolution (imported, never reimplemented), compared to today's stored snapshots for one sampled workspace (top-5 + 3 random pages, ±2%/±3 tolerance for Google's intraday finalization), plus the wrong-property tripwire (internally consistent numbers for someone else's site is the scariest failure class). Skips honestly when no snapshot was written today; without --workspace it rotates the sampled workspace by day-of-year so the fleet reconciles itself on a cycle. Complementing it, the GSC WRITE path now refuses impossible rows at ingest (clicks>impressions, 7d>28d>90d order, position outside 1-500 — snapshotRowValid in article-metrics.js) and stores position NULL, never 0, when Google returns no ranking (the 31k historical position-0 sentinels were repaired 2026-08-03).

Frontend: cd frontend && npm run build must be green when it's touched. That is tsc -b && vite buildthe type-check is the gate, not a nicety. Vite transpiles TypeScript with esbuild and never type-checks, so for as long as build was vite build alone a missing import was structurally undetectable before production: ReferenceError: Check is not defined, statusChip is not defined, activeWorkspace is not defined and three more like them reached real users' browsers in a single week, and the client-error sink is where we found out. Repo-wide strictNullChecks is off, so this catches undefined identifiers and shape mismatches, not null-safety.

Request path at a glance

client → api/index.js (host-rewrite → pixel → lead-audit → CORS → /api)
       → auth ladder (requireAuth → requireWorkspaceMember → requireActiveWorkspace)
       → /api/modules/<id> (registry-mounted module routes)  |  legacy thin api/routes/*
       → module services/queries → spine → core → Supabase