diff --git a/docs/specifications/architecture.md b/docs/specifications/architecture.md index e7793a5..6be1fd0 100644 --- a/docs/specifications/architecture.md +++ b/docs/specifications/architecture.md @@ -20,7 +20,8 @@ One long-running local Node process, no separate backend/frontend deployment: │ │ │ │ │ ▼ ▼ │ │ ┌─────────────────────────────┐ │ -│ │ In-memory Y.Doc (per shard) │ │ +│ │ In-memory Y.Doc (Phase 0: one │ │ +│ │ global shard) │ │ │ └──────────────┬───────────────┘ │ │ │ │ │ ▼ │ @@ -42,6 +43,19 @@ One long-running local Node process, no separate backend/frontend deployment: **The Y.Doc/Awareness pair is resolved, not global.** Every boundary that touches workspace state — the `/ws` WebSocket handler, each `services/*.ts` function, and the read-only SvelteKit route loads — calls `resolveWorkspaceContext()` (`src/lib/server/workspace-store.ts`) rather than reaching a bare process-global singleton. That function is a real `{workspaceId, shardId} → {doc, awareness, connections}` registry: it's already exercised with more than one key in `workspace-store.test.ts`, proving two resolved contexts never share CRDT state, Awareness state, or persisted snapshot rows. Phase 0 itself only ever has one real workspace and no auth to pick a different one, so every current boundary calls `resolveWorkspaceContext()` with no selector and always gets the same default context back — a client-supplied value (e.g. the WebSocket room path segment) is accepted as a forward-compatible selector but is never treated as authority. This is the seam #13 (Y.Doc sharding strategy) and #6 (multi-space) build on: adding real per-workspace routing is a change to what selector a boundary resolves with, not a rewrite of how state is reached. See issue #30. +**Capacity boundary (confirmed 2026-08-30).** The Phase 0 global shard is a +deliberate, bounded implementation, not the final multi-space architecture. +The [CRDT capacity baseline](../benchmarks/crdt-capacity-baseline-2026-08-30.md) +confirms it is suitable for a small daily workspace, while its larger profile +shows that every connected client pays for the whole global state. The next +architecture boundary is therefore document- and Collection-level Yjs shards, +with workspace navigation/catalog updates kept outside those content shards. +That is a direction validated by the baseline, not a claim that shard-aware +routing is shipped: Phase 0 still resolves every live connection to the one +default shard. #112 defines the proposed catalog/shard design, and #113 must +prove it with real per-shard transport measurements before this architecture +can describe it as implemented. + **Why HTTP MCP transport, not stdio:** Claude Desktop, Claude Code, and ChatGPT all support pointing at a remote MCP server via URL + bearer token in their own config (per the docs linked in the PRD). That matches the "simple local access token" decision better than stdio, which would mean each client spawns and manages its own subprocess — more moving parts for no benefit when everything's already running as one long-lived local service. ## 2. App structure (SvelteKit) diff --git a/docs/specifications/persistence.md b/docs/specifications/persistence.md index cc171fd..4823168 100644 --- a/docs/specifications/persistence.md +++ b/docs/specifications/persistence.md @@ -8,7 +8,7 @@ [Drizzle](https://orm.drizzle.team/) (Apache 2.0 — same license as this project's own goal, not just compatible with it) manages every SQLite table, including the read model in §2 — one schema/migration story (`drizzle-kit`) instead of hand-written SQL for some tables and a separate library for others. It's also the SvelteKit-idiomatic choice: the Svelte CLI ships a built-in `drizzle` setup command. -- `snapshots` — periodic binary snapshots of the `Y.Doc` state (via `Y.encodeStateAsUpdate`). Simpler than an update-log replay for now; revisit if snapshot size becomes a problem. Keyed by `(workspace_id, shard_id)` (`src/lib/server/workspace-store.ts`'s `DEFAULT_WORKSPACE_ID`/`DEFAULT_SHARD_ID` today — see `architecture.md` §1) — every read/write/retention-prune query in `store.ts`'s `getSnapshotStore()` scopes by that key, so a second workspace's snapshots, once #13/#6 land, can never collide with or prune the first's. A given key's snapshot is loaded lazily, the first time `resolveWorkspaceContext()` resolves that workspace/shard — not eagerly for every context on process start, since Phase 0 only ever resolves the one default key anyway. +- `snapshots` — periodic binary snapshots of the `Y.Doc` state (via `Y.encodeStateAsUpdate`). Simpler than an update-log replay for now; revisit if snapshot size becomes a problem. Keyed by `(workspace_id, shard_id)` (`src/lib/server/workspace-store.ts`'s `DEFAULT_WORKSPACE_ID`/`DEFAULT_SHARD_ID` today — see `architecture.md` §1) — every read/write/retention-prune query in `store.ts`'s `getSnapshotStore()` scopes by that key, so a second workspace's snapshots, once #13/#6 land, can never collide with or prune the first's. A given key's snapshot is loaded lazily, the first time `resolveWorkspaceContext()` resolves that workspace/shard — not eagerly for every context on process start, since Phase 0 only ever resolves the one default key anyway. The [2026-08-30 capacity baseline](../benchmarks/crdt-capacity-baseline-2026-08-30.md) confirms this snapshot path for a small daily workspace, but also establishes a global-snapshot budget: at 2 MiB or more, or when the associated event-loop p99 reaches 100 ms, prioritize document/Collection sharding or compaction rather than extending the Phase 0 global-state envelope. Those are engineering escalation signals, not a claim that either future design is already implemented. - `audit_log` — append-only: `id, actor_json, action, target_record_id, timestamp, diff_json`. Populated on every write/delete tool call, denied MCP attempt, and UI-originated write — including UI writes that bypass the service layer entirely, via a generic `Y.Doc`-level observer — satisfying the PRD's audit-log requirement; see [`audit-coverage.md`](./audit-coverage.md) for exactly what's covered, what's deliberately excluded, and how attribution and volume are handled. - `access_tokens` — `token_hash, client_label, allowed_document_ids, allowed_collection_ids, created_at`. `client_label` is a free-text field set when the token is created (e.g., "Claude Desktop") — this is what powers the "Brylie · via Claude Desktop" attribution tag, since there's no per-vendor OAuth to source it from otherwise.