From 847ab4e1fbd1d5755f7f00c568bdf96001a76b31 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 11 Nov 2026 21:39:36 +0100 Subject: [PATCH] GR10: the grants part holds each entry left without a grant as a granting wait, read and released by its tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kept in its own state, clock-from the start of this version's first pass, the bound 10 minutes. The seat's wait-held and wait-ended events and acting-part-late are not built (see the report). Builds: design 47 G §276, §266, §269 (G-20 A-7); A-6 needs no change: the part reads the wants view and consumes no wanted Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ --- modules/mesh-grants/cmd/mesh-grants/tools.go | 10 ++ modules/mesh-grants/go.mod | 1 + modules/mesh-grants/grants_test.go | 22 +++ modules/mesh-grants/internal/grants/grants.go | 1 + modules/mesh-grants/internal/grants/part.go | 2 + modules/mesh-grants/internal/grants/tools.go | 5 +- modules/mesh-grants/internal/grants/waits.go | 152 ++++++++++++++++++ modules/mesh-grants/module.json | 2 + 8 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-grants/internal/grants/waits.go diff --git a/modules/mesh-grants/cmd/mesh-grants/tools.go b/modules/mesh-grants/cmd/mesh-grants/tools.go index 4500e291..4a8b452a 100644 --- a/modules/mesh-grants/cmd/mesh-grants/tools.go +++ b/modules/mesh-grants/cmd/mesh-grants/tools.go @@ -248,6 +248,16 @@ func toolsOf(p *grants.Part) []stdio.Tool { Run: func(a map[string]any) (any, error) { return p.AskFirstViewAgain(stdio.CallerOf(a), grants.Answer(a, "state"), grants.Answer(a, "why"), recordHandAct) }}, + {Name: grants.ToolWaits, Description: "The grants part's granting waits (design 47 G §276): each entry held because its grant is not " + + "composed yet by this version of the part, with its step, the acting part, its version, since when, its bound (10 minutes) and " + + "clock-from (the start of this version's first pass), and which have passed their bound. Read-only.", + Run: func(map[string]any) (any, error) { return p.WaitsOf() }}, + {Name: grants.ToolRelease, Description: "Release one held granting wait by a person's act with why, recorded as a hand act " + + "(the seat mesh-hand-acts) before it is released (design 47 G §276). grants_waits lists them.", + Input: schema(map[string]string{"module": "the held entry's module", "why": "why, kept in the hand act"}, "module", "why"), + Run: func(a map[string]any) (any, error) { + return p.ReleaseWait(stdio.CallerOf(a), grants.Answer(a, "module"), grants.Answer(a, "why"), recordHandAct) + }}, {Name: grants.ToolRunning, Description: "What each node-engine said it runs (novox/hq design 47, ADR 0660 §2): the last event " + "running of each node and module, as the grants part keeps it in its state running (module, node, the build's " + "fingerprint, the digest of that build's manifest, when it was seen running healthy). A seat's holder is read from " + diff --git a/modules/mesh-grants/go.mod b/modules/mesh-grants/go.mod index b36b96d8..da5b09e7 100644 --- a/modules/mesh-grants/go.mod +++ b/modules/mesh-grants/go.mod @@ -13,6 +13,7 @@ require ( git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshhealers v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient v0.0.0-00010101000000-000000000000 + git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshnodeenroller v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-00010101000000-000000000000 git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient v0.0.0-00010101000000-000000000000 diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index 7731d55c..d8518948 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -2566,11 +2566,33 @@ func TestGrants(t *testing.T) { if p.Compose(); len(said) != 1 { t.Errorf("the same missing grant was said again: %+v", said) } + // The entry left without a grant is a granting wait (design 47 G §276, A-7): kept in the part's own state, with its + // bound of 10 minutes counted from the start of this version's first pass; a release is a hand act. + waits, err := p.WaitsOf() + if err != nil { + t.Fatal(err) + } + if w := waits.(grants.WaitsSaid).Waits; len(w) != 1 || w[0].Module != "mod-y" || w[0].Bound != "10m0s" || w[0].Step != "grant" || w[0].ClockFrom.IsZero() { + t.Fatalf("granting waits = %+v, want mod-y's, bound 10m0s, step grant", w) + } + if _, err := p.ReleaseWait("op", "mod-y", " ", nil); err == nil { + t.Error("a release with no why was taken") + } + var acts []string + if _, err := p.ReleaseWait("op", "mod-y", "why", func(what, why string) error { acts = append(acts, what); return nil }); err != nil || len(acts) != 1 { + t.Errorf("release: %v, hand acts %v", err, acts) + } + if w := func() []grants.HeldWait { x, _ := p.WaitsOf(); return x.(grants.WaitsSaid).Waits }(); len(w) != 1 || !w[0].Released { + t.Errorf("after the release: %+v", w) + } b.put(grants.ReadModules, "mod-y", meshcatalog.ModulesByModuleValue{V: keys.Version, Fp2: fp, Manifest: meshcatalogclient.ManifestDigest([]byte(yManifest)), Repository: "novox/y", Commit: "c"}) if res := p.Compose(); len(res.Grants.Missing) != 0 || len(said) != 2 || said[1].State != occ.StateCleared { t.Errorf("once mod-y has a grant: missing %v, said %+v", res.Grants.Missing, said) } + if x, _ := p.WaitsOf(); len(x.(grants.WaitsSaid).Waits) != 0 { + t.Errorf("the wait of mod-y outlived its grant: %+v", x) + } }) // Issue 1966 (the registry delivery's second bootstrap loop): an assigner that serves no first views answers no diff --git a/modules/mesh-grants/internal/grants/grants.go b/modules/mesh-grants/internal/grants/grants.go index 96eee602..3ec1411e 100644 --- a/modules/mesh-grants/internal/grants/grants.go +++ b/modules/mesh-grants/internal/grants/grants.go @@ -424,6 +424,7 @@ func (p *Part) writeGrants(res *Result, in Inputs, recs buscompose.Records, comp done := &GrantsWritten{Wanted: len(want), Missing: MissingGrants(in, want, drop, skip)} res.Grants = done p.sayMissing(done.Missing) + p.settleWaits(res, in, done.Missing) byUser := map[string]User{} for _, u := range composed { byUser[u.User] = u diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index d03ffda5..87998ee0 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -222,6 +222,8 @@ type Part struct { bindMu sync.Mutex // runningRead is the whole read of the stream running at the start (running.go), guarded by mu. runningRead *StreamRead + // firstPass is when this version's first pass began (waits.go), guarded by mu. + firstPass time.Time } // keepBrokers keeps the modules whose manifest claims the seat mesh-broker: the only emitters whose created, recreated diff --git a/modules/mesh-grants/internal/grants/tools.go b/modules/mesh-grants/internal/grants/tools.go index 07ef30c7..a1589f9f 100644 --- a/modules/mesh-grants/internal/grants/tools.go +++ b/modules/mesh-grants/internal/grants/tools.go @@ -30,6 +30,9 @@ const ( ToolFirstViewAgain = "grants_first_view_again" // ToolRunning reads the state running (running.go). ToolRunning = "grants_running" + // ToolWaits and ToolRelease read and release the granting waits (waits.go). + ToolWaits = "grants_waits" + ToolRelease = "grants_release" // ToolSeatList and ToolSeatListAgain read and read again the seat list the part composes from (mesh-sdk seatlist.Tools). ToolSeatList = "grants_seat_list" ToolSeatListAgain = "grants_seat_list_again" @@ -37,7 +40,7 @@ const ( // Tools is every tool, in the manifest's order. var Tools = []string{ToolGrants, ToolMembership, ToolUserList, ToolComposeNow, ToolAssert, ToolAccounts, ToolConsumerMatch, - ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolSeatList, ToolSeatListAgain} + ToolAcceptHashes, ToolFirstViews, ToolFirstViewAgain, ToolRunning, ToolWaits, ToolRelease, ToolSeatList, ToolSeatListAgain} // WritesGrants is what grants says of the part's own grants: written once the list the bus loaded carries a build's // accounts, and the one key the assigner reads (ADR 0571 clause 9, which changes ADR 0547 clause 5). diff --git a/modules/mesh-grants/internal/grants/waits.go b/modules/mesh-grants/internal/grants/waits.go new file mode 100644 index 00000000..ea402873 --- /dev/null +++ b/modules/mesh-grants/internal/grants/waits.go @@ -0,0 +1,152 @@ +package grants + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshgrants" +) + +// **The granting wait** (design 47 G §276, §266, §269; G-20 A-7). The grants part holds the entries of a revision whose +// accounts its new version has not yet composed: each module the pass leaves without a grant (MissingGrants) is one held +// entry, kept in the part's own state on the bus under KeyWaits. The bound's clock, `clock-from`, is the start of this +// version's first pass, which the part knows itself, so it hears no `build.ended`; the bound is GrantingBound, a constant of +// the part. A held entry ends when its grant is there (`ran`) or when a person releases it (`released`). + +// KeyWaits is the key of the part's own state user-list where it keeps the granting waits. +const KeyWaits = "waits" + +// GrantingBound is §266's bound of the granting step. +const GrantingBound = 10 * time.Minute + +// HeldWait is one granting wait as the part keeps it. +type HeldWait struct { + meshgrants.Wait + // Released says a person released the entry (grants_release): it is held no more. + Released bool `json:"released,omitempty"` +} + +// WaitsState is the value of KeyWaits, by the held module. +type WaitsState struct { + V int `json:"v"` + Waits map[string]HeldWait `json:"waits"` +} + +func (p *Part) readWaits() (WaitsState, error) { + st := WaitsState{V: 1, Waits: map[string]HeldWait{}} + raw, _, found, err := p.Store.Get(StateUserList, KeyWaits) + if err != nil { + return st, fmt.Errorf("its own %s %s cannot be read: %w", StateUserList, KeyWaits, err) + } + if found { + if err := json.Unmarshal(raw, &st); err != nil { + return st, fmt.Errorf("its own %s %s does not read: %w", StateUserList, KeyWaits, err) + } + if st.Waits == nil { + st.Waits = map[string]HeldWait{} + } + } + return st, nil +} + +// settleWaits keeps the granting waits of this pass: a module left without a grant is a held entry, begun at this pass; one +// that has its grant is ended as `ran`. A released entry stays released while it is still left without one. +func (p *Part) settleWaits(res *Result, in Inputs, missing []string) { + if p.Store == nil { + return + } + now := p.now() + p.mu.Lock() + if p.firstPass.IsZero() { + p.firstPass = now + } + clock := p.firstPass + p.mu.Unlock() + st, err := p.readWaits() + if err != nil { + res.Errors = append(res.Errors, err.Error()) + return + } + held := map[string]bool{} + for _, m := range missing { + module, _, _ := strings.Cut(m, ": ") + held[module] = true + } + changed := false + for module := range held { + if _, ok := st.Waits[module]; ok { + continue + } + st.Waits[module] = HeldWait{Wait: meshgrants.Wait{Bound: GrantingBound.String(), ClockFrom: clock, Fp: in.Fingerprints[module], + Module: module, Part: "mesh-grants", Since: now, Step: meshgrants.WaitStepGrant, Version: in.Fingerprints["mesh-grants"]}} + changed = true + } + for module := range st.Waits { + if !held[module] { + delete(st.Waits, module) + changed = true + } + } + if !changed { + return + } + data, _ := json.Marshal(st) + p.changed(res, StateUserList, KeyWaits, data) +} + +// WaitsSaid is what the tool grants_waits answers: each held granting wait, and which have passed their bound. +type WaitsSaid struct { + Waits []HeldWait `json:"waits"` + // Late is the held entries whose bound, counted from clock-from, has passed. + Late []string `json:"late,omitempty"` +} + +// WaitsOf reads the granting waits (grants_waits). +func (p *Part) WaitsOf() (any, error) { + st, err := p.readWaits() + if err != nil { + return nil, err + } + out := WaitsSaid{Waits: []HeldWait{}} + for _, module := range sortedKeys(st.Waits) { + w := st.Waits[module] + out.Waits = append(out.Waits, w) + if !w.Released && !w.ClockFrom.Add(GrantingBound).After(p.now()) { + out.Late = append(out.Late, module) + } + } + sort.Strings(out.Late) + return out, nil +} + +// ReleaseWait releases one held entry by a person's act with why, recorded as a hand act through record (the seat +// mesh-hand-acts) before anything is changed (grants_release). +func (p *Part) ReleaseWait(caller, module, why string, record func(what, why string) error) (any, error) { + if strings.TrimSpace(why) == "" { + return nil, errors.New("grants_release needs why: it is recorded as a hand act") + } + st, err := p.readWaits() + if err != nil { + return nil, err + } + w, ok := st.Waits[module] + if !ok { + return nil, fmt.Errorf("the grants part holds no granting wait for %q: grants_waits lists them", module) + } + if record != nil { + if err := record("grants_release "+module, why); err != nil { + return nil, fmt.Errorf("the hand act could not be recorded, so nothing was released: %w", err) + } + } + w.Released, w.By, w.How = true, caller+": "+why, meshgrants.WaitHowReleased + st.Waits[module] = w + data, _ := json.Marshal(st) + if _, err := p.Store.Put(StateUserList, KeyWaits, data); err != nil { + return nil, fmt.Errorf("the release could not be kept: %w", err) + } + return map[string]any{"released": module, "by": w.By}, nil +} diff --git a/modules/mesh-grants/module.json b/modules/mesh-grants/module.json index 6443652e..6d9865eb 100644 --- a/modules/mesh-grants/module.json +++ b/modules/mesh-grants/module.json @@ -63,6 +63,8 @@ "grants_first_views", "grants_first_view_again", "grants_running", + "grants_waits", + "grants_release", "grants_seat_list", "grants_seat_list_again" ],