SoloFrame · the platform

SoloFrame: the platform layer behind an expanding AI portfolio.

Mono-PaaS is the modular-monolith repo; SoloFrame is the engine. DWA (verticals/dwa, PHI, MAIA-served) and GTM-OS (verticals/gtm, non-PHI, classifier: noop) are the shared-engine receipts. BizFrameHub and Colombian Spanish Academy now extend the evidence base: the same product discipline can move into SMB AI education and localized language learning without treating every new product as a fresh company.

Architecture · four layers

The platform separates the engine from the verticals on purpose.

Four layers, each with a different audience and constraint. Verticals own brand and content. The engine owns primitives. Adapters own integration. Studio is the future self-serve surface, documented but not built.

School in days

A new academy should be a manifest and content problem, not a platform rebuild.

SoloFrame turns the reskilling wave into an implementation advantage: once the domain, brand, and curriculum are ready, the shared engine already owns auth, coaching, content serving, safety posture, LLM routing, onboarding, and deployment shape. Your branded academy, live in days, with an AI coach that knows your curriculum, and a safety layer you can dial up to clinical grade.

Layer 4
future

Studio

Self-serve vertical builder. User fills a form → engine generates a manifest → vertical deploys from the same Dockerfile. Documented in docs/architecture.md; not built. Ships only after Layer 3 has hardened across multiple vertical types.

Layer 3
live

Verticals

Branded apps. In the flagship Mono-PaaS repo, each vertical is thin: brand + content + manifest + Next 16 routes. Routes consume vertical-local lib/ shims; shims call into engine packages.

Expansion builds outside the flagship contrast now include BizFrameHub and Colombian Spanish Academy. They are proof surfaces for the platform operating model: package a domain, bind content to a product surface, deploy, measure, and harden what earns reuse.

Layer 2
1 of 14 graduated

Adapters

Per-integration packages at adapters/<name>/. Today: @svtech/adapter-nodebb (GTM forum). 13 named slots (flarum, r2-storage, resend, polar, stripe, brevo, attio, pgvector, etc.) live as vertical-local code awaiting promotion under the admission rule.

Layer 1
live

Engine packages

Sixteen @svtech/* packages. Each typed, Zod-validated, pnpm-workspace-resolved. Consumed via thin shim files in each vertical's lib/, never imported directly by routes.

auth · coaching · content · contracts · db · http · llm · logging · manifest · onboarding · presence · profile · redis · safety · security · voice
Two hard rules

What lands where, and what doesn't.

The engine stays small because two rules govern code placement. Both are documented as ADRs and enforced through review.

Rule 1 · ADR-0001

No vertical may fork an engine.

If a vertical needs behavior X, the engine declares X as a manifest knob exposed through the manifest schema. If you can't express X as a manifest knob, X doesn't belong in the engine yet. The change lands in packages/ as a primitive, not in the vertical as an override.

Carried forward as ADR-0001 from the predecessor solofame-platform attempt, which collapsed when forks accumulated.

Rule 2 · admission rule

≥2 consumers before engine entry.

Code lands in an engine package only when ≥2 verticals consume it OR a third real use case is committed. The lesson the predecessor attempt taught: an engine that absorbs single-vertical code by accident is a templated SaaS in a trench coat.

A primitive graduates from declared (in the manifest schema and engine factory surface) to load-bearing (consumed by ≥2 verticals through their lib/ shims) only when the second consumer arrives.

The structural difference between Mono-PaaS and a templated SaaS: a templated SaaS produces N forks of the same code with different config files; Mono-PaaS produces N runtime configurations of the same engine. DWA and GTM-OS are the shared-engine receipts. BizFrameHub and Colombian Spanish Academy show how the operating model generalizes into new market surfaces.

Engine package surface

Sixteen packages. Twelve wired into both flagship wiring files. Four wired selectively.

The DWA-and-GTM consumption pattern is what the admission rule was written to produce. The minimum-viable surface delivers the hard shared-engine claim - DWA + GTM-OS in production simultaneously, MAIA classifier sidecar serving, six-layer cost architecture operational. The expansion builds show what can be productized next without loosening the core.

PackageWhat it ownsConsumption
@svtech/contractsZod schemas, shared types: VerticalManifestZ, Severity, content/lesson DTOsboth flagships
@svtech/manifestdefineManifest, validateManifest, computeManifestLock (canonical-JSON sha256)both flagships
@svtech/authLucia 3 factory, Argon2 helpers, session-cookie config, requireUser/getSession route helpersboth flagships
@svtech/dbDrizzle client factory, retry/timeout helpers, base column primitives. Vertical-specific tables stay in verticals/<v>/lib/db/schema.tsboth flagships
@svtech/redisioredis client factory + lazy connect (eager .connect() breaks Next 16 build-time prerendering)both flagships
@svtech/safetyClassifier interface, defaultNoOpClassifier, createSafetyService with timeout + failSafe policyboth flagships
@svtech/coachingCoach class, context-assembly primitives, prompt-layer composition, streaming wrappers, conversation stateboth flagships
@svtech/contentMDX rendering primitives, lesson/course/module types, gray-matter frontmatter parsing, loadAllCourses loaderboth flagships
@svtech/profileProfile core type, ProfileRepository interface, Postgres + Mock implementationsboth flagships
@svtech/onboardingOnboardingFlow controller, persistence to profile, manifest-declared step orderingboth flagships
@svtech/presencePresenceContext, manifest-declared signal providers per-vertical (recent_lessons, mood_log, pipeline_state, etc.)both flagships
@svtech/loggingStructured logger factory, GlitchTip per-app DSN (Sentry-API-compatible; SDK unchanged, DSN swapped), audit-log writer. DWA's logger respects compliance.phi: true and drops free-text fields.both flagships
@svtech/llmSingle OpenAI-SDK client pointed at OpenRouter, streamChat, token accounting, ModelRegistryselective · GTM
@svtech/voiceOpenAI-direct Whisper STT + OpenAI TTS. The only place OpenAI is hit directly (OpenRouter doesn't proxy audio)selective · on demand
@svtech/securityCSRF, sanitize (DOMPurify), encryption helpers, headers middleware, rate-limit primitivesselective · both
@svtech/httpRoute helpers (with-auth, errors, request-context), React Query setup. Targeted route consumption.selective · light
12 / 16
Wired into both flagship wiring.ts composition passes
4 / 16
Wired selectively (voice on demand, http through targeted helpers)
195
Total platform tests: 47 engine-package + 123 DWA + 25 GTM (packages/*/src/*.test.ts + verticals/*/)
The manifest contrast

Same TypeScript shape. Same Zod schema. Opposite runtime behavior.

The DWA-vs-GTM manifest diff is the cleanest demonstration the platform makes that compliance is a flag, not a fork. Every licensee question - "can your platform handle HIPAA?" "can it handle a non-clinical use case without paying for clinical safety overhead?" - has a one-character answer because of how the engine flexes.

FieldDWAGTM-OSEngine effect
compliance.phitrue(omitted, defaults false)Logger drops free-text from request/response logs; retention enforced at 2557 days; audit log enabled for PHI-adjacent writes; engine refuses to boot without encryption-at-rest config
coaching.classifier"maia""noop"DWA's wiring.ts selects the MAIA classifier sidecar (sentinet/suicidality, ELECTRA, ~110M); GTM's selects defaultNoOpClassifier
coaching.guardrails["never-diagnose", "always-988-on-crisis", "no-medication-advice"]["no-financial-advice", "no-legal-advice"]Same enforcement mechanism in @svtech/coaching; different list per manifest. Refused at the orchestration layer, not the model
forum.adapter"flarum""nodebb"Different adapter selected. Same forum primitive in the engine. DWA proxies via /community/*; GTM uses public forum URL via @svtech/adapter-nodebb
evals.datasets["coach_conversations", "distress_classifications"]["coach_conversations", "lesson_completions"]Different captured signals per vertical; same eval framework. DWA's distress_classifications feed the MAIA five-stage retrain loop
features{ providerPortal, clinicalDataExport, moodTracking }{ roleplay, pods, badges }Manifest-declared feature flags. Vertical routes/components gate rendering on these. New feature = manifest field + route check, not a fork
verticals/dwa/manifest.ts (excerpt)
export default defineManifest({
  id: "dwa",
  version: "1.0.0",
  brand: { name: "Digital Wellness Academy", primaryColor: "#7c3aed" },
  domain: "digitalwellness.academy",
  compliance: {
    phi: true,
    retentionDays: 2557,            // 7-year HIPAA-flavored window
  },
  coaching: {
    classifier: "maia",                // → services/maia/ sidecar wired in wiring.ts
    guardrails: ["never-diagnose", "always-988-on-crisis", "no-medication-advice"],
    contextSignals: ["recent_lessons", "mood_log", "thought_records"],
  },
  forum: { adapter: "flarum" },
  features: { providerPortal: true, clinicalDataExport: true, moodTracking: true },
});

computeManifestLock(manifest) hashes a canonical-JSON serialization with sha256 - two semantically equivalent manifests with different key order produce the same lock.

The engine-vertical interface

Routes never import @svtech/* directly.

A small set of vertical-side lib/ shim files thread vertical-specific config (Lucia user-attribute schemas, MAIA classifier wiring, OpenRouter model selection, Postgres pool config) into engine-package factories. When an engine package changes, the blast radius is the shim, not the routes.

0
of 200 GTM route files import @svtech/* directly
All engine consumption goes through 13 GTM lib/ shims: redis, logger, auth-lucia, db/index, ai/client, ai/models, ai/openai-coaching, ai/openai-flows, ai/digest-nudge, nodebb/client, nodebb/types, services/personaService, wiring.
0
of 236 DWA route files import @svtech/* directly
All engine consumption goes through 6 DWA lib/ shims: redis, logger, auth-lucia, db/index, safety/maia-classifier, wiring.
The contract every DB-touching engine obeys · lib/db/index.ts
import { createDbFactory } from "@svtech/db";
import { schema } from "./schema";
import { logger } from "../logger";

export const { db, withTenant } = createDbFactory(schema, { logger });

// Drizzle transaction with per-call tenant binding.
// v1: audited query paths through this contract; CI tenantLeakHarness
//     blocks the build on cross-tenant visibility regression.
// v2: SET LOCAL ROLE platform_tenant; RLS policies key off the GUC.
await withTenant(ctx, async (tx) => {
  // SET LOCAL app.tenant_id = <uuid>
  return tx.select().from(schema.thoughtRecord).where(...);
});
Tenant scoping · v1 / v2

Audited at the application layer today. RLS-enforced in v2.

v1 enforces tenant scoping in application code with audited query paths and a CI tenant-leak harness. v2 moves enforcement to the database via Postgres row-level security keyed on a per-request GUC. The sequence is deliberate: ship the audited application-layer enforcement now, drive the v2 RLS rollout from a real regulated deployment.

v1 · live today

Application-layer audited query paths

  • withTenant(ctx, fn) Drizzle-transaction contract every DB-touching engine obeys
  • Audited query paths through this single contract
  • CI tenantLeakHarness blocks the build on regression
  • Lucia-3 sessions + role-based access on clinical-data routes
v2 · roadmap

Database-layer row-level security

  • Postgres RLS policies keyed on per-request GUC (app.tenant_id)
  • Two roles, neither with BYPASSRLS: platform_system + platform_tenant
  • Tenant isolation moves from app-code to data boundary
  • Sequenced behind the marquee Stage-1 pilot landing
Adapter graduation · 1 of 14

One adapter ships. Thirteen are aspirational, awaiting promotion.

Per the admission rule, an adapter graduates from vertical-local code to a shared adapters/* package only when the second consumer arrives or a third committed use case lands. Promotion is gated, not aspirational. The architecture document enumerates fourteen names; reality is that one has earned its package.

graduated · ships today

@svtech/adapter-nodebb

493 LOC including types, 101 LOC of tests. NodebbClient interface, createNodebbClient factory, full NodeBBCategory / Topic / Post / User types, tested write/read surface. GTM consumes through its lib/nodebb/client.ts shim - routes never fetch NodeBB directly.

aspirational · 13 named

Awaiting second consumer

Each maps to existing vertical-side code awaiting promotion when the second-consumer condition lands.

flarumr2-storageresendpolarstripebrevoattiopgvectorpipedrivehunternotionwhatsappbadgr

Most-likely-next-graduations: flarum (DWA forum), r2-storage (both verticals), resend (both verticals).

Deployment

Modular monolith on Docker Swarm. Stateless replicas behind Traefik. Multi-node by design.

One Dockerfile parameterized by --build-arg APP=<vertical>. Three stages: deps, builder, runner. Both verticals deploy as Next 16 standalone images. Docker Swarm schedules across nodes; Traefik handles ingress and TLS; Dokploy is the control plane on top. The same code runs on one node today and scales horizontally by adding nodes - multi-node is a topology choice, not a code fork. Stateless replicas mean a failed node doesn't take the platform down, and rolling deploys don't require maintenance windows.

Build a vertical
$ docker build -f infra/Dockerfile \
    --build-arg APP=dwa \
    -t mono-paas/dwa .

# Three stages:
# 1. deps    - corepack-pinned pnpm 9, frozen-lockfile install
# 2. builder - pnpm --filter "./verticals/${APP}" build
# 3. runner  - .next/standalone, HOSTNAME=0.0.0.0,
#             docker-entrypoint.cjs runs migrations,
#             exec node server.js
Push to main, autoDeploy fires
$ git push origin main

# GitHub-App webhook → Dokploy control plane
# → application.redeploy on the affected vertical
# → Dockerfile rebuilds, Traefik routes new container

# Manual control via dk REST wrapper:
$ dk POST /api/application.redeploy \
    '{"applicationId":"<id>"}'

# IDs pinned in infra/dokploy/state.json
The stack
Orchestration
Docker Swarm multi-node scheduling. Stateless app replicas. State in Postgres / Redis / object storage.
Ingress
Traefik with Let's Encrypt automation. HTTP→HTTPS upgrade enforced. Cloudflare DNS-only mode for HTTP-01 challenge.
Control plane
Dokploy with the GitHub-App webhook. autoDeploy on push to main. dk REST wrapper for ops.
2
Postgres instances · DWA postgres:16-alpine + GTM pgvector:pg16
1
Shared Redis · rate limiting, queues, coordination
1
MAIA sidecar · maia-mono:8001 on dokploy-network
R2
Cloudflare R2 object storage · S3-compatible, BAA-signable

Explicitly not in phase 1: Kubernetes, microservices, multi-region, per-tenant DB by default, custom AI gateway. Founder-scale infrastructure by choice.

Flagship contrast. Expansion proofs. Same platform discipline.

DWA and GTM-OS run on the Mono-PaaS engine today, with opposite compliance postures and zero forks. BizFrameHub and Colombian Spanish Academy show the same platform discipline expanding into new commercial and localized learning surfaces.