From dad27088056dd3b3738e9ddeb1e5342b85a5a267 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:09:42 +0100 Subject: [PATCH 1/4] G-catalog-B 5: distribution and gitea look at every open transfer at the idle watch's start A transfer whose idle key ended while the bundle was down was never dropped. Both hand mesh-sdk's FollowIdle their open transfers, which it gives Expired once the watch is taken. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/distribution/cmd/distribution-tools/main.go | 2 +- modules/gitea/cmd/package-registry/incoming.go | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/modules/distribution/cmd/distribution-tools/main.go b/modules/distribution/cmd/distribution-tools/main.go index de40893b5..c63bff40e 100644 --- a/modules/distribution/cmd/distribution-tools/main.go +++ b/modules/distribution/cmd/distribution-tools/main.go @@ -501,7 +501,7 @@ func main() { Transfers: parts.BusTransfers[holder.Opening](holder.TransfersState), Cap: parts.BusCap(holder.IncomingObjects), Idle: parts.BusArm(holder.IdleState)} v := &holder.Verbs{Holder: h, Catalog: store, Holds: kept[holder.Hold]{state: "holds", key: holder.Hold.Key}, Mirrors: mirrors{}} go followMirrors(v) - go parts.FollowIdle(holder.IdleState, h.Expired, func(id string, dropped bool, err error) { + go parts.FollowIdle(holder.IdleState, parts.IDs(h.Transfers), h.Expired, func(id string, dropped bool, err error) { if err != nil { logf("%v", err) return diff --git a/modules/gitea/cmd/package-registry/incoming.go b/modules/gitea/cmd/package-registry/incoming.go index 9ad5c3c75..945eaf1be 100644 --- a/modules/gitea/cmd/package-registry/incoming.go +++ b/modules/gitea/cmd/package-registry/incoming.go @@ -51,9 +51,17 @@ func incomingTool(typ, seat string, holder func() (*packageseats.Holder, error)) } } -// followIdle drops a transfer when its idle key ends (packageseats.Holder.Expired): the bus's marker is the event. +// followIdle drops a transfer when its idle key ends (packageseats.Holder.Expired): the bus's marker is the event; at the +// watch's start every open transfer is looked at too, so one that ended while the bundle was down is dropped. func followIdle(holder func() (*packageseats.Holder, error)) { - parts.FollowIdle(packageseats.IdleState, func(id string) (bool, error) { + open := func() ([]string, error) { + h, err := holder() + if err != nil { + return nil, fmt.Errorf("the holder cannot be made: %w", err) + } + return parts.IDs(h.Transfers)() + } + parts.FollowIdle(packageseats.IdleState, open, func(id string) (bool, error) { h, err := holder() if err != nil { return false, fmt.Errorf("transfer %s's idle bound ended, but the holder cannot be made: %w", id, err) From ac29f205179b3361d03170174548e2b2d7780be2 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:13:44 +0100 Subject: [PATCH 2/4] G-catalog-B 7: a transfer dropped by hand is recorded through mesh-hand-acts first registry_incoming, npm_incoming and go_incoming dropped a transfer by hand with only a log line. Each drop now records its hand act through the seat mesh-hand-acts before anything goes, as nats_requests does; an act not recorded leaves the transfer open. distribution's manifest invokes the seat. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../cmd/distribution-tools/main.go | 17 ++++++++---- .../internal/holder/holder_test.go | 18 +++++++++++++ .../distribution/internal/holder/transfer.go | 27 +++++++++++++++++++ modules/distribution/module.json | 3 ++- .../gitea/cmd/package-registry/incoming.go | 6 +---- modules/gitea/cmd/package-registry/seats.go | 9 ++++++- modules/gitea/internal/packageseats/holder.go | 27 +++++++++++++++++++ .../internal/packageseats/holder_test.go | 15 +++++++++++ 8 files changed, 110 insertions(+), 12 deletions(-) diff --git a/modules/distribution/cmd/distribution-tools/main.go b/modules/distribution/cmd/distribution-tools/main.go index c63bff40e..13fcb2826 100644 --- a/modules/distribution/cmd/distribution-tools/main.go +++ b/modules/distribution/cmd/distribution-tools/main.go @@ -20,6 +20,7 @@ import ( reg "git.novox.be/novox/mesh-contracts/go/seats/containerimageregistry" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/parts" @@ -164,6 +165,12 @@ func learnInto(state casState, module, fp string, attempts int) (bool, error) { return false, fmt.Errorf("%s was written by another writer first %d times: %w", holder.PublishingKey, attempts, stdio.ErrChanged) } +// recordAct records a hand act through the seat mesh-hand-acts alone. +func recordAct(q handacts.Request) error { + _, err := handacts.Recorder{Ask: stdio.Ask}.Record(q) + return err +} + // mirrors is the state mirrors: an ask is created once; one already kept is the same copy, taken on already. type mirrors struct{} @@ -409,12 +416,12 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. } idle, _ := v.DropIdle() if id := str(args, "drop"); id != "" { - if str(args, "why") == "" { - return nil, errors.New("a drop is a hand act: say why") + t, err := v.DropByHand(id, stdio.CallerOf(args), str(args, "why")) + if err != nil { + return nil, err } - t, err := v.Drop(id) logf("transfer %s dropped by hand by %s: %s", id, stdio.CallerOf(args), str(args, "why")) - return map[string]any{"dropped": t.ID}, err + return map[string]any{"dropped": t.ID}, nil } all, err := v.Transfers.List() used, _ := v.Objects.Used() @@ -498,7 +505,7 @@ func main() { base, _ := url.Parse("http://127.0.0.1:5001") store := &holder.OCI{Base: base, Upstream: &holder.Upstream{}} h := &holder.Holder{Objects: parts.BusObjects(holder.IncomingObjects), Builder: delivery.BuilderVersions{Ask: stdio.Ask}, Store: store, Say: say, Finding: finding, - Transfers: parts.BusTransfers[holder.Opening](holder.TransfersState), Cap: parts.BusCap(holder.IncomingObjects), Idle: parts.BusArm(holder.IdleState)} + Transfers: parts.BusTransfers[holder.Opening](holder.TransfersState), Cap: parts.BusCap(holder.IncomingObjects), Record: recordAct, Idle: parts.BusArm(holder.IdleState)} v := &holder.Verbs{Holder: h, Catalog: store, Holds: kept[holder.Hold]{state: "holds", key: holder.Hold.Key}, Mirrors: mirrors{}} go followMirrors(v) go parts.FollowIdle(holder.IdleState, parts.IDs(h.Transfers), h.Expired, func(id string, dropped bool, err error) { diff --git a/modules/distribution/internal/holder/holder_test.go b/modules/distribution/internal/holder/holder_test.go index 36ae96ae9..37dff4502 100644 --- a/modules/distribution/internal/holder/holder_test.go +++ b/modules/distribution/internal/holder/holder_test.go @@ -17,6 +17,7 @@ import ( "time" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/handacts" ) type memObjects map[string][]byte @@ -377,6 +378,23 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { if _, err := b.h.Publish(ArtifactSeat, builder, req("t5")); err == nil || !strings.Contains(err.Error(), "idle bound") { t.Errorf("a publish whose idle key is not written = %v; want it failed loudly", err) } + // Seams note 2: registry_incoming's drop is a hand act recorded through mesh-hand-acts before anything goes; + // an act not recorded leaves the transfer open. + var acts []handacts.Request + b.h.Record = func(q handacts.Request) error { return errors.New("mesh-hand-acts has no holder") } + if _, err := b.h.DropByHand("t5", "person.jochen", "stuck"); err == nil || !strings.Contains(err.Error(), "stays open") { + t.Errorf("a drop whose act is not recorded = %v; want refused, t5 open", err) + } + b.h.Record = func(q handacts.Request) error { acts = append(acts, q); return nil } + if _, err := b.h.DropByHand("t5", "person.jochen", ""); err == nil || len(acts) != 0 { + t.Errorf("a drop with no why = %v, acts %v; want refused, nothing recorded", err, acts) + } + if got, err := b.h.DropByHand("t5", "person.jochen", "stuck"); err != nil || got.ID != "t5" || len(acts) != 1 || acts[0].Why != "stuck" || acts[0].Cause != "registry_incoming" || !strings.Contains(acts[0].What, "t5") || !strings.Contains(acts[0].What, "person.jochen") { + t.Errorf("a drop by hand = %+v, %v, acts %+v; want t5 dropped and its act recorded", got, err, acts) + } + if _, found, _ := b.h.Transfers.Get("t5"); found { + t.Errorf("t5 kept after its drop by hand") + } }) t.Run("incoming's finding at 80 %, cleared below", func(t *testing.T) { b := newBed(true) diff --git a/modules/distribution/internal/holder/transfer.go b/modules/distribution/internal/holder/transfer.go index 599b105fb..1ca4a1991 100644 --- a/modules/distribution/internal/holder/transfer.go +++ b/modules/distribution/internal/holder/transfer.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/parts" ) @@ -164,6 +165,9 @@ type Holder struct { Now func() time.Time // Cap reads incoming's cap from its binding (parts.BusCap); IncomingCap when nil or when the binding names none. Cap func() (uint64, error) + // Record records a hand act through the seat mesh-hand-acts (handacts.Recorder); a drop by hand is refused + // without it. + Record func(handacts.Request) error // Idle writes a transfer's idle key with its own time to live, once (parts.BusArm over IdleState); the receiver // arms it on each open that leaves a transfer open. Nil arms nothing. Idle func(id string, ttl time.Duration) error @@ -288,6 +292,29 @@ func (h *Holder) eventOf(seat string, r PublishRequest, ref, reason string) Even // Drop drops a transfer by hand (registry_incoming) or because it was idle for TransferIdle: its chunks and its key. func (h *Holder) Drop(id string) (Transfer, error) { return h.receiver().Drop(id) } +// DropByHand is registry_incoming's drop: by a person, with why, recorded as a hand act through mesh-hand-acts before +// anything goes; an act not recorded leaves the transfer open. +func (h *Holder) DropByHand(id, by, why string) (Transfer, error) { + if strings.TrimSpace(why) == "" { + return Transfer{}, errors.New("a drop is a hand act: say why") + } + if h.Record == nil { + return Transfer{}, errors.New("no hand act can be recorded here, so nothing is dropped") + } + t, found, err := h.Transfers.Get(id) + if err != nil { + return Transfer{}, fmt.Errorf("transfer %s cannot be read, so nothing is dropped: %w", id, err) + } + if !found { + return Transfer{}, fmt.Errorf("transfer %s is not open", id) + } + what := fmt.Sprintf("dropped the open transfer %s of incoming (%s@%s on %s, opened by %s, %d parts lacked), by %s", id, t.Shape.Name, t.Shape.Version, t.Shape.Seat, t.Opener, len(t.Lacks()), by) + if err := h.Record(handacts.Request{What: what, Why: why, Cause: "registry_incoming"}); err != nil { + return Transfer{}, fmt.Errorf("the hand act cannot be recorded, so transfer %s stays open: %w", id, err) + } + return h.Drop(id) +} + // DropIdle drops every transfer with no part for TransferIdle, answering them: registry_incoming's look, by hand. The // drop itself follows each transfer's idle key (Expired); the objects template keeps for good, so nothing else does. func (h *Holder) DropIdle() ([]string, error) { return h.receiver().DropIdle() } diff --git a/modules/distribution/module.json b/modules/distribution/module.json index bf8c4f4bb..cfc9b8f2c 100644 --- a/modules/distribution/module.json +++ b/modules/distribution/module.json @@ -55,7 +55,8 @@ "distribution.registry_callers", "mesh-delivery-builder.builder_versions", "mesh-delivery-view.node_haves", - "seat:node-engine.manifest-reads" + "seat:node-engine.manifest-reads", + "seat:mesh-hand-acts.hand-act" ], "consumes": [ "mesh-delivery-view.delivered", diff --git a/modules/gitea/cmd/package-registry/incoming.go b/modules/gitea/cmd/package-registry/incoming.go index 945eaf1be..3473dab36 100644 --- a/modules/gitea/cmd/package-registry/incoming.go +++ b/modules/gitea/cmd/package-registry/incoming.go @@ -6,7 +6,6 @@ package main // when its idle key ends (the bus's marker), never on a timer. import ( - "errors" "fmt" "os" "strings" @@ -36,10 +35,7 @@ func incomingTool(typ, seat string, holder func() (*packageseats.Holder, error)) return nil, err } if id := str(args, "drop"); id != "" { - if str(args, "why") == "" { - return nil, errors.New("a drop is a hand act: say why") - } - t, err := h.Drop(seat, id) + t, err := h.DropByHand(seat, id, stdio.CallerOf(args), str(args, "why")) if err != nil { return nil, err } diff --git a/modules/gitea/cmd/package-registry/seats.go b/modules/gitea/cmd/package-registry/seats.go index 36a0ff907..eed19585b 100644 --- a/modules/gitea/cmd/package-registry/seats.go +++ b/modules/gitea/cmd/package-registry/seats.go @@ -31,6 +31,7 @@ import ( npmreg "git.novox.be/novox/mesh-contracts/go/seats/npmpackageregistry" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/health" "git.novox.be/novox/mesh-sdk/go/parts" @@ -263,6 +264,12 @@ func finding(kind, subject, words string, raised bool) { } } +// recordAct records a hand act through the seat mesh-hand-acts alone. +func recordAct(q handacts.Request) error { + _, err := handacts.Recorder{Ask: stdio.Ask}.Record(q) + return err +} + // boundCap is incoming's cap from its binding, read once per bundle. var boundCap = parts.BusCap(packageseats.IncomingObjects) @@ -275,7 +282,7 @@ func busHolder(forge func() (*Forge, error)) (*packageseats.Holder, error) { return &packageseats.Holder{Callers: callers, Objects: parts.BusObjects(packageseats.IncomingObjects), Transfers: parts.BusTransfers[packageseats.Opening](packageseats.TransfersState), Builder: delivery.BuilderVersions{Ask: stdio.Ask}, Store: seatStore{forge}, Say: say, Finding: finding, - Cap: boundCap, Idle: parts.BusArm(packageseats.IdleState)}, nil + Cap: boundCap, Record: recordAct, Idle: parts.BusArm(packageseats.IdleState)}, nil } // SeatTools are both seats' verbs as tools .. diff --git a/modules/gitea/internal/packageseats/holder.go b/modules/gitea/internal/packageseats/holder.go index 557fa1814..3b586704f 100644 --- a/modules/gitea/internal/packageseats/holder.go +++ b/modules/gitea/internal/packageseats/holder.go @@ -13,6 +13,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/parts" ) @@ -156,6 +157,9 @@ type Holder struct { Now func() time.Time // Cap reads incoming's cap from its binding (parts.BusCap); IncomingCap when nil or when the binding names none. Cap func() (uint64, error) + // Record records a hand act through the seat mesh-hand-acts (handacts.Recorder); a drop by hand is refused + // without it. + Record func(handacts.Request) error // Idle writes a transfer's idle key with its own time to live, once (parts.BusArm over IdleState); the receiver // arms it on each open that leaves a transfer open. Nil arms nothing. Idle func(key string, ttl time.Duration) error @@ -334,6 +338,29 @@ func (h *Holder) Drop(seat, transfer string) (Transfer, error) { return h.receiver().Drop(key(seat, transfer)) } +// DropByHand is _incoming's drop: by a person, with why, recorded as a hand act through mesh-hand-acts before +// anything goes; an act not recorded leaves the transfer open. +func (h *Holder) DropByHand(seat, transfer, by, why string) (Transfer, error) { + if strings.TrimSpace(why) == "" { + return Transfer{}, errors.New("a drop is a hand act: say why") + } + if h.Record == nil { + return Transfer{}, errors.New("no hand act can be recorded here, so nothing is dropped") + } + t, found, err := h.Transfers.Get(key(seat, transfer)) + if err != nil { + return Transfer{}, fmt.Errorf("transfer %s cannot be read, so nothing is dropped: %w", transfer, err) + } + if !found { + return Transfer{}, fmt.Errorf("transfer %s of %s is not open", transfer, seat) + } + what := fmt.Sprintf("dropped the open transfer %s of %s's incoming (%s@%s, opened by %s), by %s", transfer, seat, t.Shape.Name, t.Shape.Version, t.Opener, by) + if err := h.Record(handacts.Request{What: what, Why: why, Cause: Seats[seat] + "_incoming"}); err != nil { + return Transfer{}, fmt.Errorf("the hand act cannot be recorded, so transfer %s stays open: %w", transfer, err) + } + return h.Drop(seat, transfer) +} + // Incoming is what npm_incoming and go_incoming answer of a seat: its open transfers, incoming's fill (both seats // share the store) and the cap its binding carries. type Incoming struct { diff --git a/modules/gitea/internal/packageseats/holder_test.go b/modules/gitea/internal/packageseats/holder_test.go index 700001c09..bf814fea9 100644 --- a/modules/gitea/internal/packageseats/holder_test.go +++ b/modules/gitea/internal/packageseats/holder_test.go @@ -9,6 +9,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/handacts" "git.novox.be/novox/mesh-sdk/go/parts" ) @@ -190,6 +191,20 @@ func TestTheForgeHoldsBothPackageSeats(t *testing.T) { if _, err := b.h.Publish(NpmSeat, nodeBuild, pub("d", "0.1.0", file)); err == nil || !strings.Contains(err.Error(), "idle bound") { t.Errorf("a publish whose idle key is not written = %v; want it failed loudly", err) } + // Seams note 2: go_incoming's drop is a hand act recorded through mesh-hand-acts before anything goes; an act + // not recorded leaves the transfer open. + var acts []handacts.Request + b.h.Record = func(handacts.Request) error { return errors.New("mesh-hand-acts has no holder") } + if _, err := b.h.DropByHand(GoSeat, "t-b", "person.jochen", "stuck"); err == nil || !strings.Contains(err.Error(), "stays open") { + t.Errorf("a drop whose act is not recorded = %v; want refused, t-b open", err) + } + b.h.Record = func(q handacts.Request) error { acts = append(acts, q); return nil } + if got, err := b.h.DropByHand(GoSeat, "t-b", "person.jochen", "stuck"); err != nil || got.Shape.Name != "b" || len(acts) != 1 || acts[0].Cause != "go_incoming" || !strings.Contains(acts[0].What, "t-b") || !strings.Contains(acts[0].What, "person.jochen") { + t.Errorf("a drop by hand = %+v, %v, acts %+v; want t-b dropped and its act recorded", got, err, acts) + } + if in, _ := b.h.IncomingOf(GoSeat); len(in.Transfers) != 0 { + t.Errorf("after the drop by hand: %+v; want no Go transfer", in) + } }) t.Run("refusals", func(t *testing.T) { for _, c := range []struct { From 480556a329e1d69111cc7b17d6efbdcd12ca6063 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:13:44 +0100 Subject: [PATCH 3/4] G-catalog-B 8: the retire removes only the teams the npm pair provisioner made A person who later makes a team named packages or readers would have lost it at the next start. A team counts as the provisioner's only in its organisation and when every member is one of its accounts (it put its own accounts there alone, and they go with the first run), so a team made later stays. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/gitea/cmd/agent-teams/retire.go | 21 ++++++++++++++------- modules/gitea/cmd/agent-teams/teams_test.go | 19 ++++++++++++++++--- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/modules/gitea/cmd/agent-teams/retire.go b/modules/gitea/cmd/agent-teams/retire.go index 8da05e440..0ed7b2c8c 100644 --- a/modules/gitea/cmd/agent-teams/retire.go +++ b/modules/gitea/cmd/agent-teams/retire.go @@ -19,10 +19,17 @@ import ( // seats came, made each consumer of npm-package-registry an account under the mesh's derived login // (mesh__) and put it in the organisation's team packages (read and write) or readers (read only). // Reads of the packages are anonymous and every write goes through the seats, so both teams go, and each account of -// that form in no team but them goes with them. Nothing else is touched: a team of another name, an account of -// another form or one still in a team the module does not retire stays as it is. +// that form in no team but them goes with them. Only what the provisioner made goes: a team of those names in its +// organisation (RetiredOrg) is the provisioner's when every member of it is an account of the provisioner's form and +// it has one at least, since the provisioner put its own accounts in them alone and nobody else ever made one. A team +// a person makes later under such a name holds no such account (they are gone with the first run), so it stays, and +// so does a team of those names with a person in it or with no member. Nothing else is touched: a team of another +// name or organisation, an account of another form or one still in a team the module does not retire stays as it is. var RetiredTeams = []string{"packages", "readers"} +// RetiredOrg is the organisation the npm pair provisioner made its teams in (its ORG, the npm registry's owner). +const RetiredOrg = "novox" + // retiredAccount is the login form the npm pair provisioner made its accounts under. var retiredAccount = regexp.MustCompile(`^mesh_[a-z0-9-]+_[a-z0-9_]+$`) @@ -37,6 +44,7 @@ func (s *Syncer) Retire(ctx context.Context, orgs []string) error { team forgeTeam } var gone []retired + retiring := map[string]bool{} // org/team for _, org := range orgs { teams, err := s.teams(ctx, org) if err != nil { @@ -50,15 +58,14 @@ func (s *Syncer) Retire(ctx context.Context, orgs []string) error { for _, m := range members { teamsOf[m.Login] = append(teamsOf[m.Login], org+"/"+t.Name) } - if slices.Contains(RetiredTeams, t.Name) { + made := len(members) > 0 && !slices.ContainsFunc(members, func(m forgeMember) bool { return !retiredAccount.MatchString(m.Login) }) + if org == RetiredOrg && slices.Contains(RetiredTeams, t.Name) && made { gone = append(gone, retired{org, t}) + retiring[org+"/"+t.Name] = true } } } - isRetired := func(orgTeam string) bool { - _, team, _ := strings.Cut(orgTeam, "/") - return slices.Contains(RetiredTeams, team) - } + isRetired := func(orgTeam string) bool { return retiring[orgTeam] } var accounts []string for login, in := range teamsOf { if retiredAccount.MatchString(login) && slices.ContainsFunc(in, isRetired) && !slices.ContainsFunc(in, func(t string) bool { return !isRetired(t) }) { diff --git a/modules/gitea/cmd/agent-teams/teams_test.go b/modules/gitea/cmd/agent-teams/teams_test.go index a68007579..c5e746a19 100644 --- a/modules/gitea/cmd/agent-teams/teams_test.go +++ b/modules/gitea/cmd/agent-teams/teams_test.go @@ -365,11 +365,15 @@ func TestReadValue(t *testing.T) { // form still in another team, an account of another form and every other team stay; a second run changes nothing. func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) { f := newFake([]string{"novox"}, "mesh_ace_mesh_build_node", "mesh_shanks_lab", "mesh_g14_agent", "mesh_novox_owner", - "mesh-packages", "agent-ace", "jschoubben") + "mesh-packages", "agent-ace", "jschoubben", "mesh_ace_agent") f.owners = map[string]bool{"mesh_novox_owner": true} - f.add("novox", "packages", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_g14_agent", "mesh_novox_owner", "jschoubben"}) + f.add("novox", "packages", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_g14_agent", "mesh_novox_owner"}) f.add("novox", "readers", "none", nil, []string{"mesh_ace_mesh_build_node", "mesh_shanks_lab"}) f.add("novox", "agents", "none", nil, []string{"mesh_g14_agent", "agent-ace"}) + // Catalog note: a team of a retired name the provisioner did not make (a person in it, or in another + // organisation) stays. + f.orgs = append(f.orgs, "other") + f.add("other", "readers", "none", nil, []string{"mesh_ace_agent"}) f.add("novox", "mesh-packages", "none", nil, []string{"mesh-packages"}) s, said := syncer(t, f) if err := s.Sync(context.Background(), Value{}); err != nil { @@ -382,7 +386,7 @@ func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) { t.Errorf("a team the module did not retire was removed: %+v", f.teams) } for u, want := range map[string]bool{"mesh_ace_mesh_build_node": false, "mesh_shanks_lab": false, "mesh_g14_agent": true, - "mesh_novox_owner": true, "mesh-packages": true, "agent-ace": true, "jschoubben": true} { + "mesh_novox_owner": true, "mesh-packages": true, "agent-ace": true, "jschoubben": true, "mesh_ace_agent": true} { if f.users[u] != want { t.Errorf("account %s kept %v, want %v", u, f.users[u], want) } @@ -390,8 +394,17 @@ func TestTheNpmPairsTeamsAndAccountsAreRetired(t *testing.T) { if !f.prohibited["mesh_novox_owner"] || len(f.prohibited) != 1 { t.Errorf("prohibited %v, want mesh_novox_owner alone", f.prohibited) } + if f.byName("other", "readers") == nil { + t.Errorf("a readers team in another organisation was removed") + } + // A team a person makes after the first run under a retired name, with persons in it or none, stays. + f.add("novox", "packages", "none", nil, []string{"jschoubben"}) + f.add("novox", "readers", "none", nil, nil) before := len(*said) if err := s.Sync(context.Background(), Value{}); err != nil || len(*said) != before { t.Errorf("a second run: %v, said %v; want nothing", err, (*said)[before:]) } + if f.byName("novox", "packages") == nil || f.byName("novox", "readers") == nil || !f.users["jschoubben"] { + t.Errorf("a team a person made later under a retired name was removed: %+v", f.teams) + } } From 4a3d7b60a15511cbca9f38ec0cc510d4d6fbf9b7 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 18:15:24 +0100 Subject: [PATCH 4/4] G-catalog-B 6: on registry 2.8.3 a shared-digest tagged version's remove answers and says kept Saying removed for a version the store still holds told the builder something false. remove now answers kept with the reason shared-digest and the other tags naming its digest, removed false, and says the seat's event kept with them (mesh-contracts' shared registry part); the builder raises no retry. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../cmd/distribution-tools/contract.go | 4 +- .../cmd/distribution-tools/contract_test.go | 2 +- .../cmd/distribution-tools/main.go | 5 +- .../internal/holder/holder_test.go | 34 ++++++++----- .../distribution/internal/holder/transfer.go | 3 ++ modules/distribution/internal/holder/verbs.go | 48 ++++++++++++------- 6 files changed, 61 insertions(+), 35 deletions(-) diff --git a/modules/distribution/cmd/distribution-tools/contract.go b/modules/distribution/cmd/distribution-tools/contract.go index 8e724dd21..520e4cad6 100644 --- a/modules/distribution/cmd/distribution-tools/contract.go +++ b/modules/distribution/cmd/distribution-tools/contract.go @@ -59,7 +59,7 @@ func tagsAnswer(v *holder.Verbs, a holder.VersionsAnswer) reg.TagsAnswer { return out } -// eventBody is an event's body as the seat's contract defines it: an Outcome for published, refused and removed, a +// eventBody is an event's body as the seat's contract defines it: an Outcome for published, refused, removed and kept, a // Hold for held and released. func eventBody(event string, e holder.Event) any { switch event { @@ -67,5 +67,5 @@ func eventBody(event string, e holder.Event) any { return reg.Hold{Name: e.Name, Digest: e.Digest, Holder: e.Holder, By: e.By, Reason: e.Reason, At: time.Now().UTC().Format(time.RFC3339)} } return reg.Outcome{Name: e.Name, Version: e.Version, Kind: e.Kind, Tag: e.Tag, Digest: e.Digest, Fingerprint: e.Fingerprint, - Commit: e.Commit, Holder: e.Holder, Transfer: e.Transfer, Reference: e.Reference, Reason: e.Reason} + Commit: e.Commit, Holder: e.Holder, Transfer: e.Transfer, Reference: e.Reference, Reason: e.Reason, Tags: e.Tags} } diff --git a/modules/distribution/cmd/distribution-tools/contract_test.go b/modules/distribution/cmd/distribution-tools/contract_test.go index 0cc8fb8f3..2e89953be 100644 --- a/modules/distribution/cmd/distribution-tools/contract_test.go +++ b/modules/distribution/cmd/distribution-tools/contract_test.go @@ -49,7 +49,7 @@ func TestTheVerbsSpeakTheSeatsContract(t *testing.T) { if seen != 14 { t.Errorf("%d seat verbs served, want 14 (8 on the image seat, 6 on the artifact seat)", seen) } - for event, want := range map[string]any{holder.EventPublished: ®.Outcome{}, holder.EventHeld: ®.Hold{}, holder.EventReleased: ®.Hold{}} { + for event, want := range map[string]any{holder.EventPublished: ®.Outcome{}, holder.EventKept: ®.KeptEvent{}, holder.EventHeld: ®.Hold{}, holder.EventReleased: ®.Hold{}} { b, _ := json.Marshal(eventBody(event, holder.Event{Name: "m/a", Kind: holder.KindArchive, Digest: "sha256:aa", Holder: holder.Self, By: "x.artifact-store-tools"})) dec := json.NewDecoder(bytes.NewReader(b)) dec.DisallowUnknownFields() diff --git a/modules/distribution/cmd/distribution-tools/main.go b/modules/distribution/cmd/distribution-tools/main.go index 13fcb2826..46ef39faa 100644 --- a/modules/distribution/cmd/distribution-tools/main.go +++ b/modules/distribution/cmd/distribution-tools/main.go @@ -322,10 +322,11 @@ func tools(v *holder.Verbs, windows kept[holder.Window], callers func() (holder. return nil, err } r := holder.RemoveRequest{Name: in.Name, Version: in.Version, Digest: in.Digest, Reason: in.Reason} - if err := v.Remove(seat, stdio.CallerOf(args), r); err != nil { + got, err := v.Remove(seat, stdio.CallerOf(args), r) + if err != nil { return nil, err } - return reg.RemoveAnswer{Removed: true, Artifact: ®.Artifact{Name: in.Name, Version: in.Version, Tag: in.Tag, Digest: in.Digest}}, nil + return reg.RemoveAnswer{Removed: got.Removed, Kept: got.Kept, Reason: got.Reason, Tags: got.Tags, Artifact: ®.Artifact{Name: in.Name, Version: in.Version, Tag: in.Tag, Digest: in.Digest}}, nil }}, stdio.Tool{Name: seat + "." + holder.VerbHold, Description: "Keep one untagged bundle, archive or copied base from retention (the artifact sweep alone); says held.", Input: obj(map[string]any{"name": s("name"), "digest": s("sha256:…"), "reason": s("which of the sweep's rules keeps it")}, "name", "digest", "reason"), diff --git a/modules/distribution/internal/holder/holder_test.go b/modules/distribution/internal/holder/holder_test.go index 37dff4502..43318fb4b 100644 --- a/modules/distribution/internal/holder/holder_test.go +++ b/modules/distribution/internal/holder/holder_test.go @@ -299,14 +299,17 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { return err }, "holds bundles and archives"}, {"remove of a version with no tag", func(b *bed) error { - return b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Version: "0.1.4", Reason: "branch ended"}) + _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Version: "0.1.4", Reason: "branch ended"}) + return err }, "has no tag"}, {"a retention removal from another caller than the sweep", func(b *bed) error { - return b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention}) + _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/program", Digest: part.Digest, Reason: ReasonRetention}) + return err }, "artifact sweep's alone"}, {"a retention removal of a held archive", func(b *bed) error { _ = b.v.HoldOne(ArtifactSeat, "novox.artifact-store-tools", HoldRequest{Name: "m/a", Kind: KindArchive, Digest: part.Digest}) - return b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/a", Digest: part.Digest, Reason: ReasonRetention}) + _, err := b.v.Remove(ArtifactSeat, "novox.artifact-store-tools", RemoveRequest{Name: "m/a", Digest: part.Digest, Reason: ReasonRetention}) + return err }, "is held"}, {"a hold of an image", func(b *bed) error { return b.v.HoldOne(ImageSeat, "novox.artifact-store-tools", HoldRequest{Name: "m/i", Kind: KindImage, Digest: part.Digest}) @@ -423,7 +426,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { t.Run("the sweep's removal, hold and release", func(t *testing.T) { b := newBed(true) sweep := "novox.artifact-store-tools" - if err := b.v.Remove(ArtifactSeat, sweep, RemoveRequest{Name: "m/a", Digest: part.Digest, Kind: KindArchive, Reason: ReasonRetention}); err != nil { + if _, err := b.v.Remove(ArtifactSeat, sweep, RemoveRequest{Name: "m/a", Digest: part.Digest, Kind: KindArchive, Reason: ReasonRetention}); err != nil { t.Fatal(err) } if err := b.v.HoldOne(ArtifactSeat, sweep, HoldRequest{Name: "m/b", Kind: KindArchive, Digest: part.Digest}); err != nil { @@ -444,27 +447,32 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { t.Errorf("said %v, want %v", events, want) } b.cat.tags["m/p:0.1.5-feat-x.1"] = part.Digest - if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.5-feat-x.1", Reason: "branch merged"}); err != nil || b.cat.tags["m/p:0.1.5-feat-x.1"] != "" { + if _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.5-feat-x.1", Reason: "branch merged"}); err != nil || b.cat.tags["m/p:0.1.5-feat-x.1"] != "" { t.Errorf("the builder's removal of a tagged version: %v", err) } // Security review F1 on registry 2.8.3: a reproducible build gives a branch's tagged version and the trunk's - // numbered one one digest; the store cannot remove the tag alone, so nothing goes, the numbered one stays - // pullable, and the removal is said removed with that reason: no refusal, so the builder raises no retry. + // numbered one one digest; the store cannot remove the tag alone, so the version is kept whole, the numbered one + // stays pullable, and remove answers and says kept (shared-digest, the other tags): no refusal, so the builder + // raises no retry. b.cat.tags["m/p:0.1.6-feat-y.1"], b.cat.tags["m/p:0.1.6"] = part.Digest, part.Digest - if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.6-feat-y.1", Reason: "branch merged"}); err != nil { - t.Fatalf("the removal of a tagged version sharing its digest: %v; want no error", err) + got, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.6-feat-y.1", Reason: "branch merged"}) + if err != nil || got.Removed || !got.Kept || got.Reason != ReasonSharedDigest || !reflect.DeepEqual(got.Tags, []string{"0.1.6"}) { + t.Fatalf("the removal of a tagged version sharing its digest = %+v, %v; want kept, shared-digest, tags [0.1.6], no error", got, err) } if b.cat.tags["m/p:0.1.6-feat-y.1"] != part.Digest || b.cat.tags["m/p:0.1.6"] != part.Digest { t.Errorf("tags after the tagged version's removal: %v; want both kept at %s", b.cat.tags, part.Digest) } - if last := b.said[len(b.said)-1]; last.event != EventRemoved || last.body.Reason != "branch merged; the store cannot remove a tag alone, so the tag 0.1.6-feat-y.1 is kept and its bytes stay under 0.1.6" { - t.Errorf("said %s %q; want removed, saying the tag is kept under 0.1.6", last.event, last.body.Reason) + if last := b.said[len(b.said)-1]; last.event != EventKept || last.body.Reason != ReasonSharedDigest || last.body.Digest != part.Digest || !reflect.DeepEqual(last.body.Tags, []string{"0.1.6"}) { + t.Errorf("said %s %+v; want kept, shared-digest, its digest and the tags [0.1.6]", last.event, last.body) + } + if got, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.5-feat-v.1", Reason: "branch merged"}); err != nil || !got.Removed || got.Kept { + t.Errorf("the removal of a version already gone = %+v, %v; want removed", got, err) } // Any other failure of the store removes nothing and is refused naming the shared digest. b.cat.tags["m/p:0.1.7-feat-z.1"], b.cat.tags["m/p:0.1.7"] = part.Digest, part.Digest b.cat.tagErr = errors.New("the store answered 500") said := len(b.said) - if err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.7-feat-z.1", Reason: "branch merged"}); err == nil || !strings.Contains(err.Error(), "shares its digest") || b.cat.tags["m/p:0.1.7"] != part.Digest || len(b.said) != said { + if _, err := b.v.Remove(ArtifactSeat, "novox.mesh-delivery-builder", RemoveRequest{Name: "m/p", Version: "0.1.7-feat-z.1", Reason: "branch merged"}); err == nil || !strings.Contains(err.Error(), "shares its digest") || b.cat.tags["m/p:0.1.7"] != part.Digest || len(b.said) != said { t.Errorf("a store failing otherwise: %v, tags %v; want refused, 0.1.7 kept, nothing said", err, b.cat.tags) } b.cat.tagErr = nil @@ -513,7 +521,7 @@ func TestTheHolderOfTheTwoImageSeats(t *testing.T) { t.Fatal(err) } } - err := b.v.Remove(ImageSeat, sweep, RemoveRequest{Name: name, Digest: c.remove, Reason: ReasonRetention}) + _, err := b.v.Remove(ImageSeat, sweep, RemoveRequest{Name: name, Digest: c.remove, Reason: ReasonRetention}) var removed []string for _, r := range b.cat.removed { removed = append(removed, strings.TrimPrefix(r, name+"@")) diff --git a/modules/distribution/internal/holder/transfer.go b/modules/distribution/internal/holder/transfer.go index 1ca4a1991..fcd04865f 100644 --- a/modules/distribution/internal/holder/transfer.go +++ b/modules/distribution/internal/holder/transfer.go @@ -104,6 +104,8 @@ type Event struct { // Reference is the seat's answer: `//@sha256:` (ADR 0605 §9). Reference string `json:"reference,omitempty"` Reason string `json:"reason,omitempty"` + // Tags are, for kept, the other tags still naming its digest. + Tags []string `json:"tags,omitempty"` // By is who asked a removal, hold or release: the caller the tool runner proved. By string `json:"by,omitempty"` // ID is the event's id on the bus when the outcome has one of its own (a mirror ask's): the same outcome said @@ -116,6 +118,7 @@ const ( EventPublished = "published" EventRefused = "refused" EventRemoved = "removed" + EventKept = "kept" EventHeld = "held" EventReleased = "released" ) diff --git a/modules/distribution/internal/holder/verbs.go b/modules/distribution/internal/holder/verbs.go index dd184de7f..33c64a2b7 100644 --- a/modules/distribution/internal/holder/verbs.go +++ b/modules/distribution/internal/holder/verbs.go @@ -123,64 +123,78 @@ type RemoveRequest struct { // The seat definitions name it (the removal reasons' enum), so the sweep and the holder share one value. const ReasonRetention = reg.RemoveInputReasonRetention +// Removal is what remove answers: Removed, or Kept with its reason and the other tags naming its digest. +type Removal struct { + Removed bool + Kept bool + Reason string + Tags []string +} + +// ReasonSharedDigest is why a removal was kept: another tag names its digest, and the store cannot remove a tag alone. +const ReasonSharedDigest = reg.RemoveAnswerReasonSharedDigest + // Remove is `remove`: the builder removes a tagged version; the artifact sweep's holder an untagged bundle, archive or // copied base with the reason retention, which a hold keeps. Every removal says `removed` with its reason. -func (v *Verbs) Remove(seat, caller string, r RemoveRequest) error { +func (v *Verbs) Remove(seat, caller string, r RemoveRequest) (Removal, error) { if err := v.Callers.Allowed(VerbRemove, caller); err != nil { - return err + return Removal{}, err } if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.Reason) == "" { - return errors.New("remove: a name and a reason are required") + return Removal{}, errors.New("remove: a name and a reason are required") } ev := Event{Name: r.Name, Version: r.Version, Kind: r.Kind, Digest: r.Digest, Holder: Self, Reason: r.Reason, By: caller} switch { case r.Reason == ReasonRetention: if CallerModule(caller) != SweepHolder { - return fmt.Errorf("remove: refused for %s: a removal for retention is the artifact sweep's alone", caller) + return Removal{}, fmt.Errorf("remove: refused for %s: a removal for retention is the artifact sweep's alone", caller) } if r.Digest == "" { - return errors.New("remove: a removal for retention names the digest") + return Removal{}, errors.New("remove: a removal for retention names the digest") } if _, held, err := v.Holds.Get(Hold{Seat: seat, Name: r.Name, Digest: r.Digest}.Key()); err != nil { - return fmt.Errorf("remove: the holds cannot be read, so nothing is removed: %w", err) + return Removal{}, fmt.Errorf("remove: the holds cannot be read, so nothing is removed: %w", err) } else if held { - return fmt.Errorf("remove: refused: %s@%s is held; release it first", r.Name, r.Digest) + return Removal{}, fmt.Errorf("remove: refused: %s@%s is held; release it first", r.Name, r.Digest) } if err := v.removeForRetention(seat, r.Name, r.Digest); err != nil { - return err + return Removal{}, err } default: if r.Version == "" || !strings.Contains(r.Version, "-") { - return fmt.Errorf("remove: refused: %s@%s has no tag; only a tagged version is removed, but for retention", r.Name, r.Version) + return Removal{}, fmt.Errorf("remove: refused: %s@%s has no tag; only a tagged version is removed, but for retention", r.Name, r.Version) } d, err := v.Catalog.Digest(r.Name, r.Version) if err != nil { - return fmt.Errorf("remove: the store cannot read %s@%s: %w", r.Name, r.Version, err) + return Removal{}, fmt.Errorf("remove: the store cannot read %s@%s: %w", r.Name, r.Version, err) } if d != "" { // Reproducible builds share a digest between a branch's tagged version and the trunk's numbered one: a // manifest deleted by its digest would take the trunk's version with it (security review F1). When another // tag names the digest, the tag alone goes. A store that cannot remove a tag alone (registry 2.8.3) keeps the - // tag: its bytes are kept under the other tags anyway, so the removal is said removed with that reason and - // the builder lets the version go from its keeping, no refusal and no retry. Any other failure is refused. + // version whole, its tag and its bytes, which the other tags name anyway: remove answers and says kept with + // the reason shared-digest and those tags, no refusal, so the builder raises no retry. Any other failure is + // refused. others, err := v.otherTags(r.Name, r.Version, d) if err != nil { - return fmt.Errorf("remove: the store cannot say which other tags of %s name %s, so nothing is removed: %w", r.Name, d, err) + return Removal{}, fmt.Errorf("remove: the store cannot say which other tags of %s name %s, so nothing is removed: %w", r.Name, d, err) } if len(others) > 0 { if err := v.Catalog.RemoveTag(r.Name, r.Version); errors.Is(err, ErrNoTagAlone) { sort.Strings(others) - ev.Reason = fmt.Sprintf("%s; the store cannot remove a tag alone, so the tag %s is kept and its bytes stay under %s", r.Reason, r.Version, strings.Join(others, ", ")) + ev.Digest, ev.Reason, ev.Tags = d, ReasonSharedDigest, others + kept := Removal{Kept: true, Reason: ReasonSharedDigest, Tags: others} + return kept, v.Say(seat, EventKept, ev) } else if err != nil { - return fmt.Errorf("remove: refused: %s@%s shares its digest %s with %v, and the store did not remove the tag alone, so nothing is removed: %w", r.Name, r.Version, d, others, err) + return Removal{}, fmt.Errorf("remove: refused: %s@%s shares its digest %s with %v, and the store did not remove the tag alone, so nothing is removed: %w", r.Name, r.Version, d, others, err) } } else if err := v.Catalog.Remove(r.Name, r.Version); err != nil { - return fmt.Errorf("remove: the store did not remove %s@%s: %w", r.Name, r.Version, err) + return Removal{}, fmt.Errorf("remove: the store did not remove %s@%s: %w", r.Name, r.Version, err) } } ev.Digest = d // a version already gone is said removed all the same, so the builder's retry ends } - return v.Say(seat, EventRemoved, ev) + return Removal{Removed: true}, v.Say(seat, EventRemoved, ev) } // otherTags are the tags of name but version that name digest.