Repository navigation
refactor!: an edge site is a Thing; drop leaf_nodes and leaf-sync - #11
Merged
Merged
Conversation
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>
This was referenced Sep 16, 2026
`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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
An edge site is a Thing. This drops the
leaf_nodesauth collection, thecmd/leaf-syncbinary 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
leaf-synccopied 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.domaincolumn and one provisioned NATS user.thing_typesalready says whether a device is a gateway, and a second marker is a second thing to get wrong.domainwas 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 tothings, 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
$SYSaccount 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_*ornebula_*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.CONNZover the browser's own in-account connection and matcheskind: "Leafnode"entries bynameto a Thing'scode(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
$SYSasymmetry, pinned by a testEach account carries its own
$SYSsubject 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.goasserts 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:$SYS.REQ.SERVERendpoints;The third is the trap. A
nats_rolesentry 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.gosays so at the one place that sets it.Migrations
migrations/schema_update_drop_leaf_nodes.gore-importsschema.jsonand then deletes the collection — a re-import alone cannot remove one.It deliberately leaves the orphaned
nats_usersandnebula_hostsrows 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.goran a fatalUPDATE {{leaf_nodes}}backfill that breaks a freshmigrate uponce the table is gone. Caught by the new migration test, not by the build.schema_update_unique_org_code.golisted the collection in a sweep that would log "table not present yet" about a table that is never coming.The file names stay. The
_migrationsledger and theschema_versionreadiness 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.gorestores the pre-drop shape fromtestdata/through the real importer first — a fresh database imports the already-thinnedschema.json, so a removal migration tested bymigrate upreaches nothing to remove and passes without touching anything. It also pinsTestNothingRelatesIntoLeafNodes, since PocketBase refuses to delete a collection with relations still pointing at it.Upgrade order
Agents first, Control Plane second. An older
leaf-synckeeps running on its already-generatednats-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;gofmtclean against git-stored content.vue-tsc && vite buildclean;npm test174 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 tothings, one added asserting a superuser gets 404 onleaf_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