Repository atlas · branch feat/obsidian-vault-interop

WeaveForge Atlas

Every surface, every path a byte can take, and every gate it passes on the way out. Three fronts share one core and one schema: a web app on Supabase or Postgres, a desktop app that owns a folder and a database on disk, and a Python SDK that writes into either one.

150,449web app lines
48,362core lines
10,869desktop lines
3,196python sdk
131migrations
31web features

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

  • HostedNEXT_PUBLIC_BACKEND_PROVIDER=supabase, Supabase auth and storage.
  • Self-hostedprovider=postgres, server-side blob store, migration scripts under npm 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.

SliceCoreWebPythonNo accountWhat it owns
papersyesyesyesyesLiterature records, fields, notes, arXiv/DOI intake
experimentsyesyesyesyesRuns, step-indexed metrics, artifacts, comparison
planyesyesyesMilestones, dependencies, timeline
reportyesyesyesSections, drafts, word counts, progress
logbookyesyesyesDated entries, meeting notes
vaultyesyesyesMarkdown notes on disk, folder interop
libraryyesyeslocal filesFiles, blobs, tiering, previews
sharingyesyesnoGrants, supervision, RLS-backed access
ai-assistantyesyesneeds a provider13 tools, proposal queue, provider config
offline-syncyesn/aOutbox, puller, merge, conflicts, adoption
editor-workspaceyesyesEditing surface, paste and drop handling
overleafyesyeslink and editReport 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 — every route.ts must 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

GroupRoutesReason it is not a direct query
SDKwhoami, projects, experiments, metrics, artifactsToken auth for a non-browser client
blobsupload, content, signed-urls, removeStorage credentials and tiering policy
orgcreate, join, leave, codes, memberships, mine, switch, standaloneMembership changes need service-role writes
accountdelete-user, delete-user/otpDestructive, so it takes a second factor
fetcharxiv, fetch-url, pdf-proxy, url-metaOutbound requests and CORS
settingsapi-tokens, mcp-tokens, credentialsTokens are hashed server-side and shown once
mcpmcp, mcp/relay, mcp/relay/browserFail-closed gateway; see the AI section
overleafconnections, reports, report contentThird-party credentials
integrationszotero/collectionsThird-party credentials
admincreate-userProvisioning, 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

db-query vault-root vault-choose vault-list vault-read vault-write vault-remove vault-stat vault-commit vault-forget vault-changed secret-read secret-write secret-clear preference-read preference-write local-api-set local-api-state check-update sign-in fetch-title fetch-image zotero-local overleaf-read

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_KEY it starts in W&B's offline mode and lands in a local directory to wandb sync later.

Pointing it at the desktop app

  • WEAVEFORGE_API_URL=http://127.0.0.1:27123
  • WEAVEFORGE_TOKEN from Settings → Let other apps in
  • WEAVEFORGE_PROJECT="My Thesis"
  • Nothing else changes — same track(...), same curves, no account.

Measured against a populated folder database

What was drivenResult
25 experiments created over the loopback API111 ms
100,000 metric points ingested7,478 ms
Median flush / slowest flush71 ms / 135 ms
Re-ingest of an existing batch1,333,011 → 1,333,011
10 concurrent flushesall 200, 229 ms
Requests that left the machine0

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

CommandWhat it does
npm run devWeb app in development
npm run check:allTypecheck, lint, all seven boundary gates, core + web + integration + desktop tests, build
npm run check:boundariesThe seven gates alone — the fast pre-PR pass
npm run test:desktopDesktop main-process suite
npm run build:desktopesbuild step only; run the web export and electron-builder after it
npm run migrateFull move to a self-hosted Postgres: schema, data, blobs, verify
npm run seed:showcasePopulate a workspace with demo material