R2D2-MERIDIAN/AGENTS.md
Joshua Belke 610b9cac2e
Some checks failed
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Meridian Harness / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Meridian Harness / Publish rolling release (push) Has been cancelled
Meridian Harness / Publish tagged release (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Isolated DB Gate (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Admin Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Server Cross-Compile (push) Has been cancelled
CI / Server Cross-Compile-1 (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
docs: withdraw the >=4 KB crossover — shared memory was on for every measurement
Both SHM keys default true in Zenoh 1.8, the eclipse-zenoh wheel IS built
with the shared-memory feature, and transport_optimization's threshold is
3,072 B. So every measured payload at 4 KB and above took a POSIX-SHM fast
path -- one the relay build cannot take, because shared-memory is not in
its zenoh feature list -- while the Redis side had no equivalent. The error
points toward Zenoh.

RELAY_BUS_SCALING.md's own bullet said "Nothing about shared memory. SHM
was not enabled." That was false, and nothing could have caught it: the
static contract checked transport.shared_memory.enabled and was blind to
the second switch beside it. This adds that check.

The "Router mode prices the Docker boundary" reading goes with it. SHM
works host-to-host and cannot cross into the Docker VM, so an unknown
share of the peer-vs-router divergence at >=64 KB is the SHM path dropping
out rather than the boundary appearing. Both readings are unlicensed until
re-measured under the shipped posture.

Below 4 KB stands, including the 256 B verdict that failed the >=5x gate:
256 B is well under the SHM threshold, and gossip's extra transports cost
the measured process work rather than saving it.

Worth stating plainly, because it is the second time: the 0A.2 result has
now been invalidated twice for two unrelated reasons -- unmatched publish
semantics, then transport posture -- and neither was visible in the
numbers. A bus measurement is not licensed by its spread. It is licensed by
its posture being pinned, read back, and stamped beside the result, which
is what af4b92eba now does.

Signed-off-by: Joshua Belke <joshua@innovationhub-act.org>
2026-08-21 00:54:18 -04:00

76 KiB
Raw Permalink Blame History

AGENTS.md — AI Agent Contributor Guide

This guide is for AI agents contributing to the Meridian codebase. It covers agent-specific context and conventions. For general contributor info (setup, code style, PR process, architecture), see CONTRIBUTING.md.


Ecosystem

MERIDIAN — Multidomain Exchange for Realtime Integration of Data Innovation with Allied Nations. Spell the name in full caps when it stands alone as the product; meridian-* stays lower-case for crates, images, hosts and env vars. The purpose statement, and the only one the README may drift from at its peril: a low-friction, open-protocol, sovereign, decentralized exchange for real-time visibility and integration across humans, servers, agents, machines and IoT.

Meridian is a single self-hosted repo. Source, builds and deployment all live here; there is no sibling build pipeline to coordinate with.

Surface Where it lives
Source — relay, desktop, mobile, CLI, agent harness this repo (RAID/R2D2-MERIDIAN)
Container images — relay, control plane, push gateway registry.r2d2.office.ilab.zone/meridian-*
Deployment deploy/charts/ (Helm) and deploy/compose/ in this repo
Hosts *.meridian.r2d2.office.ilab.zone

Meridian descends from a Block-internal project whose builds were split across four external repos (sprout-releases, sprout-oss, block-coder-tf-stacks, sprout-backend-blox). None of those apply to this deployment and their pipelines are not reachable from here; the release flow is RELEASING.md, which is self-contained.

Historical references to those repos survive only in CHANGELOG.md and in upstream issue links, which are kept verbatim rather than rewritten into 404s.


Repo Structure

crates/
  # Relay + core
  meridian-relay          # WebSocket relay server — main entry point; also hosts git + huddle audio
  meridian-core           # Core types, event verification, filter matching, kind registry
  meridian-db             # Postgres event store and data access layer
  meridian-auth           # Authentication and authorization
  meridian-pubsub         # Cross-pod event bus: EventBus seam, Redis/Dragonfly backend, presence
  meridian-search         # Postgres FTS full-text search
  meridian-audit          # Hash-chain audit log
  meridian-media          # Blossom/S3 media storage
  meridian-relay-mesh     # Pod-to-pod QUIC mesh within ONE deployment (not federation) — off unless MERIDIAN_MESH=on
  meridian-conformance    # Trace schema + replay checker for docs/spec/MultiTenantRelay.tla
  meridian-control-plane  # Relay provisioning service — own binary, own container image
  meridian-push-gateway   # APNs push gateway — own binary, own container image
  # Agent surface
  meridian-acp            # ACP harness bridging Meridian events to AI agents
  meridian-agent          # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output)
  meridian-dev-mcp        # Developer MCP server — shell + file-edit tools
  meridian-persona        # Agent persona packs
  meridian-workflow       # YAML-as-code workflow engine (evalexpr conditions)
  # Clients + interop
  meridian-pair-relay     # Ephemeral sidecar relay for NIP-AB device pairing
  meridian-pairing-cli    # CLI for NIP-AB device pairing interop testing
  git-sign-nostr      # Sign git objects with a Nostr key
  git-credential-nostr # Git credential helper for Nostr-authed push/fetch
  # Tooling + shared
  meridian-cli            # Agent-first CLI
  meridian-sdk            # Typed Nostr event builders
  meridian-admin          # Operator CLI for relay administration
  meridian-ws-client      # Shared NIP-42 WebSocket client (connect, auth, publish)
  meridian-openapi        # Vendored Scalar renderer + OpenAPI reference pages
  meridian-test-client    # Integration test client and E2E test suite
  meridian-test-db        # Test-DB resolution + the fail-closed disposability guard
  meridian-harness        # All-in-one harness bundling ACP, agent, and dev MCP

REMAPPING/meridian-desktop/              # Tauri 2 + React 19 desktop app
REMAPPING/meridian-web/                  # Browser web client (repo browser, served by the relay)
REMAPPING/meridian-mobile/               # Flutter mobile app
migrations/           # SQL migrations (auto-applied on relay startup)
scripts/              # Dev tooling
examples/             # Samples; countdown-bot is a Cargo workspace member
.env.example          # Config template — copy to .env before running

just check-architecture-map asserts this map against the Cargo workspace in both directions, and reads only between those two markers:

  • every crate here is a workspace member, and every top-level path here exists;
  • every workspace member has a row in .settings/gauntlet/baseline/G01-architecture.md, which records whether this map names it.

The same recipe holds README.md's crate table to the same contract, in its own <!-- readme-crates:start --> / <!-- readme-crates:end --> block: every crates/ member needs a row, every row needs a member, and each row's link target must be the crate it names. Adding a crate therefore touches three files — this map, the G01 baseline, and the README table — and omitting any one of them fails CI rather than an audit. examples/countdown-bot is a workspace member but not a crates/ member, so it is exempt from the README table for the same reason it has no line of its own above.

The same recipe also pins the README's Rust version badge to rust-toolchain.toml. A badge is read as fact at a glance and revisited by nobody, so a stale one sends a contributor to install a toolchain the repo has moved off — and that lands as an unexplained build error rather than a visibly wrong number. Bump the pin and the badge in the same commit. The check reads the shields.io URL, not the alt text, because the URL is what renders.

A crate named elsewhere in this file — in the LICENSE manifest list, in Design Law prose, in the Child STELLAR Index — does not count as mapped. Comment text is not an entry: countdown-bot above is described in the examples/ comment rather than listed, which is why it has no line of its own. Do not remove the markers; the guard fails when they are absent rather than quietly widening its search back to the whole document.


Getting Started

. ./bin/activate-hermit   # activate hermit toolchain (Rust, Node, etc.)
cp .env.example .env      # configure local environment
just setup                # install deps, run migrations
just relay                # start relay at ws://localhost:3000
just ci                   # run before any PR

See CONTRIBUTING.md for full setup details and dependency requirements.

./run.sh — one command, deconflicted ports

just assumes the default ports are free. On a machine running several white-label stacks that is rarely true, and the failure is quiet: Compose loses a port bind to another project's container, or the relay dials :5432 and reaches someone else's database. ./run.sh is the launcher that removes that class of failure.

./run.sh doctor         # toolchain, Docker, port conflicts, stale processes, env drift
./run.sh all            # services + relay + control plane + web in the background,
                        # desktop in the foreground
./run.sh control-plane   # community provisioning only, foreground
./run.sh e2e            # probe a running stack: Postgres, Dragonfly, MinIO, relay, NIP-11
./run.sh bundle --mesh  # a .app with real sidecars AND shared compute

It wraps just; it does not replace it. Every build and run step is still a recipe. What run.sh adds is port resolution: each port in the profile is a starting point, probed by attempting a bind (which sees listeners from every container runtime, unlike docker ps against the active context) and walked upward until free. The result is written to the two existing override layers — never a third mechanism — and reused on later runs so builds stay cache-warm:

  • .env.local — a managed block, appended last so its values win, with all hand-written content above it preserved and a backup in .meridian-run/.
  • docker-compose.override.yml — generated, !override-tagged.

Paired values always move together (MERIDIAN_PG_HOST_PORT/DATABASE_URL, REDIS_HOST_PORT/REDIS_URL, MERIDIAN_MINIO_PORT/MERIDIAN_S3_ENDPOINT, relay port/MERIDIAN_BIND_ADDR/MERIDIAN_RELAY_URL/MERIDIAN_ADMIN_HOST). just compose-check enforces the Redis pair; the rest are kept in step by the writer in run.sh. Profiles live in .settings/run-profiles/*.yaml and are selected with --profile. PORT_KEYS in run.sh is the only list of port names — the profile parser is driven from it, because a second copy silently drops any key added to just one and the port lands in the 20000 scratch range looking deliberate.

Two rules keep that resolution from re-litigating the same conflict every run:

  • A profile's starting points are the ports this host actually settles on, not the upstream defaults. 5432, 6379, 9000, 3000, 8080 and 8090 are permanently held here by neighbouring stacks, so starting from them bought a walk on every fresh allocation and twelve relocation warnings on every doctor. local.yaml starts from the settled values instead and keeps the upstream number in a trailing comment, so the delta from a stock checkout stays visible. They are still starting points — a value that turns out to be busy is walked upward exactly as before.
  • allocation.reserved lists the neighbours, not just well-known services. A bind probe only sees what is listening right now, so allocating during a neighbour's restart hands Meridian a port that collides the moment that neighbour returns — and by then the allocation is saved and sticky, so probing alone never corrected it. Any long-lived port on this host within search_limit steps of a starting point belongs in reserved.

A saved allocation is sticky and deliberately never re-probed: a liveness test would read our own running relay as a conflict and walk the port out from under it. ./run.sh ports --fresh-ports is how you ask for a re-probe, and on a correct profile it reproduces the existing allocation exactly — which is the check that both rules above still hold.

The control plane is part of that allocation (control_plane / control_plane_health, defaults 8090/8091, which other stacks' Keycloak containers routinely hold). It carries a wider paired set than the rest, and every member is keyed off a port:

  • MERIDIAN_CONTROL_PLANE_URL + MERIDIAN_CONTROL_PLANE_ORIGIN — read by the desktop app. It fails closed when either is missing, saying so on the surface the user is standing on rather than reaching a third-party host. Four surfaces, four wordings — quote the file, never this sentence: mns.rs ("This deployment has no relay control plane configured, so relays cannot be created here."), HostedCommunityCreateFlow.tsx and RelayDirectoryBrowser.tsx ("This build has no relay control plane configured, so…"), and CurrentRelayDetails.tsx ("This build has no community control plane configured."). The URL carries the API base path (/api/meridian); the origin must not.
  • MERIDIAN_CONTROL_PUBLIC_ORIGIN — what the control plane enforces its identity-binding Origin check against. Drift from the desktop's origin fails mid-flow with invalid_origin instead of up front.
  • MERIDIAN_CONTROL_RELAY_OPERATOR_API_ORIGIN and the relay's own RELAY_OPERATOR_API_ORIGIN — the relay rebuilds the signed operator string from its copy, so the two must match byte-for-byte on the relay's port. A relay left running across a port change keeps the stale value and rejects every provisioning call as unauthorized; restart it, don't debug the signature.
  • MERIDIAN_CONTROL_DATABASE_URL — its own database on the relocated Postgres. Sharing the relay's would collide two migration histories.
  • MERIDIAN_CONTROL_COMMUNITY_HOST_PORT — the relay's port, not the control plane's; it is baked into the <name>.relays.meridian.localtest.me:PORT URLs handed back to clients.

A GUI-launched .app inherits no shell environment, so a packaged build reads the compile-time bake instead (option_env! in REMAPPING/meridian-desktop/src-tauri/src/mns.rs). ./run.sh bundle resolves ports before building, so the running bundle's endpoint matches this allocation; a bundle built any other way will not.

Opt-in features are a property of the binary, so the launcher has to carry them. --mesh compiles in shared compute (mesh-llm) on desktop, all and bundle, mirroring just's mesh=1 and exporting the same MESH_LLM_NATIVE_RUNTIME_CACHE_DIR from scripts/ensure-mesh-native-runtime.sh. It is off by default for cost (~420 crates plus a llama.cpp runtime build), but the cost of omitting it is not silent: Settings → Compute reports "Not included in this build", because every mesh_* command is the stub in REMAPPING/meridian-desktop/src-tauri/src/mesh_llm_stubs.rs. Before --mesh existed, run.sh had no way to build the feature at all while being the documented headline command, and the only recipe that did (just desktop-release-build) staged zero-byte sidecars — so neither path produced a working mesh-capable bundle.

run.sh applies .env itself on the desktop path, because it does not reach just there. dotenv-load is a just feature, and cmd_desktop's attach branch — which ./run.sh all always takes, since cmd_all sets ATTACH=1 — runs pnpm exec tauri dev directly. Every variable in .env was therefore unread on the launcher's own headline command, while the same variable worked under just dev. The visible symptom was MERIDIAN_SHARE_IDENTITY never reaching scripts/instance-env.sh, so macOS re-prompted for the login keychain on every single launch and no diagnostic said why. load_env_layers closes it, applying .env then .env.local and never overwriting a name that was already exported — so the caller's shell and the ports prepare just resolved both still win, and the documented precedence holds. It assigns values rather than sourcing the file, so a stray backtick in a committed env file cannot execute. scripts/test-run-env-layers.sh pins all of it, including the ordering bug the first implementation had (.env beating .env.local).

Teardown is scoped to the invocation, never to the checkout. Several agents run ./run.sh from this one tree, so pkill -f "$REPO_ROOT/target/debug/…" matches a neighbour's relay exactly as well as your own — it scopes by checkout, and on a shared checkout that is not the same thing as scoping by invocation. The stop_bg EXIT trap therefore walks only the process tree under a pid this invocation recorded (kill_tree), and release_frontend_ports reaps a vite by port owner only when that owner is a descendant of this shell. An explicit ./run.sh stop may reclaim an orphan by port, because that is a human clearing the checkout rather than a trap firing on the way out; it names the pid and command before killing, and returns non-zero if the socket never frees.

Both of those failure modes reported success, which is why they are law rather than a comment. A readiness probe answers from whoever owns the port, so an invocation that loses the bind race saw its own child die of AddrInUse while the winner's relay kept answering /_readiness — "ok relay is up" over a dead child, and then the EXIT trap killed the winner. assert_started closes that by checking our own pid is alive before the probe is believed. On the other side, a tracked pid is the just/pnpm wrapper, which exits while its child keeps the socket; killing it and printing ok is how a stale relay survives a stop and then serves health probes for the next build, so status and e2e go green against the wrong binary. reclaim_port asserts the socket, not the pid. scripts/test-run-process-teardown.sh pins both, under just launcher-check.

Shell environment beats just's dotenv-load (verified), which is why exporting from run.sh steers every recipe, and scripts/instance-env.sh now defers to an already-exported MERIDIAN_VITE_PORT/HMR_PORT/RELAY_PORT rather than overwriting it.

Local Backing Stack (docker-compose.yml)

relay ──► postgres      (events — the system of record)
  │
  └──► dragonfly        (pub/sub, presence, rate limits)
  • Dragonfly replaces Redis. It speaks the Redis wire protocol (reports redis_version:7.4), so REDIS_URL, the redis crate, deadpool-redis, Lua Script calls and pub/sub all keep working unchanged. Do not reintroduce a redis service — the container is meridian-dragonfly and the compose service is dragonfly (with a redis network alias so in-network redis://redis:6379 still resolves).
  • One system of record, and for events it is Postgres. Do not add a second store that also claims to hold truth. This is refused by name in DIAGRAM.md § Scaling axes — R2 (a second durable log), R3 (a second read path for data the spine stores), and Invariant 2 (one system of record per fact). Derived views are legitimate on the A2 consumer axis: downstream of the spine, cursor-bearing, freshness-stamped, and invisible at the NIP-01 edge. A self-hosted Convex tier used to run here with zero call sites in any crate or client; it was removed rather than left as inventory (R8), and just compose-check now asserts it stays out.
  • Host ports are overridable (REDIS_HOST_PORT, …) because 6379 and friends commonly collide with other local stacks. A container from another Compose project publishing the same port can silently win the bind — just setup fails early on this rather than leaving a half-working stack.
  • Profiles. Core (postgres, dragonfly, minio*, the *-db-init one-shots) has no profile and always starts. Opt-in extras: tools → adminer, auth → keycloak, observability → prometheus, artifacts / artifacts-s3 → reductstore. Selected with COMPOSE_PROFILES. Do not put a core service behind a profile — just _ensure-services blocks on postgres/dragonfly health and would hang for 120s against a disabled service. For the same reason, removing a service means removing it from that gate in the same change.
  • The artifact tier is opt-in and holds no truth. ReductStore serves artifact bytes (recordings, large attachments, telemetry) over a REST API on the A2 consumer axis. Event truth stays in Postgres — nothing resolves chat history from it. artifacts (local disk) and artifacts-s3 (MinIO behind it) are mutually exclusive: same host port, two backings of one tier. artifacts-s3 needs a ReductStore Pro licence and a licensed image — the public reduct/store image ignores RS_REMOTE_* and silently boots OSS mode on local disk while reporting healthy, so the profile is gated by a blocking reduct-license-check one-shot rather than trusted to fail on its own. It currently has no call sites in any crate or client; wire a consumer or remove it, exactly as the Convex tier was removed (R8).
  • ReductStore's Zenoh environment is two omissions away from being refused, and just check-reductstore-zenoh is what refuses them. From v1.19 the tier joins a Zenoh network natively — a subscriber for writes, a queryable for reads — configured entirely by RS_ZENOH_*. Point either RS_ZENOH_SUB_KEYEXPRS or RS_ZENOH_QUERY_KEYEXPRS anywhere that overlaps the event key space meridian/v1/** and the tier becomes a second durable log and a second read path in one string — R2 and R3, the arrival DIAGRAM.md § Scaling axes names as R9. Leave mode= out of RS_ZENOH_CONFIG and it inherits Zenoh's own default of peer, so a storage box becomes a routing peer that can transit between communities — R4, and Law 2 answers "Leaves. Always." Both wrong values work: records land, queries answer, nothing goes red. That is why it is a gate rather than a review item, and why it lands before any Zenoh wiring exists to guard.

Local override layers

Machine-specific settings never go in .env (team-shared) or the compose file. Two gitignored layers, each with a committed .example:

Layer Loaded by Use for
.env.local Compose only via COMPOSE_ENV_FILES; just recipes source it values the relay/scripts read from the environment
docker-compose.override.yml Compose automatically, no wiring anything that must survive a bare docker compose up -d

Precedence: .env < .env.local < docker-compose.override.yml < shell env. just env-doctor prints the active layers and resolved host ports.

Three traps, each now covered by a just compose-check assertion:

  • COMPOSE_ENV_FILES must come from the shell. Setting it inside .env is silently ignored (verified). The Justfile exports it; that is why just up honours .env.local and a bare docker compose up -d does not.
  • Compose merges sequences by appending. A plain ports: in the override publishes the default and the relocation, recreating the collision. Tag ports:/command:/volumes: with !override to replace.
  • REDIS_HOST_PORT and REDIS_URL must move together. Changing only one yields Connection refused (os error 61) from the relay with every container healthy — the single most time-wasting failure in this stack.
  • In entrypoint: folded scalars, keep shell operators (&&, ||) at the end of a line. Continuation lines are more-indented, so YAML preserves the newline and a leading || is a shell syntax error.

Quality Gates

Run just ci before every PR — it runs fmt + clippy + desktop lint + unit tests + builds. Clippy passing does not mean fmt passes; run both. Tests passing does not mean clippy passes either — a test build compiles through an unused import that -D warnings rejects, so a green cargo test says nothing about the lint gate.

Read the exit code, not the output. Piping a gate into tail/head/grep reports the filter's status, so a failed run looks clean; if you must truncate, capture ${PIPESTATUS[0]} or redirect to a file and grep it after.

Run just test for integration tests if you touched meridian-relay, meridian-db, or meridian-auth — these require a running Postgres and Dragonfly (the Redis-protocol service in docker-compose.yml).

Pre-commit hooks are installed automatically by just setup and auto-fix formatting via stage_fixed. Pre-commit runs fix variants in parallel (Rust fmt, Tauri Rust fmt, desktop biome fix, web biome fix, mobile dart format). Auto-fixable issues are fixed and re-staged; unfixable lint issues block the commit. Pre-push hooks run clippy (workspace + Tauri) and fast unit tests in parallel (Rust, desktop JS, Tauri Rust, mobile Flutter) — no overlap with pre-commit. Builds are CI-only. Run just fix-all to auto-fix all formatting in one shot. Run just ci for the full local gate. Run just hooks to re-install hooks after env changes. Before agents run Git or hooks, activate the repo's Hermit environment (. ./bin/activate-hermit); do not rewrite hook commands to compensate for an unconfigured shell PATH.

Commit with git commit -s. The required DCO Check fails any PR with a commit missing a Signed-off-by trailer, and just hooks installs a commit-msg hook that adds it to commits you create locally (git rebase and git cherry-pick still need --signoff) — if you build commit commands programmatically, include -s every time. To repair a branch that already has unsigned commits: git rebase --signoff main, then force-push.

Incremental compilation is off, and just target-sweep reclaims what it left behind. Cargo has no garbage collector: it deletes a crate's previous incremental session only when the build that replaced it succeeds, so every killed or interrupted build orphans its session permanently. Parallel agents share these target dirs and routinely kill each other's test runs, so the orphans grew to 1,423 sessions / 38 GB in target/ plus 174 / 19 GB in the desktop tree — 42% of all build output, for a cache that kept getting invalidated anyway. incremental = false in .cargo/config.toml closes that class; set CARGO_INCREMENTAL=1 per-shell if you want it back for a tight single-crate edit loop. just target-sweep clears existing caches from both trees, and refuses while incremental compilation is in flight — deleting a session dir under a live rustc fails that build and reads as a compiler bug. Note the test is not "a build is running": this repo is never quiet, so that gate made the sweep unrunnable. Only a rustc carrying -C incremental= writes these dirs, so the script checks for exactly that, plus any session dir written in the last two minutes to catch a build predating the config change.

deps/ is a separate decision. It is the warm cache that keeps a rebuild cheap, and it is also where the other accumulation lives: each cargo test -p <crate> resolves a different feature union than the full workspace, minting a fresh -C metadata hash and a complete new artifact set that is then kept forever (62 cached builds of libsqlx_postgres here). Clearing it means a cold build — that is just clean, never folded into a sweep.

Additional rules:

  • No unsafe code
  • Do not introduce new unwrap() or expect() in production paths — use ? and proper error types. Enforced by just check-unwrap-budget: a per-crate ratchet in scripts/unwrap-budget.json that fails on growth, so a new panic is argued for in a commit message rather than merged in silence. Rows only move down — after a real reduction, run just check-unwrap-budget --write. The workspace holds 138 across 29 crates and meridian-relay alone holds 71, so the ratchet is where that concentration gets worked down rather than a formality
  • New public API must have doc comments

Key Patterns

Nostr-first HTTP surface: Meridian's primary API is NIP-29 over WebSocket. The relay also exposes a narrow HTTP surface: NIP-11/NIP-05 metadata, POST /events, POST /query, POST /count, workflow webhooks at /hooks/{id}, Blossom media, git smart HTTP, git policy hooks, and health probes. These HTTP paths all preserve the same host-derived community boundary.

Prefer Nostr events over new HTTP endpoints: For new feature work, model the operation as a Nostr event (new kind in meridian-core/src/kind.rs, handler in meridian-relay) rather than adding endpoint-specific JSON APIs. HTTP is reserved for things that genuinely need an HTTP-only surface: media upload/download (Blossom), webhooks, git smart HTTP, NIP-11/NIP-05 metadata, health checks, and the generic Nostr bridge endpoints:

  • POST /events — submit any signed event (same path the WebSocket uses).
  • POST /query — Nostr REQ filters over HTTP. NIP-50 search filters are routed to meridian-search (Postgres FTS) automatically.
  • POST /count — Nostr COUNT filters over HTTP.

If you find yourself reaching for a new HTTP endpoint, first check whether an event kind would do the job — it usually will, and you get realtime fan-out, NIP-29 scoping, and the existing auth pipeline for free.

Reference https://github.com/nostr-protocol/nips

Event kinds: All event kind integers are defined in meridian-core/src/kind.rs. New features get new kind integers — add them here first, then implement handling in the relay.

Channel scoping: Channels use h tags (NIP-29 group tag), not e tags. Filters and queries must scope to h tags when operating within a channel.

Agent-facing operations go in meridian-cli: New agent-facing features belong in meridian-cli — add a subcommand there first, then wire the REST/WebSocket call in client.rs. meridian-dev-mcp (shell + file tools for meridian-agent) is separate.

Workflow conditions: meridian-workflow uses evalexpr for condition evaluation. Keep expressions simple and testable.

Thread counters: reply_count and descendant_count are materialized on thread root events. Any code that inserts replies must update these counters — check existing reply handlers for the pattern.


Design Law

Binding constraints on new protocol, identity and access-control surface. Each is cheap to honour up front and expensive to retrofit, and each is written down because the failure it prevents is silent rather than loud. Derived from the GOAT/EFDI analysis in .settings/reference-docs/goat/; the laws below are the subset already binding here, and DIAGRAM.md § Part II is the full conformance register — which of those principles we meet, will meet, or deliberately deviate from, and why.

Names never encode ownership. A producer key freezes into identity, ACL entry, topic prefix and storage key all at once, while ownership stays mutable. Channel UUIDs already get this right — keep it that way. Do not introduce org/team/channel paths in repo, topic or namespace keys; that turns every reorg into a fleet-wide rename.

Standing is registry-derived and effective-dated, never stamped into a credential. Do not bake org or role into a profile or auth claim — a frozen value is stale everywhere else. Model membership as [since, until) so history is queryable rather than reconstructed from logs.

Add attributes, not mechanisms. A new capability is a new attribute plus a new event kind, never a new policy engine. This is the authorization-side twin of "prefer Nostr events over new HTTP endpoints" above. Rule- or formula-based policy DSLs are explicitly rejected: they are un-auditable by enumeration.

No third shim. One grant store; every enforcement point reads it; caches are freshness-stamped projections of it. A mapping table or sync job between two enforcement points is a design violation, not an optimization — it is the seam along which the human and agent paths drift apart.

Explicit denial, never a silent empty result. An access decision must be answerable with the record that decided it, at each layer. That stays possible only while grants are enumerable dated rows, which is why the row shape is load-bearing rather than an implementation detail.

Nothing is resolved until deployed to a live relay and re-probed. Green CI is not evidence that an enforcement path runs in production. Two failure modes to guard explicitly: a deployed image predating the enforcement code makes a feature flag a silent no-op, and a trusted proxy header is forgeable unless something strips it at the edge.

No vocabulary without an enforcement point. A kind, tag or grant name that nothing reads must fail CI, not wait to be found by audit. This repo carries 17 draft NIPs under docs/nips/, so the gap between declared and enforced vocabulary is the standing risk here. Enforced by just check-kinds, and the count in this sentence by just check-architecture-map — a hand-maintained number in the same sentence as the law it justifies is the failure that law names.

Revocation is monotonic and absorbing — never timestamp-wins. NIP-33 addressable events resolve last-write-wins by created_at (hence the CLI's exit code 5). Membership is already modelled that way: KIND_NIP29_GROUP_MEMBERS (39002) and KIND_NIP29_GROUP_ADMINS (39001) sit in the parameterized replaceable range. So a revoke and a concurrent grant healing across a partition or across two relays would resolve by later timestamp and resurrect the revoked grant.

Any revoke, end-date or removal must therefore carry an explicit epoch or generation counter over an append-only log, resolved fail-closed — not a wall-clock created_at comparison. Once a subject is revoked at epoch n, no grant at epoch ≤ n may reinstate it.

This is decided now and built at the second node. The defect is real but dormant while a single relay is the only writer, so building multi-master enforcement machinery today would be inventory. It must land before any relay-to-relay federation or multi-master registry — after is too late, because the resurrection is silent.

A mutable document has exactly one arbiter: the relay that owns its address. Anything resolved by compare-and-swap — a canvas cell carrying baseVersion against a stored version, a block, any future op-addressed state — needs its writes serialized at a single point. Two relays each accepting an op at baseVersion n would both mint n+1, both answer applied, and the document would diverge with no client ever seeing an error. Compare-and-swap without an arbiter is not weaker than last-write-wins; it is last-write-wins wearing a version number.

Document resolution today leans on that single arbiter more than it looks. Kind 40100 resolves as newest created_at, ties to the lowest event id — which is the relay's own ORDER BY created_at DESC, id ASC, a single-relay total order. Across two relays, created_at ties are common and event ids share no ordering anyone agreed to, so the rule stops being a rule. Every reader implements it (meridian-acp/src/pool.rs, the desktop's canvasSync.ts, meridian-cli's cmd_get_canvas), and all three would silently disagree.

Same gate as monotonic revocation above: decided now, built at the second node. One arbiter per document address, and collaborative-document federation stays blocked until the arbiter is explicit rather than incidental. Deciding it after the second relay exists is too late for the same reason — the divergence is silent, and both sides think they wrote successfully.

Scaling moves pick from four axes — replica, function, tenant, traffic class — never vocabulary. Add identical relay pods behind one URL; peel unbounded derived state into leaf consumers; shard whole communities so every kind of a tenant stays co-located; class ephemeral kinds onto droppable lanes. Never shard, place, or ACL by kind/NIP: reads and invariants are cross-kind, gift wrap (1059) encrypts the routing key, and NIP-33 needs one arbiter per address — a kind is a column in every shard, never a shard. Trust-domain federation is the fifth axis and stays blocked behind monotonic revocation, above. Every axis must leave the edge unable to tell, keep one system of record per fact, fail stale-never-wrong, add no new authorizer, and follow a measured number — the full axis register and refusal list live in DIAGRAM.md § Scaling axes.


Published Throughput Ceilings

One relay has two very different ceilings depending on traffic class, and a bare number misleads in both directions. Never publish a throughput figure without its profile attached — traffic class, auth profile, whether the payload is parsed, and whether it is stored. This applies to README.md, NIP-11 advertisement, benchmark output, and any external material.

Profile Ceiling Basis
Bus layer only — pub/sub fabric, no relay in the path, nothing verified or stored vendor charts show ~4–5M msg/s at small payloads someone else's benchmark, against someone else's baseline — see below
Opaque pre-decoded frames, trusted P0 link, payload never parsed, not stored ~600k msg/s estimate, not measured — see below
Signed NIP-01 events, per 16-core pod, BIP-340 verification bound ~250–320k events/s measured per-core, extrapolated
Stored-and-indexed chat events substantially lower again not yet measured

The gap is roughly two orders of magnitude, and it is architectural: the two paths share a process and almost nothing else. Quoting the frame-path number for chat sells an obligation the binary cannot meet.

The bus-layer row is the newest way to get this wrong. Published Zenoh benchmarks (~4–5M msg/s, peer and brokered) are real, but they measure a transport against Kafka, MQTT and CycloneDDS — not against Dragonfly, which is multi-threaded and Redis-wire-compatible and therefore a far closer baseline than MQTT's ~35k msg/s. Two rules follow, and both are binding:

  • Never quote a bus figure as a Meridian throughput number. It is the pipe, not the plumbing. End-to-end signed persistent ingest is bounded by BIP-340 verification and does not improve when the bus gets faster. This is a statement about the signed path only — on the machine, agent and realtime classes the ceiling does move, by the measured ~120–400× below, and the transport work exists to make that class separable enough to exploit it.
  • Meridian's own bus number now exists, and the ≥5× gate at 256 B FAILED. Measured 2026-08-19 — meridian-ctg, full profile in perf/RELAY_BUS_SCALING.md § Phase 0A.2. Zenoh peer against Dragonfly at 256 B is 1.51× [1.42–1.78]; against a same-footing host-native Redis it is 1.15× [0.98–1.34] — statistical parity, lower bound below 1.0. With express it is 0.71×, an outright loss. Crossover is ~2× at 2 KB and ~5× at 16 KB against Dragonfly — but the ≥4 KB half of that curve is WITHDRAWN, see below; against host-native Redis, ≥5× appears nowhere in the sweep. Profile: opaque bytes, nothing signed, parsed or stored; Python binding; M3 Max; host load 22–31. No ≥5× claim may be published at any payload size.
  • The first comparison was invalid, and the failure mode needs a name. The harness called a Redis client that flushes and blocks on the reply, while Zenoh's batched put returns on enqueue — a synchronous client against a fire-and-forget one, with the difference reported as a property of the two buses. It read 26.87× at 256 B against 1.51× once publish semantics were matched. Dragonfly alone measures 7,072 msg/s synchronous against 518,163 pipelined: 73.3× of pure client semantics, inside one bus. A bus comparison must state its publish semantics beside its payload size, or it is not a comparison.
  • Below ~4 KB both arms are receiver-bound, so those ratios largely compare two Python receive paths rather than two buses — they license nothing.
  • The ≥4 KB crossover is withdrawn, and the "Docker boundary" reading with it. Shared memory was on for every measured arm: both SHM keys default true in Zenoh 1.8, the eclipse-zenoh wheel is built with the feature, and transport_optimization.message_size_threshold is 3,072 B. So every payload at 4 KB and above took a POSIX-SHM fast path — one the relay build cannot take, since shared-memory is not in its zenoh feature list — while the Redis side had no equivalent. The error points toward Zenoh. The peer-vs-router divergence at ≥64 KB was read as the Docker boundary appearing; an unknown share of it is instead the SHM path dropping out, because SHM works host-to-host and cannot cross into the Docker VM. Both readings are now unlicensed until re-measured under the shipped posture. Neither was ever a capacity number for the Rust adapter; that is Phase 0B.
  • The 0A.2 result has now been invalidated twice, for two unrelated reasons — first unmatched publish semantics (a blocking Redis client against fire-and-forget Zenoh, 26.87× → 1.51×), then transport posture (SHM and gossip on, against a relay that ships both off). Neither was visible in the numbers. A bus measurement is not licensed by its spread; it is licensed by its posture being pinned, read back, and stamped beside the result.
  • Cold start cost ~15.5× on our own hardware (51.3 µs first publication vs 3.42 µs steady), independently replicating dora's ~16×. Sessions and publishers must be declared once and reused, never created per event.
  • express is a latency instrument, not a throughput one. Below 16 KB it gives up roughly half the throughput and returns a p50 ~10× lower and nearly flat (61–100 µs vs 214–2,451 µs batched); at ≥64 KB it wins on both. That maps onto traffic class, not onto a global setting.
  • The gate failed at 256 B, and that is not a verdict on Zenoh. dora-rs measured its own Zenoh cutover across payload sizes (.settings/reference-code/dora/docs/plan-zenoh-shared-memory.md): 55–80% slower below 2 KB, against a break-even near 2 KB, with the 35% latency and 3–10× throughput wins arriving only at ≥4 KB. Their baseline is TCP+bincode rather than Dragonfly, so the crossover point does not transfer — but the mechanism does, because it is per-message Zenoh session and framing overhead that a small payload cannot amortise. 256 B sits well inside that band. The design conclusion they shipped was a threshold guard: small messages stay on the incumbent path and only large ones take the new one, which means a single-payload-size gate can reject a transport that is correct for the traffic class it was chosen for. Measure meridian-ctg across a size sweep and state the crossover, rather than passing or failing on one number.

Two more of their findings bind work already on this repo's roadmap. Zenoh shared memory needs ulimit -l unlimited / CAP_IPC_LOCK and sits behind Zenoh's unstable feature flag, and the first message after session init costs a 16× latency spike unless the session is pre-warmed — a cold-start artefact that will contaminate any short benchmark that does not discard it. And their recording path broke when data stopped flowing through the daemon: anything that moves bytes off the relay path leaves meridian-audit's hash chain the same way, silently, which is the sharper version of that failure here.

The verification bound is measured, by crates/meridian-core/benches/event_cost.rs (cargo bench -p meridian-core). verify_event — id hash plus BIP-340 signature — runs at ~30k events/s on one core, observed 21.6–33.1k over 3 runs (M3 Max, release, one core, batch sizes 1/8/64 — reproduce with just bench). The spread is the honest figure: this previously read 26.5–29.4k, an interval narrower than what re-running the same bench on the same machine reproduces, and nothing isolates it from host load (meridian-9fqe). Taking ~30k/core, a 16-core pod tops out near ~480k/s for verification alone (350–530k across the observed spread), and the ~250–320k figure is what remains after everything else on the path. Re-run the bench — just bench — rather than trusting the extrapolation when the number matters; it prints the profile alongside the numbers so a figure cannot be lifted out of it bare.

Two findings from that bench constrain how this ceiling can be raised:

  • Batch signature verification is not available and would not be enough. No stable batch BIP-340 API exists at any level of the pinned tree. Measured on ed25519, where batching does exist, batch-64 amortizes to only ~2.9× — and is a net loss below about N=4.

  • A symmetric MAC is two orders of magnitude faster than signature verification — the ~120–400× above, at batch 1, with honest bands ~110–170× (HMAC-SHA256) and ~360–550× (keyed BLAKE3). So the way past this ceiling is to skip per-event verification on links that qualify for a trusted NIP-XP profile, never to make verification faster.

    Compare at a matched batch size. schnorr_verify_nostr is flat across the 1/8/64 sweep; the MAC groups are not. At batch 64 the matched ratios are ~200× and ~420×. This bullet previously read 179–382×, which divided MAC throughput at batch 64 by Schnorr throughput at batch 1 — a mixed profile matching neither row, and stated two paragraphs after the corrected ~120–400× as though it were the same quantity. A ratio carries its batch size for the same reason a throughput carries its traffic class.

The 600k figure is an estimate, not a measurement, and the pre-/post-dedup question behind it is still open (a ~10× sizing difference). Do not publish it externally as measured until the measurement track has replaced it.


Agent CLI (meridian-cli)

meridian is the agent-first CLI. Auth env vars (MERIDIAN_RELAY_URL, MERIDIAN_PRIVATE_KEY, MERIDIAN_AUTH_TAG) are auto-injected by the ACP harness into managed agent subprocesses. In development, set MERIDIAN_PRIVATE_KEY and MERIDIAN_RELAY_URL in your environment manually.

Building the CLI

cargo build --release -p meridian-cli

Binary location: ./target/release/meridian. Add ./target/release to PATH or invoke with the full path.

meridian://message?channel=<uuid>&id=<hex> links reference a specific message thread. To read the linked thread:

Meridian messages thread --channel <uuid> --event <hex> --format compact

Extract channel and id from the URL query parameters. The optional thread parameter (root event ID) can be ignored — messages thread resolves the full thread from the event ID alone.

All reads return sig-stripped JSON arrays; all writes return {event_id, accepted, message}; creates add the entity ID. Exit codes: 0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW).

--format compact is a global flag — it goes before the subcommand: Meridian --format compact channels list, NOT Meridian channels list --format compact.

See crates/meridian-cli/TESTING.md for the full live-testing runbook.


Testing

just test-unit    # unit tests, no infrastructure needed
just test         # full integration suite (requires Postgres + Dragonfly)

E2E tests live in crates/meridian-test-client/tests/:

  • e2e_relay.rs — WebSocket relay protocol
  • e2e_media.rs — media upload/download (Blossom)
  • e2e_media_extended.rs — extended media scenarios
  • e2e_nostr_interop.rs — Nostr interop (NIP-50 search, NIP-10 threads, NIP-17 gift wraps)

Desktop E2E: cd REMAPPING/meridian-desktop && pnpm exec playwright test

See TESTING.md for the full multi-agent E2E guide.

PR Screenshots

Do NOT use Meridian upload, the relay media endpoint, or any third-party image host for PR screenshots. Relay media URLs fail through GitHub's camo proxy. Always use scripts/post-screenshots.sh for PNGs before linking them from a PR body/comment. If you hand-edit PR markdown, run scripts/check-pr-image-urls.sh <markdown-file> first to catch relay URLs.

For mobile simulator screenshots, save the PNGs in a local directory and run ./scripts/post-screenshots.sh <PR-number> <png-dir> or use the third argument with a markdown template containing {{filename}} placeholders.

The desktop app requires the E2E mock bridge to render — it cannot run in a plain browser. Use just desktop-screenshot to capture screenshots (builds frontend, starts preview server, runs Playwright automatically):

just desktop-screenshot --name home
just desktop-screenshot --name channel --route /channels/general
just desktop-screenshot --name search --click open-search
just desktop-screenshot --name settings --click open-settings

Options: --name (filename), --route (client route), --active-channel (channel to view), --click (left-click data-testid or CSS selector; repeatable, and the clicks run in order, so a state two gestures deep — a popover and then a button inside it — needs no bespoke spec. A bare word is a data-testid; anything starting with [ or a Playwright engine prefix (text=, role=, …) is passed through, so an item carrying no testid is reachable without editing source to add one. --right-click composes: it fires first, then the clicks, which is how a context-menu item gets picked), --right-click (right-click for context menus), --hover (hover before capture), --clip (crop region as x,y,w,h — e.g. 0,0,256,720 for sidebar only), --wait (ms, default 2000), --viewport (WxH, default 1280x720), --outdir (default test-results/screenshots), --messages (JSON file path). Output is a PNG path on stdout.

Use --messages to inject content into a channel before capture. The JSON file is an array of objects — channelName and content are required, all other fields are optional and passed through to __MERIDIAN_E2E_EMIT_MOCK_MESSAGE__:

[
  {
    "channelName": "random",
    "content": "Hey @tyler check this out",
    "pubkey": "953d...",
    "kind": 40002,
    "mentionPubkeys": ["deadbeef..."],
    "extraTags": [
      ["broadcast", "1"],
      ["e", "some-root-id"]
    ],
    "parentEventId": "abc123"
  }
]

Without --active-channel, all messages must target the same channel and the helper navigates to that channel (useful for showing message content). With --active-channel, messages can target multiple channels while the "camera" stays on the specified channel (useful for unread indicators, badges, etc.).

# Messages in the channel you're viewing (code blocks, formatting, etc.)
just desktop-screenshot --name code-blocks --messages /tmp/msgs.json

# Messages in OTHER channels to trigger unread state
just desktop-screenshot --name unread-dot \
  --active-channel general --messages /tmp/badge-msgs.json

# Cropped to sidebar only (256px wide)
just desktop-screenshot --name sidebar-unread \
  --active-channel general --messages /tmp/badge-msgs.json \
  --clip 0,0,256,720

# Context menu on an unread channel (wider crop to include popup)
just desktop-screenshot --name ctx-mark-read \
  --active-channel general --messages /tmp/badge-msgs.json \
  --right-click channel-random --clip 0,200,320,300

# Hover state (e.g. copy button reveal)
just desktop-screenshot --name copy-hover \
  --messages /tmp/code-msgs.json --hover "[data-testid='copy-code']"

Available mock channels: general, random, design, sales, engineering, agents, watercooler, announcements, alice-tyler, bob-tyler.

scripts/post-screenshots.sh hosts PNGs on a per-developer branch (agent-screenshots/<github-username>) and posts a PR comment with commit-SHA-based image URLs (immutable — safe from later overwrites):

./scripts/post-screenshots.sh 803 test-results/screenshots
./scripts/post-screenshots.sh 803 test-results/screenshots body.md  # custom body prepended

The body file supports {{filename}} placeholders (without .png) to inline images at specific positions. Images not referenced by any placeholder are appended at the end. Without placeholders, all images are appended (backward compatible).

### Unread dot

A message arrives in `#random`.

{{01-unread-dot}}

### Context menu

Right-click shows "Mark as read".

{{02-context-menu}}

Re-runs overwrite the image blobs on the agent-screenshots/<username> branch, but the script appends a new PR comment — it does not edit or delete the previous one. After reposting, delete the superseded comment so only the current set remains, otherwise reviewers still see the stale images:

# List screenshot comments to find the stale one's id
gh pr view <pr> --repo RAID/R2D2-MERIDIAN --json comments \
  --jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
gh api -X DELETE repos/RAID/R2D2-MERIDIAN/issues/comments/<stale-comment-id>

Branch cleanup when fully done: git push origin --delete agent-screenshots/<username>.

Writing E2E Screenshot Specs

When screenshots need seeded state, live messages, or UI interaction before capture, write a Playwright spec instead of using just desktop-screenshot. Add specs to REMAPPING/meridian-desktop/tests/e2e/ and register them in playwright.config.ts (smoke project testMatch). Every test calls installMockBridge(page) for mock Tauri IPC. Mock pubkey, channel names, and UUIDs live in e2eBridge.ts.

Always build with pnpm build:e2e, never pnpm run build. The mock Tauri bridge is compiled in only for --mode e2e (see installE2eBridgeIfConfigured in REMAPPING/meridian-desktop/src/main.tsx). A plain pnpm run build strips it, so window.__TAURI_INTERNALS__ is never defined and every mock-mode spec fails with Cannot read properties of undefined (reading 'invoke') — the app renders "Community connection failed" instead of the UI under test. That looks exactly like a product bug rather than a build mistake, so it burns real time. pnpm test:e2e:smoke and pnpm test:e2e:integration run the right build for you; prefer them over a manual build plus playwright test.

Stale server: reuseExistingServer: true means a previous build's server serves old code. Kill port 4173 and re-run pnpm build:e2e before re-running tests after code changes.

addInitScript before bridge: page.addInitScript (localStorage seeding) must run BEFORE installMockBridge(page) — React reads state on mount, the bridge triggers mount.

Live messages: Call waitForMockLiveSubscription(page, channelName) before __MERIDIAN_E2E_EMIT_MOCK_MESSAGE__ — messages are silently dropped without a subscription. Navigate to the channel first (triggers subscription), then away (so unread indicators appear), then inject.

Animation timing: Radix components animate in via CSS. toBeVisible() resolves mid-animation — wait for completion before screenshotting. Use the shared helper (mandatory before any page.screenshot() or locator.screenshot() in specs):

import { waitForAnimations } from "../helpers/animations";

// ... after the element is visible but before capturing:
await waitForAnimations(page);
await page.screenshot({ path: "...", clip: { ... } });

The just desktop-screenshot path (screenshot.mjs) calls waitForAnimations automatically — no manual step needed there.

For per-element waits (rare — prefer the page-level helper above):

await menuItem.evaluate((el) =>
  Promise.all(
    el
      .closest("[data-state]")
      ?.getAnimations()
      .map((a) => a.finished) ?? [],
  ),
);

Cropping: Use clip — full-window (1280x720) screenshots are unreadable for sidebar features. Sidebar = 256px; context menus ~450px.

Distinct states — verify before posting: when one view renders many elements at once (e.g. all team cards in a single grid), an unscoped full-page page.screenshot() captures the same pixels for every shot, so multiple PNGs come out byte-identical. Scope each shot to its subject with locator.screenshot() (full-page clip only when an overlay like an open dropdown must be included). Then gate on hash distinctness before posting:

shasum -a 256 test-results/<dir>/*.png   # every hash must be unique

Identical hashes mean two shots captured the same state — fix the spec, do not post. This catches the most common screenshot regression.

general has pre-seeded messages making hasUnread always true. Use engineering for "muted + no unread" visual states.

PR comments: Use a body template (3rd arg to post-screenshots.sh) with {{filename}} placeholders. Each screenshot gets a ### heading + one-line description. See PR #803.


Common Gotchas

  1. Kind 39000 for channel metadata, not 41 — kind 41 is NIP-01 (unused). All kinds defined in meridian-core/src/kind.rs.
  2. Relay queries must specify kinds — omitting kinds triggers the p-gate (403). Always include explicit kind filters.
  3. messages search must include --kinds — an open-ended search (no kinds) hits the relay p-gate and returns 403. Pass at least --kinds 9,45001,45003 to scope the query.
  4. Worktrees: cd in the same command — shell CWD doesn't persist between tool calls. Use cd /path && cargo build as one command.
  5. Desktop crate excluded from root workspace — cargo test at repo root does NOT run desktop tests. Use cargo test --manifest-path REMAPPING/meridian-desktop/src-tauri/Cargo.toml explicitly.
  6. Desktop Tauri fmt fails in worktrees and blocks commits — the pre-commit hook runs just desktop-tauri-fmt, which fails in git worktrees because cargo fmt resolves workspace paths relative to the worktree root. Run just desktop-tauri-fmt from the main checkout to apply the fix, then re-stage and commit. CI is unaffected.
  7. React render perf: React.memo is all-or-nothing — it only skips a re-render when every prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh {}/[]/Map each render) defeats it. Two repeat offenders: (a) React Query results (useMutation/useQuery) are a new object each render — depend on the stable method (mutation.mutateAsync), not the object; (b) derived Map/array state that recomputes on a version bump — wrap in a content-equality ref cache (shared/hooks/useStableReference.ts). When chasing interaction lag, measure with DevTools closed and no perf probes (an open Web Inspector + per-keystroke console.log inflate the numbers), and isolate by removing one suspect at a time rather than guessing.

Desktop App

The desktop app is Tauri 2 + React 19 + Vite + Tailwind CSS. Features are organized under REMAPPING/meridian-desktop/src/features/. Biome handles linting and formatting.

just desktop-dev   # web-only dev server (faster iteration)
just dev           # full Tauri app with native shell

Text sizing & zoom (use rem, never px)

The desktop app implements Cmd +/- zoom by scaling the root <html> font-size (REMAPPING/meridian-desktop/src/app/useWebviewZoomShortcuts.ts) and pinning the native webview zoom. Only rem-based text scales with zoom — hardcoded px text sizes are frozen.

So for any readable text, reach for rem-based Tailwind tokens, never arbitrary px:

  • ✅ Stock rem tokens (text-base, text-sm, text-xs, …). Chat body/author text === text-base (16px) — chat is the app's base type size, and the surrounding timeline elements (timestamps, system rows, code, reactions) are deliberate steps on that same stock ramp.
  • ✅ The text-2xs (0.6875rem / 11px) and text-3xs (0.5rem / 8px) meta-text tokens (in REMAPPING/meridian-desktop/tailwind.config.js under theme.extend.fontSize) for the sub-text-xs ramp — timestamps, count badges, tracking labels, tiny glyphs. These replaced the dozens of arbitrary text-[…rem] literals that had drifted apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary values.
  • ❌ text-[15px], text-[13px], CSS font-size: 15px — px froze against zoom and caused the message-timeline regression (PR #891).
  • ❌ Arbitrary rem literals too: text-[0.6875rem], text-[0.9rem], etc. They zoom fine but re-fragment the scale we consolidated. Use a named token.

Prefer stock tokens — they're rem and zoom-safe. Only if a design genuinely needs a size the stock/2xs/3xs scale can't express should you add a rem-based token (in REMAPPING/meridian-desktop/tailwind.config.js under theme.extend.fontSize) rather than an arbitrary literal. A CI guard (pnpm check:px-text, in REMAPPING/meridian-desktop/scripts/check-px-text.mjs) scans all of REMAPPING/meridian-desktop/src and fails on any new arbitrary text-size literal — px or rem/em. Genuinely decorative glyphs (e.g. the text-[6rem] avatar emoji) are allowlisted by path:line in that script.

Community Switching

The desktop app supports multiple communities (each backed by a different relay). Switching communities does not reload the page — it uses React key-based remounting. <AppReady key={communityKey} /> in App.tsx forces the entire community-scoped subtree to unmount and remount with fresh state.

Module-level singletons must be explicitly reset. React remounting only clears React state (useState, useRef, context). Module-level variables (Maps, class instances, cached promises) survive across remounts. Every community-scoped singleton needs a reset function wired into resetCommunityState() in REMAPPING/meridian-desktop/src/features/communities/useCommunityInit.ts.

Current singletons that are reset on relay boundary changes (same-relay reconnects preserve pending avatar verification work):

  • relayClient.disconnect() — WebSocket teardown + promise rejection
  • resetRateLimitGate() — clears any active rate-limit window from the old relay
  • clearAllDrafts() — message draft cache
  • resetAgentObserverStore() — agent observer relay store
  • resetActiveAgentTurnsStore() — active agent turn timers
  • resetAgentWorkingSignal() — agent working indicator signal
  • resetAvatarProfileSync() — pending verified-avatar profile writes
  • resetAvatarPresentations() — avatar probes, previews, and Retry toasts
  • resetRelayStatusNoticeState() — top relay status bar dismiss state
  • resetMediaCaches() — proxy port and relay origin caches
  • resetVideoPlayerState() — video player singleton
  • resetRenderScopedReactionHydration() — reaction hydration cache
  • clearSearchHitEventCache() — search result event cache
  • clearMarkdownNodeCache() — markdown parse-node cache
  • resetEntityTags() — entity tag store and its relay sync manager

If you add a new module-level cache, Map, or class instance that holds community-scoped data, you must add its reset to resetCommunityState(). Failure to do so causes data from the old community to leak into the new one.

Key files:

  • REMAPPING/meridian-desktop/src/app/App.tsx — community key, init gate, remount boundary
  • REMAPPING/meridian-desktop/src/features/communities/useCommunityInit.ts — resetCommunityState(), applies config to Tauri backend
  • REMAPPING/meridian-desktop/src/main.tsx — provider hierarchy (QueryClientProvider > App)

Mobile App (Flutter)

The mobile app lives in REMAPPING/meridian-mobile/ — a Flutter app using Riverpod + Hooks.

Architecture

  • State management: Riverpod + flutter_hooks (HookConsumerWidget)
  • Theme: Catppuccin Latte (light) / Macchiato (dark) — matches desktop
  • Features: Isolated under lib/features/, shared code in lib/shared/
  • Nostr models: lib/shared/relay/nostr_models.dart — event kinds must stay in sync with REMAPPING/meridian-desktop/src/shared/constants/kinds.ts

Rules

  • NEVER use StatefulWidget — favor Riverpod for state and always use HookConsumerWidget or ConsumerWidget with flutter_hooks for local state.
  • NEVER run flutter run, flutter build, flutter clean, or flutter upgrade — only flutter test, flutter analyze, and dart format are safe for agents to run.
  • Do NOT use print() — use debugPrint() or structured logging.
  • Prefer context.colors and context.textTheme (via theme extensions) over raw Theme.of(context) calls.
  • Keep widgets small and composable. One public widget per file; push private sub-widgets (_Foo) into sibling part files under a <page>/ folder rather than growing the page file. Hard ceiling: 1000 lines/file, enforced by REMAPPING/meridian-mobile/scripts/check-file-sizes.mjs via just mobile-check (runs in just check + pre-push, mirroring desktop/web). If the guard trips, split the file — never bump the limit or add an override to slip under it.
  • Feature modules must not import from other feature modules — only from shared/.
  • Use Grid tokens for spacing, Radii for border radius.

Quality Checks

cd REMAPPING/meridian-mobile
dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test

Or from repo root: just mobile-fmt (auto-fix), just mobile-check (lint + fmt check), just mobile-test (tests).

To run the app locally (starts Docker, relay, iOS simulator automatically):

just mobile-dev

When run from a git worktree, just mobile-dev (and just mobile-build-android) give the debug build a per-worktree app identifier (keyed to the worktree directory name) and a branch-labelled app name via scripts/mobile-worktree-overrides.sh, so builds from multiple worktrees install side by side. Release builds are unaffected. just mobile-clean removes stale worktree-suffixed installs from simulators/emulators. See REMAPPING/meridian-mobile/README.md for direct Xcode / Android Studio usage.

Testing Conventions

  • Prefer widget tests over unit tests for UI components — test the whole widget tree, not individual methods.
  • Use ProviderScope(overrides: [...]) to inject fake notifiers.
  • Fake notifiers should extend the real notifier class and override build().
  • Use the WidgetHelpers.testable() wrapper for simple widget tests or build a custom ProviderScope + MaterialApp when you need specific overrides.

See Also

  • CONTRIBUTING.md — setup, code style, PR process, how to add event kinds / CLI subcommands / HTTP endpoints
  • TESTING.md — multi-agent E2E test guide
  • ARCHITECTURE.md — system design and component relationships
  • RELEASING.md — release process: release-desktop, release-relay, scripts/mobile-release.sh, candidate tags, internal builds
  • README.md — project overview and quick start

STELLAR framework

  • STELLAR is highly performant AGENTS.md hierarchy installed here
  • Agent must follow STELLAR instructions across any edits

Core Contract

  • AGENTS.md files are binding work contracts for their subtrees
  • Work products, source materials, instructions, records, assets, and durable docs must stay understandable from the nearest applicable AGENTS.md plus every parent AGENTS.md above it

Read Before Editing

  1. Read the root AGENTS.md
  2. Identify every file or folder you expect to touch
  3. Walk from the repository root to each target path
  4. Read every AGENTS.md found along each route
  5. If a parent AGENTS.md lists a child AGENTS.md whose scope contains the path, read that child and continue from there
  6. Use the nearest AGENTS.md as the local contract and parent docs for repo-wide rules
  7. If docs conflict, the closer doc controls local work details, but no child doc may weaken STELLAR

Do not rely on memory. Re-read the applicable STELLAR chain in the current session before editing.

The chain has a committed snapshot, and the pass is a delta against it. .settings/stellar/snapshot.json records, per AGENTS.md, what the doc said (its sections and their sizes) and what it owned (the tracked paths beneath it that no nearer doc claims). just stellar-scan prints which subtrees moved; just check-stellar (part of just check) fails on a chain error — an orphaned doc, a child-index row resolving to nothing — or on a loss: a section dropped or gutted, a child row removed, a doc shrunk past 60% of its bytes. Because the snapshot is tracked, accepting a loss is a reviewable diff rather than a silent regeneration, which is the failure a re-scan from memory produces. Run the pass with $steward-stellar-docs; just stellar-snapshot accepts the result and belongs in the same commit as the doc edits it describes.

DOX is the same contract under a different name; the tooling reads both spellings of the index heading, so a rename does not have to be atomic.

Update After Editing

Every meaningful change requires a STELLAR pass before the task is done.

Update the closest owning AGENTS.md when a change affects:

  • purpose, scope, ownership, or responsibilities
  • durable structure, contracts, workflows, or operating rules
  • required inputs, outputs, permissions, constraints, side effects, or artifacts
  • user preferences about behavior, communication, process, organization, or quality
  • AGENTS.md creation, deletion, move, rename, or index contents

Update parent docs when parent-level structure, ownership, workflow, or child index changes. Update child docs when parent changes alter local rules. Remove stale or contradictory text immediately. Small edits that do not change behavior or contracts may leave docs unchanged, but the STELLAR pass still must happen.

Hierarchy

  • Root AGENTS.md is the STELLAR rail: project-wide instructions, global preferences, durable workflow rules, and the top-level Child STELLAR Index
  • Child AGENTS.md files own domain-specific instructions and their own Child STELLAR Index
  • Each parent explains what its direct children cover and what stays owned by the parent
  • The closer a doc is to the work, the more specific and practical it must be

Child Doc Shape

  • Create a child AGENTS.md when a folder becomes a durable boundary with its own purpose, rules, responsibilities, workflow, materials, or quality standards
  • Work Guidance must reflect the current standards of the project or user instructions; if there are no specific standards or instructions yet, leave it empty
  • Verification must reflect an existing check; if no verification framework exists yet, leave it empty and update it when one exists

Default section order:

  • Purpose
  • Ownership
  • Local Contracts
  • Work Guidance
  • Verification
  • Child STELLAR Index

Style

  • Keep docs concise, current, and operational
  • Document stable contracts, not diary entries
  • Put broad rules in parent docs and concrete details in child docs
  • Prefer direct bullets with explicit names
  • Do not duplicate rules across many files unless each scope needs a local version
  • Delete stale notes instead of explaining history
  • Trim obvious statements, repeated rules, misplaced detail, and warnings for risks that no longer exist

Closeout

  1. Re-check changed paths against the STELLAR chain
  2. Update nearest owning docs and any affected parents or children
  3. Refresh every affected Child STELLAR Index
  4. Remove stale or contradictory text
  5. Run existing verification when relevant
  6. Report any docs intentionally left unchanged and why

User Preferences

When the user requests a durable behavior change, record it here or in the relevant child AGENTS.md

  • Route non-trivial decisions through the advisory councils. Before recommending or approving a course of action on anything with real trade-offs, invoke the consult-council-approval skill: route the decision to the matching chartered council (Protocol, Security & Identity, Architecture & Scale, Experience, Release & Operations, Agent & Automation, or General), seat the chair plus the seats the decision touches, and collect from each a position, one falsifiable objection or named risk, and what would change it — no roleplay filler, seats with nothing concrete pass. The chair closes with the call and its reversal evidence; record the call in the Bead or PR. Charters live in skills/consult-council-approval/councils/. This supersedes the former single "LLM Council" roster.

Child STELLAR Index

Direct children of the root. Read the root doc plus every AGENTS.md on the path to your target before editing.

Child Covers
crates/AGENTS.md The Rust workspace — relay, protocol libraries, agent surface, CLI, tooling crates. Nested: meridian-core, meridian-relay, meridian-db, meridian-acp, meridian-agent, meridian-cli, meridian-test-client
migrations/AGENTS.md Forward-only Postgres schema; checksum-frozen files, partition/fence invariants
docs/AGENTS.md Repo-local NIPs, TLA+/Tamarin specs, formal NIP-PL models, operator guides, and audience-orientation guides — ux-end-to-end-workflow.md is the end-to-end user journey a new UI/UX contributor reads first
deploy/AGENTS.md Helm charts and Compose stack; chart values/schema/test contract
scripts/AGENTS.md Dev tooling, shared lint guard cores, release/contract scripts, operator SQL
benchmarks/AGENTS.md harbor-meridian-orchestra multi-agent benchmark (the repo's only Python toolchain)
.github/AGENTS.md Required CI checks, release automation, DCO, action pinning, filter/lefthook parity
REMAPPING/AGENTS.md IHLC delivery grammar — one directory per deployable (infra Dockerfiles/config + client source for desktop/web/mobile/admin-web), GitLab pipelines, Compose, OpenResty edge, Keycloak realm. Nested: meridian-desktop/, meridian-web/, meridian-mobile/, meridian-admin-web/
.settings/AGENTS.md Working docs — feature/parity specs, the rebrand record, GOAT/EFDI distillations, the stellar/ chain snapshot and reference-code index, and the gitignored third-party checkouts. Nested AGENTS.md under .settings/reference-code/ are upstream docs and not part of this chain

Owned by this file

Not delegated to any child — change these under the root contract:

  • Justfile (the entry point for every verification command), lefthook.yml
  • run.sh — the local launcher that wraps just with port deconfliction; its profiles live in .settings/run-profiles/
  • Cargo.toml / Cargo.lock (workspace), rust-toolchain.toml, deny.toml, .cargo/
  • package.json, pnpm-workspace.yaml, pnpm-lock.yaml, biome.json, patches/
  • bin/ — Hermit-pinned toolchain (activate with . ./bin/activate-hermit)
  • .env.example, docker-compose.yml, docker-compose.harness.yml, Dockerfile, Dockerfile.push-gateway, Dockerfile.control-plane, prometheus.yml, preview-features.json, ct.yaml
  • Root docs: README.md, CONTRIBUTING.md, ARCHITECTURE.md, TESTING.md, RELEASING.md, NOSTR.md, SECURITY.md, GOVERNANCE.md, CODE_OF_CONDUCT.md, CHANGELOG.md, DIAGRAM.md, TASKS.md, VISION*.md
  • LICENSE — MIT, © STELLAR. The manifests that must agree with it: workspace Cargo.toml, crates/meridian-persona/Cargo.toml, the meridian-desktop clarification in deny.toml, and deploy/charts/meridian/Chart.yaml. Third-party attributions under REMAPPING/meridian-desktop/*/harness-logos/CREDITS.md carry upstream terms and are not ours to change
  • TASKS.md — the 10-week decentralized-relay program plan (MQTT/TBMQ bus displacement). Executes the ratified council call in DIAGRAM.md § Part III; it does not re-argue it. Beads remains the source of truth for task state — TASKS.md mirrors Bead IDs, never the reverse. Its MIP suite is governed by MIP-RG under docs/mips/
  • examples/ (countdown-bot, meadow-core), perf/ (relay bus scaling), schema/schema.sql, script/start, .vscode/
  • skills/ — canonical project skill suite (routing rules in skills/README.md), symlinked into .claude/skills/, .codex/skills/, and .agents/skills/ for runtime discovery; gated by just check-skills
  • REMAPPING/ is not owned here — it is a child of this doc with its own AGENTS.md, listed in the index above. It arrived as an embedded clone of the IHLC GitLab project (gitlab.ilab.zone/…/team-alpha/meridian); that nested .git has since been removed, so it is now ordinary untracked content of this repo and commits there land on git.office.ilab.zone like any other path

Beads Issue Tracker

This project uses bd (beads) for issue tracking. Run bd prime to see full workflow context and commands.

Quick Reference

bd ready              # Find available work
bd show <id>          # View issue details
bd update <id> --claim  # Claim work
bd close <id>         # Complete work

Rules

  • Use bd for ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists
  • Run bd prime for detailed command reference and session close protocol
  • Use bd remember for persistent knowledge — do NOT use MEMORY.md files

Architecture in one line: issues live in a local Dolt DB; sync uses refs/dolt/data on your git remote; .beads/issues.jsonl is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns.

Session Completion

When ending a work session, you MUST complete ALL steps below. Work is NOT complete until git push succeeds.

MANDATORY WORKFLOW:

  1. File issues for remaining work - Create issues for anything that needs follow-up
  2. Run quality gates (if code changed) - Tests, linters, builds
  3. Update issue status - Close finished work, update in-progress items
  4. PUSH TO REMOTE - This is MANDATORY:
    git pull --rebase
    git push
    git status  # MUST show "up to date with origin"
    
  5. Clean up - Clear stashes, prune remote branches
  6. Verify - All changes committed AND pushed
  7. Hand off - Provide context for next session

CRITICAL RULES:

  • Work is NOT complete until git push succeeds
  • NEVER stop before pushing - that leaves work stranded locally
  • NEVER say "ready to push when you are" - YOU must push
  • If push fails, resolve and retry until it succeeds