Keep every seat's holder under a placement, and hold a user list that would lose a grant (issue 1945)

The user list of 2026-10-27 09:21 granted no holder for nine seats: the seeded
placements hold only the seats the controller had on record, and the grants part
read those holds as every holder (records.go heldWherePlaced). A placement's
holds are now the holders on record, as catalogue.World's Holdings are: a mesh
seat no placement holds is held by derivation, wherever its claimant is placed.

The membership loss guard (Losses) did not cover the user list: after take-over
the list that lost those seats went out. UserLosses applies the same guard per
user against the loaded list, and holdsBack holds a list that would lose a grant,
or whose loaded grants cannot be read, raising the held warning.

Quick fix chosen over moving the on-record rule into mesh-sdk: mesh-grants is the
only part reading placement holds itself; the others pass them to the catalogue.
This commit is contained in:
2026-10-27 20:54:30 +01:00
parent 24cb379434
commit bc1978ec67
8 changed files with 734 additions and 22 deletions
+104
View File
@@ -761,6 +761,62 @@ func TestGrants(t *testing.T) {
if read := grants.ReadInputs(b, &grants.Manifests{}); slices.ContainsFunc(read.Gaps, func(g grants.Gap) bool { return g.Input == grants.ReadPlacement && g.Failed }) {
t.Errorf("placement not on the bus is a failed read: %v", read.Gaps)
}
// Issue 1945, the user list of 2026-10-27 09:21: a placement's holds are the holders on record, not every holder.
// A mesh seat no placement holds keeps the module claiming it as its holder, wherever placement puts it.
in.Manifests["other"] = catalogue.Manifest{Module: "other", Version: "1", Claims: []catalogue.Claim{{Name: "other-seat", Scope: catalogue.ScopeMesh}},
DefinesSeats: []catalogue.SeatDeclaration{{Name: "other-seat", Scope: catalogue.ScopeMesh, Serves: []catalogue.Verb{{Name: "v"}}}}}
in.Assigned["b"] = append(in.Assigned["b"], "other")
in.Placed["b"] = delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "other"}}}
if recs, _ := grants.Records(in); holds(recs, "a") != 1 || holds(recs, "b") != 1 {
t.Errorf("a seat no placement holds is held on b %d times, want once (and store-seat on a alone: a %d)", holds(recs, "b"), holds(recs, "a"))
}
in.Placed["b"] = delivery.Placement{Node: "b", Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "other"}},
Holds: []delivery.Holding{{Claim: "other-seat", Scope: "mesh", Module: "someone-else"}}}
if recs, _ := grants.Records(in); holds(recs, "b") != 0 {
t.Errorf("a seat placement holds by another module is still held by other on b")
}
// The live shapes: the seed's novox placement (mesh-catalog 0c61bad, its twelve holdings, without
// anthropic-licence-manager), the licence manager's manifest (mesh-catalog c05aa5e6), and the subjects the bus
// grants novox.node-tools for its seat (nats_users, 2026-10-27 19:40Z).
file := func(name string, v any) {
raw, err := os.ReadFile(filepath.Join("testdata", name))
if err == nil {
err = json.Unmarshal(raw, v)
}
if err != nil {
t.Fatal(err)
}
}
var seed delivery.Placement
var live struct{ Users []grants.User }
file("placement-novox-0c61bad.json", &seed)
file("node-tools-novox-licence-subjects.json", &live)
licence, _ := os.ReadFile(filepath.Join("testdata", "claude-licence-manager.json"))
n := newFakeBus()
n.manifestOf("nats", natsManifest)
n.manifestOf("claude-licence-manager", string(licence))
n.assign("novox", "nats")
n.assign("novox", "claude-licence-manager")
n.manifestOf("node-tools", `{"module":"node-tools","version":"1"}`) // the machine's tool runner, which carries the seat
n.assign("novox", "node-tools")
n.put(grants.ReadHave, "novox.node", map[string]any{"v": 1})
pl, _ = delivery.EncodePlacement(seed)
n.states[grants.ReadPlacement] = map[string][]byte{"placement.novox": pl}
recs, _ := grants.Records(grants.ReadInputs(n, &grants.Manifests{}))
if m := grants.Memberships(recs)["novox.claude-licence-manager"]; len(m.Membership.Seats) == 0 {
t.Errorf("the licence manager's membership under the seeded placement carries no seats: %+v", m.Membership)
}
var runtime grants.User
for _, u := range shadowOf(t, n) {
if u.User == "novox.node-tools" {
runtime = u
}
}
for _, s := range live.Users[0].Subscribe {
if strings.Contains(s, ".call.") && !slices.Contains(runtime.Subscribe, s) {
t.Errorf("under the seeded placement novox.node-tools is not granted %s, which the bus grants it today", s)
}
}
})
t.Run("compare excuses a retired verb and a multiply claimed mesh seat's added subscriptions on its machines, nothing else", func(t *testing.T) {
@@ -787,6 +843,27 @@ func TestGrants(t *testing.T) {
t.Errorf("%s: equal %v, excused %v", row.name, c.Equal(), c.Excused)
}
}
// The user list's loss guard (issue 1945): the membership's Losses, per user of the list in force.
u := func(user string, responses bool, sub ...string) grants.User {
return grants.User{User: user, Subscribe: sub, Publish: []string{}, AllowResponses: responses}
}
for _, row := range []struct {
name string
inForce, now []grants.User
gone []string
lost int
}{
{"a seat grant dropped", []grants.User{u("a.node-tools", false, "x", store)}, []grants.User{u("a.node-tools", false, "x")}, nil, 1},
{"only added", []grants.User{u("a.node-tools", false, "x")}, []grants.User{u("a.node-tools", true, "x", store)}, nil, 0},
{"a retired verb dropped", []grants.User{u("a.node-tools", false, "x", retired)}, []grants.User{u("a.node-tools", false, "x")}, nil, 0},
{"responses taken away", []grants.User{u("a.node-tools", true, "x")}, []grants.User{u("a.node-tools", false, "x")}, nil, 1},
{"a user dropped", []grants.User{u("a.node-tools", false), u("a.mod-x", false)}, []grants.User{u("a.node-tools", false)}, nil, 1},
{"a user gone from placement", []grants.User{u("a.node-tools", false), u("a.mod-x", false)}, []grants.User{u("a.node-tools", false)}, []string{"a.mod-x"}, 0},
} {
if lost := grants.UserLosses(row.inForce, row.now, row.gone); len(lost) != row.lost {
t.Errorf("%s: lost %v, want %d", row.name, lost, row.lost)
}
}
})
t.Run("tools", func(t *testing.T) {
@@ -1068,6 +1145,33 @@ func TestGrants(t *testing.T) {
if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared {
t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh)
}
// Issue 1945, 2026-10-27 09:21: a list that would lose a seat grant of the list in force is held, as a
// membership that would lose one is: not written, the warning raised naming what it would lose; and a
// list whose loaded grants cannot be read is held too.
inForce := slices.Clone(b.loaded)
const seatGrant = "mesh.seat.anthropic-licence-manager.call.licences.*"
for i := range b.loaded {
if b.loaded[i].User == "a.mod-x" {
b.loaded[i].Subscribe = append(slices.Clone(b.loaded[i].Subscribe), seatGrant)
}
}
b.put(grants.ReadAssignments, "a.mod-y", map[string]any{"node": "a", "module": "mod-y", "assigned": "2026-10-27T08:20:00Z"})
b.put(grants.ReadAccounts, "a.mod-y", map[string]any{"user": "a.mod-y", "hash": "$2a$10$x", "generation": 1})
said = nil
res := p.Compose()
if res.Sealed == nil || !strings.Contains(res.Sealed.Why, "publishing would lose a.mod-x: subscribe") || !strings.Contains(res.Sealed.Why, seatGrant) {
t.Errorf("a list losing a seat grant = %+v", res.Sealed)
}
if now, _, _ := p.Sealed(); now.Digest != g.Digest {
t.Errorf("a list losing a seat grant was written: %s, was %s", now.Digest, g.Digest)
}
if len(said) != 1 || said[0].Key != grants.HeldCondition || said[0].State != asks.StateRaised || !strings.Contains(said[0].Summary, seatGrant) {
t.Errorf("the held list did not raise the warning naming the lost grant: %+v", said)
}
b.loaded = inForce
if p.Compose(); func() bool { now, _, _ := p.Sealed(); return now.Digest == g.Digest }() {
t.Errorf("a list that only adds a.mod-y was not written")
}
}
})
}
@@ -184,6 +184,41 @@ func sameMembership(was, now []byte) bool {
return reflect.DeepEqual(a, b)
}
// UserLosses is the same guard as a membership's (Losses) over the user list (issue 1945): everything a user of the list
// in force holds that the list composed now drops, `<user>: <what>`. A user the composed list lacks is lost whole unless
// gone (placement no longer names it); a subscription to a verb the seat catalogue retired is no loss, as in a
// membership (WithoutRetired); and a user allowed responses now where it was not is a gain, not a change. Empty means
// the composed list holds all the list in force did, and maybe more. Pure.
func UserLosses(inForce, composed []User, gone []string) []string {
now := map[string]User{}
for _, u := range composed {
now[u.User] = u
}
var out []string
for _, l := range inForce {
c, ok := now[l.User]
if !ok {
if !slices.Contains(gone, l.User) {
out = append(out, l.User+": the user, with every grant it holds")
}
continue
}
l.Subscribe = slices.DeleteFunc(slices.Clone(l.Subscribe), func(s string) bool {
m := seatSubject.FindStringSubmatch(s)
return m != nil && retiredVerb(m[1], m[2])
})
if c.AllowResponses {
l.AllowResponses = true
}
was, _ := json.Marshal(l)
is, _ := json.Marshal(c)
for _, lost := range Losses(was, is) {
out = append(out, l.User+": "+lost)
}
}
return out
}
// ShadowDifference opens what a membership's pass says when it was not published because it would lose something.
const ShadowDifference = "not published, a shadow difference: publishing would lose "
+3 -2
View File
@@ -236,11 +236,12 @@ func (p *Part) Compose() Result {
res.Errors = append(res.Errors, "the user list could not be composed whole: "+err.Error())
} else {
// The users the bus loaded: what a first list after take-over must keep where the part has not yet written one.
var bus []string
var bus, lost []string
if st.Compare.Unread == "" {
bus = names(loaded)
lost = UserLosses(loaded, users, st.Compare.Gone)
}
p.writeSealed(&res, list, n, leftOut, in, bus)
p.writeSealed(&res, list, n, leftOut, in, bus, lost)
}
ms := Memberships(recs)
res.Memberships = len(ms)
+30 -8
View File
@@ -61,7 +61,7 @@ func Records(in Inputs) (buscompose.Records, []Gap) {
}
d := declaredFor(m, seats, declarers)
if len(in.Placed) > 0 {
d.Holds = heldWherePlaced(d.Holds, in.Placed[node], m.Module)
d.Holds = heldWherePlaced(d.Holds, in.Placed, node, m.Module)
}
for _, sr := range catalogue.ReadsGranted(m, here, node) {
d.KeyedReads = append(d.KeyedReads, buscompose.KeyedRead{Read: sr.Read, Key: sr.Key})
@@ -304,21 +304,43 @@ func hearsOf(m catalogue.Manifest, declared map[string]catalogue.Manifest, node
return out, touches, onEvents
}
// heldWherePlaced is a module's holds on a node as the catalogue's placement says them (ADR 0549 §1, §3): a seat held once
// for the mesh is held there only when the node's placement names this module holding it, so a module claiming such a seat
// on two machines holds it on the one placement names, not on both; a seat of every node is held wherever the module runs.
func heldWherePlaced(holds []buscompose.Seat, p delivery.Placement, module string) []buscompose.Seat {
// heldWherePlaced is a module's holds on a node as the catalogue's placement says them (ADR 0549 §1, §3). A placement's
// holds are the holders on record, as catalogue.World's Holdings are: a seat held once for the mesh that some node's
// placement holds is held only where a placement names it (this node's naming this module), so a module claiming it on
// two machines holds it on the one placement names, not on both. A seat no placement holds is held by derivation, by the
// module claiming it wherever it is placed, as it was before placement: the seeded placements hold only the seats the
// controller had on record (twelve on the control-node), and treating their holds as every holder left nine seats with
// no holder in the user list of 2026-10-27 09:21 (issue 1945). A seat of every node is held wherever the module runs.
func heldWherePlaced(holds []buscompose.Seat, placed map[string]delivery.Placement, node, module string) []buscompose.Seat {
same := func(x delivery.Holding, h buscompose.Seat) bool {
return (x.Scope == "" || x.Scope == catalogue.ScopeMesh) && sameSeat(x.Claim, h.Name) && x.Kind == h.Kind
}
var out []buscompose.Seat
for _, h := range holds {
if h.Scope != "" && h.Scope != catalogue.ScopeMesh {
out = append(out, h)
continue
}
if slices.ContainsFunc(p.Holds, func(x delivery.Holding) bool {
return x.Claim == h.Name && x.Module == module && x.Kind == h.Kind
}) {
onRecord := false
for _, p := range placed {
if slices.ContainsFunc(p.Holds, func(x delivery.Holding) bool { return same(x, h) }) {
onRecord = true
break
}
}
if !onRecord || slices.ContainsFunc(placed[node].Holds, func(x delivery.Holding) bool { return same(x, h) && x.Module == module }) {
out = append(out, h)
}
}
return out
}
// sameSeat says two claims name one seat, under either of its names (catalogue.SeatNamed, ADR 0122).
func sameSeat(a, b string) bool {
if a == b {
return true
}
sa, oka := catalogue.SeatNamed(a)
sb, okb := catalogue.SeatNamed(b)
return oka && okb && sa.Name == sb.Name
}
+17 -12
View File
@@ -72,7 +72,7 @@ func followOf(state string) string {
// writeSealed puts the list sealed to the bus node and writes the key, each only when what it says changed. It says in
// res what it wrote, or why it wrote nothing.
func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs, bus []string) {
func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string, in Inputs, bus []string, lost []string) {
cur, _, had := p.Sealed()
next := cur
next.Follow = followOf(p.SwitchState())
@@ -91,10 +91,10 @@ func (p *Part) writeSealed(res *Result, list []byte, users int, leftOut []string
why = "no node runs the bus's module, so the list is sealed to nobody"
case key == "":
why = "the bus node's have of the bus module names no key yet (its node-engine says it from this release)"
case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus) != "":
case next.Follow == delivery.FollowGrants && cur.Digest != digest && p.holdsBack(in, leftOut, bus, lost) != "":
// After take-over the list in force is never replaced by one composed past a failed read, or one that drops a user
// of it for want of a hash: that user's module would be locked out of the bus (review of the switch).
why = p.holdsBack(in, leftOut, bus)
why = p.holdsBack(in, leftOut, bus, lost)
held = why
case cur.Digest != digest || cur.SealedTo != delivery.SealingKeyID(key) || cur.Node != in.BusNode:
object, err := delivery.SealUserList(list, key)
@@ -297,14 +297,21 @@ func listUsers(list []byte) []string {
return out
}
// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, or
// a user of the list in force is left out for want of a hash. The list in force is the one the part wrote, by the users
// holdsBack says why a list composed now may not replace the one in force after take-over: a read failed this pass, a
// user of the list in force is left out for want of a hash, or the list would lose a grant of the list in force (lost,
// UserLosses: the membership's guard, issue 1945; nil when the loaded list could not be read, which holds it too). The list in force is the one the part wrote, by the users
// it kept of it; where it kept none (a list written before this release), the users the bus loaded (bus, nil when
// they could not be read). Empty when it may.
func (p *Part) holdsBack(in Inputs, leftOut, bus []string) string {
func (p *Part) holdsBack(in Inputs, leftOut, bus, lost []string) string {
if f := in.ReadFailures(); len(f) > 0 {
return "held: the list in force is kept while a read of this pass failed (" + strings.Join(f, "; ") + ")"
}
if bus == nil {
return "held: the grants of the list in force cannot be read, so a loss of seats cannot be ruled out"
}
if len(lost) > 0 {
return "held, " + ShadowDifference + strings.Join(lost, "; ")
}
raw, _, found, err := p.Store.Get(StateUserList, KeyWrittenUsers)
if err != nil {
return "held: the users of the list in force cannot be read: " + err.Error()
@@ -313,19 +320,17 @@ func (p *Part) holdsBack(in Inputs, leftOut, bus []string) string {
switch {
case found:
_ = json.Unmarshal(raw, &had)
case bus == nil:
return "held: the users of the list in force are not known: the part kept none, and the loaded list could not be read"
default:
had = bus
}
var lost []string
var unhashed []string
for _, u := range leftOut {
if slices.Contains(had, u) {
lost = append(lost, u)
unhashed = append(unhashed, u)
}
}
if len(lost) > 0 {
return "held: the list in force is kept, since " + strings.Join(lost, ", ") + " would be left out for want of a hash in the vault"
if len(unhashed) > 0 {
return "held: the list in force is kept, since " + strings.Join(unhashed, ", ") + " would be left out for want of a hash in the vault"
}
return ""
}
+184
View File
@@ -0,0 +1,184 @@
{
"module": "claude-licence-manager",
"version": "1",
"slug": "licmgr",
"requires": [
"postgres-database",
"secret"
],
"contributes": {
"postgres-database": {
"name": "claude_licences",
"readable": [
"audit",
"binding"
]
}
},
"binds": {
"postgres-database": "${dir:state}/database.json"
},
"secrets": {
"postgres-database": "${dir:state}/database.secret",
"secret": {
"grant-key": "${dir:state}/grant.key"
}
},
"seats": [
{
"name": "anthropic-licence-manager",
"scope": "mesh",
"serves": [
"licences",
"bindings",
"bind",
"switch",
"release",
"refresh",
"usage",
"adopt",
"current",
"public-key"
]
}
],
"claims": [
{
"name": "anthropic-licence-manager",
"scope": "mesh",
"serves": [
"licences",
"bindings",
"bind",
"switch",
"release",
"refresh",
"usage",
"adopt",
"current",
"public-key"
]
}
],
"emits": [
"licence.adopted",
"licence.refused",
"licence.bound",
"licence.switched",
"licence.released",
"licence.rotated",
"licence.failing",
"usage.read"
],
"state": [
"bindings"
],
"reads": [
"claude-code.holdings"
],
"config": {
"cadence_minutes": {
"type": "number",
"description": "How old a licence's grant may grow before it is rotated, in minutes; 0 keeps 240.",
"minimum": 0,
"trusted": true,
"default": 240
},
"floor_minutes": {
"type": "number",
"description": "How little time a grant may have left before it is refreshed in any case, in minutes; 0 keeps 60.",
"minimum": 0,
"trusted": true,
"default": 60
},
"failures_to_notify": {
"type": "number",
"description": "How many refreshes of a licence fail in a row before the operator is told, a count; 0 keeps 3.",
"minimum": 0,
"trusted": true,
"default": 3
},
"cooldown_hours": {
"type": "number",
"description": "How long after telling the operator about a licence nothing more is said about it, in hours; 0 keeps 24.",
"minimum": 0,
"trusted": true,
"default": 24
},
"refresh_warn_days": {
"type": "number",
"description": "How long before a licence's refresh token itself expires the operator is warned, in days; 0 keeps 3.",
"minimum": 0,
"trusted": true,
"default": 3
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": ".",
"unknown": "program"
},
{
"id": "database-url",
"type": "file",
"path": "${dir:state}/database.url",
"mode": "0600",
"template": "files/database.url"
},
{
"id": "settings",
"type": "file",
"path": "${dir:state}/settings.json",
"mode": "0600",
"takes-config": ["cadence_minutes", "floor_minutes", "failures_to_notify", "cooldown_hours", "refresh_warn_days"],
"merge": "json",
"template": "files/settings.json"
},
{
"id": "prepare",
"type": "process",
"name": "claude-licence-manager-prepare",
"artifact": "code",
"run": [
"./claude-licence-manager",
"prepare"
],
"run-once": true,
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url"
},
"restart-on": [
"database-url"
]
}
],
"build": {
"artifacts": [
{
"name": "code",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/claude-licence-manager",
"binary": "claude-licence-manager",
"loads": [
"claude-licence-manager"
],
"env": {
"DATABASE_URL_FILE": "${dir:state}/database.url",
"MESH_LICENCE_STATE": "${dir:state}",
"MESH_LICENCE_KEY_FILE": "${dir:state}/grant.key",
"MESH_LICENCE_SETTINGS": "${dir:state}/settings.json"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -0,0 +1,35 @@
{
"users": [
{
"user": "novox.node-tools",
"subscribe": [
"$SRV.INFO.anthropic-licence-manager",
"$SRV.INFO.anthropic-licence-manager.>",
"$SRV.PING.anthropic-licence-manager",
"$SRV.PING.anthropic-licence-manager.>",
"$SRV.STATS.anthropic-licence-manager",
"$SRV.STATS.anthropic-licence-manager.>",
"mesh.seat.anthropic-licence-manager.call.adopt.*",
"mesh.seat.anthropic-licence-manager.call.bind.*",
"mesh.seat.anthropic-licence-manager.call.bindings.*",
"mesh.seat.anthropic-licence-manager.call.current.*",
"mesh.seat.anthropic-licence-manager.call.licences.*",
"mesh.seat.anthropic-licence-manager.call.public-key.*",
"mesh.seat.anthropic-licence-manager.call.refresh.*",
"mesh.seat.anthropic-licence-manager.call.release.*",
"mesh.seat.anthropic-licence-manager.call.switch.*",
"mesh.seat.anthropic-licence-manager.call.usage.*",
"mesh.seat.anthropic-licence-manager.tool.adopt",
"mesh.seat.anthropic-licence-manager.tool.bind",
"mesh.seat.anthropic-licence-manager.tool.bindings",
"mesh.seat.anthropic-licence-manager.tool.current",
"mesh.seat.anthropic-licence-manager.tool.licences",
"mesh.seat.anthropic-licence-manager.tool.public-key",
"mesh.seat.anthropic-licence-manager.tool.refresh",
"mesh.seat.anthropic-licence-manager.tool.release",
"mesh.seat.anthropic-licence-manager.tool.switch",
"mesh.seat.anthropic-licence-manager.tool.usage"
]
}
]
}
@@ -0,0 +1,326 @@
{
"v": 1,
"node": "novox",
"modules": [
{
"module": "artifact-store-tools"
},
{
"module": "avahi"
},
{
"module": "ca-trust"
},
{
"module": "claude-code"
},
{
"module": "claude-licence-manager"
},
{
"module": "dbus"
},
{
"module": "de-spiegel"
},
{
"module": "desk-channel"
},
{
"module": "dev-tools"
},
{
"module": "disk-load"
},
{
"module": "distribution"
},
{
"module": "dnsmasq"
},
{
"module": "docker"
},
{
"module": "fail2ban"
},
{
"module": "gitea"
},
{
"module": "go"
},
{
"module": "hostname"
},
{
"module": "invoicing"
},
{
"module": "kernel"
},
{
"module": "keycloak"
},
{
"module": "link2pay"
},
{
"module": "localization"
},
{
"module": "logrotate"
},
{
"module": "mailu"
},
{
"module": "mesh-build-go"
},
{
"module": "mesh-build-node"
},
{
"module": "mesh-catalog"
},
{
"module": "mesh-cli"
},
{
"module": "mesh-config"
},
{
"module": "mesh-controller"
},
{
"module": "mesh-delivery-checker"
},
{
"module": "mesh-delivery-forge-bridge"
},
{
"module": "mesh-delivery-gate"
},
{
"module": "mesh-delivery-indexer"
},
{
"module": "mesh-delivery-teller"
},
{
"module": "mesh-delivery-watchman"
},
{
"module": "mesh-files"
},
{
"module": "mesh-grants"
},
{
"module": "mesh-host"
},
{
"module": "mesh-issues"
},
{
"module": "mesh-knowledge"
},
{
"module": "mesh-mail"
},
{
"module": "mesh-mail-reader"
},
{
"module": "mesh-mail-web"
},
{
"module": "mesh-provisioner"
},
{
"module": "mesh-provisioner-client"
},
{
"module": "mesh-reads"
},
{
"module": "mesh-vault"
},
{
"module": "mesh-wireguard"
},
{
"module": "messenger"
},
{
"module": "minio"
},
{
"module": "mongodb"
},
{
"module": "mssql"
},
{
"module": "nats"
},
{
"module": "netcheck"
},
{
"module": "nextcloud"
},
{
"module": "nftables"
},
{
"module": "node-env"
},
{
"module": "node-tools"
},
{
"module": "nodejs"
},
{
"module": "only-office"
},
{
"module": "pacman"
},
{
"module": "photos"
},
{
"module": "postgres"
},
{
"module": "power"
},
{
"module": "powerlevel10k"
},
{
"module": "records"
},
{
"module": "restic"
},
{
"module": "route-proxy"
},
{
"module": "ssh-client"
},
{
"module": "sshd"
},
{
"module": "step-ca"
},
{
"module": "sudo"
},
{
"module": "systemd"
},
{
"module": "systemd-networkd"
},
{
"module": "telegram"
},
{
"module": "time-sync"
},
{
"module": "umami"
},
{
"module": "website"
},
{
"module": "zsh"
},
{
"module": "zsh-autosuggestions"
},
{
"module": "zsh-syntax-highlighting"
}
],
"holds": [
{
"claim": "git",
"scope": "mesh",
"module": "gitea",
"site": "hosting"
},
{
"claim": "mesh-artifact-store",
"scope": "mesh",
"module": "distribution",
"site": "hosting"
},
{
"claim": "mesh-broker",
"scope": "mesh",
"module": "nats",
"site": "hosting"
},
{
"claim": "mesh-catalog",
"scope": "mesh",
"module": "mesh-catalog",
"site": "hosting"
},
{
"claim": "mesh-config",
"scope": "mesh",
"module": "mesh-config",
"site": "hosting"
},
{
"claim": "mesh-controller",
"scope": "mesh",
"module": "mesh-controller",
"site": "hosting"
},
{
"claim": "mesh-delivery",
"scope": "mesh",
"module": "mesh-delivery-gate",
"site": "hosting"
},
{
"claim": "mesh-dns-resolver",
"scope": "mesh",
"module": "dnsmasq",
"site": "hosting"
},
{
"claim": "mesh-provisioner",
"scope": "mesh",
"module": "mesh-provisioner",
"site": "hosting"
},
{
"claim": "mesh-store",
"scope": "mesh",
"module": "postgres",
"site": "hosting"
},
{
"claim": "mesh-vault",
"scope": "mesh",
"module": "mesh-vault",
"site": "hosting"
},
{
"claim": "npm-package-registry",
"scope": "mesh",
"module": "gitea",
"site": "hosting"
}
]
}