Skip to content

refactor!: an edge site is a Thing; drop leaf_nodes and leaf-sync - #11

Merged
skeeeon merged 3 commits into
mainfrom
refactor/drop-leaf-sync
Sep 16, 2026
Merged

skeeeon merged 3 commits into
mainfrom
refactor/drop-leaf-sync

Conversation

@skeeeon

@skeeeon skeeeon commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

An edge site is a Thing. This drops the leaf_nodes auth collection, the cmd/leaf-sync binary and the config mirror, and replaces the site-connectivity view with a live query to NATS.

The agent that ran those leaf nodes now lives in stone-age-io/agent and absorbs the binary; the docs follow in stone-age-io/platform-docs.

Why each of the three went

  • The mirror. leaf-sync copied an organization's inventory collections into the edge's local KV so devices could read them offline. Nothing consumed the mirrored rows — no rule-router rule, no firmware — so it was moving data nobody asked for, and it was the reason an edge identity needed read grants spread across the inventory. If a real consumer appears, the thing to build is that consumer's read path, not a general mirror.
  • The collection. With the mirror gone, a leaf node was a Thing with a domain column and one provisioned NATS user. thing_types already says whether a device is a gateway, and a second marker is a second thing to get wrong. domain was a second copy of the code that could disagree with it — and when it did, the site simply stopped appearing.
  • leaf_status. The heartbeat travelled over the very link whose failure it reported, so a missing beat could not distinguish "edge box down" from "WAN down" from "agent crashed". The Control Plane could not read it anyway: it holds the operator and $SYS, and no credential inside any tenant account.

What replaces them

GET /api/me/leaf-config (hooks/leaf_config_routes.go), bound to things, taking no record id. Ten named fields — everything a NATS leaf server needs to come up.

It gates on nothing, deliberately. Everything it serves is either public trust material (the operator, account and $SYS account JWTs, which every server in the network validates) or the caller's own credential, which it must already hold to connect at all. A Thing that will never run a leaf can ask and learns nothing it could not already read. A gate here would be a permission over data that is not secret — and it would need the marker field this change exists to avoid inventing.

The consequence worth keeping: a gateway needs no read grant on any nats_* or nebula_* collection. The edge's blast radius is a fixed list of named fields rather than a consequence of rules that change for unrelated reasons.

The JetStream domain is the Thing's code, computed and never stored. No edge- prefix and no org-code segment: JetStream is already inside the account, so the account is the namespace.

Site connectivity is asked of NATS, not stored. The console requests $SYS.REQ.ACCOUNT.PING.CONNZ over the browser's own in-account connection and matches kind: "Leafnode" entries by name to a Thing's code (ui/src/composables/useLeafConnections.ts). The hub always knows which leaves it is holding, so ask the hub.

It renders as one row in the Thing page's NATS card, not a separate screen. An "Edge Sites" list view was built during this change and deleted: a site is a Thing, it already has a list, and a second inventory list is a second list that can disagree with the first. The badge's empty state is neutral none, not error-red — the console cannot distinguish a down gateway from a Thing that will never run a leaf at all.

The $SYS asymmetry, pinned by a test

Each account carries its own $SYS subject space, so $SYS.REQ.ACCOUNT.PING.* answers for that organization and no other, while $SYS.REQ.SERVER.PING.* spans every tenant and is operator-only. internal/health/leaf_visibility_test.go asserts all three halves against a real hub with a real leaf attached, because fixtures cannot answer a question about the server's own trust decision:

  1. a tenant can read CONNZ for its own account;
  2. it cannot reach the operator-wide $SYS.REQ.SERVER endpoints;
  3. a publish DENY beats a publish ALLOW.

The third is the trap. A nats_roles entry carrying $SYS.> in its publish deny list cannot reach the account endpoints no matter what its allow list says — and the symptom is a plain request timeout, with the real reason arriving asynchronously on the connection's error handler and never on the request. Deny $SYS.REQ.SERVER.> instead. internal/demoseed/contract.go says so at the one place that sets it.

Migrations

migrations/schema_update_drop_leaf_nodes.go re-imports schema.json and then deletes the collection — a re-import alone cannot remove one.

It deliberately leaves the orphaned nats_users and nebula_hosts rows working, and reports them. A leaf node's relations are all non-cascade, so deleting the collection strands two signed, live credentials. Revoking them here would have been the tidy thing to do and the wrong one: those credentials are what the edge boxes at real sites are connected with right now, and a migration that runs on deploy is not a place to take a fleet off the bus. Deactivate them from the console once each site runs against its Thing — deactivate, not delete, since revoking a Nebula certificate needs it in the database to fingerprint.

Two historical migrations were edited in place, which needs saying out loud:

  • schema_update_device_active_flag.go ran a fatal UPDATE {{leaf_nodes}} backfill that breaks a fresh migrate up once the table is gone. Caught by the new migration test, not by the build.
  • schema_update_unique_org_code.go listed the collection in a sweep that would log "table not present yet" about a table that is never coming.

The file names stay. The _migrations ledger and the schema_version readiness check key on them, so deleting the files would 503 every existing deployment. Both files carry a comment explaining the edit.

migrations/drop_leaf_nodes_test.go restores the pre-drop shape from testdata/ through the real importer first — a fresh database imports the already-thinned schema.json, so a removal migration tested by migrate up reaches nothing to remove and passes without touching anything. It also pins TestNothingRelatesIntoLeafNodes, since PocketBase refuses to delete a collection with relations still pointing at it.

Upgrade order

Agents first, Control Plane second. An older leaf-sync keeps running on its already-generated nats-leaf.conf — the bus does not drop — but it can no longer log in or re-fetch anything.

Verification

  • go build / go vet / go test ./... pass; gofmt clean against git-stored content.
  • vue-tsc && vite build clean; npm test 174 passed across 10 files.
  • ./scripts/check-sort-fields.sh — 54 terms resolve.
  • ./scripts/test-authz.sh — 194 passed, 0 failed (was 204: 11 leaf checks removed, the code-freeze pair moved to things, one added asserting a superuser gets 404 on leaf_nodes).

Merge with agent: stone-age-io/agent#4 and docs: stone-age-io/platform-docs#6 — the three land together.

🤖 Generated with Claude Code

The `leaf_nodes` auth collection, the `leaf-sync` binary and the config
mirror all go. A site that runs a NATS leaf node is an ordinary Thing whose
agent has its leaf capabilities turned on; the agent itself now lives in
stone-age-io/agent.

Three things went, each for its own reason:

- The mirror. `leaf-sync` copied an org's inventory collections into the
  edge's local KV so devices could read them offline. Nothing consumed the
  mirrored rows -- no rule, no firmware -- so it was moving data nobody
  asked for, and it was the reason an edge identity needed read grants
  spread across the inventory.
- The collection. With the mirror gone, a leaf node was a Thing with a
  `domain` column and one provisioned NATS user. `thing_types` already says
  whether a device is a gateway, and `domain` was a second copy of the code
  that could disagree with it.
- `leaf_status`. The heartbeat travelled over the very link whose failure it
  reported, so a missing beat could not tell "edge box down" from "WAN down"
  from "agent crashed" -- and the Control Plane could not read it anyway,
  holding the operator and $SYS and no credential inside any tenant account.

What replaces them:

- GET /api/me/leaf-config (hooks/leaf_config_routes.go), bound to `things`,
  taking no record id. Ten named fields. It gates on NOTHING: everything it
  serves is public trust material or the caller's own credential, so a gate
  would be a permission over data that is not secret -- and would have
  needed the marker field this change exists to avoid inventing.
- The JetStream domain is computed from the Thing's code, never stored. No
  `edge-` prefix and no org segment: JetStream is already inside the
  account, so the account is the namespace.
- Site connectivity is asked of NATS. The console requests
  $SYS.REQ.ACCOUNT.PING.CONNZ over the browser's own in-account connection
  and matches `kind: "Leafnode"` entries by name to a Thing's code
  (ui/src/composables/useLeafConnections.ts). It renders as one row in the
  Thing page's NATS card -- not a separate screen, because a site is a Thing
  and a second inventory list can disagree with the first.

internal/health/leaf_visibility_test.go pins the three nats-server
behaviours that view rests on, against a real hub with a real leaf attached:
that a tenant can read CONNZ for its own account, that it cannot reach the
operator-wide $SYS.REQ.SERVER endpoints, and that a publish DENY beats a
publish ALLOW -- which surfaces as a bare request timeout, so it is exactly
the trap someone re-tightening `nats_roles` would spend a day on.

Migration notes:

- schema_update_drop_leaf_nodes.go re-imports and then deletes the
  collection. It deliberately LEAVES the orphaned nats_users and
  nebula_hosts rows working, and lists them: those are live credentials at
  real sites, and a migration that runs on deploy is not where you take a
  fleet off the bus. Deactivate from the console once each site runs against
  its Thing -- deactivate, not delete, since revoking a Nebula certificate
  needs it in the database to fingerprint.
- Two historical migrations were edited in place. device_active_flag ran a
  fatal `UPDATE leaf_nodes` that breaks a fresh `migrate up` once the table
  is gone; unique_org_code listed it in a sweep that would log "table not
  present yet" about a table that is never coming. The file NAMES stay --
  the _migrations ledger and the schema_version readiness check key on them,
  so deleting the files would 503 every existing deployment.

Upgrade order is agents first, Control Plane second. An older `leaf-sync`
keeps running on its already-generated nats-leaf.conf but can no longer log
in or re-fetch anything.

test-authz.sh: 194 passed, 0 failed (was 204; 11 leaf checks removed, the
code-freeze pair moved to `things`, one added asserting a superuser gets 404
on leaf_nodes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
skeeeon and others added 2 commits September 15, 2026 22:30
`cd ui && npm run build` writes the real console over this file, and
verifying the UI change in the previous commit is exactly what did it.
The placeholder is tracked because main.go embeds the directory, so an
empty one is a compile error on a fresh clone -- and CI greps for the
word to catch precisely this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
None of these changes behaviour; all three would mislead someone reading to
find out what is there.

- CLAUDE.md said test-authz.sh asserts 198 authorization behaviours. The
  script says 194, and said 204 when this line already said 198 -- it has
  drifted in both directions, which is what a hand-copied count does. Point at
  EXPECTED_CHECKS instead of keeping a second copy.
- README.md said 17 widget types. WIDGET_TYPES in ui/src/types/dashboard.ts
  has 16; the CHANGELOG records the drop ("17 widget types are now 16") and
  this line was missed at the time.
- ui/README.md listed a PocketBase config panel, which went with that widget,
  and omitted ConfigStreamTable.vue, which exists. CLAUDE.md's tree said 23
  files in dashboard/config/; there are 22, and 23 matched neither before nor
  after the drop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@skeeeon
skeeeon merged commit 3c6c4c8 into main Sep 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant