indexer, mesh-catalog contract: an unwatch of a branch a placement names is refused as placed

The indexer asks the seat mesh-module-assigner's verb placement (input {module}, answer {placements: [{branch, nodes}]}, A-62)
before an unwatch is proposed and refuses one whose branch a placement names, with the reason placed (new in the seat
mesh-catalog's contract) naming the placement; its manifest invokes the verb.

Builds: design 47 G §704 (unwatch), A-62
Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
This commit is contained in:
2026-11-11 23:04:51 +01:00
parent 676d4bace4
commit 626a824e65
10 changed files with 60 additions and 5 deletions
@@ -98,6 +98,17 @@ func newRegistry(js jetstream.JetStream, conn *nats.Conn, cred process.Account,
Say: func(_ context.Context, e meshcatalog.RegistrationChangedEvent) error {
return publishAs(conn, cred, seatEventSubject(meshcatalog.EventRegistrationChanged), fmt.Sprintf("%s-%s-%d", meshcatalog.EventRegistrationChanged, e.Module, e.Version), e)
},
Placement: func(module string) ([]mma.ModulePlacement, error) {
raw, err := call("seat:"+mma.Seat+"."+mma.VerbPlacement, mma.PlacementInput{Module: module})
if err != nil {
return nil, err
}
var a mma.PlacementAnswer
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("the answer of %s.%s is not its seat's: %w", mma.Seat, mma.VerbPlacement, err)
}
return a.Placements, nil
},
Branch: func(repository, branch string) (gitseat.BranchAnswer, error) {
var a gitseat.BranchAnswer
raw, err := call("seat:"+gitseat.Seat+"."+gitseat.VerbBranch, gitseat.BranchInput{Repository: repository, Branch: branch})
@@ -11,6 +11,7 @@ import (
"sync"
"time"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
@@ -123,6 +124,9 @@ type Registry struct {
Say func(ctx context.Context, e meshcatalog.RegistrationChangedEvent) error
// Branch reads a branch through the seat git; an empty branch asks the repository's default branch.
Branch func(repository, branch string) (gitseat.BranchAnswer, error)
// Placement reads a module's placements through the seat mesh-module-assigner's verb placement: one per watched branch a row
// of its assignments names, with the nodes that run the module from it (design 47 G §704, A-62).
Placement func(module string) ([]mma.ModulePlacement, error)
// Ask publishes an ask under the indexer's own name, Cancel takes it back, Where reads the router's own record of an
// ask, read under the indexer's module's name (state, and the warrant when it holds one; none when it holds no ask).
Ask func(a oc.Ask) error
@@ -417,6 +421,22 @@ func (r *Registry) Propose(ctx context.Context, verb, caller string, change json
if verb != verbRegister && !found {
return refused(module, meshcatalog.RegistrationAskedRefusedUnknownModule, module+" is not registered: register it first"), nil
}
// An unwatch of a branch a placement names is refused, naming the placement (§704, A-62).
if verb == verbUnwatch {
if r.Placement == nil {
return meshcatalog.RegistrationAsked{}, errors.New("the placements of a module are not asked of the seat mesh-module-assigner, so no branch is unwatched")
}
placed, err := r.Placement(module)
if err != nil {
return meshcatalog.RegistrationAsked{}, fmt.Errorf("the placements of %s are not read: %w", module, err)
}
for _, p := range placed {
if p.Branch == branch {
return refused(module, meshcatalog.RegistrationAskedRefusedPlaced,
fmt.Sprintf("the branch %s of %s is placed: %s run it from that branch", branch, module, strings.Join(p.Nodes, ", "))), nil
}
}
}
// register with no branch takes the repository's default branch (§342).
if verb == verbRegister {
var in meshcatalog.RegisterInput
@@ -8,6 +8,7 @@ import (
"testing"
"time"
mma "git.novox.be/novox/mesh-contracts/go/seats/meshmoduleassigner"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
gitseat "git.novox.be/novox/mesh-delivery/contracts/go/seats/git"
@@ -26,6 +27,7 @@ type regRig struct {
whereErr error
chosen map[string]string // ask id -> option the router's record holds
missing map[string]bool
placed map[string][]mma.ModulePlacement // module -> its placements, as the seat mesh-module-assigner's placement answers
}
func newRegRig() *regRig {
@@ -45,8 +47,9 @@ func newRegRig() *regRig {
}
return gitseat.BranchAnswer{Branch: branch, Repository: repo, Protected: !g.unprotect[branch]}, nil
},
Ask: func(a oc.Ask) error { g.asks[a.ID] = a; return nil },
Cancel: func(id string) error { g.cancelled = append(g.cancelled, id); return nil },
Placement: func(module string) ([]mma.ModulePlacement, error) { return g.placed[module], nil },
Ask: func(a oc.Ask) error { g.asks[a.ID] = a; return nil },
Cancel: func(id string) error { g.cancelled = append(g.cancelled, id); return nil },
Where: func(id string) (string, *oc.Warrant, error) {
if g.whereErr != nil {
return "", nil, g.whereErr
@@ -176,6 +179,13 @@ func TestRegistrationsAreWrittenOnTheirWarrantOnly(t *testing.T) {
delete(g.unprotect, "beta")
w, _ = g.r.Propose(ctx, "watch", "s@n", mustJSON(meshcatalog.WatchInput{Module: "a", Branch: "beta"}))
g.decide(t, w.Ask, "approve")
// A branch a placement names is not unwatched: refused as placed, naming the placement, and no ask is raised (§704, A-62).
g.placed = map[string][]mma.ModulePlacement{"a": {{Branch: "beta", Nodes: []string{"n1", "n2"}}}}
if p, err := g.r.Propose(ctx, "unwatch", "s@n", mustJSON(meshcatalog.WatchInput{Module: "a", Branch: "beta"})); err != nil || p.Refused != meshcatalog.RegistrationAskedRefusedPlaced ||
p.Ask != "" || !strings.Contains(p.Words, "beta") || !strings.Contains(p.Words, "n1, n2") {
t.Fatalf("unwatch of a placed branch: %+v, %v; want refused placed naming the placement", p, err)
}
g.placed = nil
u, _ := g.r.Propose(ctx, "unwatch", "s@n", mustJSON(meshcatalog.WatchInput{Module: "a", Branch: "beta"}))
g.decide(t, u.Ask, "approve")
if now, _, _ := g.r.Registration(ctx, "a"); now.Version != 4 || len(now.Branches) != 1 {
@@ -122,7 +122,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build
}
// Design 47 G §700, A-74: the router's record of an ask is read through the seat operator-channel, which the indexer uses,
// and its holder is asked of the seat mesh-module-assigner.
if !slices.Equal(m.Uses, []string{"operator-channel"}) || !slices.Contains(m.Invokes, "seat:mesh-module-assigner.assignments") {
if !slices.Equal(m.Uses, []string{"operator-channel"}) || !slices.Contains(m.Invokes, "seat:mesh-module-assigner.assignments") || !slices.Contains(m.Invokes, "seat:mesh-module-assigner.placement") {
t.Errorf("uses = %v, invokes = %v, want the seat operator-channel used and seat:mesh-module-assigner.assignments invoked", m.Uses, m.Invokes)
}
// 1126 note 76: heard keeps the last 64 FORGE messages the indexer handled, for indexer_heard.
@@ -178,7 +178,7 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build
// Three invokes: the file of a registered module's manifest and lock files (registered.go), the forge's trees (the named paths' objects, since the indexer is the one reader of a manifest's paths:
// ADR 0499 clauses 2 and 3, ADR 0495 clause 1; and the module directories of the trunk, issue 1900) and whether a
// commit is in another (issue 1900).
if want := []string{"mesh-delivery-assigner.assignments", "seat:git.trees", "seat:git.file", "seat:git.contains", "seat:git.open-pulls", "seat:git.branch", "mesh-delivery-checker.checker_tries", "mesh-delivery-forge-bridge.bridge_merged_message", "seat:node-engine.composition", "seat:mesh-delivery.source", "seat:mesh-module-assigner.assignments", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence"}; !reflect.DeepEqual(m.Invokes, want) {
if want := []string{"mesh-delivery-assigner.assignments", "seat:git.trees", "seat:git.file", "seat:git.contains", "seat:git.open-pulls", "seat:git.branch", "mesh-delivery-checker.checker_tries", "mesh-delivery-forge-bridge.bridge_merged_message", "seat:node-engine.composition", "seat:mesh-delivery.source", "seat:mesh-module-assigner.assignments", "seat:mesh-module-assigner.placement", "seat:mesh-hand-acts.hand-act", "seat:mesh-watchdogs.presence"}; !reflect.DeepEqual(m.Invokes, want) {
t.Errorf("invokes = %v, want %v", m.Invokes, want)
}
// 1945: indexer_take_in, served by its tools bundle through the takeins state; the process serves none. The same bundle
@@ -153,6 +153,7 @@
"seat:node-engine.composition",
"seat:mesh-delivery.source",
"seat:mesh-module-assigner.assignments",
"seat:mesh-module-assigner.placement",
"seat:mesh-hand-acts.hand-act",
"seat:mesh-watchdogs.presence"
],