System topology
One core package holds the domain and application logic. Everything else is a delivery mechanism around it: a Next.js app, an Electron shell, a Python package. The teal lane needs a network; the amber lane never does.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"14px","clusterBkg":"#eaf0f1","clusterBorder":"#ccd9dc"}}}%%
flowchart TB
subgraph clients["Clients"]
WEB["Web app / PWA
Next.js 14 App Router"]
DESK["Desktop app
Electron 33"]
SDK["Python SDK
training scripts"]
MCPC["MCP clients
Claude, editors"]
end
subgraph shared["@weaveforge/core"]
DOM["domain
pure rules, no I/O"]
APP["application
use cases, ports"]
PORTS["ports
repositories, blobs, auth"]
end
subgraph backends["Backends (pick one)"]
SUPA["Supabase
Postgres + Auth + Storage"]
PG["Self-hosted Postgres
OCI + object store"]
PGL["PGlite in the folder
no account, no network"]
end
WEB --> APP
DESK --> APP
SDK -->|"HTTP /api/sdk/*"| WEB
SDK -->|"HTTP 127.0.0.1:27123"| DESK
MCPC -->|"stdio / relay"| WEB
MCPC -->|"stdio"| DESK
APP --> DOM
APP --> PORTS
PORTS --> SUPA
PORTS --> PG
PORTS --> PGL
SUPA --- SCHEMA["119 SQL migrations
+ row-level security"]
PG --- SCHEMA
PGL --- SCHEMA
The schema is the contract. The same migrations create the hosted database, the self-hosted one, and the one inside the user's folder — no second definition exists.
Three ways to run it
- Hosted —
NEXT_PUBLIC_BACKEND_PROVIDER=supabase, Supabase auth and storage. - Self-hosted —
provider=postgres, server-side blob store, migration scripts undernpm run migrate. - Local — desktop app, PGlite in the chosen folder, no account at all.
What crosses the wire
- Hosted: PostgREST calls from the browser under RLS as the signed-in user.
- Local: nothing. Verified by a driven run that records every request the window makes.
- SDK: bearer token to one of two hosts, one of which is loopback.
Repository tree
npm workspaces. packages/* and apps/* are the workspaces;
python/ ships separately to PyPI; supabase/migrations is
shared by all of them.
weaveforge/
├── packages/core/src/ 48,362 lines — the only place rules live
│ ├── features/ 19 slices: papers, experiments, plan, report,
│ │ logbook, library, vault, wiki, org, sharing,
│ │ collab, tags, relations, reading-lists,
│ │ projects, dashboard, settings, integrations,
│ │ ai-assistant
│ ├── backend/ auth ports, session ports, admin provisioner
│ ├── database/ local-postgres (PGlite) helpers
│ ├── storage/ blob ports, tiering, content types
│ ├── search/ reader/ paste/ cross-cutting engines
│ ├── config/ defineConfig + plugin contract
│ └── shared/ clock, dates, three-way-merge, tree, repository
│
├── apps/web/src/ 150,449 lines
│ ├── app/ 26 pages + 37 API routes
│ ├── features/ 31 slices — core's 19 plus the web-only ones:
│ │ offline-sync, sync, workspace, editor-workspace,
│ │ auth, search, export, overleaf, reader, startup,
│ │ legal
│ ├── backend/ provider wiring: supabase | postgres | local
│ └── deployment/ generated registry — the one composition root
│
├── apps/desktop/src/ 10,869 lines, 41 files, main + preload only
│ ├── main.ts preload.ts channels.ts
│ ├── local-db.ts local-db-host.ts PGlite in the main process
│ ├── local-api-server.ts local-api.ts 127.0.0.1:27123
│ ├── local-sdk-api.ts local-mcp.ts SDK routes + 8 MCP tools
│ ├── vault-folder.ts vault-watch.ts vault-git.ts vault-handlers.ts
│ ├── auto-update.ts update-check.ts app-menu.ts app-protocol.ts
│ └── secret-store.ts preference-store.ts auth-loopback.ts zotero-local.ts
│
├── apps/pitch/ 803 lines — public site, GitHub Pages
│
├── python/weaveforge/ 3,196 lines + 1,523 lines of tests
│ ├── features/{experiments,papers,projects}/{domain,application,infrastructure}
│ ├── sync/ tensorboard · wandb · matplotlib · registry
│ ├── integrations/ lightning · keras callbacks
│ └── tracking.py container.py cli.py
│
├── supabase/migrations/ 0001 → 0132, 6,970 lines
├── scripts/ 53 files — boundary gates, migration, seeding
├── plugins/weaveforge-research/ stdio MCP server, outside every workspace
└── docs/ 87 files, 29,370 lines
Feature graph
Features are vertical slices, not layers. A slice owns its domain types, its use
cases, its repository implementation and its UI. Slices talk to each other through
relations and tags rather than by importing each other's internals —
which is what check:solid enforces on every PR.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px","clusterBkg":"#eaf0f1","clusterBorder":"#ccd9dc"}}}%%
flowchart LR
subgraph intake["Bringing work in"]
PAPERS["papers"]
LIBRARY["library"]
READER["reader"]
LISTS["reading-lists"]
end
subgraph doing["Doing the work"]
EXP["experiments"]
LOG["logbook"]
PLAN["plan"]
VAULT["vault"]
WIKI["wiki"]
end
subgraph out["Getting it out"]
REPORT["report"]
EXPORT["export"]
OVERLEAF["overleaf"]
end
subgraph fabric["Connective tissue"]
REL["relations"]
TAGS["tags"]
SEARCH["search"]
DASH["dashboard"]
end
subgraph people["People & access"]
PROJ["projects"]
ORG["org"]
SHARE["sharing"]
COLLAB["collab"]
SETT["settings"]
end
subgraph machine["Machine surfaces"]
AI["ai-assistant"]
SYNC["offline-sync"]
INTEG["integrations"]
end
PAPERS --> READER --> LOG
LIBRARY --> PAPERS
LISTS --> PAPERS
EXP --> REPORT
LOG --> REPORT
PLAN --> DASH
VAULT --> WIKI
REPORT --> EXPORT
REPORT --> OVERLEAF
REL -.-> PAPERS
REL -.-> EXP
REL -.-> LOG
REL -.-> PLAN
TAGS -.-> PAPERS
TAGS -.-> VAULT
SEARCH -.-> PAPERS
SEARCH -.-> VAULT
SEARCH -.-> LOG
PROJ --> ORG --> SHARE --> COLLAB
AI -.->|"proposals only"| PAPERS
AI -.->|"proposals only"| LOG
SYNC -.-> PROJ
INTEG --> LIBRARY
Solid arrows: a slice produces material the next one consumes. Dotted: cross-cutting services that attach to many slices without owning any of them.
Where each slice lives
The first three columns are where the code for a slice is written, not whether it runs:
a dash under Core means the slice has no packages/core module, which
is the ordinary case for anything that is only a screen. No account is the
column about capability — what the slice does on a desktop copy with the network
unplugged, where the database is PGlite on the same machine.
| Slice | Core | Web | Python | No account | What it owns |
|---|---|---|---|---|---|
| papers | yes | yes | yes | yes | Literature records, fields, notes, arXiv/DOI intake |
| experiments | yes | yes | yes | yes | Runs, step-indexed metrics, artifacts, comparison |
| plan | yes | yes | — | yes | Milestones, dependencies, timeline |
| report | yes | yes | — | yes | Sections, drafts, word counts, progress |
| logbook | yes | yes | — | yes | Dated entries, meeting notes |
| vault | yes | yes | — | yes | Markdown notes on disk, folder interop |
| library | yes | yes | — | local files | Files, blobs, tiering, previews |
| sharing | yes | yes | — | no | Grants, supervision, RLS-backed access |
| ai-assistant | yes | yes | — | needs a provider | 13 tools, proposal queue, provider config |
| offline-sync | — | yes | — | n/a | Outbox, puller, merge, conflicts, adoption |
| editor-workspace | — | yes | — | yes | Editing surface, paste and drop handling |
| overleaf | yes | yes | — | link and edit | Report push to an Overleaf project; link rules and the LaTeX section tree in core, so a copy with no account shares them |
Anatomy of a slice
Every slice in core and in the web app has the same four directories, and the dependency arrow only ever points inward. A domain file that imports a repository is a build failure, not a code review comment.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"14px","clusterBkg":"#eaf0f1","clusterBorder":"#ccd9dc"}}}%%
flowchart LR
UI["ui/
React components
knows use cases"]
APP["application/
use cases + ports
knows domain"]
DOM["domain/
types, invariants, pure functions
knows nothing"]
INF["infrastructure/
repository impls
implements ports"]
TEST["test/
in-memory fakes
no database needed"]
UI --> APP
APP --> DOM
INF -. "implements" .-> APP
TEST -. "substitutes" .-> INF
Enforced, not suggested
check:solid— layer direction, slice isolation, single composition root.check:dry— patterns already centralised in a helper cannot reappear inline.check:ui— shared controls instead of raw elements, so keyboard and ARIA behaviour stays in one place.check:api-route-tests— everyroute.tsmust have a colocated test.check:hygiene— one rule per bug the repo has already been bitten by.check:deployment-surface— nothing enters the bundle that the deploy config did not select.check:mcp-plugin— drives the stdio MCP server the way a client does.
Why the fakes matter
Because the repository is a port, the whole application layer is testable with in-memory doubles. That is what lets the suite run on a PR with no database, no account, and no secrets — and why schema and RLS tests could be moved out of the opt-in job and onto every pull request.
Online read & write
In the hosted configuration the browser talks to Postgres directly through PostgREST, under row-level security, as the signed-in user. Next.js API routes exist only where a request needs a secret, a third party, or a decision the browser must not be trusted to make.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px"}}}%%
sequenceDiagram
autonumber
participant U as User
participant UI as Feature UI
participant UC as Use case
participant R as Repository
participant DB as Postgres + RLS
participant API as Next API route
U->>UI: edits a paper field
UI->>UC: updatePaperField(id, field, value)
UC->>UC: validate against domain rules
UC->>R: save(paper)
R->>DB: PostgREST PATCH with the user's JWT
DB-->>DB: RLS: owner or an active grant?
DB-->>R: row or 403
R-->>UI: updated entity
Note over API,DB: routes only for secrets and third parties
UI->>API: /api/arxiv, /api/fetch-url, /api/blobs/*
API->>DB: service-role work the browser must not do
The 37 API routes, by why they exist
| Group | Routes | Reason it is not a direct query |
|---|---|---|
| SDK | whoami, projects, experiments, metrics, artifacts | Token auth for a non-browser client |
| blobs | upload, content, signed-urls, remove | Storage credentials and tiering policy |
| org | create, join, leave, codes, memberships, mine, switch, standalone | Membership changes need service-role writes |
| account | delete-user, delete-user/otp | Destructive, so it takes a second factor |
| fetch | arxiv, fetch-url, pdf-proxy, url-meta | Outbound requests and CORS |
| settings | api-tokens, mcp-tokens, credentials | Tokens are hashed server-side and shown once |
| mcp | mcp, mcp/relay, mcp/relay/browser | Fail-closed gateway; see the AI section |
| overleaf | connections, reports, report content | Third-party credentials |
| integrations | zotero/collections | Third-party credentials |
| admin | create-user | Provisioning, service-role only |
Offline sync engine
The web app keeps working with no network and reconciles afterwards. Writes queue in an outbox; a pump drains it when connectivity returns; a puller brings down what changed; a three-way merge decides the rest and only escalates what it genuinely cannot.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px","clusterBkg":"#eaf0f1","clusterBorder":"#ccd9dc"}}}%%
flowchart TB
EDIT["local edit"] --> OUTBOX["outbox.ts
ordered, idempotent entries"]
OUTBOX --> PUMP{"pump.ts
online?"}
PUMP -->|"no"| OUTBOX
PUMP -->|"yes"| TRANSPORT["postgrest-transport.ts"]
TRANSPORT --> REMOTE[("remote row")]
REMOTE --> PULLER["puller.ts
changed since cursor"]
PULLER --> MERGE["merge.ts
three-way against the base"]
MERGE -->|"clean"| STATE["sync-state.ts
cursor advances"]
MERGE -->|"divergent"| CONFLICT["conflicts.ts
surfaced to the user"]
ADOPT["adoption.ts
a local-only workspace signs in"] --> OUTBOX
SCOPE["offline-scope.ts
what is eligible to cache"] --> PULLER
BLOBS["blob-cache.ts"] --> SCOPE
Adoption is the hard case
A folder that has never signed in already holds real work. When an account arrives,
that work is not a conflict to resolve — it is the base. adoption.ts replays
it through the outbox so the server sees ordinary writes, in order, with the same
idempotency the normal path has.
Desktop process map
The renderer is the same Next.js bundle, exported statically and served from the
app://weaveforge protocol. It has no Node access. Everything privileged —
the database, the folder, secrets, the loopback server — lives in the main process
behind named IPC channels.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px","clusterBkg":"#eaf0f1","clusterBorder":"#ccd9dc"}}}%%
flowchart TB
subgraph rend["Renderer — sandboxed, no Node"]
NEXT["Next.js static export
app://weaveforge"]
end
BRIDGE["preload.ts
window.weaveforge
one bridge object, 26 members"]
subgraph main["Main process"]
DB["local-db.ts
PGlite + the 131 migrations"]
FOLDER["vault-folder.ts
read / write / list / stat"]
WATCH["vault-watch.ts
notices outside edits"]
GIT["vault-git.ts
commit history"]
SECRETS["secret-store.ts
OS keychain"]
OVERLEAF["overleaf-source.ts
clones a linked project
with the keychain token"]
PREFS["preference-store.ts"]
SERVER["local-api-server.ts
127.0.0.1:27123"]
MCP["local-mcp.ts
list_workspace · read_entry · search_workspace"]
UPDATE["auto-update.ts"]
MENU["app-menu.ts"]
end
DISK[("The chosen folder
markdown + PGlite data")]
OUTSIDE["Obsidian, an editor,
a training script"]
NEXT <--> BRIDGE
BRIDGE --> DB
BRIDGE --> FOLDER
BRIDGE --> SECRETS
BRIDGE --> OVERLEAF
OVERLEAF --> SECRETS
BRIDGE --> PREFS
BRIDGE --> SERVER
DB --- DISK
FOLDER --- DISK
WATCH --- DISK
GIT --- DISK
OUTSIDE -->|"edits files"| DISK
WATCH -->|"vault-changed"| NEXT
OUTSIDE -->|"bearer token"| SERVER
SERVER --> MCP
SERVER --> DB
The IPC surface
Teal: pushed from main to the renderer. Amber: turns the loopback server on and reports its state.
Two things that only recently worked without an account
The editor. Its realtime channel is built from configuration compiled
into the app, not from whichever database the app is talking to — so a copy with no
account opened it anyway, failed on every keystroke, and showed a "live sync
unavailable" notice under a document nobody else can open. The provider now takes
live, and the editor passes live: !isLocalMode(). Solo means no
transport, not no history: the CRDT log is still written.
Overleaf. Linking a report does three jobs and only one needs a server:
the row is an ordinary row, the section tree is a pure core function, and only
the token needs somewhere safe. So the token moved to the OS keychain, the clone moved
into the main process behind overleaf-read, and the clone code is the
web app's own reader — imported through the esbuild @ alias, not copied.
Viewing a report still needs a network: that is a clone from overleaf.com.
Offline is a property, not a mode switch
Two separate questions, deliberately kept apart in the code:
isOfflineBuild() asks which build this is; isLocalMode() asks
whether this window has an account. A packaged build with an account still syncs;
a hosted build with no session still must not offer "Sign out".
Folder & vault interop
The desktop app does not own a private store. It owns a folder the user picked, in plain Markdown, which other tools may edit while the app is running. That constraint is what the watch, the merge, and the git history exist to survive.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px"}}}%%
sequenceDiagram
autonumber
participant OB as Obsidian
participant FS as The folder
participant W as vault-watch
participant APP as WeaveForge window
participant G as vault-git
OB->>FS: saves note.md
W->>W: sees the write, ignores its own
W->>APP: vault-changed(path)
APP->>FS: re-read note.md
APP->>APP: three-way merge against the last known text
alt no local edit since
APP->>APP: adopt the newer copy
else both changed
APP->>APP: keep both, mark the conflict
end
APP->>G: commit, so the history survives the mistake
Available today
- Markdown notes read and written in place, wikilinks preserved.
- Outside edits noticed and merged, not clobbered.
- Git history for the folder from inside the app.
- Loopback REST on the port Obsidian's REST plugin already uses.
- Eight MCP tools over the same server.
Deliberately not available
- Figures and artifacts offline — there is no blob store in a folder, and a half-working upload is worse than an honest gap.
- Supervision and sharing routes in the desktop bundle — both are about other people's copies, so they are not shipped and not linked.
AI and MCP boundary
The assistant has thirteen tools. Three read. Ten propose, and a proposal is a row in
a review queue, never a write. /api/mcp is fail-closed: it answers 404 when
MCP is off and 503 with the manifest when it is on, because live access goes through
the browser relay with an active session grant.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px","clusterBkg":"#eaf0f1","clusterBorder":"#ccd9dc"}}}%%
flowchart TB
CLIENT["MCP client"]
DIRECT["/api/mcp
404 when off · 503 + manifest when on"]
RELAY["/api/mcp/relay
MCP token from Settings"]
GRANT{"active browser
session grant?"}
RULES{"user's aiAccess
ruleset"}
subgraph read["Read tools — results are untrusted text"]
T1["search_workspace"]
T2["get_source_excerpt"]
T3["get_workspace_outline"]
end
subgraph propose["Propose tools — require confirmation"]
P["propose_append_paper_note
propose_create_vault_note
propose_create_log_entry
propose_paper_update
propose_paper_field_value
propose_reading_list_change
propose_relation
propose_zotero_import
propose_milestone_follow_up
propose_experiment_follow_up"]
end
QUEUE["/ai-review
the user accepts or discards"]
DATA[("workspace")]
CLIENT --> DIRECT
CLIENT --> RELAY
DIRECT -.->|"never live"| CLIENT
RELAY --> GRANT
GRANT -->|"no"| CLIENT
GRANT -->|"yes"| RULES
RULES --> read
RULES --> propose
read --> DATA
propose --> QUEUE
QUEUE -->|"only on accept"| DATA
Read results are somebody else's writing
Every read tool returns library content — other people's papers and notes. The
manifest marks those results untrusted, and a transport must pass them through
mcpReadResult rather than sending them raw. Shipping them raw contradicts
the manifest rather than quietly omitting a wrapper.
The desktop app runs its own smaller server: list_workspace,
read_entry, search_workspace — read-only, loopback-only, bearer
token compared with timingSafeEqual. A separate stdio server lives in
plugins/weaveforge-research/ and is driven by check:mcp-plugin
in CI, because nothing else imports it.
Python SDK & W&B
The SDK is the same architecture in another language: domain, application, infrastructure, with an API client as the outer edge. It does not know or care whether the address it was given is a hosted deployment or the desktop app on loopback.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px","clusterBkg":"#eaf0f1","clusterBorder":"#ccd9dc"}}}%%
flowchart TB
SCRIPT["training script
@track_experiment / with track(...)"]
RUN["Run
log_metric · log_metrics · log_figure · set_status"]
BUF["buffered points
flushed in batches"]
CLIENT["ApiClient
bearer token"]
subgraph targets["One of two hosts"]
HOSTED["hosted app
/api/sdk/*"]
LOCAL["desktop app
127.0.0.1:27123"]
end
subgraph mirror["Mirror — live second home"]
WB["wandb
online with a key,
offline without"]
end
subgraph sources["Sync sources — import a finished run"]
TB["tensorboard"]
WBR["wandb (read)"]
MPL["matplotlib figures"]
end
CB["Lightning / Keras callbacks"] --> RUN
SCRIPT --> RUN
RUN --> BUF --> CLIENT
CLIENT --> HOSTED
CLIENT --> LOCAL
RUN -->|"every point, as it happens"| WB
RUN -->|"set_status → finish once"| WB
TB --> RUN
WBR --> RUN
MPL --> RUN
The mirror's two promises
- It never breaks training. The first failure logs a warning, switches mirroring off, and the run carries on writing where it always was.
- It never prompts for a login. With no
WANDB_API_KEYit starts in W&B's offline mode and lands in a local directory towandb synclater.
Pointing it at the desktop app
WEAVEFORGE_API_URL=http://127.0.0.1:27123WEAVEFORGE_TOKENfrom Settings → Let other apps inWEAVEFORGE_PROJECT="My Thesis"- Nothing else changes — same
track(...), same curves, no account.
Measured against a populated folder database
| What was driven | Result |
|---|---|
| 25 experiments created over the loopback API | 111 ms |
| 100,000 metric points ingested | 7,478 ms |
| Median flush / slowest flush | 71 ms / 135 ms |
| Re-ingest of an existing batch | 1,333,011 → 1,333,011 |
| 10 concurrent flushes | all 200, 229 ms |
| Requests that left the machine | 0 |
Re-ingest is idempotent because experiment_metrics is a view whose insert
trigger upserts on (experiment_id, metric_id, step).
Data layer
131 numbered migrations, applied in order, by all three backends. Access control is in the database rather than in the app: a missing check in a React component is a bug, a missing RLS policy is a breach, so the policy is the one that decides.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px"}}}%%
erDiagram
PROJECTS ||--o{ PAPERS : holds
PROJECTS ||--o{ EXPERIMENTS : holds
PROJECTS ||--o{ LOG_ENTRIES : holds
PROJECTS ||--o{ MILESTONES : holds
PROJECTS ||--o{ REPORT_SECTIONS : holds
PROJECTS ||--o{ VAULT_NOTES : holds
EXPERIMENTS ||--o{ EXPERIMENT_METRIC_POINTS : records
EXPERIMENTS ||--o{ ARTIFACTS : produces
PAPERS ||--o{ PAPER_NOTES : annotates
PAPERS }o--o{ READING_LISTS : listed_in
RELATIONS }o--|| PROJECTS : scoped_to
TAGS }o--o{ PAPERS : labels
ORGS ||--o{ MEMBERSHIPS : grants
MEMBERSHIPS }o--|| PROJECTS : reaches
SHARES }o--|| PROJECTS : opens
Local identity
With no account there is still a user, because RLS still applies:
00000000-0000-4000-8000-000000000001. Signing in later adopts that
work rather than importing it.
Metrics are a view
experiment_metrics is a security_invoker view over the
points table, with an INSTEAD OF trigger that upserts. Re-sending a run is
therefore free rather than duplicating every curve.
CI & gates
Six workflows. One runs on every push and pull request; the rest are opt-in, scheduled, or release-triggered. The rule is that anything needing a secret is opt-in and everything else runs on the PR.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px","clusterBkg":"#eaf0f1","clusterBorder":"#ccd9dc"}}}%%
flowchart TB
PR["push / pull request"]
subgraph ci["ci.yml"]
BT["build-and-test"]
DCO["dco
every commit signed off"]
PY["python-sdk
ruff · mypy · pytest"]
FV["full-validation
workflow_dispatch only"]
end
subgraph steps["build-and-test, in order"]
S1["build core → core tests"]
S2["web unit tests"]
S3["schema + RLS on in-process Postgres"]
S4["editor paste tests in Chromium"]
S5["typecheck"]
S6["check:boundaries — all seven gates"]
S7["lint"]
S8["build web"]
S9["check:deployment-surface"]
end
OTHER["verify.yml — sharing-rls · sdk-integration · ts-settings-contract
pages.yml — pitch site
release-desktop.yml — installers + update feed on v*
publish-python.yml — PyPI on py-v*
android-twa.yml · android-fingerprint.yml"]
PR --> BT --> S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8 --> S9
PR --> DCO
PR --> PY
FV -.->|"manual"| OTHER
DCO, and the one way to fix it
Every commit needs a Signed-off-by trailer naming its own author email.
When the check fails the remedy is git rebase --signoff origin/main then a
force-push with lease — not a new empty commit.
Build & release
The desktop build has an order, and the order is not optional: the esbuild step wipes
dist, so exporting the web bundle first throws it away.
%%{init: {"theme":"base","themeVariables":{"background":"#f4f7f7","primaryColor":"#e4eef0","primaryTextColor":"#101619","primaryBorderColor":"#0d6f78","lineColor":"#5f7178","fontFamily":"IBM Plex Sans, sans-serif","fontSize":"13px"}}}%%
flowchart LR
A["scripts/build.mjs
esbuild main + preload
wipes dist/"] --> B["scripts/build-web.mjs
Next static export → dist/web"]
B --> C["electron-builder --dir
--dir, never the installer"]
C --> D["release/win-arm64-unpacked/
WeaveForge.exe"]
D --> E["installed by copying the folder"]
E --> F["auto-update.ts checks
and swaps in place"]
Why --dir and a folder copy
The NSIS installer writes to the Windows registry. This project's packaging deliberately does not: the app is built unpacked and installed by copying a directory, so uninstalling is deleting a folder and nothing outside it is ever touched.
Everyday commands
| Command | What it does |
|---|---|
| npm run dev | Web app in development |
| npm run check:all | Typecheck, lint, all seven boundary gates, core + web + integration + desktop tests, build |
| npm run check:boundaries | The seven gates alone — the fast pre-PR pass |
| npm run test:desktop | Desktop main-process suite |
| npm run build:desktop | esbuild step only; run the web export and electron-builder after it |
| npm run migrate | Full move to a self-hosted Postgres: schema, data, blobs, verify |
| npm run seed:showcase | Populate a workspace with demo material |