Skynet — Architecture and Deployment Design
For: Agon, Sead, Sven (CTO), Vince. Companion to: the approved functional design (what), the workflow source map (how work flows and where each step comes from), the delivery plan (in which order).
Supersedes the technical plan v5 §2–§4 and the implementation PRD for everything about host, topology, routing, auth scope, staging, deploy, backup and rollback. Contracts, stack and monorepo discipline carry over unchanged.
1. Decisions this design is built on
- Host. The unified app runs on
server.ecomprofits.io(48 cores, 250 GB RAM, 1.2 TB free; Plesk + Portainer; the production ecomprofits Supabase lives here). "We can migrate to a new server anytime" — nothing in this design is host-specific beyond a compose file and a vhost. - Standalone apps stay standalone. meta-ads-scraper and RescaleOS keep running on
server.rescale.media. Their functionality is merged into Skynet natively, module by module, in later slices; each standalone is retired only when its replacement is accepted. No sidecar mounting, no cross-host proxying. The only allowed pre-merge change set is bridge track B while S1 runs. - rescale-workos stays on Railway and is reused over an API (naming assembler, launch bridge). ecomprofits is read-only for Skynet, always.
- Staging runs on
server.rescale.media; production onserver.ecomprofits.io. A change reaches production only after the staging smoke gate passes. - CI/CD and alerting replicate the ecomprofits pattern (GitHub Actions → GHCR → SSH →
docker compose pull/up→ Telegram), with one improvement: the whole env file comes from one CI secret. - Delivery = 100 % complete. No calendar dates; slices are a sequence with gates. S1 exit gate is §12.
- Backups: Skynet has its own backup path and sweep on the production box, independent of the ecomprofits pipeline. Agon owns Plesk vhosts and TLS on both boxes.
2. System boundaries and coexistence
server.ecomprofits.io (prod) server.rescale.media (staging + standalones) ┌──────────────────────────────────────┐ ┌──────────────────────────────────────┐ │ Plesk (TLS, vhosts) │ │ Plesk (TLS, vhosts) · Portainer(view) │ │ app.rescale.media → 127.0.0.1:38000 │ │ staging.app.rescale.media → :38001 │ │ ┌ compose project `skynet` ────────┐ │ │ ┌ compose project `skynet-staging` ─┐ │ │ │ caddy · api · web · postgres 17 │ │ │ │ caddy · api · web · postgres 17 │ │ │ │ migrate · backup · synthetic │ │ │ │ migrate · synthetic │ │ │ └──────────────────────────────────┘ │ │ └───────────────────────────────────┘ │ │ ┌ /opt/supabase-ecomprofits ───────┐ │ │ /opt/meta-ads (scraper, untouched) │ │ │ production Supabase (untouched) │ │ READ │ /opt/rescaleos (console, untouched) │ │ └──────────────────────────────────┘ │◀────────▶│ /opt/techdesk │ └──────────────────────────────────────┘ └──────────────────────────────────────┘ │ REUSE (HTTPS + service token) ▲ links only (no proxy) ▼ │ Railway: rescale-workos (naming, launch bridge, ClickUp) shell nav → standalone apps
Five boundaries, each with one rule:
- Skynet ↔ ecomprofits Postgres: read-only role
skynet_readonly, account-scoped queries, one view (skynet_creative_daily); no writes, ever. Skynet's own Postgres is a separate container; it never shares the Supabase instance. - Skynet ↔ workos: HTTPS to Railway with a Skynet service token; workos owns ClickUp; Skynet writes only human-owned fields through the bridge.
- Skynet ↔ scraper / RescaleOS: links in the shell nav, nothing else, until each merge slice. Their logins remain separate; bridge track B may add temporary features in those standalone repos while S1 runs.
- Skynet ↔ the box: hard resource limits (§9) so the Supabase workload is never starved.
- Skynet ↔ its own future: every table carries
workspace_id; queue rows, outbox and R2 keys carry it; credentials are per workspace. This is what makes later merges and SaaS hardening additive instead of rewrites.
3. Repository (monorepo Rescale-Media/skynet)
apps/api/ Python 3.13 · FastAPI — core (auth, workspaces, creative codes, bindings, lineage, read layer, readback) + production (the static-ads backend, ported) apps/web/ React 19 · Vite · TS · Tailwind — shell + Production pages + admin packages/contracts/ JSON Schema + TS types + Python models + fixtures (auth, creative code, lineage, bindings, launch, metrics) db/migrations/unified/<schema>/NNN_*.sql forward-only SQL; runner `apps/api/migrate.py`; ledger core.schema_migrations infra/ compose files (prod, staging, dev), Caddyfile, backup + synthetic scripts, secret-scan config, GitHub workflows docs/ ARCHITECTURE, decisions/ (ADRs), runbooks/, checklists/ services/ imported code for later slices only — NOT part of the S1 runtime: scraper/ transcriber/ agent-runtime/ workos/ (scrubbed, no secrets)
Imports keep history (git subtree); the workos tree is a scrubbed copy (its history contains credentials). The services/* packages are built in CI for tests only; no image is published from the monorepo for them until their merge slice (ADR-006).
4. Stack (per package)
| Package | Runtime / framework | Image | Notes |
|---|---|---|---|
apps/api | Python 3.13 · FastAPI · psycopg 3 · pytest | ghcr.io/rescale-media/skynet-api | Base = static-ads backend; core module added; supervisord runs uvicorn + the job worker (readback, storyboard queue); no Postgres inside the container any more |
apps/web | React 19 · Vite · TypeScript · React Router · Tailwind v4 | ghcr.io/rescale-media/skynet-web (Caddy + built SPA) | The one gateway container: serves the SPA, proxies /api/* |
postgres | postgres:17 + pgvector | official | Schemas core, production; later meta_ads (S2), studio (S3) |
migrate | skynet-api image, entrypoint migrate | — | One-shot before every deploy |
backup | postgres:17 client + cron | — | Nightly pg_dump -Fc (§10) |
synthetic | curlimages/curl + cron | — | Health checks → Discord/Telegram (§11) |
| Model access | OpenRouter default; direct vendor optional; Claude subscription runner = internal binding inside Studio (S3) | — | Per-workflow bindings, spend guard |
5. Runtime topology on server.ecomprofits.io (production)
/opt/skynet/ git checkout of the monorepo (main), compose project `skynet` infra/compose/compose.prod.yml name: skynet · network skynet-net (internal) · volumes skynet_pg_data, skynet_api_tmp, skynet_backups .env.production written by CI from secret SKYNET_ENV_B64 (never edited on the box)
| Service | Port (host) | Volumes | Purpose |
|---|---|---|---|
caddy (skynet-web) | 127.0.0.1:38000:80 | — | / → SPA; /api/* → api:8000; X-Request-Id; 512 MB uploads; auth rate-limit; websocket/SSE passthrough; 1 h read timeout for streams |
api | internal 8000 | skynet_api_tmp | FastAPI core + production, job worker |
postgres | 127.0.0.1:38432:5432 (admin tools only) | skynet_pg_data | Skynet's own database |
migrate | — | — | profile ops; run by CI before api is recreated |
backup | — | skynet_pg_data (ro), skynet_backups → host path (§10) | nightly dump |
synthetic | — | — | 5-min checks |
The compose files never reference mutable latest at deploy time; they read IMAGE_TAG, API_IMAGE_DIGEST and WEB_IMAGE_DIGEST from .env.release, so staging and production run the exact images the build job produced. Deploy-time variables live in two files on each host: .env.production / .env.staging (the full application environment, written by CI from *_ENV_B64) and .env.release (image selection for that deploy only). Operators never edit either by hand.
Plesk: vhost app.rescale.media → proxy 127.0.0.1:38000, Let's Encrypt, HSTS, client_max_body_size 512m. Caddy trusts X-Forwarded-Proto only from 127.0.0.1. Portainer is a viewer; host-side docker compose owns every container; nothing is ever edited in the Plesk/Portainer GUI (both rewrite mounts and env — the RescaleOS lesson).
Rules that keep the ecomprofits stack safe on the same box: separate compose project and network (no shared network with supabase-*); separate Postgres (never the Supabase instance); published ports only on 127.0.0.1 and only the two above; resource limits (§9); Skynet never touches /opt/supabase-ecomprofits; deploys never run docker system prune, down -v, or anything project-wide.
6. Staging on server.rescale.media
Same compose file, project skynet-staging, port 127.0.0.1:38001, own network skynet-staging-net, own volumes, .env.staging from secret SKYNET_STAGING_ENV_B64, vhost staging.app.rescale.media. Staging reads ecomprofits with the same read-only role (the only shared dependency; it cannot write). Staging does not run scrapers or agents; it proves the unified app, migrations, the read layer, the loop, and the deploy itself. Staging keeps the existing box's backup sweep (Plesk private/pgdump on rescale.media) for its dumps.
7. CI/CD — the ecomprofits pattern, applied
ci.yml (PR + main; path-filtered jobs, all required on main): contracts · api (pytest) · web (lint + build) · secrets (gitleaks with the workos custom patterns) · test-only builds of services/*.
deploy.yml (push to main):
- build → GHCR
skynet-api,skynet-web; tagsmain-<sha>+latest; the job records the pushed image digests as outputs (API_DIGEST,WEB_DIGEST). - deploy-staging (SSH to
server.rescale.media,/opt/skynet-staging):git reset --hard origin/main→ decodeSKYNET_STAGING_ENV_B64, validate (non-empty, required keys, noANTHROPIC_API_KEY=), keep.bak-<ts>, atomic write → writeIMAGE_TAG=main-<sha>,API_IMAGE_DIGEST,WEB_IMAGE_DIGESTinto.env.release→docker compose run --rm migrate→pull→up -d→ wait/api/healthz. - smoke-staging (
infra/smoke/run.sh): login with a seeded user;/,/api/auth/me,/production200 with one cookie;/api/version{sha, migration_head}—sha == git rev-parse HEADandmigration_head == ledger head; one production call (GET /api/products); one read-layer call (GET /api/performance?code=<known>). Any failure stops the run. - deploy-prod (SSH to
server.ecomprofits.io,/opt/skynet, gated byvars.DEPLOY_PROD_ENABLED): identical steps withSKYNET_ENV_B64, reusing the exactAPI_IMAGE_DIGESTandWEB_IMAGE_DIGESTthat staging ran; pre-migratepg_dumpintoskynet_backups/pre-deploy/; refuses if disk free < 20 GB. - notify → Telegram (build / staging / smoke / prod result, actor, sha, run link) — the ecomprofits message format.
Migrate-step guarantees: one transaction per file; checksum ledger core.schema_migrations; refuses a changed checksum; idempotent; forward-only and additive by rule (docs/MIGRATIONS.md); a destructive migration needs an ADR and a manual dispatch.
Rollback: infra/deploy/rollback.sh <sha> — reset the checkout to <sha>, pin/pull skynet-api:main-<sha> + skynet-web:main-<sha>, up -d; restore the pre-migrate dump if the ledger head is newer than the target. Drilled on staging before the first production deploy.
8. Auth, identity, permissions (S1 scope)
Skynet issues RS256 access + refresh tokens (rs_access 12 h, rs_refresh 30 d; HttpOnly, Secure, SameSite=Lax, exact-host cookie domain), refresh rotation with family revocation, logout via token_version; claims {sub, email, name, ws, ws_slug, roles[], tv, typ, exp}; roles as data: core.permissions (fixed catalogue, action-level), core.roles (six seeded system roles admin, strategist, media_buyer, editor, researcher, viewer + custom roles per workspace from S5), core.workspace_member_roles (a member may hold several roles; effective permissions = union of allows, no deny rules); every route gate checks a permission, never a role name; the JWT carries roles[] only and /api/auth/me returns the effective permissions[]; roles[] remains in JWTs for compatibility, membership display and legacy mapping only; Skynet UI and route gating use /api/auth/me.permissions[] as the canonical authorization surface. JWKS at /api/auth/jwks; service tokens (typ=service) for workos and internal jobs. Legacy static-ads users are imported and invited once; the ported Production API verifies the unified token (Bearer / ?token= / cookie) — the ?token= bridge is a documented S1 limitation (ADR-005) removed when asset URLs become signed. The standalone scraper and console keep their own logins until their merge slices. CSRF = origin check; CORS none. Contract detail: day-0 lock §4.1 (unchanged).
9. Resource protection on the production box
Current host facts: 48 cores, 250 GB RAM (197 GB available), 1.2 TB free, load ≈ 0.1; Supabase db limit 160 GB, pooler 16 GB.
Skynet is budgeted as a bounded tenant on this box: ≤ 12 cores, ≤ 32 GB RAM, with hard limits and soft reservations. Limits are enforced in the host-side compose files with runtime-supported settings (cpus, mem_limit, mem_reservation, pids_limit, restart) — not Swarm-only deploy.resources.
| Service | cpus | mem_limit | mem_reservation | pids_limit | Notes |
|---|---|---|---|---|---|
| api | 6 | 12 GB | 8 GB | 512 | uvicorn + job worker; bounded thread pools only |
| postgres | 4 | 12 GB | 8 GB | 256 | Skynet DB only |
| caddy | 1 | 1 GB | 256 MB | 128 | proxy only |
| backup | 0.5 | 512 MB | 128 MB | 64 | off-hours, nice + ionice |
| synthetic | 0.5 | 512 MB | 128 MB | 64 | curl only |
Skynet Postgres tuning, conservative for a shared host: shared_buffers = 3GB, effective_cache_size = 8GB, work_mem = 16MB, maintenance_work_mem = 512MB, max_connections = 80, max_wal_size = 4GB.
S1 app-level concurrency caps: MAX_CONCURRENT_IMAGE_JOBS = 4, MAX_CONCURRENT_STORYBOARD_JOBS = 2, MAX_CONCURRENT_EXPORT_JOBS = 4; queue backpressure instead of unbounded threads or per-request fan-out.
Host-protection rules:
- Skynet never runs Playwright, scraping, browser automation or proxy workloads on this box in S1.
- Backups run with lowered CPU and I/O priority (
nice+ionice) and never overlap deploy migrations. - Every Skynet service uses
restart: unless-stopped; an OOM kill restarts that container only — never a compose-wide recovery. mem_reservationstays belowmem_limitso the kernel has reclaim room before hard kills;pids_limitprevents runaway forks.- Production deploys abort if host free disk < 20 GB; alerts at < 100 GB.
Alert thresholds: host load > 24 for 10 min · available RAM < 32 GB · swap > 2 GB · disk free < 100 GB · Skynet Postgres restart / OOM · backup duration over its window · two consecutive synthetic failures.
S2 review trigger: before any scraper or runtime workload lands on this box, this table is re-issued with separate limits for those services and a fresh pressure test.
10. Backup and restore
- Nightly
pg_dump -Fcof Skynet's Postgres by thebackupsidecar into the host path/var/lib/skynet-backups/daily/skynet-<date>.dump(own folder, mode 640), retention 20 days locally; a Plesk Scheduled Task syncs that folder to the Hetzner Storage Box under its own prefix (skynet/), independent of the ecomprofits sweep. R2 object protection is handled separately and must be confirmed explicitly (bucket versioning / lifecycle policy is an infra input, not assumed here). - The backup path is created and owned before the first deploy (
/var/lib/skynet-backups/{daily,pre-deploy}), and the Scheduled Task runs asrootor an account explicitly granted read access to that tree — part of the production-box prep card (A0). - Pre-deploy dumps in
/var/lib/skynet-backups/pre-deploy/(never auto-pruned; alert at 20 GB). - Restore drill (
docs/runbooks/RESTORE.md): restore last night's dump into a scratch container, compare row counts per table, time it; repeated by a second person. Done before the first production deploy and monthly after.
11. Observability and operations
/api/healthz(process + ledger readable),/api/version(sha + migration head).syntheticevery 5 min:/,/api/healthz,/api/auth/jwks, the workos bridge/health, one read-layer query, and backup freshness (latest daily dump exists and is newer than 24 h); two consecutive failures → Discord + Telegram; history incore.synthetic_checks;/statuspage behind SSO.- Telegram deploy messages (ecomprofits format); Discord for spend-guard warnings.
- Runbooks: local dev, deploy, rollback, restore, alerts, legacy hosts (what still runs on
server.rescale.mediaand Railway, and who to call). - Working rules: compose is the sole owner of containers; config only via CI secrets; no GUI edits; no prune; every incident gets a note in
docs/runbooks/INCIDENTS.md.
12. S1 exit gate (what must be true before S2 starts)
app.rescale.mediaserves the unified app fromserver.ecomprofits.io; one login for Skynet itself.- Production fully native at parity.
- Creative-code loop proven with one real ad.
- Bindings + spend guard + minimal admin.
- Export package attached to the existing ClickUp Launch card via the workos bridge.
- Intelligence and Studio entries link to the standalones.
- Staging + CI/CD + backups + rollback drill + monitoring.
13. Later slices — what this design already makes room for
- S2 Intelligence merge: scraper engine + transcriber become services in
compose.prod.ymlwith their own limits;meta_adsschema in Skynet's Postgres (the A4 spike decides the data path: PostgREST over unified PG vs a pg adapter); native Intelligence pages; Reddit VOC (A18); the standalone scraper retired. - S3 Studio merge + strategy layer: agent runtime on Postgres (
studioschema), night-shift jobs on the stack, Claude subscription binding internal-only, Bian's coexistence agreement first; coverage map, hook session, instinct loop, validated memory. - S4 Workspace cutover: native boards one by one with the ClickUp mirror as rollback; workos moves off Railway into the stack; credentials discontinued; Railway off.
- S5 SaaS hardening: RLS per schema, per-workspace keys and quotas, audit log, billing, onboarding, security review. Everything above is built with
workspace_idso this is additive.
Bridge track B (temporary coexistence). While S1 runs, the standalone scraper and RescaleOS may gain temporary features for Mathew (LFS extraction package, /lfs, /evidence-layer, PubMed adapter/MCP). They live outside the unified runtime, are tracked as track-B cards in the delivery plan, and are retired by A19/A20/B0. Model boundary: RescaleOS sessions choose Claude models only (setModel); non-Claude models reach the loop through the scraper's OpenRouter tiers or an MCP, never inside the agent runtime.
14. Risks specific to this design
| Risk | Mitigation |
|---|---|
| Skynet load affects the production Supabase | §9 limits; separate Postgres; no scraping on the box in S1; synthetic box-pressure alerts; rollback under 15 min |
| Two boxes, two deploy targets | One workflow, two SSH steps, identical compose; smoke gate before prod |
| Standalone apps drift while merges wait | Bridge track B is the only allowed temporary standalone change set; sunset is enforced by A19/A20/B0 and merge slices still start from a fresh code scout |
| Cross-box confusion for users | The shell labels links clearly ("opens the scraper in a new tab"); comms pack |
| Workos on Railway is a remote dependency for naming/launch | Bridge calls are idempotent and retried; the loop test covers it; S4 brings workos into the stack |