Durable facts for this repo, shared by every assistant setup. Each entry = one or two tight lines. Claude Code imports this file at session start; Kilo Code loads it through .kilo/kilo.json. General rules are harness-specific: Claude Code's live in .claude/CLAUDE.md and its mode skills (.claude/skills/mode-*/SKILL.md), Kilo's in .kilo/agent/working-partner.md and .kilo/skills/mode-*/SKILL.md. Add new entries inline as ##. Keep this small.
Every table is written down twice and both are kept. The canonical CREATE TABLE lives in Rust on a SchemaGroup next to its code (edit in place; builds a fresh database). One SQL file per change under crates/weft-task-store/migrations/<group>/ carries an existing database forward; each database records what it ran in weft_migration, so any old database reaches today and two branches converge whichever way they merge. The crate's build script walks that directory, so a file being there IS its registration: nothing lists it, no Rust changes, and it travels with the crate into every build context except a worker's (left_out_of_workers, the migrate feature), so a migration rebuilds no program. NEVER hand-write a migration and NEVER edit a released one (the boot refuses it, checksummed). The flow: change the DDL, then ./setup.sh --migration <name> --release. ONE command, always: it writes the released file, runs it on your database, and records it as applied, whether or not a draft came first. NEVER tell the [user] to run two. The draft pass (./setup.sh --migration <name> with no --release) exists only for iterating on a table across several edits (a gitignored DRAFT under drafts/, applied as you go, your data kept); the release then collapses every draft into one file. Each group also has a frozen origin.sql (its DDL at ship time, written by the generator in the same run; the generator reads the history off disk, the boot only its embedded copy); the schema_agreement test replays origin+released (drafts excluded, so an unreleased draft fails CI) and compares against the canonical build. Changing the DDL with no migration fails the boot printing reset SQL. The generator splits a change into <id>.sql (expand: adds, nullable, a key on a new sequence column) and <id>.contract.sql (drops, NOT NULL, CHECK/UNIQUE); a function whose arguments changed is remade in the expand (no DEFAULT on new ones). The boot runs pending expands, then carries the documents, then (one transaction) the groups' data steps (DataStep: idempotent work on rows already there, SQL or Rust (StepWork::Sql / StepWork::Code, the Rust for what SQL cannot read, such as a run's record, read in bounded pages), run while its column is not yet what the contract makes it, Until::Dropped(column) to copy out a dropped column, Until::NotNull(column) to fill a column made NOT NULL, Until::Indexed(index) to merge rows a new unique index would refuse), pending contracts and the shape check. The history has restarted twice (each time every origin.sql re-frozen, older migrations deleted, the epoch named in weft_migration's _history row): at the move off Kubernetes, and at the execution→run rename. Each restart is a clean break: the boot refuses a database from before it, and scripts/scrub-old-install.sh wipes that one.
JSON a column keeps is versioned too: every long-lived column is a Document in weft_runtime::schema::documents (one list), its type's steps (weft_core::stored::Op: rename/add/drop key, rename tag, Within, Code) written BY HAND next to the type, one committed sample file per version under crates/weft-runtime/documents/<table>.<column>/. Changing a stored type's shape: append a step, add v<N+1>.json (the same documents, carried); the every_document_sample_reads_as_todays_type test fails until you do. A Document is built from its type's Kind (Document::of); a dropped column leaves the list, and if its content lives on elsewhere, a DataStep (Until::Dropped(column)) on its new home's SchemaGroup copies it. Every row carries its document's version in <column>_version (INTEGER NOT NULL, no default; the migration backfills 1; every INSERT binds STORED.version(), readers go through STORED.decode, typed DecodeError plus .at(row)). The boot rewrites rows behind in key-order batches from the recorded version (the version column is the cursor; weft_document_version records the version each column was carried to, refusing one newer than this build), runs a step's Fill first when the step moves a value out of the JSON of a column that stays (keyed by the row's version), and re-derives hashes through rederive. A stored program keeps its definition hash through an upgrade. examples/*.json carry specVersion and the CLI rewrites an old one on read.
[Update Notice Warning] If we touch crates/weft-task-store/src/schema_guard.rs or crates/weft-task-store/src/documents.rs, revisit this entry.
Four tiers, one role each; the broker is not a tier but the scoped door to Postgres for every role except the dispatcher. Listener: holds signals and hands their fires on (the dispatcher calls it; a fire that starts a run goes straight to the project worker's door, parked through the broker when the worker is unreachable, and only an answer to a waiting run goes back as a fire_signal task); pooled + tenant-agnostic; never decides/spawns/touches the journal. A signal whose kind keeps a connection open (BetweenFires::Holds, decided per signal at /prepare, stored as signal.holds) is held by a holder: the listener's code where something stays up, claiming rows by lease through the broker (weft_listener::hold); on GCP a worker pool the dispatcher sizes from the held rows, locally the one process. Supervisor: pooled + tenant-agnostic; exclusive infra_owner lease per project so one supervisor applies a project's infra at a time, through the platform's InfraHost. Dispatcher: pure routing + lifecycle + placement; persists to Postgres; never executes node logic. Worker: runs the compiled project binary, one per program, multiplexes runs, idle-shuts-down. Listener is the kind-aware processor (kinds live in crates/weft-listener/src/kinds/); dispatcher is pure transport and acts on a kind-agnostic ProcessTarget (weft_core::signal::listener_protocol). New signal kinds = listener code only; never a dispatcher-side kind branch.
Dispatcher replicas are stateless coordination-wise: Postgres is the single source of truth, process RAM is just optimization. Ownership = DB leases (any replica claims an expired one). Reconnect to a sibling replica must rebuild from DB (no state across the gap). Before adding an in-RAM mutex/dashmap/counter on DispatcherState, ask "what if a sibling replica handles the next request?": if that means stale reads/lost updates, it belongs in Postgres. N replicas is the target; a single replica is the degenerate case.
The language is generic: compiler/dispatcher/engine/shared infra never contain node-specific knowledge (no hardcoded field names, no if node is X). Nodes do no plumbing: a node's code only does its own logic; translating it into language concepts (triggers, URLs, suspensions, sidecar manifests) is the language's job. When a node needs something the language lacks, build the general mechanism first, then have the node use it through the ctx. The same holds for WORDS: docs, Tangle prompts and code comments outside a node's own folder teach the general mechanism, and may name a node only as an example of it ("a node that keeps a growing value takes a file, like LlmInference's conversation"), never as the subject ("LlmInference's historyFile does X"). A sentence that only makes sense for one node belongs in that node's own metadata.json description, where the editor and Tangle read it.
RunId (weft_core::RunId = Uuid) IS one run: minted at RunStarted, spent on terminate; a re-run is a NEW id. No retry-across-runs, no per-run attempt cap (a give-up-after-N concept would be per-PROJECT, out of scope).
Real workflows (LLM pipelines, infra builds, human-in-the-loop, runs) can run hours/days. No deadlines on user-controlled waits (build/deploy/infra/run/drain/user node code); make stuck-state legible (periodic breadcrumb + a documented recovery like Ctrl+C / weft stop). Deadlines only on internal service-to-service waits the user can't control.
When a shape changes (a stored document, a wire message, a node's metadata, a config key), weft never accepts both the old and the new. Updates happen in maintenance, so an old and a new weft never run side by side: weft maintenance on, update, weft maintenance off, which builds each project it brings back again on the new weft from the request its last build kept (project.running_build_inputs) (setup.sh and the GCP workflow refuse through weft maintenance check while anything is on; the e2e runner wraps its own setup in it). What is stored is migrated to the new shape (the Schema entry). What is running on the old shape (a project not yet resynced) is never accommodated and never restarted by the update: it fails loudly with an error telling the user to run weft resync (or the one command that brings it up to date), weft status shows what is behind, and the release's CHANGELOG says which command to run.
The folder a command runs from picks the install: on main or outside any weft checkout it is the default install (~/.local/share/weft); on any other branch it is that branch's own (~/.local/share/weft/installs/<name>, <name> the branch when it is already a valid install name, else one made from it: fix/journal-holes gives fixjour plus 5 hex of its hash); on a detached HEAD a command that acts on an install is an error (one that needs none, like weft validate, runs). Never a fallback: a branch whose install is not set up is an error naming ./setup.sh. WEFT_INSTALL=<name> overrides from anywhere (default names the main one); weft daemon which says which install and dir a command acts on. scripts/add-worktree.sh <new-branch> makes the branch and its worktree; ./setup.sh inside it sets up that branch's install, and tests and scripts run there follow it. scripts/remove-worktree.sh removes the worktree and its install. To try a branch's VS Code extension, scripts/dev-editor.sh opens one extra window running it from source; setup.sh on a branch installs no editor or browser extension. .env, .env.extension and access-apps.json are linked into every worktree; .env.local is per checkout and wins over .env, so a setting that must differ per branch goes there.
A lasting public address for a branch: ask the [user] to run cloudflared tunnel login once, and ask which domain to use (recommend one that is not their main one), then do the rest yourself: cloudflared tunnel create weft-<install>, cloudflared tunnel route dns weft-<install> <random 4-6 chars>-<install>.<domain>, the token from cloudflared tunnel token weft-<install>; write WEFT_PUBLIC_TUNNEL_TOKEN=<token> and WEFT_PUBLIC_TUNNEL_HOSTNAME=https://<that hostname> (must be https, with no port, path or query) into that checkout's .env.local, then ./setup.sh --public-url. Without them, --public-url reopens the named tunnel this install last ran (kept in <install dir>/tunnel.env until --no-public-url), else a random quick tunnel. Two installs on one tunnel token are refused.
[Update Notice Warning] If we touch crates/weft-core/src/infra/install.rs, setup.sh or scripts/run-e2e.sh, revisit this entry.
Writing one: an e2e is the last resort. If layers 1 to 3 can prove the behavior, the test goes there and no e2e is written. Each e2e must prove something no other e2e already proves: a mechanism (buses, files, a resync, a trigger's life) end to end. A node is never a reason for an e2e by itself; one is earned only when the node exercises a mechanism no other e2e covers. Before adding one, check whether an existing e2e already goes through that path, and extend it or a lower layer instead.
You run these yourself, every time the code is done (not only at the end of a session), after the unit tests, scripts/run-db-tests.sh, clippy, svelte-check and pnpm run compile for the change are green. Background each one.
- Install:
./setup.sh, plus--public-urlwhen<install dir>/public-url-enabledexists (its tunnel is on). A change only to the runtime can skip this: the e2e runner runssetup.sh --cli --daemonfirst anyway. - Pick the tests: ONLY the e2e tests you added plus the ones that exercise the behavior you changed, each named
file::test(never a whole file when one test covers it; one test per changed path is enough), even at the very end of a session. The same holds forscripts/run-db-tests.sh <crate> <test-name>: named tests only. Never the whole suite, even for a change in a shared tier: some tests spend real money, and a full run is the [user]'s call alone. A test that costs money is fine to run when it is one of those; never run it otherwise. - Run:
scripts/run-e2e.sh --keep-going <file> <file::test> ...(always name the tests; nothing after the flags runs everything, which you never do). Size-j(tests at once, default 4) to the machine you are on: check its cores and free memory (nproc,free -g) and whether other sessions are building or testing, since too many at once fail under load. On a large machine with nothing else running, go higher (Quentin's 24-core machine runs-j 12when this session is the only one working); on a small one, or beside other sessions, stay at 4 or lower. Compile lanes default to 4, raiseWEFT_COMPILE_LANESonly with disk to spare (about 2GB each, plus a seed of the same size, folders under~/.local/share/weft/worker-target/shared by every install on the machine;weft daemon starthands it to the runtime, so the e2e runner's own install picks it up). One e2e run at a time per machine: a second waits for the first. - Until green: read the failing test's log and
.post-mortem/under<install dir>/e2e/run/(the default install's is~/.local/share/weft/e2e/run/;weft daemon whichprints the dir), fix the cause (a flaky test is a bug in the code under test, UNLESS the failure is in the local rig itself: docker, the machine's load; then rerun that one test alone, and if it passes, it is done: never chase a rig flake), thenscripts/run-e2e.sh --keep-going --failed, which runs only what has not passed yet. When nothing is left, rerun the whole selection from step 2 once, so the fixes are proven not to have broken what passed before them. - The runner ends every run (pass or fail) with
weft clean --images --all, which also drops every compile cache no install on the machine uses; if you stopped a run by hand or ran e2e binaries yourself, run it before you finish. A long session of rebuilds otherwise leaves tens of GB behind. The e2e README (crates/weft-e2e/README.md) has the runner's flags and how to read a failure. Where each one logs, to hand the [user] when you background it: the background output file,<install dir>/e2e/run/<file>::<test>.log, the machine-wide~/.local/share/weft/setup-runs.log. Never wait on a runner withuntil ! pgrep -f "<script>":pgrep -fmatches the loop's own command line, which contains that text, so the loop never ends (this once cost an hour). No single e2e test runs longer than 5 minutes: the runner stops it and reports itSLOW. [Update Notice Warning] If we touchscripts/run-e2e.sh,setup.sh's flags or the worktree scripts, revisit this entry.
The scripts already share the machine (scripts/lib/machine-lock.sh: one heavy cargo build and one e2e run at a time), so a session runs its tests as wide as the machine allows: nextest's own thread count, e2e -j sized as above. Throttling (nice/ionice, fewer build jobs or test threads, e2e -j 1) is only for a wave of several implementer sessions building at once, where overlapping disk work once crashed the machine; only those briefs say so. setup.sh, scripts/run-db-tests.sh, scripts/run-node-tests.sh and scripts/run-e2e.sh take the build slot themselves: wrapping one in with_build_slot waits on itself forever.
[Update Notice Warning] If we touch scripts/lib/machine-lock.sh, revisit this entry.
tangle/<assistant>/ holds one independent copy of the Tangle persona, skills, specialists and commands per assistant (eleven folders: the ten named ones plus fallback/). They start identical and are MEANT to drift as each is tuned for its host: no shared source, no SYNC markers, no CI check, and deduplicating them is wrong. The line: what Tangle IS must hold in every folder (shape the graph first, read a node's real interface, keep a level small, fail loudly, report plainly); how it is SAID may differ per assistant. fallback/ installs its root AGENTS.md ONLY when no named assistant matched, because Cursor, Cline and Gemini read a root AGENTS.md on top of their own persona file rather than instead of it.
[Update Notice Warning] If we touch crates/weft-cli/src/commands/new.rs (the ASSISTANTS table), revisit this entry.
Before building anything, answer all three, and say so. Levers: what a person can reach and from where (a node setting, a CLI flag, a graph control, a ctx call, a metadata.json key, a word in the language). Name the lever and where it lives; a knob nobody can turn is not a lever, a knob nobody needs is clutter. Defaults: what everybody gets without asking, which is the part nobody should have to know exists. Two questions: is it tedious and useful to make somebody fill it in every time, and is there a value right for almost everybody? Yes to both, set it and keep the lever. Never invent a default to paper over a setting that honestly depends on the case: if leaving it unset is legitimate, it stays explicit and optional and the VALIDATION is what makes the bad shape impossible. A hidden default that is right half the time is worse than a refusal naming what is missing. Protection: as long as there is a way to build it properly, the bad shape is refused and the message points at the right one, at the earliest place that can see it (compiler, parser, a node's metadata rules, and a runtime floor where nothing earlier can). An obvious footgun (an infinite loop, a run that waits forever on somebody gone) must be impossible to configure, not warned about. Nodes are written by users, so a problem found in a node (a costly pattern, a wrong shape) is fixed with a weft default or protection that makes any node written that way right; only where weft cannot, the docs and the Tangle prompts teach the right pattern. The full wording: docs/src/thinking/how-we-decide.md.
The architecture was reworked to be lean and fast, and it stays that way. A bug is fixed by reshaping so the fast path stays fast and the bug is impossible, never by bolting a check, a retry, an extra query or an extra hop onto it. Before adding anything a common path pays for (a query per call, a lock, a round trip, a per-run write, a wider transaction), ask the [user] first, with what it costs and the alternative.
weft is serverless: an install nobody uses runs nothing and its database sleeps. Every change is checked against that, including what calls in from outside (the editor, the CLI, a frontend, a holder): no timer, poll, held connection, self-wake or background loop may keep a part up, or the database awake, for good while nothing real is happening. Something reasonable that stays up while a person is actually using it is fine (an open editor, a running run), as long as it ends on its own once they stop. If a change could keep something up indefinitely, ask the [user] first.
PascalCase: node types, custom types. camelCase: node names in a program, ports, settings, record fields, every metadata.json key, connection value names, and every JSON field weft produces (API answers, live events, CLI --json, program-call answers, saved spec files). snake_case: values of a fixed set (statuses, kinds, event tags, edit ops), the language's own words (@per_instance, @require_one_of, the _should_flow gates), .weft files and folders, weft.toml keys (and the worker levers that mirror them), SQL, URL query parameters. kebab-case: CLI commands and flags. SCREAMING_SNAKE_CASE: environment variables. Rust and TS keep their own (ctx.run_id in Rust, runId on the wire, via #[serde(rename_all = "camelCase")]). A Rust type that is purely weft-internal (task payloads, run records, broker wire) keeps Rust's snake_case fields. A name a third party defines (an OAuth parameter, a provider's field) keeps its spelling. A node whose added ports are named after outside data (a body's fields, a table's columns) sets features.addedPortsAreDataKeys, and its ports keep that data's spelling. Enforced by the catalog's spelling.rs (a warning for every node it loads, a project's own included; tests/casing.rs holds the stdlib to none) and the compiler's not-camel-case warning, both through weft_core::casing.
[Update Notice Warning] If we touch crates/weft-core/src/casing.rs or crates/weft-catalog/src/spelling.rs, revisit this entry.
When talking about weft, use the names weft itself uses (node, bus, port, run, signal, listener, trigger, infra, group, include, scope, gate, the node and flag names as written), never a synonym of your own ("step" for node, "pipe" or "channel" for bus...). A renamed concept reads as a different system the [user] does not know, and every one costs a round of "is this the same thing?". If weft has no name for something, say so and describe it plainly instead of coining one.