Multi-Site & Campus
Status: partially implemented (2026-09-06). No second site actually runs against this stack today, so "Site as data, not as infrastructure" and "When mesh-style networking would actually be justified" below are still design reasoning, not built systems. The command channel and MQTT reliability hardening described further down are now implemented — see those sections for what changed. This page still records the reasoning so none of it gets re-litigated (or re-guessed) later.
Site as data, not as infrastructure
Sites own data by topic prefix (see "Who sees which data" below).
point_history has no site column, and point identity is a flat
urn:point:{topic} namespace (ingest.py).
That flat namespace is actually the reason a campus rollup doesn't need new
infrastructure. Point and equip identity are derived straight from the MQTT
topic string, and per Windrow's own wire contract the topic is
f"{topic_prefix}/{point}" — topic_prefix already varies per Windrow
instance (e.g. fbf/mock-ahu-1/...). Point two Windrow instances at two
buildings at the same shared mosquitto/mqtt_listener/Postgres/Oxigraph
stack, give each a distinct topic_prefix (e.g. fbf/hospital-a/... vs
fbf/hospital-b/...), and their points/equipment never collide — no
migration, no new column, because identity was never global to begin with.
For campus-level filtering ("all faults across sites," "just Site B"),
tag each equip with a site Haystack tag through the ingestion path that
already exists (ingest_tags/ingest_equip_tags in ingest.py take an
unconstrained tag dict). site is then just a queryable property in
Oxigraph like any other tag — no new mechanism.
This only holds if sites can share one Postgres/Oxigraph. If a site must
stay data-sovereign or run standalone when disconnected, it needs its own
full stack, with the central campus stack pulling from it — the same
pull shape haystack_puller/sql_puller already implement, just pointed
at a peer Baler instead of a third-party source. That's a bigger
decision than this page covers and hasn't come up yet.
Who sees which data
Each site can own one or more topic prefixes (POST /auth/sites with
topic_prefixes, or PUT /auth/sites/{id}/topic-prefixes). A site owning
fbf/hospital-a owns every point urn:point:fbf/hospital-a/... and every
equip urn:equip:fbf/hospital-a/....
- No site anywhere owns a prefix: everyone sees everything. This is a single-tenant install, and it's how every install behaved before scoping existed.
- Your org owns prefixes: you see only those - your token's
site_ids' prefixes, or all of your org's if the token carries none. - Your org owns none (while another does): you see everything except what other orgs claimed. Scoping is per org, so one org partitioning its data doesn't blind the rest.
- Locations (Site, Building, Floor, Zone) have no prefix: one is visible
when it contains something you can see (through
hasPart, backwards alongfeeds,hasPoint), and hidden when it contains something another org claimed. - Out-of-scope records read as missing, never as "forbidden", so their
existence isn't revealed. Graph reads apply scope inside the query: to
the result and to every record a filter path reaches, so
spaceRef->dis == "x"can't test tags on a record you can't see. This applies to the Haystack API (/api: read, nav, hisRead, hisWrite, watches) and to/ingest/hisand/ingest/history. - Prefixes never overlap across orgs. Claiming
fbfwhile another org ownsfbf/hospital-a(or the reverse) is rejected, otherwise the broader claim would expose the other org's data. - First claim wins. In a shared instance, an org can claim a prefix no one owns yet, including one another tenant's devices already publish under. Provision each site's prefixes when you create the site.
Also scoped, by the same rule:
- Faults (
/fault/faults): the list shows only faults on your points; acking or snoozing another site's fault is a 404. - Derivations: a derivation's targets list, dry-run trace and target enable cover only equipment in your sites.
- Validation (
/validate/validate): only violations on your points and equipment, andconformsdescribes those alone.
Shared across the org (configuration, not site data): fault rules, webhooks and derivation definitions.
Known gap: a derivation's output points (urn:point:computed/...) belong to no site prefix, so
once scoping is on they don't appear in /api or /ingest/his reads. Read them unscoped (a
single-tenant install) until computed points are scoped through the equipment they attach to.
Why not a mesh network
Some building platforms connect sites as a peer-to-peer mesh: every node has its own identity, and any node can route requests (reads, writes, history, watches) to any other. That's the right shape when sites need to talk to each other.
Baler's topology is a star, not a mesh: N sites report up to one gateway/broker/store, and sites never need to talk to each other. A star doesn't need peer identity, routing, or an RPC surface between leaves; a plain broker's client/hub model already covers everything a star topology requires. Building mesh machinery (node keypairs, signed frames, a node-type registry) for a topology that's provably a star would be solving a problem this system doesn't have.
The property that does matter is that the site opens the connection outbound, so it doesn't fight building-network firewalls. MQTT already has that property. Once an MQTT client opens its session to the broker, either side can send over it, and the broker never needs a route back into the site's network. Infrastructure already in this stack covers the one topology this system actually has.
Reaching back into a site: commands, not just ingest
Windrow already has a real REST surface for exactly the kind of thing you'd
want to trigger remotely — POST /discover, POST /learn, POST /modbus/discover (takes a CIDR), device/credential management (see Windrow's
own docs/bacnet-discovery-api.md and
docs/periodic-discovery-and-credentials.md). The gap isn't capability,
it's reachability: that server binds inside the building, and a central
Baler has no inbound path to it, especially on a network that can
only originate connections outward and never receive them.
Implemented (2026-09-06): windrow.api subscribes to a command topic —
cmd/{site}/{instance}/# — on the same MQTT connection it already holds
open to publish readings, once started with --site-id (see Windrow's own
docs/command-channel.md). A command message (e.g.
{"action": "modbus_discover", "cidr": "10.2.0.0/24"}) gets dispatched
in-process to the same handle_discover/handle_learn/
handle_modbus_discover functions its own HTTP routes call, and the
outcome is published to cmd/{site}/{instance}/result. No new server, no
new port, no firewall exception — it rides the identical egress rule that
already lets Windrow publish telemetry today, because it's the same
connection.
The two things this needed to be safe once it controls real scans, not just telemetry, are both done:
- Persistent session (
clean_session=False, QoS 1) on Windrow's client, set once--site-idis given (fbf/src/windrow/mqtt_sink.py'sconnect()) — a command sent while the site is offline is queued by the broker instead of dropped. - Per-site broker ACLs (
mosquitto/acl).allow_anonymousis off; every client authenticates, and apattern readwrite cmd/%u/#ACL entry means a site's username is its command-topic scope — a client can only read/write its owncmd/<its own username>/#, enforced by the broker itself, not by convention. Live-verified: an authenticatedsite-aclient publishing tocmd/site-b/...is silently dropped by the broker — a subscriber oncmd/site-b/#never receives it.
Edge nodes: per-site broker accounts
A Windrow running at a site (a Raspberry Pi on the building network)
connects to this broker from outside the Docker host. It gets its own
account, made in Ops → Edge nodes (admin-only; API:
POST /auth/broker-accounts, see the auth API's OpenAPI spec):
- One account per node, tied to one site. It may publish only under
that site's topic prefixes (
<prefix>and<prefix>/…; a prefix is a whole topic level, sofbf/hospital-adoesn't coverfbf/hospital-ab), and publish/subscribe only on its owncmd/<username>/#. It can't read telemetry at all, not even its own site's. The broker enforces this (mosquitto's dynamic-security plugin), so one site's node can't write into another site's points. - Use the Windrow's
--site-idas the username - Windrow's command channel iscmd/{site-id}/{instance}/#, which is the account'scmd/<username>/#. Its--topic-prefixmust sit under the site's prefix. - The password is generated and shown once, with where to connect
(
BALER_MQTT_PUBLIC_HOST/_PORT/_TLS_PORTin.env). Baler stores only the account's metadata; the broker keeps a salted hash. New password rotates it (the old one stops working at once); Revoke deletes the account and disconnects the node. - Changing a site's topic prefixes re-applies its nodes' permissions.
If the broker is unreachable at that moment the prefixes are still saved
and the response says so (
broker_error); the next change retries.
Use TLS (port 8883) from outside the host. Set BALER_MQTT_TLS_DIR
to a directory holding the certificate (cert.pem + key.pem, or a
Caddy-style <host>.crt + <host>.key) and mosquitto also listens on
8883 with TLS, authenticating the same accounts. On nash-srv,
setup-runner.sh step 7 points it at the Let's Encrypt cert Caddy already
keeps for timberdoodle.nash.engineering and installs a systemd path unit
that restarts mosquitto when Caddy renews it; 8883 is published publicly
there while plain 1883 stays loopback-only. A node connects with
--mqtt-host timberdoodle.nash.engineering --mqtt-port 8883 and TLS on,
verifying against the system CA store (it's a public Let's Encrypt cert).
On a Raspberry Pi: Windrow's deploy/pi/install-edge.sh sets a Pi up
as a site gateway (Windrow itself), a simulated BACnet or Modbus device, or a
simulated weather station, each under systemd with the edge account's
credentials in /etc/windrow/edge.env. See Windrow's
docs/edge-nodes.md,
and Edge nodes for the runbook: which topology to use,
connecting the devices, and checking them end to end.
How it fits with the existing accounts: MOSQUITTO_USERS (the baler
backend account and any hand-added ones) stay in the password/ACL files.
The plugin's own file, dynamic-security.json on the mosquitto-data
volume, holds only edge accounts; a client it doesn't know falls through
to the files. The baler account manages the plugin over
$CONTROL/dynamic-security/v1 (granted in mosquitto/acl), so there's no
separate broker admin secret. Back up the mosquitto-data volume with the
rest: losing that file loses the edge accounts (re-create them; Ops still
lists them, and revoking tolerates an account the broker no longer has).
MQTT delivery guarantees
This matters more once sites are remote and links aren't as reliable as
one Docker network. This section used to describe several open gaps; as of
2026-09-06 all of them are closed. QoS 1 publish and disk spooling on
Windrow's side, and clean_session=False on mqtt_listener.py, turned out to
already be done by the time this page was checked against the code again
— the mosquitto queue cap below was the one piece still genuinely open:
fbf/src/windrow/mqtt_sink.py'spublish_reading()publishes at QoS 1, and spools to a local JSONL file (replayed on reconnect) when the client isn't currently connected — a link drop no longer means silently dropped readings on Windrow's side.mqtt_listener.py's client uses a stableclient_idandclean_session=False, so mosquitto's queued messages actually have a session to be queued against.fault_detector.pyandderivation_engine.pyauthenticate the same way (all three now go through the sharedbaler.mqtt_util.make_client()) but still use ephemeral sessions — they evaluate live telemetry, not a durable log, so losing a queued backlog across a restart is an accepted gap, unlike the command channel below where a dropped message means a scan silently never runs.mosquittonow runs with a real mounted config (mosquitto/mosquitto.conf) settingpersistence trueandmax_queued_messages 200000(up from the image default of 1000; 10000 until the scale test (#190) showed a listener restart overflowing it at 5k points) — seemosquitto/entrypoint.shfor how the password file is generated from.envat container start rather than committing hashed credentials.retain=Trueis still not a history buffer — it only caches the latest value per topic for new subscribers. The queue depth above is what actually protects an outage window, notretain.
200000 queued messages per client is still not unlimited — a busy multi-point site can still overflow it during a long enough outage. If that turns out to matter in practice, the next step is backfilling from BACnet/Modbus devices' own trend logs on reconnect rather than raising the cap indefinitely.
When mesh-style networking would actually be justified
If Baler deployments themselves needed to federate — separate orgs, data-sovereignty requirements forcing separate stores — and those deployments needed to query or command each other live, not just roll up for viewing, that's when a peer-to-peer mesh starts to earn its cost. That's a stronger requirement than campus aggregation and isn't implied by anything on the roadmap today; if it shows up, revisit this page and design that layer openly (on the Haystack protocol), rather than growing one by accident.

