System Architecture
AI Software Factory — overall system design. This document is the top-level view; deep dives live in the sibling documents: frontend · backend · database · realtime · search · ai · security.
Authority order:
ARCHITECTURE.md→ this document →PRODUCT-SPEC.md. Conflicts are recorded in the relevant task file (docs/tasks/) rather than resolved by guesswork.
1. System Overview
AI Software Factory is a Notion-like, self-hostable, multi-tenant collaborative workspace platform. The system is a modular monolith plus four runtime apps, a set of shared packages, and five backing systems (PostgreSQL, Redis, S3-compatible object storage, OpenSearch, ClamAV).
USERS (browsers)
│ HTTPS (REST) / WSS (realtime)
▼
┌────────────────────────────────────────────────────────────────────┐
│ edge: nginx (TLS termination, WS upgrade, gzip, security headers) │
└────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌───────────────┐ ┌───────────────┐
│ apps/web │ │ apps/api │ │ apps/realtime │
│ Next.js │──REST──▶ NestJS │ │ ws + Yjs sync │
│ 14 SSR │ │ modular │ │ rooms/presence│
│ + editor │ │ monolith │ └───────┬───────┘
└──────────┘ └───────┬───────┘ │
│ ┌────────┴────────┐
┌───────────────────┼────────────────┤ │
▼ ▼ ▼ ▼
┌────────────┐ ┌──────────────┐ ┌──────────────┐ ┌───────────┐
│ PostgreSQL │ │ Redis │ │ apps/worker │ │ OpenSearch│
│ (Prisma, │ │ cache+streams│ │ BullMQ: auto-│ │ full-text │
│ system of │ │ +queues │ │ mation, files│ │ + kNN │
│ record) │ └──────────────┘ │ index, hooks │ └───────────┘
└────────────┘ └──────┬───────┘ ▲
▲ │ │
│ ┌─────────────────┘ │
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│ S3 / MinIO │ │ ClamAV │ domain events ────┘
│ objects │ │ (scan) │ (Redis streams)
└──────────────┘ └──────────────┘
1.1 Runtime Apps
| App | Runtime | Ports | Responsibility | Scales as |
|---|---|---|---|---|
apps/web |
Next.js 14 (App Router), React 18 | 3000 | UI, block editor, database views, offline client state, BFF route handlers | Stateless, horizontally |
apps/api |
Node 20, NestJS | 4000 | REST API v1, auth, all 16 domain modules, event bus producer | Stateless, horizontally (HPA) |
apps/worker |
Node 20, BullMQ | — (queue consumer) | Automation runs, file pipeline (scan/thumbnails), search indexing, webhooks, AI jobs, email | Stateless consumers, horizontally per queue |
apps/realtime |
Node 20, ws + Yjs |
4001 | Yjs CRDT sync, presence, cursors, realtime comments/notifications | Sharded by page hash (sticky rooms) |
1.2 Shared Packages
| Package | Responsibility |
|---|---|
packages/shared |
DTOs, Zod schemas, domain event contracts, ApiError codes, constants — the single source of truth for wire format, used by web, api, worker, realtime |
packages/auth |
JWT issuing/verification, refresh-token rotation, session management, SSO helpers (SAML/OIDC) |
packages/permissions |
RBAC + resource-level permission engine (pure evaluation, used by api guards and search indexer) |
packages/database-engine |
Properties, views, filters, sort, group, relations, rollups, safe formula AST evaluator, buttons — no Prisma imports |
packages/editor |
Tiptap-based block editor: extensions, slash menu, context menus, DnD, fractional-position engine |
packages/collaboration |
Yjs provider, awareness (presence/cursors), offline queue (IndexedDB), block tree ↔ Yjs doc codec |
packages/search |
OpenSearch client, index schema, indexer logic, hybrid (keyword + semantic) query builders |
packages/ai |
AI gateway + provider abstraction (OpenAI / Anthropic / Google / OSS / custom), RAG pipeline |
packages/storage |
S3-compatible client, presigned upload pipeline, thumbnails, metadata extraction |
packages/ui |
Design system: shadcn/ui components + CSS design tokens (light/dark/system) |
packages/config |
Shared ESLint / Prettier / tsconfig presets |
1.3 Backing Systems
| System | Role | Self-hosted reference |
|---|---|---|
| PostgreSQL 15+ | System of record (Prisma). All tenant data, versions, audit | RDS or plain cluster + PITR |
| Redis 7+ | Caching, BullMQ queues, domain-event streams (XADD/XREADGROUP), rate-limit counters, Yjs update log | ElastiCache or single instance (dev) |
| S3-compatible storage | File objects, private buckets, short-lived signed URLs | MinIO (dev), S3/GCS/NFS-backed (prod) |
| OpenSearch 2.x | Full-text (BM25), prefix/fuzzy, kNN vector store, metadata filters | Single node (dev), cluster (prod) |
| ClamAV | Virus scanning in the file pipeline | Sidecar container |
2. Data Flow Diagrams
2.1 Synchronous Request Path (REST)
Browser
│ 1. HTTPS POST /v1/pages (Bearer JWT or cookie)
▼
nginx ── 2. TLS terminate, X-Forwarded-For, security headers ──▶
apps/api (NestJS)
│ 3. Global pipes: ZodValidationPipe (packages/shared schema)
│ 4. Guards (in order):
│ ApiKeyAuthGuard | JwtAuthGuard → resolve principal (user/apiKey)
│ TenantGuard → resolve workspaceId, attach to ctx
│ PermissionGuard (packages/permissions) → evaluate resource × level
│ RateLimitGuard → Redis sliding window
│ 5. Controller → ApplicationService
│ 6. Service → Repository (Prisma) ──▶ PostgreSQL [tx: mutation + audit_log row]
│ 7. Service → EventBus.publish('page.created') ──▶ Redis XADD factory:events
│ 8. Repository → DTO (packages/shared serializer)
▼
Browser ◀── 201 JSON { data: PageDto, error: null }
Key properties:
- Synchronous path does not wait on side effects. Search indexing, webhooks,
automations, notifications all run off the event bus. A
page.createdrequest returns as soon as the Postgres transaction commits. - Every handler is permission-checked server-side. The frontend is a convenience layer; the guard chain is the only authorization. See security.
- Audit rows are written in the same transaction where possible (see backend, §9).
2.2 Event Path (async side effects)
apps/api (or apps/realtime, or apps/worker)
│ EventBus.publish('record.updated', envelope)
▼
Redis stream factory:events
│ consumer groups (one per consumer, independent lag)
├──────────────────────┬──────────────────────┬───────────────────────┐
▼ ▼ ▼ ▼
search-indexer webhook-dispatcher automation-engine notification-
(worker: index into (worker: sign payload, (worker: match fan-out (worker:
OpenSearch, chunk + POST w/ retry/backoff, triggers → conditions in-app rows +
embed via AI gateway) delivery log) → actions → BullMQ) email)
- Exactly one envelope schema (see backend, §7):
{ eventId, type, version, workspaceId, actor, resource, payload, occurredAt, traceId }. - Consumers are at-least-once; each consumer group is idempotent (upserts keyed by
eventIdor resource state). - Dead-letter: poison messages (3 failed parses/decodes) move to
factory:events:deadfor inspection and replay.
2.3 Realtime Path (editing)
Browser A (Tiptap + y-prosemirror)
│ WebSocket /realtime?token=<JWT>&page=<pageId>
▼
apps/realtime (room = hash(workspaceId, pageId) → instance)
│ 1. auth: verify JWT, check EDIT/VIEW permission for page
│ 2. hydrate Y.Doc from PostgreSQL blocks (if no cached doc)
│ 3. apply incoming Yjs updates to server doc (server is authoritative while editing)
│ 4. broadcast update to other peers in room (≤ 200 ms p95 budget)
│ 5. debounced (1 s) persist: server doc → block rows (re-encode Yjs → Block tree)
│ 6. on persist: EventBus.publish('page.updated', { pageId, blockDelta, actor })
▼
PostgreSQL (source of truth at rest)
Full protocol, room model, resume, and failure modes: realtime.
2.4 Search Query Path
Browser (Cmd/Ctrl+K)
│ POST /v1/search { query, types[], filters, mode: "fulltext"|"hybrid" }
▼
apps/api SearchModule ──▶ packages/search
│ build OpenSearch query:
│ • bool: must(match/multi_match/prefix/fuzzy)
│ + filter(terms: workspace_id, type,
│ permission grants snapshot ← packages/permissions)
│ • hybrid mode: BM25 ⊕ kNN (top_k) → RRF fusion
│ • hybrid+answer: top-N chunks → AI gateway (RAG) → answer + citations
▼
OpenSearch ◀── filtered, ranked docs ──▶ JSON results (≤ 500 ms p95)
Indexing pipeline and lag SLA: search.
2.5 AI Generation Path (structured tool calls)
Browser "generate a product launch plan page"
│ POST /v1/ai/generate { intent, context: {parentId} }
▼
apps/api AIModule → packages/ai gateway
│ 1. RAG retrieval: hybrid search for relevant workspace context (optional)
│ 2. LLM call with tool schemas: create_page, add_blocks, create_database, ...
│ 3. LLM returns tool calls (NOT raw SQL / raw block JSON)
│ 4. Gateway validates each call against Zod tool schemas
│ 5. Tool executor calls the SAME application services the REST API uses
│ (PageService.create, BlockService.bulkCreate, DatabaseService.create)
│ → each performs its own permission check, audit row, domain event
▼
Browser receives { generated: [{type: "page", id}, {type: "database", id}] }
Design: ai.
3. Deployment Topology
3.1 Local Development
infrastructure/docker/docker-compose.yml:
| Service | Image | Notes |
|---|---|---|
| postgres | postgres:15 |
volume, port 5432, init from prisma/ |
| redis | redis:7 |
port 6379 |
| minio + minio-console | minio/minio |
ports 9000/9001, buckets factory-files, factory-thumbs |
| opensearch | opensearchproject/opensearch:2.x |
single node, security plugin disabled in dev |
| clamav | clamav/clamav (optional) |
port 3310 |
Apps run via pnpm dev (Turborepo): web :3000, api :4000, worker, realtime :4001.
3.2 Production (Kubernetes)
┌────────────────────────────────────────────┐
│ ingress (TLS) │
└──────┬───────────────┬────────────────────┘
│ │ /realtime (WSS upgrade)
┌───────▼──────┐ ┌─────▼──────────┐
│ web ×2 │ │ realtime ×N │
│ (HPA: CPU) │ │ (HPA: conns, │
└───────┬──────┘ │ room sharding)│
│ └─────┬──────────┘
┌───────▼───────────────▼──────┐
│ nginx (app tier proxy): │
│ gzip, headers, client_max_ │
│ body 512m (multipart), WSS │
└───────┬───────────────────────┘
│ /v1 → api, /share → web
┌───────▼────────────┐
│ api ×N (HPA: CPU) │ PDB: minAvailable 2
└───────┬────────────┘
│ XADD / BRPOP
┌───────────────────┼──────────────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌──────────────────┐ ┌────────────────┐
│ worker- │ │ worker-files │ │ worker- │
│ automation │ │ (scan/thumbs, │ │ search+webhooks│
│ + email │ │ concurrency 4) │ │ + ai │
│ (HPA: queue │ │ (HPA: queue) │ │ (HPA: queue) │
│ depth) │ └──────────────────┘ └────────────────┘
└─────────────┘
external: RDS Postgres (multi-AZ) · ElastiCache Redis · S3 · OpenSearch cluster
Manifests: infrastructure/kubernetes/ (Deployments, HPA, Ingress, PDB, NetworkPolicy);
secrets via an external secret store (never baked into images); nginx config:
infrastructure/nginx/; cloud reference: infrastructure/terraform/.
Statelessness contract: api, worker, web replicas hold no local state; all mutable state lives in Postgres/Redis/S3/OpenSearch. Realtime is the one stateful tier — it keeps in-memory Yjs documents and is sharded by room hash (§6).
3.3 Environment Configuration
All configuration is env-driven and validated at boot with Zod (apps/api/src/config);
the process refuses to start on missing/invalid keys. .env.example documents every key.
Secrets (DB URL, Redis URL, JWT secret, encryption keys, S3 credentials, provider API
keys) come exclusively from the environment / secret store — never from source or image
layers. See security, §8.
4. Service Responsibilities (module map)
apps/api contains 16 NestJS modules. Their responsibilities and key endpoint families:
| # | Module | Responsibility | Key endpoints |
|---|---|---|---|
| 1 | AuthModule |
register, login, logout, refresh rotation, password reset, email verification, SSO entry | POST /v1/auth/* |
| 2 | UsersModule |
profile, avatar, security settings | GET/PATCH /v1/users/me |
| 3 | OrganizationModule |
org CRUD, org members, policies, SSO/SCIM config, retention | GET/PATCH /v1/organizations/:id |
| 4 | WorkspaceModule |
workspace CRUD, members, invitations, teams | POST /v1/workspaces, POST /v1/workspaces/:id/invitations |
| 5 | PageModule |
page CRUD, tree, nesting, trash/restore, favorites, versions, sharing | GET /v1/workspaces/:wid/pages/tree, PATCH /v1/pages/:id |
| 6 | BlockModule |
block CRUD, batch ops, fractional positioning, synced blocks, versions | POST /v1/pages/:id/blocks, PATCH /v1/blocks/:id/batch |
| 7 | DatabaseModule |
databases, properties, views, records (record = page), query engine (filter/sort/group/paginate) | POST /v1/databases/:id/query, POST /v1/databases/:id/records |
| 8 | RelationModule |
relations, relation links, rollups, formulas (safe AST), buttons, recompute jobs | POST /v1/databases/:id/relations |
| 9 | FileModule |
presigned upload/download, pipeline status, thumbnails | POST /v1/files, GET /v1/files/:id/download |
| 10 | CommentModule |
threads, replies, resolve/reopen, reactions, mentions | POST /v1/pages/:id/comments |
| 11 | SearchModule |
query API over OpenSearch, suggestions, recent searches | POST /v1/search |
| 12 | NotificationModule |
in-app list, mark read, email channel config | GET /v1/notifications |
| 13 | AutomationModule |
automation CRUD, manual trigger, run history, pause/resume | POST /v1/automations/:id/trigger |
| 14 | AIModule |
chat (Ask AI), page/database/formula/template/task generation, RAG search, usage/budgets | POST /v1/ai/chat, POST /v1/ai/generate |
| 15 | IntegrationModule (+ ApiKeyModule, WebhookModule, ShareModule, TemplateModule) |
public API keys, OAuth2, webhook subscriptions + deliveries, public share links, templates | POST /v1/api-keys, POST /v1/webhooks, GET /share/:token |
| 16 | AuditModule |
audit log query/export (enterprise), retention enforcement | GET /v1/audit-logs |
(15/16 are grouped because IntegrationModule owns the adjacent API-key, webhook, share,
and template concerns; each is a separately registrable Nest module.)
Cross-cutting concerns live in apps/api/src/common/: guards (JwtAuthGuard,
ApiKeyAuthGuard, TenantGuard, PermissionGuard, RateLimitGuard), interceptors
(serialize, logging, audit), filters (AllExceptionsFilter), pipes
(ZodValidationPipe), and common/repositories/ (all Prisma implementations).
5. Architectural Decision Records (ADRs)
ADR-001 — Modular monolith (NestJS)
- Status: accepted
- Context. The platform has 16 domains with overlapping concerns (a record write touches blocks, search, automations, notifications). A microservice split at day one would force distributed transactions, network hops on the hot path, and 4× the operational surface for a product that must be self-hostable on a single VPS.
- Decision. One NestJS application (
apps/api) composed of 16 modules with strict boundaries: modules communicate via injected application services or the domain event bus; no module may import another module's repository or Prisma client. Module boundaries are the future service boundaries.apps/workerandapps/realtimeare already separate processes because their scaling profiles (CPU-bound batch, connection-bound) differ from the request path. - Consequences. Simple deployment, in-process transactions, fast hot path. Cost:
every module is deployed together; we mitigate with shared package boundaries
(
packages/database-engine,packages/permissionsare dependency-invertible and unit-tested standalone) so extraction later is a move, not a rewrite.
ADR-002 — Block tree with fractional positions
- Status: accepted
- Context. Blocks (and pages, properties, records, views, questions, widgets) must be reorderable by concurrent users at 60 fps locally. Persisting array indexes forces an O(n) rewrite of sibling positions on every move and races under concurrency.
- Decision. Every ordered sibling list uses a LexoRank-style fractional string
position (
position TEXT, e.g."a0","a0V","a1"). A move rewrites exactly one row's position (the moved item), computed as the midpoint between neighbors (packages/editor/src/positions.ts, algorithm shared with the database engine). When the gap between two neighbors is too small to midpoint (< 32 chars), the affected sibling subtree is re-batched in the background. - Consequences. Single-row reorder, no lock convoy, mergeable under CRDT. Cost:
unbounded string growth is bounded by periodic re-balance; the position column is
indexed (
(pageId, parentId, position)) so ordering scans stay range-indexed.
ADR-003 — Yjs as the realtime source of truth (while editing)
- Status: accepted
- Context. Two users editing the same page concurrently is the core product promise. Operational transform over HTTP is too fragile; per-keystroke REST round-trips are too slow; a plain document-lock is unacceptable.
- Decision. Yjs (CRDT) is the source of truth while a page/database is open for
editing; the persisted
blockstable is the source of truth at rest. The realtime server hydrates aY.Docfrom Postgres on first connect, applies peer updates to its authoritative doc, broadcasts to the room, and debounced-persists (1 s quiet, max 10 s dirty) by encoding the doc back into block rows. Awareness (presence/cursors) rides Yjs's awareness protocol and is never persisted. - Consequences. Conflict-free merging, offline-first clients (IndexedDB + sync on
reconnect), no per-edit HTTP. Cost: a Yjs ↔ Block tree codec is required
(
packages/collaboration), must be round-trip tested (it is on the critical unit-test list), and realtime nodes must be sharded by room to keep one authoritative doc per page (§6).
ADR-004 — OpenSearch as search and vector store
- Status: accepted
- Context. Search must serve full-text + prefix + fuzzy + metadata filters, and AI RAG needs kNN vector retrieval. Running both Elasticsearch and a vector DB doubles operational cost for self-hosters.
- Decision. OpenSearch 2.x does BM25,
fuzzy/prefixqueries, andknndense vectors (HNSW) in one engine. One index (factory-content) holds typed documents (page,block,database,record,comment,file,user) with avectorfield; AI embeddings live there, not in Postgres. - Consequences. One indexing pipeline, one query path (hybrid = BM25 ⊕ kNN fused by reciprocal rank fusion), permission metadata stored per document for query-time filtering. Cost: OpenSearch is a cluster to operate; vector model changes require a re-embedding job (dimension is per-index, so a new model = new index + alias swap).
ADR-005 — Domain events via Redis streams
- Status: accepted
- Context. Side effects (indexing, webhooks, automations, notifications, audit-adjacent fan-out) must not slow writes. A broker (Kafka/NATS) is overkill for self-hosting at MVP scale; in-process EventEmitter would not survive the api/worker process split.
- Decision. Domain events are published to Redis streams
(
factory:events), consumed with consumer groups — one group per consumer (search-index,webhooks,automation,notifications,ai-index). Consumers are at-least-once and idempotent; theworkerapp runs all consumer loops. The same Redis instance hosts BullMQ queues for task work (file pipeline, webhook retries, rollup recompute); streams carry facts (domain events), queues carry work. - Consequences. Zero extra infrastructure, per-consumer lag observability
(
XPENDING/XLEN), easy replay. Cost: single-stream ordering is per-consumer only; event envelopes carryoccurredAtso consumers can detect staleness.
ADR-006 — Record = page
- Status: accepted
- Context. Notion semantics: every database row opens as a full page with properties and block content. Modeling records as standalone rows would fork the permission, versioning, comment, sharing, and trash subsystems.
- Decision.
database_records.pageIdis a 1:1 unique reference to apagesrow created at record creation. Records reuse page permissions (inherited by default), page versions, comments (comments.recordId), sharing, trash, and audit. The record row adds only database-specific data:position,archivedAt,isSubItem, property values (EAV inrecord_properties). - Consequences. One permission/versioning/trash engine; record pages render in the
page tree only when they are also nested (record pages are normally unparented).
Cost: two tables joined per record query; mitigated by the
(databaseId, position)index and a query engine that batches property fetches (§ database doc).
6. Scaling Strategy
6.1 General principles
- Stateless request tier. api/web scale with HPA on CPU; no session affinity needed (auth is token-based; cookies are read by any replica).
- Queue consumers scale on depth. Worker HPA watches queue depth (BullMQ metrics
exposed via Prometheus
/metrics);concurrencyis per-queue. - Hot reads go to caches. Redis caches page trees, permission decisions (short TTL), and search suggestions with explicit invalidation via domain events.
- Postgres reads scale out first. Read replicas handle search-indexer hydration,
audit queries, and report/dashboard reads; writes always go to the primary. The
repository layer selects the data source per operation
(
@ReadReplicadecorator; writes and txs always primary).
6.2 Realtime sharding (room hash)
The realtime tier is stateful: each open page/database holds a Y.Doc in memory.
- Room key:
hash(workspaceId + ':' + resourceId) % N(consistent hashing by SHA-256 truncated to 32 bits, mod instance count). - The edge routes WSS connections by room key via the
Sec-WebSocket-Protocolextension or a sticky cookie set at connection (nginxhash $room consistent); fallback: on connect, the realtime node that does not own the room answers302-style redirect frame and the client reconnects to the owner. - Rebalancing (scale up/down) moves rooms in batches; the new owner hydrates from
Postgres, and the old owner drains its Yjs update log to Postgres before closing
sockets (graceful drain,
SIGTERM→ stop accepting → flush → exit, 30 s budget). - Capacity: one node ≈ 2–5k steady-state rooms at < 200 ms p95 (measured in
tests/performance); per-connection backpressure and rate limits bound a misbehaving client. Details: realtime, §6.
6.3 Queue consumers
| Queue / stream consumer | Concurrency | Notes |
|---|---|---|
automation (BullMQ) |
10 | run executors; retries w/ exponential backoff, dead-letter after 8 |
files (BullMQ) |
4 | CPU-heavy (thumbnails); ClamAV is a sidecar, not per-worker |
webhook-deliveries (BullMQ) |
20 | per-URL concurrency 1 to preserve ordering; backoff 1m → 2h |
search-index (stream) |
8 | upserts by (type, refId); idempotent |
notifications (stream) |
16 | fan-out writes + email jobs |
ai-jobs (BullMQ) |
8 | embedding + generation; per-workspace token budget enforced at submit |
rollup-recompute (BullMQ, delayed) |
16 | event-driven on relation/record change |
6.4 Read replicas & data-tier scaling
- Postgres: primary + 1–2 replicas (logical streaming). Replication lag is monitored; any query serving user-visible current state (page opens, record edits) reads primary; replicas serve search hydration, audit listing, dashboards.
- Redis: primary + replica; streams are consumed from primary (replica is fallback read-only for cache).
- OpenSearch: 1–3 data nodes, 1–3 replicas for the content index as data grows; index rollover by size (30 GB) with alias-based zero-downtime reindex.
- S3/MinIO: object tier scales independently; multipart upload for ≥ 100 MB files.
6.5 Capacity targets (Phase 15 load tests)
| Scenario | Target |
|---|---|
| API throughput | 1,000 rps sustained, p95 < 300 ms |
| WebSocket clients | 10,000 concurrent, p95 update < 200 ms |
| Database query | 100,000-record database, filtered page of 100 rows < 300 ms |
| Page load | 100k-block page tree metadata < 2 s (virtualized) |
| Search | p95 < 500 ms at 10M indexed docs |
7. Cross-Cutting Concerns
| Concern | Where | Mechanism |
|---|---|---|
| Multi-tenancy | every tenant-scoped table | workspaceId column + repository-layer scoping; TenantGuard resolves the workspace from the JWT claim / API key / path; cross-tenant reads are structurally impossible at the repository level |
| Authorization | apps/api/src/common/guards |
PermissionGuard + packages/permissions; every handler declares @RequirePermission(resource, level) |
| Input validation | ZodValidationPipe |
Schemas from packages/shared; same schemas type the frontend |
| Errors | AllExceptionsFilter |
Stable ApiError codes → consistent JSON envelope |
| Rate limiting | RateLimitGuard |
Redis sliding window per user / API key / IP |
| Observability | all apps | OpenTelemetry traces (traceId in event envelopes), Prometheus metrics, structured JSON logs → Loki |
| Audit | AuditService |
mandatory events, same-transaction writes |
8. Non-Functional Summary
| Dimension | Commitment |
|---|---|
| Performance | PRODUCT-SPEC §26 targets: page load < 2 s, editor interaction < 50 ms, local block move < 16 ms, search < 500 ms, API p95 < 300 ms, realtime < 200 ms |
| Availability | No single-node hard dependency in production; realtime drains gracefully; DB PITR + S3 versioning; documented restore runbook |
| Security | OWASP ASVS L2 (see security); server-side authorization always; secrets from env only |
| Portability | Self-hostable: docker-compose for dev, plain-K8s manifests, no cloud-locked services (S3-compatible, Postgres, Redis, OpenSearch are all vanilla) |
| Testability | Every engine (formula, permissions, positions, filter/sort, Yjs codec) is a pure shared package with standalone unit tests; integration via Testcontainers; E2E via Playwright |