Architectural Principles
Non-negotiable. Every decision runs through these.
1. Compute economics (priority order)
- Reduce work first. Don't do work that doesn't need doing. Cache, dedup, lazy-load, skip-when-unchanged.
- Place on client CPU when feasible. Free for us, users have idle CPU.
- Place on server only when necessary or prudent. "Prudent" = the alternative would burn AI tokens, leak secrets, or hammer rate-limited APIs.
- AI / external credit conservation as its own axis above generic server cost. Cascade, BYOK, free-tier preference, retry hygiene, masked keys, dedup of LLM calls.
Decision lens: does this reduce work or just move it? If it moves work, is it client→server (bad) or server→client (good)? Does it touch AI tokens? If so, is it making those calls fewer/cheaper?
We are on free-tier Cloudflare. Server compute is the cost center to minimize.
1a. WASM is only worth it when boundary cost is amortized
Empirical from the nodeToTypst spike (2026-05-11, full report at docs/wasm-spike-2026-05-11.md):
- Native Rust IS 5× faster than TypeScript on pure compute (0.067 ms vs 0.31 ms for our reference function).
- But the JS↔WASM boundary tax is fixed: ~120 µs trampoline + ~170 µs for
serde_jsonparse of a typical tree. - For sub-millisecond functions, that ~290 µs floor cannot be amortized — WASM ends up 2-7× slower end-to-end than TypeScript.
- Bundle is ~24× larger over the wire for small standalone modules.
- Debug DX is materially worse (
panic = "abort"strips messages; stack traces showat unknown).
The decision rule: WASM pays for itself when at least one of these holds:
- Compute time per call ≫ boundary tax. Rule of thumb: ≥10 ms of work inside the WASM call. Typst rendering fits (
5-20 ms); nodeToTypst alone does not (0.3 ms). - The work co-locates with already-WASM work, so the boundary crossing is paid once and amortized across multiple operations. E.g. folding nodeToTypst into the Typst render bundle pays the boundary cost ONCE for tree-walk + Typst render together, instead of twice.
- The property gained is not perf but determinism / obfuscation / cross-runtime portability, AND we have a concrete use for it (not speculative).
Don't reach for WASM for any pure-TS surface where:
- The work is sub-millisecond AND
- It doesn't share a boundary with other WASM work AND
- We aren't actively forced into binary obfuscation by competitive pressure
The Typst WASM in the codebase is the canonical "yes, WASM was right here" example: ~5-20 ms of compute per render, no JS-equivalent library, render quality matters. Future WASM candidates should clear at least one of the three bars above.
2. State machine semantics for all entity lifecycles
Every entity has BIRTH, JOIN, LEAVE, REMOVE transitions. Each preserves the invariant. No reconciliation logic, no auto-fix-on-load, no "normalize" that fills in missing data on every read.
- Entity is created → assigned its identity (nanoid, never position-derived)
- Operation moves entity through lifecycle → state stays valid
- Migration of pre-canonical data is a separate explicit one-off pass
If you find yourself writing "fix on load" code, you have a state machine bug, not a safety-net opportunity.
3. No fallbacks. Strict invariants.
If data is wrong, throw. Don't paper over. Fallbacks hide bugs.
The only exception: explicit boundary helpers that take pre-canonical data and produce canonical data — and these run ONCE at the boundary (a one-off migration script), not on every read.
4. Tests are the spec, not regression guards
Behavior tests describe what the product DOES. Writing the test forces the writer (or agent) to surface invariants. Default to MAX strictness; relax only when an assertion is demonstrably wrong, not when it's inconvenient.
"The purpose of testing is to build consciousness and awareness."
5. Link = identity. No auth.
Anyone with the workspace URL has equal edit rights. No login, no permissions, no roles. This is intentional — it's how multi-device + multi-agent collaboration works without auth surface.
6. Multi-cooperator first. Single-user is a degenerate case.
Humans + AI agents work on every part of a workspace together. CV bullets, cover letter prose, narrative content, kanban moves, scoring tweaks — all of it has multiple authors at once. Friends, family, mentors, and agents are first-class collaborators, not occasional visitors. Per-job admin work is mostly single-operator but multi-operator convening happens routinely.
The common misread. Most CV/resume tooling models its data as single-author content (one user editing their resume privately). That assumption silently leaks into schema choices — fields end up as Y.Map scalars (last-write-wins; concurrent edits drop one user's keystrokes) instead of Y.Text (CRDT merge; every author's input is preserved). Don't model CareerVector that way. Even when the dev team is currently the only user, the product is collaborative-editor-shaped from day one.
Implication at the data layer: every collaborative-prose field uses Y.Text. Y.Map scalars are reserved for structured facts (job title, salary, status, format settings, locale codes) where LWW is correct because there's one right answer at any moment. Document content — bullets, headings, summaries, narrative, cover letter prose — is Y.Text by default. If you're adding a new text field and you're not sure, it's Y.Text.
Architecture must support this from the foundation, not as a retrofit.
7. Agent API parity
Every action a human can take in the GUI must have a programmatic equivalent. Without this, agent + human cooperation breaks at the capability boundary. Plan an MCP server early.
8. Client compute is the default
Typst rendering, LLM calls (via cascade), CRDT op application — all client-side. Workers should be thin: REST endpoints, the realtime broker, MCP server. Each request well under 10ms CPU.
9. Storage is cheap. Bandwidth is small (CRDT deltas are tiny).
Don't optimize away from D1/R2. Do dedup external API calls (singleflight pattern is fine).
10. Conventions over configuration
Meta-framework (SvelteKit) decides routing, data loading, deploy. We don't reinvent. Less surface area for agents to make wrong calls.
11. Everything is a Node
Every document component is a Node in the recursive tree (see #16 and TREE-OF-CHOICES.md). No special parameters, no side-channel data, no separate types for personal info or variant groups. If it renders, it's a Node. The serializer is one loop with archetype dispatch.
Top-level section variants are also Nodes: they live inside variant-pool wrapper Nodes whose own selection.active[] chooses which member renders.
12. One owner per data class
D1/Y.Doc owns private workspace documents and workspace facts: CVL, CL, workspace jobs, evaluations, custom columns, settings, notes, and local summaries. The realtime worker is a live relay, not durable authority.
CrateDB owns shared role/ad knowledge: jobcache, public role/ad facts, shared work cache, embeddings, matching, evidence, and reducer-approved aggregate outputs. (CrateDB Cloud replaced Cockroach as the hot DB in the 2026-06 cutover — see CRATEDB-MIGRATION.md.)
Private workspace data does not leave D1 by default. Cross-boundary movement requires a named reducer/exporter or a product-permitted candidate artifact.
13. Presence is feature-complete
The upper-right indicator and chat are deliberately tuned. Don't extend presence/activity UI. Don't add "who's editing what right now" overlays, expanded panels, agent-identity markers. Route new collaboration through existing surface.
14. Chesterton's fence — don't refactor what works
If a load-bearing pattern LOOKS ugly, the right reaction is to ask "what bug or edge case did this solve?" not "let me clean it up." Code that exists in production through bug-fix iteration represents knowledge nobody can fully articulate. Replacing it means re-deriving edge cases that won't surface until production.
The cascade chains, the xstate pipeline machine, pipeline-derived UI states, the section type registry, _underscoreFields — all have apparent "cleanups" available, all of which were considered and rejected. They're working. Don't touch them as a side-quest.
If something feels weird, write the observation in LEARNINGS.md for a future focused refactor with proper test coverage. Don't smuggle it into an unrelated change.
"Weeks of debugging can save hours of design." — apply in reverse: hours of preserving battle-tested design saves weeks of debugging.
15. Selection cardinality is policy by actor class, not absolute law
selection.minCount / selection.maxCount (and any analogous cardinality field) are enforced differently depending on who is driving the operation:
| Actor | Enforcement |
|---|---|
| System (any server-side automation — LLM stage, commute, scrape, regex enrich, score recompute, …) | Strict reject. No bypass. |
| Human via UI | No enforcement. The UI displays current / bounds as honest state; the user sees the result live and self-corrects. |
| External agent (MCP) | Rejects when violated unless force: true is set on the op. Explicit override required. |
"System" is the unified term for all server-internal automation — pipeline-LLM stages (extract, enrich, evaluate, tailor) are a subset, alongside non-LLM service writers (commute via Google Maps, posting-metadata via regex, score recompute, …). The provider/service that produced the value lives in actor_id (<provider>:<details>, e.g. groq:llama-3-70b, google-maps:directions), not in a separate sub-class.
The broker classifies the actor by connection origin and applies the right enforcement. The op envelope carries an optional force?: boolean flag that is only honored for agent (MCP) connections.
Root-level maxCount (section count cap) and page-limit enforcement are separate concerns. A maxCount does not guarantee a page count — that derives from Typst render output.
16. Tree of choices
Every CV/CL is a recursive tree where each node has a pool of candidate children and a selection (which to render and in what order). selection.active[] is the generalized choose n out of m primitive; one-out-of-many variants are just the maxCount=1 case. The same logic applies at every depth: section choice, item variants, bullet alternatives. OPEN-tier nodes generate fresh text from a prompt instead of choosing from a pool.
See TREE-OF-CHOICES.md for the full design record, including the Node shape, option comparison, op surface, and endian warning.
17. CV and CL are unified — never split at the type, op, or tool layer
CV and CL are both Node trees. The tree shape is identical; there is no kind: 'cv' | 'cl' discriminator on Nodes, on ops, or on MCP tools. The op takes a branch_path whose root segment names the profile (cv_profile.<lang>.tree vs cl_profile.<lang>.tree); the op does not care which.
Concrete rules:
tailored.snapshot,node.create,node.update, etc. take abranch_path— never akinddiscriminator.- One editor component (CVL) renders any tree. Not "a CV editor" and "a CL editor."
nodeToTypst()runs on any Node tree regardless of profile origin.- MCP tools that operate on a tree accept a path, not a
kind. Don't add CL-specific twins of CV-specific tools.
Anti-patterns to reject:
tailored.snapshot({ kind: 'cv' | 'cl', ... })— add thebranch_path, not the discriminator.- Separate
update_cv_sectionandupdate_cl_sectionMCP tools. if (kind === 'cv') doc.getMap('cv_profile')branches — usedoc.getMap(path.split('.')[0]).
Workspaces have separate cv_profile and cl_profile Y.Map roots (different containers). The unification is at the type, op, and UI layers — not at the storage container layer.
18. No identity inference — every call is explicit
The server never infers workspace, language, user, file, or any other identity from session state, environment variables, or implicit defaults. Every endpoint, tool, and op requires explicit identifiers.
Concrete rules:
- Every MCP tool input schema requires the relevant identifier(s) (
wsId,branch_path,jobId, etc.). Reject calls missing them. - Every REST endpoint resolves identity from explicit URL params or body fields. No cookies, no sessions, no
WORKSPACE_IDenv var consumed by the server. - No "set current workspace" tool. No server-side session defaults.
Agent layer is free to be smart. An MCP client can cache list_workspaces, prompt the user once, infer from chat context, or hold a default in its own config. That is the agent's problem, not the server's.
Why: inference of identity creates footguns — agents and users silently target the wrong workspace, the wrong file, the wrong job. kubectl --context is the canonical bad example. Same posture as #1 (dumb server / smart client): server stays thin and explicit.
19. Browser verification is mandatory
Every user-visible change must be verified in a real browser session before it is considered done. Typechecks, unit tests, and route-level tests are necessary but not sufficient: they do not prove that Svelte hydration, client state, realtime wiring, layout, focus, modals, canvas/PDF preview, or browser-only APIs actually work.
Use Playwright or an equivalent browser session against local wrangler dev / Miniflare by default. Deployed-worker verification is allowed only when the Cloudflare quota policy permits it. Capture screenshots or browser-observed evidence for UI, routing, persistence, realtime, and visual changes.
If browser verification is impossible in a session, say so explicitly and treat the work as unverified, not complete.