Appearance
Architecture — the 3 rings
The backend is organized as three rings with dependencies pointing inward only:
modules/ → workspace/ → core/
(features) (the spine) (infra)| Ring | Path | Holds |
|---|---|---|
| core | backend/src/core/ | Universal infra only: anthropic, supabase, settings engine, notifications, dataforseo, perplexity, pdf, url-guard |
| spine | backend/src/workspace/ | Shared across ALL modules: accounts, collections (+ the CMS adapter layer), site-context, site-audit, readiness |
| modules | backend/src/modules/<group>/<module>/ | The features: content/*, performance/*, market, orbit-pixel, lead-audit, web, mcp |
| platform | modules/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_idstrings ('1a-blog-publisher','orbit-pixel') — live keys for real workspaces. Code names rename freely; these never do (scripts/guards.mjsenforces). - 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.jsandcore/services/workspace-active.jsaccept no edit without their colocated*.test.jsgreen 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:
| Gate | Catches |
|---|---|
| parse | syntax errors (node --check on every src file) |
| undefined | scripts/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) |
| resolve | wrong import paths, missing named exports (dynamically imports every module) |
| frozen ids | a table/RPC/module_id literal added or renamed vs the baseline |
| signal closers | scripts/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 |
| rings | scripts/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 hints | scripts/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 coverage | scripts/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 surface | scripts/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 contract | an adapter missing the CMSAdapter surface, a missing setup guide, or wire-shape drift |
| CMS dry-run | a real publish() per adapter with HTTP stubbed — catches an arbitrary (non-blog) collection whose custom fields never reach the wire |
| boot | the real server fails to boot (waits on /api/health) |
| route contract | scripts/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 tests | scripts/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 |
| namespaces | a settings key not assigned to exactly one namespace |
| module crons | scripts/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 |
| docs | a 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 build — the 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