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>
76 KiB
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.yamlstarts 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.reservedlists 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 withinsearch_limitsteps of a starting point belongs inreserved.
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.tsxandRelayDirectoryBrowser.tsx("This build has no relay control plane configured, so…"), andCurrentRelayDetails.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 withinvalid_origininstead of up front.MERIDIAN_CONTROL_RELAY_OPERATOR_API_ORIGINand the relay's ownRELAY_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:PORTURLs 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), soREDIS_URL, therediscrate,deadpool-redis, LuaScriptcalls and pub/sub all keep working unchanged. Do not reintroduce aredisservice — the container ismeridian-dragonflyand the compose service isdragonfly(with aredisnetwork alias so in-networkredis://redis:6379still 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-checknow 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 setupfails early on this rather than leaving a half-working stack. - Profiles. Core (
postgres,dragonfly,minio*, the*-db-initone-shots) has no profile and always starts. Opt-in extras:tools→ adminer,auth→ keycloak,observability→ prometheus,artifacts/artifacts-s3→ reductstore. Selected withCOMPOSE_PROFILES. Do not put a core service behind a profile —just _ensure-servicesblocks onpostgres/dragonflyhealth 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) andartifacts-s3(MinIO behind it) are mutually exclusive: same host port, two backings of one tier.artifacts-s3needs a ReductStore Pro licence and a licensed image — the publicreduct/storeimage ignoresRS_REMOTE_*and silently boots OSS mode on local disk while reporting healthy, so the profile is gated by a blockingreduct-license-checkone-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-zenohis what refuses them. From v1.19 the tier joins a Zenoh network natively — a subscriber for writes, a queryable for reads — configured entirely byRS_ZENOH_*. Point eitherRS_ZENOH_SUB_KEYEXPRSorRS_ZENOH_QUERY_KEYEXPRSanywhere that overlaps the event key spacemeridian/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. Leavemode=out ofRS_ZENOH_CONFIGand it inherits Zenoh's own default ofpeer, 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_FILESmust come from the shell. Setting it inside.envis silently ignored (verified). TheJustfileexports it; that is whyjust uphonours.env.localand a baredocker compose up -ddoes not.- Compose merges sequences by appending. A plain
ports:in the override publishes the default and the relocation, recreating the collision. Tagports:/command:/volumes:with!overrideto replace. REDIS_HOST_PORTandREDIS_URLmust move together. Changing only one yieldsConnection 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
unsafecode - Do not introduce new
unwrap()orexpect()in production paths — use?and proper error types. Enforced byjust check-unwrap-budget: a per-crate ratchet inscripts/unwrap-budget.jsonthat 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, runjust check-unwrap-budget --write. The workspace holds 138 across 29 crates andmeridian-relayalone 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-50searchfilters are routed tomeridian-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 inperf/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. Withexpressit 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
putreturns 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
truein Zenoh 1.8, theeclipse-zenohwheel is built with the feature, andtransport_optimization.message_size_thresholdis 3,072 B. So every payload at 4 KB and above took a POSIX-SHM fast path — one the relay build cannot take, sinceshared-memoryis not in itszenohfeature 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.
expressis 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. Measuremeridian-ctgacross 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_nostris 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 read179–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.
Deep Links
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 protocole2e_media.rs— media upload/download (Blossom)e2e_media_extended.rs— extended media scenariose2e_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 usescripts/post-screenshots.shfor PNGs before linking them from a PR body/comment. If you hand-edit PR markdown, runscripts/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
- Kind
39000for channel metadata, not41— kind 41 is NIP-01 (unused). All kinds defined inmeridian-core/src/kind.rs. - Relay queries must specify
kinds— omittingkindstriggers the p-gate (403). Always include explicit kind filters. messages searchmust include--kinds— an open-ended search (no kinds) hits the relay p-gate and returns 403. Pass at least--kinds 9,45001,45003to scope the query.- Worktrees:
cdin the same command — shell CWD doesn't persist between tool calls. Usecd /path && cargo buildas one command. - Desktop crate excluded from root workspace —
cargo testat repo root does NOT run desktop tests. Usecargo test --manifest-path REMAPPING/meridian-desktop/src-tauri/Cargo.tomlexplicitly. - Desktop Tauri fmt fails in worktrees and blocks commits — the pre-commit hook runs
just desktop-tauri-fmt, which fails in git worktrees becausecargo fmtresolves workspace paths relative to the worktree root. Runjust desktop-tauri-fmtfrom the main checkout to apply the fix, then re-stage and commit. CI is unaffected. - React render perf:
React.memois 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{}/[]/Mapeach 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) derivedMap/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-keystrokeconsole.loginflate 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) andtext-3xs(0.5rem / 8px) meta-text tokens (inREMAPPING/meridian-desktop/tailwind.config.jsundertheme.extend.fontSize) for the sub-text-xsramp — timestamps, count badges, tracking labels, tiny glyphs. These replaced the dozens of arbitrarytext-[…rem]literals that had drifted apart pixel-by-pixel; keep meta text on these two tokens, not new arbitrary values. - ❌
text-[15px],text-[13px], CSSfont-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 rejectionresetRateLimitGate()— clears any active rate-limit window from the old relayclearAllDrafts()— message draft cacheresetAgentObserverStore()— agent observer relay storeresetActiveAgentTurnsStore()— active agent turn timersresetAgentWorkingSignal()— agent working indicator signalresetAvatarProfileSync()— pending verified-avatar profile writesresetAvatarPresentations()— avatar probes, previews, and Retry toastsresetRelayStatusNoticeState()— top relay status bar dismiss stateresetMediaCaches()— proxy port and relay origin cachesresetVideoPlayerState()— video player singletonresetRenderScopedReactionHydration()— reaction hydration cacheclearSearchHitEventCache()— search result event cacheclearMarkdownNodeCache()— markdown parse-node cacheresetEntityTags()— 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 boundaryREMAPPING/meridian-desktop/src/features/communities/useCommunityInit.ts—resetCommunityState(), applies config to Tauri backendREMAPPING/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 inlib/shared/ - Nostr models:
lib/shared/relay/nostr_models.dart— event kinds must stay in sync withREMAPPING/meridian-desktop/src/shared/constants/kinds.ts
Rules
- NEVER use
StatefulWidget— favor Riverpod for state and always useHookConsumerWidgetorConsumerWidgetwithflutter_hooksfor local state. - NEVER run
flutter run,flutter build,flutter clean, orflutter upgrade— onlyflutter test,flutter analyze, anddart formatare safe for agents to run. - Do NOT use
print()— usedebugPrint()or structured logging. - Prefer
context.colorsandcontext.textTheme(via theme extensions) over rawTheme.of(context)calls. - Keep widgets small and composable. One public widget per file; push
private sub-widgets (
_Foo) into siblingpartfiles under a<page>/folder rather than growing the page file. Hard ceiling: 1000 lines/file, enforced byREMAPPING/meridian-mobile/scripts/check-file-sizes.mjsviajust mobile-check(runs injust 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
Gridtokens for spacing,Radiifor 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 customProviderScope+MaterialAppwhen 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
- Read the root AGENTS.md
- Identify every file or folder you expect to touch
- Walk from the repository root to each target path
- Read every AGENTS.md found along each route
- If a parent AGENTS.md lists a child AGENTS.md whose scope contains the path, read that child and continue from there
- Use the nearest AGENTS.md as the local contract and parent docs for repo-wide rules
- 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
- Re-check changed paths against the STELLAR chain
- Update nearest owning docs and any affected parents or children
- Refresh every affected Child STELLAR Index
- Remove stale or contradictory text
- Run existing verification when relevant
- 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-approvalskill: 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 inskills/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.ymlrun.sh— the local launcher that wrapsjustwith 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: workspaceCargo.toml,crates/meridian-persona/Cargo.toml, themeridian-desktopclarification indeny.toml, anddeploy/charts/meridian/Chart.yaml. Third-party attributions underREMAPPING/meridian-desktop/*/harness-logos/CREDITS.mdcarry upstream terms and are not ours to changeTASKS.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.mdmirrors Bead IDs, never the reverse. Its MIP suite is governed byMIP-RGunderdocs/mips/examples/(countdown-bot,meadow-core),perf/(relay bus scaling),schema/schema.sql,script/start,.vscode/skills/— canonical project skill suite (routing rules inskills/README.md), symlinked into.claude/skills/,.codex/skills/, and.agents/skills/for runtime discovery; gated byjust check-skillsREMAPPING/is not owned here — it is a child of this doc with its ownAGENTS.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.githas since been removed, so it is now ordinary untracked content of this repo and commits there land ongit.office.ilab.zonelike 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
bdfor ALL task tracking — do NOT use TodoWrite, TaskCreate, or markdown TODO lists - Run
bd primefor detailed command reference and session close protocol - Use
bd rememberfor 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:
- File issues for remaining work - Create issues for anything that needs follow-up
- Run quality gates (if code changed) - Tests, linters, builds
- Update issue status - Close finished work, update in-progress items
- PUSH TO REMOTE - This is MANDATORY:
git pull --rebase git push git status # MUST show "up to date with origin" - Clean up - Clear stashes, prune remote branches
- Verify - All changes committed AND pushed
- Hand off - Provide context for next session
CRITICAL RULES:
- Work is NOT complete until
git pushsucceeds - 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