Ctrl K
ComunidadGuías…

DEVELOPMENT.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Weave development

GitHub is the canonical repository. Pull requests replace the Gerrit review queue.
Agents work in isolated branches and run targeted checks locally. A ready pull
request lands on main after a fast `Dev gate` and deploys to dev.weave.grunion.ai
within a minute. The full test inventory runs on main. The `promote` workflow
moves production to the newest main commit that passed both the `CI gate` and
the dev soak.

## Daily flow

1. Query the weave Development tracker before work and add the regression test
   before the fix. Keep one concern per worktree and coordinate file ownership.
2. Push the feature branch to origin early and open a GitHub pull request; the
   `PR gate` runs the tests on GitHub-hosted runners. Record the tracker
   reference, changes and test evidence. Never push directly to main. Local runs
   through the shared test manager are optional, for debugging a failure. Read
   the complete diff, including lifecycle, undo, CLI, API and MCP parity. Check
   both themes for UI changes and update the Handbook where behavior changes.
3. Self-review, then arm auto-merge: `gh pr merge --auto --squash`. Two checks
   are required: `Dev gate` (syntax plus a boot health check, under a minute)
   and `PR gate` (the `pr` workflow, under six minutes). The branch does not
   have to be up to date with main.
4. Fix a red `PR gate` by pushing again; a new push cancels the superseded run.
5. Every main push swaps dev.weave.grunion.ai to that commit. The dev supervisor
   boots the new commit beside the serving one, moves traffic only after its
   health check passes, and keeps the old worker serving when it fails. A failed
   commit is skipped until main moves. Roll back a bad change by landing its
   revert. The dev service runs `weave supervise` with `WEAVE_AUTO_UPDATE=1`,
   `WEAVE_TRACK=main` (follow main commits into `releases/c<sha>/`; after every
   swap, failed health check and boot the supervisor deletes every folder under
   `releases/` except the serving commit and the most recently served good one,
   kept for rollback, and never touches `releases/state.json`) and
   `WEAVE_DEPLOY_TOKEN` (32+ characters; `POST /_supervisor/check` with that
   bearer token checks main at once, which the `Deploy dev` job calls).
   `WEAVE_TRACK=branch:<name>` follows another branch, such as a `production`
   promotion branch, with the same `c<sha>` folders and deploy hook. Migrated draft PRs stay drafts until their own review finishes.
6. After every switch the supervisor watches the new release for
   `WEAVE_WATCH_MS` (default ten minutes). The serving worker exiting, three
   failed `/api/health` polls in a row, or a 5xx share of proxied responses at
   or above `WEAVE_WATCH_5XX_RATE` (default 0.25, over at least 20 responses)
   marks the release failed and swaps back, through the same health-checked
   path, to the kept rollback folder, or the image when none is kept.
   `lastSwap.rollback` names the trip and `/api/health` shows
   `supervisor.watch` with `until` and `ok`. A release is written good
   (`.weave-release-ok`) only when its watch passes, so a restart mid-watch
   boots the last proven release; a boot that fails falls back to the newest
   good `v<version>` or `c<sha>` folder before the image.
   Migration rule: a release whose deferred migration wrote to any workspace in
   the data directory (`PRAGMA data_version` moved across the migrate step) is
   never rolled back automatically. It is written good at once, and a later
   trip logs an `ALERT` and sets `watch.alert` for a person to act on; old code
   never runs on a schema a newer release migrated.
7. Before it downloads a release the supervisor reads the free space on the
   releases volume. Under `WEAVE_RELEASE_FREE_MB` (default 128) it fetches
   nothing, records `lastSwap.ok: false` with the free space in the reason, and
   tries again on the next check; `/api/health` shows `supervisor.disk` with
   `freeMB` and `totalMB`. Grow the Railway volume when that number runs low.
8. `railway.json` sets `build.watchPatterns` to `/Dockerfile`, `/railway.json`
   and `/src/supervisor.js`. A push to main that touches none of them builds
   nothing on Railway, so the one-volume container restarts only for an image
   or supervisor change; workers move through the supervisor swap.

## Verification

The `pr` workflow runs on every ready pull request. Its plan, `node
scripts/ci-test.mjs pr-plan`, reuses the full-suite planner: every unit file in
four shards, plus the browser tests the diff maps to. A diff that touches
`public/app.js`, `public/style.css`, `src/engine.js`, `src/routes.js`,
`src/commands.js` or a `test/lib/` file that a browser test imports, directly or
through another helper, maps to every gating browser test, spread over eight
shards as on main and weighed by file size (Feature #368). The plan finds those
helpers by following each browser test's import statements, so there is no list
to keep up to date. Any other diff keeps the name and reach selection, sharded
at about twenty files each. A changed browser test runs itself. Any other changed
file maps to browser tests named for its module (`public/chip-core.js` maps to
`test/chip-*-browser.test.mjs`) or whose source cites its file name. The diff
also maps by what it names: the plan reads the changed blocks with
`git diff -W`, takes every dashed or slashed literal of six characters or more
out of them (`sheet-grip`, `/api/tables/subtotals`), and adds each browser test
whose source names one. A literal more than `REACH_SPREAD` browser tests name
is too common to place a change, so it is dropped, and the reached tier stops
at `REACH_CAP` files beyond the name matches, which holds the gate inside its
twenty-minute job timeout. A change to any other public or server module
therefore runs the journeys that name the DOM classes and routes it touches,
which file names alone never reach (Issue #1075). A fixed smoke set (`SMOKE` in
`scripts/ci-test.mjs`) always runs, so a diff nothing maps to still boots the
app in a browser. `PR gate` succeeds only
when every shard succeeded and `pr-aggregate` finds executed evidence for every
planned file at the exact commit. A newer push to the same pull request cancels
the running one. Every browser job installs through `node scripts/ci/install-browsers.mjs
<browser...>`, the one copy the `pr`, `tests`, `budgets` and dev soak workflows
share. It writes an apt drop-in (twenty-second acquire timeout, three retries, a
300-second `DPkg::Lock::Timeout` so an attempt outlasts the runner's own
boot-time `apt-get`, Issues #1230, #1141, #1107), installs the browsers from the
`~/.cache/ms-playwright` cache, and then asks Playwright's own
`install-deps --dry-run` whether every system package is present. When one is
missing it installs the `.deb` files kept in `~/.cache/weave-apt` with one
`dpkg -i`, which never reaches an Ubuntu mirror and waits up to 300 seconds
for a dpkg lock the runner's own `apt-get` holds. `apt-get install
--no-download` of the same files is the wrong tool: it re-resolved them against
the image's package lists and asked to fetch (PR run 42654813034). That
cache is keyed on the runner image (`ImageOS` and `ImageVersion`), the pinned
`PLAYWRIGHT_VERSION` and the browser set, so a new runner image never installs
another image's packages. Only when the cache is empty, or leaves a dependency
missing, does the script run `playwright install --with-deps` against the
mirror: three attempts with `dpkg --configure -a` between them, reading its
result, after which it copies the downloaded packages into the cache for the
next run. Before Issue #1438 every browser job took that mirror path, and a slow
mirror hour timed the step out at 14 minutes on main 13 times between
2026-11-05 and 2026-11-09. Every path ends on the `install-deps --dry-run`
check, so a browser or system package that is still absent fails the step
loudly, and the log line says no test ran. The only deadline is the step's own
`timeout-minutes`, 12 on the `pr` workflow and 14 elsewhere; an inner
per-attempt `timeout` was removed on 2026-11-04 because it killed attempt one
mid-download (Issue #1347). `test/ci-install-browsers.test.mjs` and
`test/ci-test.test.mjs` hold these rules.

When a shard dies before any test records a result, the sheriff has no file to
name, so it opens or appends to a row named after the shard. That line names the
step the job died in, so an install timeout and a shard that crashed in its test
step no longer read the same, and an install failure never adds a line to a
per-file flake row (Issue #1353). The step name comes off the `steps` the jobs
API already returns with each job, so it costs no extra call.

The `security scan` check downloads gitleaks, actionlint and hadolint from the
GitHub release CDN, and the release workflow downloads the MCP publisher the
same way. Each download retries across 120 seconds
(`--retry 6 --retry-delay 20`), because a four-attempt budget spread over six
seconds fit entirely inside one 504 blip on 2026-11-09 and, since the check is
required, took every pull request in the repository down with it (Issue #1490).
The three scanner downloads run before the first checksum under an ERR trap, so
a scanner that cannot be fetched is annotated as a network failure instead of
reading as a finding in the code, and a bad SHA256 pin is still reported as
itself. `test/security/workflows.test.mjs` holds that floor for every
release-asset download in every workflow.

The `tests` workflow runs the full inventory on every main push. Its `plan` job
downloads the `ci-results` artifacts of the most recent successful main run and
weights each test file by the `duration_ms` the reporter recorded for it, so the
eight browser shards balance on measured runtime and all ten shards start at
once (`max-parallel: 10`). Four browser shards ran 15 to 19 minutes each by
2026-11-08 and six of seven retried main runs that week were a shard killed at
the test manager's 20-minute job timeout; eight bring each to about nine
(Issue #1351); a file with no recorded
duration keeps the byte-size weight, and a run with no usable history (expired
artifacts, a first run, a download failure) plans entirely by byte size. Byte
size is anti-correlated with runtime for the worst offenders, because the
slowest browser files are small files that loop:
`test/render-budget-browser.test.mjs` is 1,682 bytes and ran 164 seconds in run
41959983156, where `test/setup-flows-browser.test.mjs` is 52,749 bytes and ran
119. On that run's inventory the byte-weighted plan gave the browser shards
1,171, 1,107, 1,939 and 1,622 seconds of work; the duration-weighted plan gives
them 1,460 each (Issue #966). The shards and the `CI gate` rebuild the same
manifest from the same weights: the plan job publishes what it downloaded as
the run's `plan-history` artifact and both take their copy from there, so a
divergence can only come from a failed download, and `aggregate` then refuses
the shard whose `manifest` digest does not match rather than passing a plan
nobody ran. Every step that reaches for the durations tolerates their absence in
the shell rather than through `continue-on-error`, which a killed step never
reaches. Its final
`CI gate` succeeds only after every required job succeeds, and a release can
only publish a main commit whose `CI gate` succeeded. Browser engines are installed on
the browser lanes only, in both workflows: a unit shard opens no browser, and the
two unit shards of run 39439094335 spent 46 and 37 seconds downloading Chromium
and WebKit they never used (Issue #967). `test/ci-test.test.mjs` holds the lane
condition and refuses a unit-lane test that reaches a browser at load.
A missing browser or skipped required browser suite fails the gate. Parallel CI
jobs have isolated runners and test data. Superseded branch runs are canceled.
Each main push holds its own concurrency lane, keyed by the commit under test.
A shared lane keeps one pending run at a time, so the next merge cancelled the
gate queued behind it: eight merges in the half hour from 18:32Z on 2026-10-25
left nine consecutive main gates cancelled and the deploy watcher two hours
behind the tip (Issue #1181). A merge rate above the gate's nineteen-minute
runtime now costs parallel runs instead of a stalled deploy.

Local runs are for debugging: `npm test -- --targeted <file...>` reproduces a
red shard, and CI owns verification. Never invoke bare `node --test`; empty
selections must fail before Node starts. The shared test manager serializes
local browser work across worktrees.
Use `npm test -- --status` to inspect the queue and cancel only your own job.
Browser assertions use `styleOf`, `settled` or `atRest` from
`test/lib/browser.mjs`, never fixed sleeps. `settled` reads the animations
running right now, so it answers at once when the interaction has cleared an
inline transform and the transition it starts has not been resolved yet.
`atRest(locator)` is the wait for that: it holds until the element has no
running animation, its transform is identity and its box has stayed put for a
frame (Issue #901).
`WEAVE_CPU_THROTTLE=4` helps reproduce gate-only browser failures.
Retries remain visible in test output; repeated flakiness gets a tracker issue.
A test that needs a scratch directory calls `tempDir(prefix)` from
`test/lib/temp.mjs`, which removes every directory it opened when the process
exits. `test/temp-dirs.test.mjs` refuses a bare
`mkdtempSync(join(tmpdir(), ...))` anywhere under `test/`, because a suite run
outside the manager, such as a bare `node --test` or a browser suite that only
takes a browser lease, keeps the caller's own `TMPDIR` and leaves those
directories on the disk (Issue #1083).

Each lane is spawned with `WEAVE_KEYSTORE` and `WEAVE_DATA` pinned into that
lane's own temp directory, so a test that builds a `Weave` without a keystore
path or runs the CLI without `--data` lands in the sandbox instead of the
developer's `~/.weave` (Issue #949). `test/test-runner.test.mjs` fails when a
lane resolves either default under the real home directory.

### Sheriff

`.github/workflows/sheriff.yml` owns a red main (Feature #320). A failed main
push `tests` run gets its failed jobs rerun by `scripts/sheriff.mjs`. Only
the rerun's `CI gate` conclusion decides guilt, and only `success` and `failure`
count: a rerun that ends `cancelled`, `timed_out` or `skipped`, or whose
`CI gate` job never ran, carries no verdict (Issue #1139). The sheriff reruns
the failed jobs once more and, with still no verdict, skips the run and leaves
the next red main run to judge that commit. The `tests` concurrency group
cancels a rerun whenever a newer main commit pushes, and the cancelled rerun
used to read as a second red.

A green rerun is a flake: it appends a line to the Issue row
`Error: <test file> flakes on main`, creating it if none is open, and stops. A
second red finds the commits since the last main run whose `CI gate` succeeded
and bisects them by dispatching `tests` with its `ref` input, at most four
runs. Completed runs for a commit are reused for free. A probe that goes red on
a test file other than the one holding main red says nothing about this red, so
the sheriff escalates with both file lists instead of narrowing to that commit.
It checks once more that
main has not gone green on its own, then opens a `sheriff/revert-<sha>` PR with
squash auto-merge, comments on the original PR, files
`Error: <test> fails on main after PR #<n>` with the run links, and writes
one alert line to the job summary and `SHERIFF_ALERT_URL`. Then it reads the
`Issue #<n>` and `Feature #<n>` references off each reverted commit's subject
and sets each of those rows back from `Fixed` to `Open`, or from `Shipped` to
`Building`, with a dated line naming the revert PR, so the reland is queued
instead of the row reading done while its fix is off main. A row in any other
state is left alone. The test it names is
a file still red after the in-run retry; a file whose shard ended before its
retry answered is listed under its own heading in the evidence instead. When the
run reached no retry verdict on any file and at least one file was red without
one, as a shard killed by its own timeout leaves it, the sheriff convicts
nothing: it escalates with that list and logs a `no-verdict` line on each of
those files' flake rows, because a shard that ended before its retry answered
says nothing about the commits under it (Issue #1322). A timed-out shard can
also leave no red file at all, when the manager killed it before any of its
files answered: `scripts/ci-test.mjs` records `timedOut` in a shard's evidence
when the manager exits 124, and a red held only by timed-out shards, with no
file red and none unverdicted, escalates the same way under `Error: tests
shards <ids> time out on main at <sha>`, since the gate conclusion alone
names no culprit. Past the cap it
reverts what is still suspect. A revert whose range took no bisect probe, the
single-commit range among them, says so in its subject, so main's history shows
how much evidence the revert carried. It never reverts a revert, never acts on a
culprit that already has a revert branch, never pushes to main and never
forces a push. Replay a run without writing anything:
`gh workflow run sheriff -f run_id=<id> -f dry_run=true`, or locally
`node scripts/sheriff.mjs <id> --dry-run`.

Secrets: `SHERIFF_TOKEN` (fine-grained token with contents, pull requests,
actions and workflows write, so the revert PR starts `Dev gate` and its merge
starts `tests`; without it the PR opens unarmed and the sheriff job fails), `WEAVE_TRACKER_TOKEN`
(a dedicated editor bearer for the canonical tracker; a missing token fails incident and flake recording) and the
optional `SHERIFF_ALERT_URL`, which receives the alert line as a POST body. When
`SHERIFF_ALERT_TOKEN` is also set, the POST carries `Authorization: Bearer <token>`;
the `weave-alerts` Worker refuses a request without it. The repository's
Actions setting must allow GitHub Actions to create pull requests when using
the built-in token. Keep the default workflow permission read-only; the sheriff
job declares its required write permissions. Scope `SHERIFF_TOKEN` to this
repository only. An escalation or unarmed revert fails the sheriff job after
writing its evidence. Read-only dry runs still succeed.

### Dev smoke and soak

`.github/workflows/dev-soak.yml` runs after the `dev` workflow's `Deploy dev`
job confirms a main commit is serving on dev.weave.grunion.ai. It runs
`scripts/dev-soak.mjs`: a Playwright smoke (load the app, open the `/w/weave`
workspace, open a table, open a document) every three minutes for fifteen
minutes, and dev's `/api/health` every thirty seconds. A failed smoke runs
again at once; a second failure in a row fails the soak. The soak passes when
the supervisor's `watch` for `c<sha>` reports `ok: true`, no rollback
happened, the worker's pid never changed and dev answered no 5xx. It then
posts the commit status `dev-soak` (success) on that SHA; promotion reads that
context. A failure posts `dev-soak` failure linking the run and sends one line
to `SHERIFF_ALERT_URL` and the job summary.

A new main merge never ends or restarts a window, because the soak exists to
review a batch. The job's concurrency keeps one running soak and one pending
run, and a newer pending run replaces the older one (it shows as cancelled), so
merges queue and the next window takes the newest commit. When dev moves to a
newer commit and main's head differs from the soak's SHA, the run keeps its
clock and its smoke schedule to the end of the window. Main is linear, so every
later commit dev serves contains the soak's SHA. From then on the pid check and
the SHA's own watch no longer apply. Each poll instead checks whatever release
dev serves now for a rollback, a watch alert or `watch.ok === false`, and a
release with no watch yet is fine. The run passes when the window has elapsed,
at least one smoke ran, 5xx stayed within `DEV_SOAK_MAX_5XX` and none of those
checks tripped, and it never waits for a watch. Its `dev-soak` success names
the commit dev moved to. Dev serving another commit while main's head still
equals the SHA stays a failure.

A failure after a move cannot be pinned on the SHA, so it posts `dev-soak`
pending described as superseded, with no alert, and promotion skips it. The
queued soak on the newest commit catches a real break and alerts. A smoke, 5xx
or health failure polls health once more first, so a swap the last poll missed
counts as a move. A rollback of a later release counts as a move even when dev
lands back on the soak's SHA. A rollback of the SHA itself is a failure.

With the `DEV_SMOKE_TOKEN` secret (an editor `wv_` token on dev) the smoke
also creates a row through the API, sees it in the grid, and hard-deletes it,
in the workspace named by the `DEV_SMOKE_WORKSPACE` variable (default
`/w/smoke`, which needs at least one table). The token goes only to that
workspace, since a token from another workspace answers 401 even on a public
page. Without it the smoke covers the anonymous read surface only. Run one smoke locally with
`node scripts/dev-soak.mjs smoke`, or soak a commit dev serves with
`gh workflow run dev-soak -f sha=<sha>`.

## Releasing

A release starts with a notes-bearing `Development/Release` row in the canonical
weave workspace on `:4400`: name `v<version>`, Date, Commit, Fixes and Ships relations,
and Description. Preserve this tracker contract before bumping `package.json`.

```bash
node scripts/export-development.mjs
node scripts/changelog-fold.mjs
node scripts/architecture.mjs
```

The fold also stamps the version into `server.json`. After the `publish` job tags
the release, `release.yml` pushes `ghcr.io/grunion-ai/weave:<version>` and
`:latest`, then publishes `server.json` to the Official MCP Registry with a pinned
`mcp-publisher` signed in through GitHub OIDC.

Once the version PR is merged, write the merged commit onto the Release row:

```bash
node scripts/release-commit.mjs --from https://weave.grunion.ai/w/weave \
  --token-env WEAVE_TRACKER_TOKEN --release v<version> --sha <merged sha>
```

That step appends one line to the notes rather than replacing the document, then
reads the row back and refuses when the notes came back empty or without the
commit. A release whose notes are lost by its own run is caught by that run
(Issue #1329). An older row that has lost its notes stops nothing: the export
names it, keeps the notes `docs/development.json` already carries, and carries
on.

### Canonical tracker

The `weave` docs workspace on production, https://weave.grunion.ai/w/weave, is
the one canonical tracker for Development/Issue, Feature and Release rows. Every
other server holds a read copy that boot syncs from `docs/development.json`.

- The canonical server sets `WEAVE_TRACKER_CANONICAL=1`. Boot then skips the
  manifest sync and logs one line saying so, so edits made on the tracker are
  never overwritten by an older build's manifest. A canonical server never
  forwards bug reports.
- Every other Grunion server sets `WEAVE_BUG_FORWARD_URL` to the workspace base
  (`https://weave.grunion.ai/w/weave`) and `WEAVE_BUG_FORWARD_TOKEN` to a bearer
  token for it. `POST /api/bug-report` then posts the report to
  `<url>/api/bug-report` with the instance's origin (`WEAVE_ORIGIN`, or the
  request host) as `instance`, which the canonical report shows as its Instance
  row. A forwarded report answers 201 with the canonical row and `forwardedTo`.
  A network error, a 10 s timeout or a non-2xx answer files the report locally
  as before, notes "Forward to <url> failed: <reason>" in it, and answers with
  `forward: "failed"` and `forwardError`. With either variable unset nothing is
  forwarded.
- Export reads the canonical tracker over HTTP:
  `node scripts/export-development.mjs --from https://weave.grunion.ai/w/weave --token-env WEAVE_TRACKER_TOKEN`.
  The file mode (`node scripts/export-development.mjs [path/to/weave.db]`) still
  works, and `--out <path>` writes somewhere other than `docs/development.json`.

Land the version, exported manifest, folded changelog and architecture map in one
PR. Between releases every change adds its own
`changelog.d/<short-slug>-<Issue or Feature number>.md`; only a version bump edits
CHANGELOG.md. The fold sorts and deletes fragments. A refactor or documentation
change without a release bullet carries `No-changelog: <reason>` in its commit.
The changelog and development-sync tests enforce these contracts.

Releases publish automatically after successful same-repository main push `tests`
runs and never move production. Retry publication by dispatching the
`release` workflow with that tests run ID.
Its read-only guard validates the same-repository event, run ID and completed
`CI gate`. Its publishing job checks out the exact tested SHA and checks GitHub
main again; a superseded run skips publication. The publisher validates the
version, matching tracker manifest and nonempty `## v<version>` changelog
section before creating an immutable `v<version>` tag and GitHub Release.
Existing published ancestor versions stay unchanged. A partial publication
resumes only if its tag names the tested commit; a conflicting tag fails and is
never moved. To revert a release, land a tested revert PR and cut a new version.
Historical recovery uses `gh workflow run release -f run_id=<id> -f historical=true`.
It requires a successful main push tests run for a commit still on main history.
It publishes that exact commit, leaves GitHub Latest and the image `:latest` alone,
and never moves an existing tag. Recover versions oldest first. A later ordinary
run uses the existing immutable tag for image and registry retries. Automatic
runs skip image and registry publication for an already published ancestor version.
Confirm the publication with `gh release list -R grunion-ai/weave --limit 1`.

### Production promotion

Production follows the `production` branch. The hosted supervisor at
https://weave.grunion.ai runs with `WEAVE_TRACK=branch:production` and a
`WEAVE_DEPLOY_TOKEN`, so it serves `releases/c<sha>/` folders and swaps, watches
and rolls back exactly as dev does (Daily flow, steps 5 and 6).

`.github/workflows/promote.yml` (Feature #322) moves the branch. It runs when a
`tests` or `dev-soak` run on main completes with success, so a commit promotes
as soon as the later of its two checks passes (Issues #1535, #965). The chain
`dev` (push) → `dev-soak` → `promote` is three `workflow_run` levels, the most
GitHub fires. A completion run acts at once, like `workflow_dispatch`, and
reads main itself instead of the event's SHA. The hourly schedule stays as the
recovery path: a scheduled run acts when the newest `production` deployment is
at least the repository variable `PROMOTION_EVERY_HOURS` (default 2) less half
an hour old. GitHub delivers scheduled runs late or drops them under load, so
that gate counts from the last promotion and never from the clock hour (Issue
#1493). When the pick was committed to main more than `PROMOTION_EVERY_HOURS`
ago, the promote job writes one alert line naming the lag before it moves
production, since the completion triggers should have taken it sooner. The
`promotion` concurrency group keeps one run at a time. `workflow_dispatch` acts
at once, and its `dry_run` input only prints the pick. `scripts/promote.mjs select` picks the
newest main commit that has a push `tests` run whose `CI gate` succeeded and a
`dev-soak` commit status of success, and that descends from the `production`
head. With none it exits quietly. The `promote` job then, in the `production`
environment:

1. pushes `<sha>:refs/heads/production` through the promotion deploy key
   (`PROMOTION_DEPLOY_KEY`), a fast-forward that is never forced;
2. calls `POST /_supervisor/check` with `PROD_DEPLOY_TOKEN`;
3. waits up to 25 minutes for `/api/health` to show `supervisor.commit` at that
   SHA, `lastSwap.ok` true and `supervisor.watch` for `c<sha>` with `ok` true.

A failed watch or a rollback fails the run, files the Issue row
`Error: production rolled back the promotion of <sha>`, writes one alert line
and opens a `promote/revert-<sha>` PR on main for the promoted range (armed when
`SHERIFF_TOKEN` is set). `production` is never rewound: it stays on the failed
commit while prod serves its rollback, and the next promotion waits for a main
commit that reverts it. A timeout files and alerts without a revert.

A promotion that passes while `/api/health` reads `auth.ok: false` stays
promoted and writes one alert line. A broken sign-in comes from provider
configuration, such as a rotated client secret, so holding the promotion
would only hold back a fix.

Set the repository variable `PROMOTION_PAUSED=true` to stop promotion; delete it
or set it to anything else to resume. Two rulesets guard the branch: nobody
deletes it or pushes a non-fast-forward, and only the promotion deploy key
updates it. Both secrets live in the `production` environment, which has no
required reviewer and admits main only.

After a promotion passes its watch, the job runs `node scripts/exposure.mjs`
(Issue #879). It reads every workspace name from `GET /_supervisor/workspaces`,
which the worker answers only to `Bearer <WEAVE_DEPLOY_TOKEN>`, then asks each
one for `GET /api/tables` with no credentials, by `/w/<name>/` and by
`<name>.weave.grunion.ai`. Any 200 fails the run, unless the workspace is named
in the repository variable `PUBLIC_WORKSPACES` (comma-separated). Run it by hand
with `WEAVE_DEPLOY_TOKEN` set to the production token.

Check a promotion:

```bash
gh run list -R grunion-ai/weave --workflow promote --limit 3
gh api repos/grunion-ai/weave/branches/production --jq .commit.sha
curl -fsS https://weave.grunion.ai/api/health
```

Never hand-deploy Railway after a routine landing. Container redeployment is
reserved for Node, Dockerfile or supervisor changes.

## Architecture map

`docs/architecture/` holds an interactive map of the runtime rendered by
[archify](https://github.com/tt-a1i/archify): `weave.architecture.json` is the source,
`weave.architecture.html` the rendered page, and every node links to its code at the
commit named in `meta.repository.revision`. `test/architecture-map.test.mjs` goes red
when a cited file moves or is deleted, when a new `src/` or `bin/` module is not named
on the map, or when the HTML no longer matches the JSON. The change that trips it
updates the JSON and runs `node scripts/architecture.mjs`, which re-pins HEAD and
re-renders. Release step 5 re-pins it too, so every tagged version carries a map of the
code it shipped. archify is a dev tool, found at `$ARCHIFY` or
`~/.claude/skills/archify/bin/archify.mjs`; weave never depends on it.

## Retired Gerrit queue

Gerrit and its review worker are retired from the landing path. Preserve their
repositories and open-change exports until migrated draft PRs are reconciled.
Do not run the old mirror or release publisher alongside GitHub delivery. Retained
Gerrit refs are historical recovery material, not an authority for new landings.

## Shared test manager

Use `npm test` for full verification and pass several filenames to batch related
regression checks. Every CLI request goes to one per-user manager shared across
worktrees. Selections of unit suites only run at once and in parallel, within the
admission limits below; a selection with any browser suite waits for the one browser
slot, which runs one job at a time. Each job runs `scripts/test.mjs`'s two lanes (unit
suites at node's default, browser suites at half the cores). A persistent browser
server supplies fresh Playwright connections and contexts to each browser suite;
closing one suite cannot close another suite's contexts. Direct browser-harness
callers also lease the slot. A suite that launches a browser imports
`test/lib/browser.mjs` or ends in `-browser.test.mjs`; the manager refuses a browser
to a unit-only job. `npm test -- --status` lists the slot holder under `active`, the
unit-only jobs under `unit` and waiting jobs, with their reason, under `queued`. Use
the CLI for predictable whole-job admission.

| Operation | Command |
| --- | --- |
| Batch targeted regressions | `npm test -- --targeted test/formula.test.mjs test/service.test.mjs` |
| Inspect affected selection | `npm test -- --affected --base=HEAD --plan` |
| Run affected selection | `npm test -- --affected --base=HEAD` |
| Landing check | GitHub Actions `dev` runs `Dev gate` on ready pull requests |
| Full verification | GitHub Actions `tests` runs every suite on main; `CI gate` reports the result |
| Queue and browser status | `npm test -- --status` |
| Cancel one queued or active job | `npm test -- --cancel=<id>` |
| Stop owned jobs and browser | `npm test -- --stop` |
| Bound one job, milliseconds | `npm test -- --timeout=120000 test/formula.test.mjs` |

Affected selection includes staged, unstaged and untracked files relative to the
base. Only changed tests narrow it; source, UI, configuration and unknown paths
require the full suite. The
printed plan explains the decision. Full landing verification remains mandatory.
An empty explicit selection fails. File lists must name existing files; use
`--test-name-pattern='pattern'` or its separate-value form to filter test names.

The manager hashes the paths a selection reaches at enqueue, start and finish: the
selected tests, the code and config paths the sheriff counts as reaching (`src/`,
`public/`, `bin/`, `test/`, `scripts/`, `agents/`, `tools/`, the workflows, the
Dockerfile and root config files), and any other top-level path that a selected
test, a module it loads or a script it names mentions. A changed file invalidates a
result only when it sits on one of those paths. Results are never cached, and duplicate
requests remain separate jobs. Keep those paths unchanged while tests run. An
agent may assess scope or propose redundant-test removal, but cannot omit required
verification. Existing `run()` imports are the low-level synchronous fixture API;
agents submit work through the CLI.

The browser recycles between jobs after 20 browser jobs or 15 minutes, after a
browser-slot job fails or is cancelled, and when resource pressure blocks admission
while the slot is free. The
manager exits after five idle minutes. It starts automatically on the next request.
Browser versions and browser kinds use separate lifetimes. `WEAVE_BROWSER=webkit`
uses the same queue. No npm dependency or launchd job is installed.

Admission waits below 10 GB available disk, below 10% available memory, or above a
one-minute load average of twice the CPU count. Status and waiting messages name
the reason. An admitted job times out after one hour by default, the gate's 3600 s
cap, which is also the maximum `--timeout`. Queue waiting expires after
`WEAVE_TEST_QUEUE_WAIT_MS` (default 30 minutes). A job that is never admitted, because
its wait ran out or the manager stopped first, prints
`# TEST MANAGER NOT ADMITTED: <reason>` and exits 75: its tests did not run, so a gate
skips its vote rather than rejecting. A disconnect cancels its job. A responsive
supervisor reaps the owned process group if the manager dies. Job temp directories
are removed after process cleanup; stale manager-owned temp directories older than
24 hours are pruned on startup. Other agents' files and processes are untouched.

`WEAVE_TEST_MIN_FREE_GB`, `WEAVE_TEST_MIN_MEMORY_PERCENT`, `WEAVE_TEST_MAX_LOAD` and
`WEAVE_TEST_QUEUE_WAIT_MS` override admission for a deliberately sized CI machine, or
for a caller that is already serial, such as a hosted CI runner. A raw `node --test`
browser suite sends them with its lease. Keep the defaults for ad hoc runs on this
shared workstation. One process holds one browser lease however many times its suites
call `launch()`, and releases it when the last connection closes.
`WEAVE_TEST_MANAGER_DIR` selects a private socket directory for isolated manager
integration tests; ordinary jobs use the shared default, which is keyed on a hash of
`scripts/test-manager.mjs`. Worktrees on the same manager code share one queue; a
worktree with changed manager code gets its own manager, and the old one exits after
five idle minutes. `--stop` cancels active work, so inspect `--status` first.

## Theme loops

A browser case runs in both themes only when it reads paint: a computed colour, a
contrast, a screenshot, a border or shadow, an icon or image. A theme cannot change a
geometry, a count, a class name or a text value, so a case that reads only those runs
once, in light. `test/theme-loop-guard.test.mjs` fails a `for (const theme of ['light',
'dark'])` loop whose body reads no paint (Issue #667).

## State catalog

`test/lib/states.mjs` is the one list of app states the runtime probes walk
(Issue #1119). A state names a route, a layout (`table`, `list`, `board`), an
open state (`journey`, `rest`, `overlay`, `hover`, `drag`), the seeds it
renders on, its cells and its themes. `PROBES` says which seed, open states,
cells and themes each probe measures, and `plan(probe)` is the list it walks:
the cut-off sweep, the flicker gate, the budgets, the a11y gate and the touch
gate all read it. `test/lib/journeys.mjs` and `test/lib/overlays.mjs` are
filtered views of it. A state that names no seed renders on every seed, so a
surface added that way falls under every probe at once; the board and list
views entered first.

Every seed also carries the kitchen sink: a Lab/Sample table with all 22
field types and adversarial values (a 112-character unbroken word as name,
text, key, URL, email and file name, an eleven-digit currency, a 55-character
option, a 48-character workflow state, a century-long date range, and a
one-letter name beside them). The shared states `sink-table`, `sink-list`,
`sink-board` and `sink-row` walk it on the table, list and board layouts and
the record page, so every probe sees every field type.

A state that fails a probe it newly joins carries a `hold` naming the Issue
rows that track the failure, keyed by probe (`touch`), probe and cell
(`cutoff|desktop`), or probe, cell and platform (`touch|phone|darwin`), which
holds that platform alone while the others read the plain key. The touch gate's
offender ledger `test/touch-held.json` carries the same platform last in its
own key, so one platform's font metrics can be held without reddening the other,
as `test/cutoff-baseline.json` already does per platform (Issue #1542). A held
state stays out of that gate and is still walked: the gate asserts it still
shows what its rows track and prints the counts.
**Fixing one:** fix the rows, watch the held test fail because the state is
clean, and drop the hold in the same change so the gate walks it. A hold never
stands in for a baseline: `test/cutoff-baseline.json` and the touch gate's
zero tolerance are unchanged.

## Quality scorecard

`scripts/quality-scorecard.mjs` prints one JSON row of quality numbers for the
current ISO week, computed from the repo at HEAD and the last 7 days of main
(Issue #1120). The nightly flicker routine writes it to the `Development/Scorecard`
table on the canonical tracker, one row per week, so the trend sits beside the
Issue rows it explains.

```bash
node scripts/quality-scorecard.mjs                         # this week's row
node scripts/quality-scorecard.mjs --issues issues.json    # plus the probe answers on those Issue rows
```

| Field | Read from | Direction |
| --- | --- | --- |
| UI fixes, Class-wide fixes, Generalization rate | Commits naming an Issue that touch `public/`. Class-wide means the commit touched a baseline or budget file (the byte budget excepted), a probe module in `test/lib/` or a static source gate | Rate rises |
| Touch, Cut-off and Alignment repeats | Subjects of those UI fixes | Falls |
| Reverts | `Revert "…"` and sheriff reverts on main | Falls |
| Coverage per probe | States each probe walks over every state in `test/lib/states.mjs` | Rises |
| Field types seeded | Types the catalog seeds create, out of 22 | Rises |
| Width bands covered | Probe viewports across the bands cut at 480, 600, 700 and 900 px, out of 5 | Rises |
| Cut-off instances and fingerprints, A11y entries, Touch holds, Touch baseline entries, Held states | `test/cutoff-baseline.json` (linux), `test/a11y-baseline.json`, the catalog's holds | Falls |
| Spacing and Z-index on tokens | Declarations in `public/*.css` whose value reads a `var(--…)`, comments stripped | Rises |
| Probe answers, Probe answers none | The bug report's "Which probe would have caught this?" section on Issue rows updated in the window | None falls |

The class-wide test reads file paths, and the repeat classes read subject
keywords, so small counts are approximate. Over the 27 days to 24 Oct the script
counts 18 class-wide fixes of 263, where the hand audit counted 23 of 247.

## Flicker loop

A flicker is something the reader saw that should not have been there: a node
painted for a beat and gone, a list painted empty and refilled, a class that
flipped and flipped back, content that jumped with no input, a screencast
frame that differs from both neighbours while they match. `test/lib/flicker.mjs`
is the probe; it walks the journeys and the rest states the state catalog plans
for it (load, open a table, scroll, edit a cell, open a row, switch view, type
in a document, trash and undo, flip the theme, the phone journeys, and loading
the board and list views).

```bash
node scripts/flicker-sweep.mjs                         # 3 runs, keeps what 2 saw
node scripts/flicker-sweep.mjs --journey open-row --runs 1 --min 1   # reproduce one
```

The harness routine `weave-flicker` runs the sweep nightly against GitHub main
and files each confirmed finding as an Issue row carrying its fingerprint
(`journey|kind|selector`) and the frames around it. The overnight fixer picks
those rows up like any other. **Fixing one:** reproduce with the sweep, fix,
and add the fingerprint to `test/flicker-fixed.json` with the Issue number in
the same change. `test/flicker-gate-browser.test.mjs` walks every journey in
the gate and fails when a listed fingerprint comes back, so a fixed flicker
stays fixed. A journey whose selectors rot fails the gate too.

The gate reads the DOM probe only, so it reads the kinds in `GATED`
(`transient`, `blank`, `revert`, `shift`, `cut`). A `frame` finding comes from
the sweep's screencast, which the gate does not run: taking it would put the
gate's own paint timing into the result. `fixedFault` refuses a `frame`,
`jank` or `rerender` key in `test/flicker-fixed.json` for that reason
(`test/flicker-core.test.mjs`, Issue #702), because such an entry would sit
there unchecked. Hold a frame finding with a `frameFlashes` case in
`test/flicker-core.test.mjs` built from the frames on the Issue row, and mark
the row Fixed naming that case.

## Click budget

Every journey in `test/lib/journeys.mjs` is an action a person takes, and
`walk` counts the clicks it takes: trusted primary pointer presses (mouse
clicks and taps) during the run phase. Setup clicks, keys and scripted
`element.click()` calls count nothing. `test/click-budget.json` holds the
budget per `journey|cell`.

`test/click-budget-browser.test.mjs` walks every journey on every PR (it is in
`SMOKE`) and logs `clicks <journey|cell> <count> budget <n>` as a test
diagnostic. Budgets only ratchet down:

- a count above its budget fails: remove the extra click;
- a count below its budget fails until the same change lowers the budget, so
  the saving holds;
- a new journey adds its own `journey|cell` entry with its measured count.

Raising a budget is the one way to merge an action that takes more clicks.
`test/click-budget.test.mjs` compares the file with `HEAD^1` and refuses a
raised number unless the same change writes a new reason under
`raised["<journey|cell>"]`. The reason names the Issue or Feature that needs
the click.

## Vitals budget

`test/vitals-budget-browser.test.mjs` loads home (`#/`) and a table
(`#/table/<id>`) cold, once to warm up and then seven times, on desktop and on the phone, and reads
LCP, CLS and INP from PerformanceObserver (`test/lib/vitals.mjs`). After the
page is ready it makes one real interaction for INP: desktop opens the Task
table from the sidebar or a row's dock, the phone opens the menu or taps a row.
CLS is the largest session window of shifts without recent input. The medians
are judged against `test/vitals-budget.json`
(`vitals["<journey|cell|metric>"]`) and logged as
`vitals <journey|cell|metric> <value> runs <...> budget <n>`. The phone cell runs
Chromium with the iPhone 15 profile, because WebKit reports no `layout-shift`
entries. Budgets hold the Linux runner's medians with headroom; a raise needs a
new reason under `raised[...]`, checked against `HEAD^1` by
`test/vitals-budget.test.mjs`.

### Trend lane

The vitals and feedback suites (`TREND` in `scripts/ci-test.mjs`) time pages in
milliseconds on shared GitHub runners, where the same code read an open-table
INP of 88 to 216 ms (Issue #1215). As gates they turned main red on runner
noise, and the sheriff reverted at least three PRs for it in the week to
2026-11-01 (#505, #513 and #534). They now sit outside every gating plan: the
`pr` and `tests` workflows never plan them, so the sheriff, release and
promotion never read them. `.github/workflows/budgets.yml`
runs them on every main push through `scripts/ci-trend.mjs`. A breach fails
that run, which leaves a visible red on the commit; the third consecutive
failed run (cancelled runs neither count nor break the streak, `SUSTAINED`)
writes an error and posts one alert line to `SHERIFF_ALERT_URL`, since a
breach that repeats is a regression and one that does not is the runner. The
budget files, their `raised[...]` reasons and the `HEAD^1` checks in
`test/vitals-budget.test.mjs` and `test/feedback-budget.test.mjs` still gate
every PR. The click and render budgets count events, not milliseconds, and
stay on the gate.

## Feedback budget

An action answers on screen before the server does. `test/feedback-budget-browser.test.mjs`
walks every journey three times with each `/api/` request held 1000 ms by a
Playwright route, and times each trusted press or commit key (Enter, Tab,
Escape) to the first animation frame after a visible DOM change. A skeleton, a
loader or `aria-busy` is not an answer. The journey's time is its slowest
action; the gate reads the median of the three walks against
`test/feedback-budget.json` (`ms["<journey|cell>"]`) and logs
`feedback <journey|cell> <ms> runs <a,b,c> budget <n> acts <kind:ms ...>`.

A fourth walk runs against the unheld server and refuses any loading state
shown for an action that settles inside the skeleton delay: a skeleton or
loader visible sooner than `SKELETON_SHOW_AFTER_MS` after an action, or
`aria-busy` that sets and clears inside it. `busy["<journey|cell>"]` lets a
journey off that check only while it names the Issue that will fix it.

Budgets hold the Linux runner's times with headroom, since a timing gate cannot
ratchet down on every run the way clicks do. A time over its budget fails the
trend lane (below), not the gate.
Raising a budget needs a new reason under `raised["<journey|cell>"]`, checked
against `HEAD^1` by `test/feedback-budget.test.mjs`, the same rule as the click
budget. Journeys that wait on the server today carry their measured time and the
`Slow:` Issue that tracks them; lower the budget in the change that fixes one.

## Render budget

`test/render-budget-browser.test.mjs` walks every journey three times with the
flicker probe and counts its renders: animation frames in which the app wrote to
the DOM under `<body>` (child lists, `class`, `hidden` or `style`). The median
of three walks is judged against `test/render-budget.json`
(`renders["<journey|cell>"]`) and logged as
`renders <journey|cell> <n> runs <a,b,c> budget <n>`. Budgets hold the Linux
runner's largest median plus a quarter and one frame, since network timing
splits writes across frames from run to run. A count over its budget fails.
Batch the writes, or raise the budget with a new reason under
`raised["<journey|cell>"]`, which `test/render-budget.test.mjs` checks against
`HEAD^1`.

## Cut-off loop

Weave never shows cut-off text. Cut-off text is glyphs sliced by a box edge: a
line cut through its middle, a letter cut in half, a chip cut at its cell's
edge. An ellipsis is not a cut-off (Kyle, 2026-11-06): a `text-overflow:
ellipsis` box or a `-webkit-line-clamp` box that ends on a whole line shows
every glyph it draws, and its three dots say there is more. `test/lib/cutoff.mjs`
is the probe: a page function that reports text cut by an overflow hidden or
clip box, an ellipsis or line-clamp box that an ancestor box cuts, and a
line-clamp box whose height falls short of its whole lines (all `clipped`), and
an input or textarea whose value or placeholder runs past its box
(`field-value`, `field-placeholder`). Text inside an overflow auto or scroll
container, visually hidden text and invisible elements are not cut-offs. The
current breadcrumb's name is skipped by design (Issue #1159): the title row
under every crumb row shows the full name, so the crumb keeps a fixed share and
gives way before any other crumb shrinks. A finding's fingerprint is its kind plus its
selector with every number folded to `#`, and `|phone` for the phone cell.

```bash
node scripts/cutoff-sweep.mjs                       # both cells, writes cutoff-out/findings.json
node scripts/cutoff-sweep.mjs --cell phone --overlay field-editor,new-field   # reproduce one
node scripts/cutoff-sweep.mjs --lower               # after a fix: lower this platform's baseline
```

The sweep walks the journeys and rest states the state catalog plans for it and
opens every dialog and popover listed in `test/lib/overlays.mjs`, at desktop 1440x900 in Chromium and phone 390x844 in
WebKit, on the messy seed. `test/cutoff-gate-browser.test.mjs` runs the same
sweep in the gate against `test/cutoff-baseline.json`, which holds today's counts
per platform, since system fonts differ between macOS and the Linux CI runner.
The baseline is a ceiling. The gate fails on a fingerprint the baseline does not
hold, or more instances than it holds. A count under the ceiling is a stale
baseline: the gate prints it as `stale:` and passes, since the same commit
measured one breadcrumb fingerprint 5, 7 and 8 times on loaded runners (Issue
#1103). `test/cutoff-core.test.mjs` refuses a baseline that grows past its
committed version or past the base a pull request merges into, so the ceiling
only ever comes down. **Fixing one:** fix the cut-off, run the sweep until the
lower count holds, run it once with `--lower`, and commit the lowered counts in
the same change. A red gate prints the Linux counts it measured; copy them into
the `linux` section.

### Line endings

`DESIGN.md` under Text gives every text one role. The same page function checks
line endings when it is called as `probe({ root, endings: true })`, and reports
two kinds with no baseline, since each is a defect on its first appearance
(Issue #1306):

| Kind | What it found |
| --- | --- |
| `sliced-line` | A line partly inside and partly outside the box that clips it by more than 2 px, or a line clamp shorter than its whole lines |
| `lone-word` | A single word on the last line of a paragraph, when the word before it would fit beside it |

```bash
node scripts/line-endings.mjs                                  # both cells, all three densities
node scripts/line-endings.mjs --cell phone --density spacious  # reproduce one
```

`PROBES.endings` in the state catalog walks the journeys, rest states and
overlays of the `prose` seed: the messy seed, plus a description whose paragraph
gaps fall inside the fold and a long guide. A state with a grid, list or board
runs at `compact`, `comfortable` and `spacious`; one without runs once.
`test/line-endings-gate-browser.test.mjs` runs each of the six cells on its own
and fails on any finding.

### Mandatory onboarding verification

[The onboarding verification contract](docs/onboarding-verification.md) maps the
required journeys, permutations and remaining coverage gaps to executable tests.
`ONBOARDING` in `scripts/ci-test.mjs` runs on every ready PR, alongside `SMOKE`,
regardless of diff selection. Missing mandatory suites fail planning. Keep the
contract and regression assertions current in the same PR as a flow change.