The checker takes a head's seat definitions as the indexer does, from one reading in the domain SDK, so an outside definer is never laid over the seat list (ADR 0636 §5, ADR 0634 §1-2; issue 1966)

The checker read any touched contracts module's definitions, skipped the
check that a definition names its own directory's seat, and copied the
indexer's tree reading. sdk/seatdefs now holds the reading and the rule of
which contracts module may define a seat; the indexer's keeping, its
revision seats and the checker's head seats all use it. Two heads defining
one seat fail the judging unless one hands it over to its domain's module.

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-06 10:37:00 +01:00
parent 289ba69da6
commit 67127ce60d
8 changed files with 379 additions and 292 deletions
@@ -1071,12 +1071,17 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
// try judges the same change as a whole group try, whose manifests come from the indexer's candidate of the try's
// group (issue 1921): the same judge, the same verdicts.
try bool
// heads has the change touch web-contracts too, a contracts module whose head defines mesh-catalog with a verb of a
// part it includes: the judges get that definition over the list's (ADR 0636 §4, ADR 0633 §6).
heads bool
// heads has the change touch a contracts module too, whose head defines the Delivery seat mesh-catalog with a verb of
// a part it includes: "domain" is the domain's contracts module, so the judges get that definition over the list's
// (ADR 0636 §4, ADR 0633 §6); "outside" is mesh-contracts, whose definition of another domain's seat is not taken
// (ADR 0636 §5); "misnamed" is the domain's module whose seats/mesh-catalog/seat.json names another seat.
heads string
}{
{name: "the seat list read is the request's Seats", seats: "read"},
{name: "the members' heads' definitions over the list's, for the trunk's manifest validator too", seats: "read", indexer: "ok", heads: true},
{name: "the members' heads' definitions over the list's, for the trunk's manifest validator too", seats: "read", indexer: "ok", heads: "domain"},
{name: "a head's definition of a seat outside its module's domain is not taken", seats: "read", indexer: "ok", heads: "outside"},
{name: "a head's definition naming another seat than its directory's fails the judging", seats: "read", heads: "misnamed",
fails: `names the seat "other", not "mesh-catalog"`},
{name: "the seat list not read judges nothing", seats: "unread",
fails: "the change's manifests cannot be judged: the seat list is not read (the seat list is held from the bootstrap"},
{name: "a seat definition that does not parse", unparsed: "seats/web-api/seat.json: unexpected end of JSON input",
@@ -1218,15 +1223,23 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
b.fetch.Changed = []string{JudgeDir + "/judge/judge.go"}
}
p.Modules = []string{"web"}
if row.heads {
p.Modules = append(p.Modules, "web-contracts")
b.ex.Forge = headFiles{
repoA + " modules/web-contracts/module.json " + head1: `{"module": "web-contracts", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`,
repoA + " modules/web-contracts/seats/mesh-catalog/seat.json " + head1: `{"format":1,"seat":"mesh-catalog","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1","includes":["lists"],` +
`"verbs":[{"name":"modules","input":{"type":"object"},"answer":{"type":"object"}}]}`,
repoA + " modules/web-contracts/parts/lists.json " + head1: `{"format":1,"part":"lists","verbs":[{"name":"seat-definitions","input":{"type":"object"},"answer":{"type":"object"}}]}`,
if row.heads != "" {
contracts, seat := "mesh-delivery-contracts", "mesh-catalog"
switch row.heads {
case "outside":
contracts = "mesh-contracts"
case "misnamed":
seat = "other"
}
b.ex.Trees = headTrees{repoA + " modules/web-contracts/seats " + head1: {"mesh-catalog/seat.json": "1", "mesh-catalog/fixtures/a.json": "2"}}
dir := "modules/" + contracts
p.Modules = append(p.Modules, contracts)
b.ex.Forge = headFiles{
repoA + " " + dir + "/module.json " + head1: `{"module": "` + contracts + `", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`,
repoA + " " + dir + "/seats/mesh-catalog/seat.json " + head1: `{"format":1,"seat":"` + seat + `","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1","includes":["lists"],` +
`"verbs":[{"name":"modules","input":{"type":"object"},"answer":{"type":"object"}}]}`,
repoA + " " + dir + "/parts/lists.json " + head1: `{"format":1,"part":"lists","verbs":[{"name":"seat-definitions","input":{"type":"object"},"answer":{"type":"object"}}]}`,
}
b.ex.Trees = headTrees{repoA + " " + dir + "/seats " + head1: {"mesh-catalog/seat.json": "1", "mesh-catalog/fixtures/a.json": "2"}}
}
p.Fps = map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"web": {Fp2: "src2:" + strings.Repeat("f", 64), Manifest: web}}
// Issue 1921: the judge is built at the group's heads too, also a member that publishes no package yet.
@@ -1252,7 +1265,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
}) {
t.Errorf("%s: the case says %v, want the new indexer's manifest said accepted by the trunk's manifest validator", row.name, cs.notes)
}
if row.seats == "unread" {
if row.seats == "unread" || row.heads == "misnamed" {
if v.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(v.Failed.Words, row.fails) || len(judged) != 0 {
t.Errorf("%s: %+v, judged %d, want a fail saying %q before any judge", row.name, v.Failed, len(judged), row.fails)
}
@@ -1298,7 +1311,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
req.Cases[0].Manifests["mail"].Digest != strings.Repeat("a", 64) {
t.Fatalf("%s: the request %+v: %v", row.name, req, err)
}
if row.heads {
if row.heads != "" {
var got []string
for _, raw := range req.Seats {
var d struct {
@@ -1313,7 +1326,11 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
}
got = append(got, d.Seat)
}
if want := []string{"a", "b", "mesh-catalog.seat-definitions.modules"}; !slices.Equal(got, want) {
want := []string{"a", "b", "mesh-catalog.seat-definitions.modules"}
if row.heads == "outside" {
want = want[:2]
}
if !slices.Equal(got, want) {
t.Errorf("%s: the trunk's manifest validator's seats %v, want %v: the list's with the head's definition, its part resolved", row.name, got, want)
}
} else if row.seats == "read" && (len(req.Seats) != 2 || string(req.Seats[0]) != `{"seat":"a"}` || string(req.Seats[1]) != `{"seat":"b"}`) {
@@ -5,8 +5,8 @@ package check
// group's contracts modules add to a seat (a verb, an event), which the seat list the checker follows (the trunks') does not
// hold yet; judged without them, by the trunk's manifest validator above all, the group's new indexer would be refused for
// serving its own seat's new verb, and so would every module that calls it. The definitions are read from each member's head
// as the indexer reads a revision's (the contracts module's `seats/<seat>/seat.json` with the parts it includes), through the
// forge, and put over the list's in the request's Seats.
// with the indexer's own reading (the domain SDK's seatdefs: the contracts module's `seats/<seat>/seat.json` with the parts it
// includes, and the rule which module may define a seat), through the forge, and put over the list's in the request's Seats.
import (
"context"
@@ -14,13 +14,10 @@ import (
"errors"
"fmt"
"maps"
"path"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/sdk/seatdefs"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// Trees lists the files of a repository under directories at a commit (the forge's commit trees): {directory: {path
@@ -30,35 +27,87 @@ type Trees interface {
}
// headSeats are the definitions, resolved, that the contracts modules the case's members touch hold at their heads, by
// seat; none where the checker lists no trees. A head's definition that cannot be read or does not parse is an error: the
// manifests cannot be judged by the group's seats then.
// seat; none where the checker lists no trees. They are read and ruled on as the indexer reads and rules on a revision's,
// with the domain SDK's one reading (mesh-delivery sdk/seatdefs): a seat of the mesh's own contracts is taken only from
// its domain's contracts module (ADR 0636 §5), so an outside definer's is left out and said, and a seat two touched modules
// define is refused unless one hands it over to its domain's module. A head's definition that cannot be read, does not
// parse or names another seat than its directory's is an error: the manifests cannot be judged by the group's seats then.
func (st *state) headSeats(ctx context.Context) (map[string]json.RawMessage, error) {
if st.j.Trees == nil || st.j.Forge == nil {
return nil, nil
}
out := map[string]json.RawMessage{}
by := map[string]string{} // seat -> the module whose head's definition is taken
for _, p := range st.c.Pulls {
for _, module := range p.Modules {
defs, ok, err := st.contractsDir(ctx, p, module)
c, ok, err := st.contractsModule(ctx, p, module)
if err != nil {
return nil, err
}
if !ok {
continue
}
got, err := st.definitionsAt(ctx, p, module, defs)
defs, unparsed, err := st.headForge().Read(ctx, p.Repository, p.Head, c)
if err != nil {
return nil, err
return nil, fmt.Errorf("the seat definitions of %s at the head of %s cannot be read%s: %w", module, pullKey(p), unheardWords(err), err)
}
if len(unparsed) > 0 {
seat := slices.Sorted(maps.Keys(unparsed))[0]
return nil, fmt.Errorf("the seat definition of %s (module %s) at the head of %s is not taken: %s", seat, module, pullKey(p), unparsed[seat])
}
for _, seat := range slices.Sorted(maps.Keys(defs)) {
d := defs[seat]
if why := seatdefs.Refusal(c.Module, d); why != "" {
st.c.note(fmt.Sprintf("the head of %s's definition of %s is not taken: %s", pullKey(p), seat, why))
continue
}
if prev, twice := by[seat]; twice && prev != c.Module {
switch {
case seatdefs.HandsOver(prev, c.Module, d):
case seatdefs.HandsOver(c.Module, prev, d):
continue
default:
return nil, fmt.Errorf("the seat %s is defined at the heads by both %s and %s: a seat has one definition", seat, prev, c.Module)
}
}
raw, err := json.Marshal(d)
if err != nil {
return nil, fmt.Errorf("the definition of %s cannot be encoded: %w", seat, err)
}
out[seat], by[seat] = raw, c.Module
}
maps.Copy(out, got)
}
}
return out, nil
}
// contractsDir is the directory a touched module's definitions live in at its member's head (its package artifact's
// generated-from, under the module's directory), and whether the module is a contracts module at all.
func (st *state) contractsDir(ctx context.Context, p Pull, module string) (string, bool, error) {
// headForge is the domain SDK's reading bound to the checker's forge listing and get-file: a directory or a file the head
// does not hold is none, every other failure the forge's.
func (st *state) headForge() seatdefs.Forge {
return seatdefs.Forge{
Files: func(ctx context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) {
got, err := st.j.Trees.Files(ctx, repository, commit, dirs)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
return got, err
},
File: func(ctx context.Context, repository, commit, file string) ([]byte, error) {
text, err := st.j.Forge.File(ctx, repository, file, commit)
if errors.Is(err, ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return []byte(text), nil
},
}
}
// contractsModule is a touched module as a contracts module at its member's head (its package artifact's generated-from,
// under the module's directory), and whether the module is a contracts module at all.
func (st *state) contractsModule(ctx context.Context, p Pull, module string) (seatdefs.Module, bool, error) {
var m catalogue.Manifest
dir, known := "", false
if st.j.Dirs != nil {
@@ -67,91 +116,17 @@ func (st *state) contractsDir(ctx context.Context, p Pull, module string) (strin
if digest := p.Fps[module].Manifest; known && digest != "" && st.j.Read != nil {
var err error
if m, err = st.j.Read.Manifest(ctx, digest); err != nil {
return "", false, fmt.Errorf("the manifest of %s at the head of %s cannot be read: %w", module, pullKey(p), err)
return seatdefs.Module{}, false, fmt.Errorf("the manifest of %s at the head of %s cannot be read: %w", module, pullKey(p), err)
}
} else {
var found bool
var err error
if m, dir, found, err = st.manifestAtHead(ctx, p, module); err != nil || !found {
return "", false, err
return seatdefs.Module{}, false, err
}
}
if m.Build == nil {
return "", false, nil
}
for _, a := range m.Build.Artifacts {
if a.GeneratedFrom == "" {
continue
}
base := strings.Trim(dir, "/")
if base == meshcatalogclient.RootPath {
base = ""
}
return path.Clean(path.Join(base, a.GeneratedFrom)), true, nil
}
return "", false, nil
}
// definitionsAt reads every seat a contracts module defines at the member's head, each resolved with its parts.
func (st *state) definitionsAt(ctx context.Context, p Pull, module, defs string) (map[string]json.RawMessage, error) {
under := func(rel string) string {
if defs == "." || defs == "" {
return rel
}
return defs + "/" + rel
}
seatsDir := under("seats")
listed, err := st.j.Trees.Files(ctx, p.Repository, p.Head, []string{seatsDir})
if errors.Is(err, ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("the seats of %s at the head of %s cannot be listed%s: %w", module, pullKey(p), unheardWords(err), err)
}
read := func(rel string) ([]byte, error) {
text, err := st.j.Forge.File(ctx, p.Repository, under(rel), p.Head)
if err != nil {
return nil, fmt.Errorf("%s of %s at the head of %s cannot be read%s: %w", under(rel), module, pullKey(p), unheardWords(err), err)
}
return []byte(text), nil
}
var seats []string
for file := range listed[seatsDir] {
if seat, ok := strings.CutSuffix(file, "/seat.json"); ok && seat != "" && !strings.Contains(seat, "/") {
seats = append(seats, seat)
}
}
out := map[string]json.RawMessage{}
parts := map[string]seatdef.Part{}
for _, seat := range slices.Sorted(slices.Values(seats)) {
raw, err := read("seats/" + seat + "/seat.json")
if err != nil {
return nil, err
}
d, err := seatdef.Parse(raw)
if err != nil {
return nil, fmt.Errorf("the seat definition of %s (module %s) at the head of %s does not parse: %w", seat, module, pullKey(p), err)
}
for _, name := range d.Includes {
if _, ok := parts[name]; ok {
continue
}
praw, err := read("parts/" + name + ".json")
if err != nil {
return nil, err
}
if parts[name], err = seatdef.ParsePart(praw); err != nil {
return nil, fmt.Errorf("the part %s of %s at the head of %s does not parse: %w", name, module, pullKey(p), err)
}
}
if d, err = seatdef.Resolve(d, parts); err != nil {
return nil, fmt.Errorf("the seat definition of %s (module %s) at the head of %s does not resolve: %w", seat, module, pullKey(p), err)
}
if out[d.Seat], err = json.Marshal(d); err != nil {
return nil, fmt.Errorf("the definition of %s cannot be encoded: %w", d.Seat, err)
}
}
return out, nil
c, ok := seatdefs.Of(dir, m)
return c, ok, nil
}
// overSeats is the list's definitions with each of the heads' put in place of the one of its seat, or added, sorted by seat.
@@ -1,58 +1,16 @@
package index
// Where a seat of the mesh's own contracts may be defined (novox/hq ADR 0636 §5, ADR 0634 §7): a definition names its
// glossary domain; the indexer accepts a seat of the mesh's own contracts only from that domain's contracts module
// (mesh-<domain>-contracts), or from mesh-contracts for a domain with none. A second definer is refused with a finding
// naming both. A definition moving from mesh-contracts to its domain's contracts module is a handover: the new
// definer takes the seat over whichever trunk merges first, and a removal from the module that is not the seat's
// domain's keeps the last trunk definition until the domain's module defines it.
// Where a seat of the mesh's own contracts may be defined (novox/hq ADR 0636 §5, ADR 0634 §7): the rule is the domain
// SDK's (mesh-delivery sdk/seatdefs), shared with the checker; here only how the indexer's kept entries meet it. A second
// definer is refused with a finding naming both, unless it is a handover to the seat's domain's module.
import (
"encoding/json"
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/sdk/seatdefs"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// fallbackContracts is the mesh's contracts module for a domain with no contracts module of its own.
const fallbackContracts = "mesh-contracts"
// domainsWithContracts are the domains whose seats have a contracts module of their own (ADR 0634 §7): the six
// the milestone creates. Every other domain's seats are defined in fallbackContracts.
var domainsWithContracts = []string{"Delivery", "Provisioning", "Identity and access", "Health and repair",
"Connectivity", "Operator and conversation"}
// domainContracts is the module that defines a domain's seats: mesh-<domain>-contracts, the heading lowercased with
// its spaces as dashes, or fallbackContracts for a domain with none.
func domainContracts(domain string) string {
if !slices.Contains(domainsWithContracts, domain) {
return fallbackContracts
}
return "mesh-" + strings.ReplaceAll(strings.ToLower(domain), " ", "-") + "-contracts"
}
// ownContracts says a contracts module is one of the mesh's own: fallbackContracts or a domain's.
func ownContracts(module string) bool {
return module == fallbackContracts || slices.ContainsFunc(seatdef.Domains, func(d string) bool {
return module == domainContracts(d) && module != fallbackContracts
})
}
// domainRefusal is, for a definition a contracts module of the mesh's own holds, why it may not: the words naming the
// module and the domain's. Empty when the module may define the seat (a module outside the mesh's own contracts defines
// what it likes, ADR 0625 §7).
func domainRefusal(module string, d seatdef.Definition) string {
if !ownContracts(module) {
return ""
}
if want := domainContracts(d.Domain); want != module {
return fmt.Sprintf("it is a seat of the domain %q, whose seats %s defines, and %s defines it", d.Domain, want, module)
}
return ""
}
// domainOf is the domain a kept definition names ("" when it is none or does not decode).
func domainOf(raw json.RawMessage) string {
var d struct {
@@ -62,14 +20,12 @@ func domainOf(raw json.RawMessage) string {
return d.Domain
}
// handsOver says a kept trunk entry is handed over to a definer: the entry is held by a module of the mesh's own
// that is not the seat's domain's, and the definer is the seat's domain's contracts module.
// handsOver says a kept trunk entry is handed over to a definer (seatdefs.HandsOver).
func handsOver(cur SeatDefinitionEntry, module string, d seatdef.Definition) bool {
return cur.Module != module && ownContracts(cur.Module) && domainContracts(d.Domain) == module
return seatdefs.HandsOver(cur.Module, module, d)
}
// handoverPending says a trunk entry its holder no longer defines waits for its domain's module: the holder is a
// module of the mesh's own and not the seat's domain's, so the last trunk definition stays until that module defines it.
// handoverPending says a trunk entry its holder no longer defines waits for its domain's module (seatdefs.HandoverPending).
func handoverPending(cur SeatDefinitionEntry) bool {
return ownContracts(cur.Module) && domainContracts(domainOf(cur.Definition)) != cur.Module
return seatdefs.HandoverPending(cur.Module, domainOf(cur.Definition))
}
@@ -211,11 +211,14 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633)
// definitions add (ADR 0633 §1, §6): refused without them, with its reason said on the wait and in indexer_pending;
// accepted with them, and the revision waits for the build it names.
const verb, contracts = "revision-verb", "modules/seat-contracts"
// The third row's definer is the Delivery domain's contracts module defining a Core seat: not taken (ADR 0636 §5).
for _, row := range []struct {
name string
defined bool
refused bool
}{{"definitions the revision does not carry", false, true}, {"definitions the revision carries", true, false}} {
module string
}{{"definitions the revision does not carry", false, true, ""}, {"definitions the revision carries", true, false, "seat-contracts"},
{"definitions of a seat outside the carrier's domain", true, true, "mesh-delivery-contracts"}} {
b := newTakeInBed(t)
f := b.merge(21, sha('c'), "tree-avahi-3", "tree-indexer-2")
f.Manifests = map[string]string{}
@@ -232,7 +235,7 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633)
f.Locks[d] = l
}
f.Locks[contracts] = map[string]string{}
f.Manifests[contracts] = `{"module":"seat-contracts","version":"1","build":{"artifacts":[{"name":"go-seats","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}`
f.Manifests[contracts] = `{"module":"` + row.module + `","version":"1","build":{"artifacts":[{"name":"go-seats","kind":"package","language":"go","paths":["go"],"series":"0.1","generated-from":"."}]}}`
b.ix.Files = func(_ context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) {
if repository != f.Repository || commit != f.Merge || !slices.Equal(dirs, []string{contracts + "/seats"}) {
t.Errorf("listed %v of %s at %s", dirs, repository, commit)
@@ -29,7 +29,7 @@ import (
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-delivery/sdk/seatdefs"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/seatdef"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -120,128 +120,20 @@ func OutOfStepKey(repository string, pull int) string {
}
// errForgeRead is a read the forge did not answer (never a file it does not hold): the message is left for redelivery.
var errForgeRead = errors.New("the forge did not answer")
var errForgeRead = seatdefs.ErrUnanswered
// contractsModule is a contracts module of a message: its module, its directory and its definitions' directory.
type contractsModule struct{ module, dir, defs string }
// contractsModule is a contracts module of a message (the domain SDK's reading, seatdefs.Module).
type contractsModule = seatdefs.Module
// contractsModules are the message's module directories whose manifest marks a contracts module (a package artifact
// naming `generated-from`), in directory order. A manifest that does not decode is no contracts module here: the judge
// says why it is refused.
// contractsModules are the message's module directories whose manifest marks a contracts module, in directory order.
func contractsModules(manifests map[string]string) []contractsModule {
var out []contractsModule
for dir, raw := range manifests {
m, err := catalogue.Decode([]byte(raw))
if err != nil || m.Build == nil {
continue
}
for _, a := range m.Build.Artifacts {
if a.GeneratedFrom == "" {
continue
}
base := dir
if base == meshcatalogclient.RootPath {
base = ""
}
out = append(out, contractsModule{module: m.Module, dir: dir, defs: path.Clean(path.Join(base, a.GeneratedFrom))})
break
}
}
slices.SortFunc(out, func(a, b contractsModule) int { return strings.Compare(a.dir, b.dir) })
return out
return seatdefs.ContractsModules(manifests)
}
// listSeats names the seats a contracts module defines at a commit: every directory of its definitions' `seats/` that
// holds a `seat.json`, whatever the seat's grain (ADR 0635 §2, §6), from the forge's one listing of that directory. No
// index file names them. A listing the forge did not answer is a read it did not answer (the message is delivered again).
func (ix *Indexer) listSeats(ctx context.Context, repository, commit string, c contractsModule) ([]string, error) {
if ix.Files == nil {
return nil, fmt.Errorf("%w: the indexer has no listing of %s's seats", errForgeRead, c.module)
}
dir := path.Join(c.defs, "seats")
if c.defs == "." || c.defs == "" {
dir = "seats"
}
got, err := ix.Files(ctx, repository, commit, []string{dir})
if err != nil {
return nil, fmt.Errorf("%w: the seats of %s at %.12s: %w", errForgeRead, c.module, commit, err)
}
var seats []string
for p := range got[dir] {
if seat, ok := strings.CutSuffix(p, "/seat.json"); ok && seat != "" && !strings.Contains(seat, "/") {
seats = append(seats, seat)
}
}
slices.Sort(seats)
return seats, nil
}
// readDefinitions reads a contracts module's definitions at a commit: each seat's definition (listSeats) and the
// parts it includes, each resolved. A seat that does not parse is answered by its words.
// readDefinitions reads a contracts module's definitions at a commit with the domain SDK's reading (seatdefs.Forge.Read),
// through the indexer's forge listing and get-file.
func (ix *Indexer) readDefinitions(ctx context.Context, repository, commit string, c contractsModule) (map[string]seatdef.Definition, map[string]string, error) {
file := func(p string) ([]byte, error) {
if c.defs != "." && c.defs != "" {
p = c.defs + "/" + p
}
raw, err := ix.ReadFile(ctx, repository, commit, p)
if err != nil {
return nil, fmt.Errorf("%w: %s at %.12s: %w", errForgeRead, p, commit, err)
}
if raw == nil {
return nil, fmt.Errorf("%s at %.12s: the forge holds no such file", p, commit)
}
return raw, nil
}
seats, err := ix.listSeats(ctx, repository, commit, c)
if err != nil {
return nil, nil, err
}
defs, unparsed := map[string]seatdef.Definition{}, map[string]string{}
parts := map[string]seatdef.Part{}
for _, seat := range seats {
raw, err := file("seats/" + seat + "/seat.json")
if err != nil {
if errors.Is(err, errForgeRead) {
return nil, nil, err
}
unparsed[seat] = err.Error()
continue
}
d, err := seatdef.Parse(raw)
if err == nil && d.Seat != seat {
err = fmt.Errorf("it names the seat %q, not %q", d.Seat, seat)
}
if err == nil {
for _, name := range d.Includes {
if _, ok := parts[name]; ok {
continue
}
praw, perr := file("parts/" + name + ".json")
if perr == nil {
var p seatdef.Part
if p, perr = seatdef.ParsePart(praw); perr == nil {
parts[name] = p
}
}
if errors.Is(perr, errForgeRead) {
return nil, nil, perr
}
if perr != nil {
err = fmt.Errorf("its part %s: %w", name, perr)
break
}
}
}
if err == nil {
d, err = seatdef.Resolve(d, parts)
}
if err != nil {
unparsed[seat] = err.Error()
continue
}
defs[seat] = d
}
return defs, unparsed, nil
return seatdefs.Forge{Files: ix.Files, File: ix.ReadFile}.Read(ctx, repository, commit, c)
}
// digestOf is a resolved definition's bytes and their sha256.
@@ -395,27 +287,27 @@ func (ix *Indexer) keepTrunk(ctx context.Context, repository, commit string, c c
return err // the forge did not answer: the message is delivered again
}
if err != nil {
ix.unparsedFinding(ctx, c.module, map[string]string{"seats": err.Error()})
ix.unparsedFinding(ctx, c.Module, map[string]string{"seats": err.Error()})
return nil
}
ix.unparsedFinding(ctx, c.module, unparsed)
ix.unparsedFinding(ctx, c.Module, unparsed)
var errs []error
twice := map[string]string{} // seat -> the repository and module whose trunk holds it
outside := map[string]string{} // seat -> why its module may not define it (ADR 0636 §5)
for seat, d := range defs {
if why := domainRefusal(c.module, d); why != "" {
if why := seatdefs.Refusal(c.Module, d); why != "" {
outside[seat] = why
continue
}
holder, err := ix.putDefinition(ctx, SeatDefinitionEntry{Seat: seat, Source: SourceTrunk, Repository: repository,
Module: c.module, Directory: c.dir, Commit: commit}, d)
Module: c.Module, Directory: c.Dir, Commit: commit}, d)
if holder != "" {
twice[seat] = holder
}
errs = append(errs, err)
}
ix.twiceFinding(ctx, repository, c.module, twice)
ix.outsideFinding(ctx, c.module, outside)
ix.twiceFinding(ctx, repository, c.Module, twice)
ix.outsideFinding(ctx, c.Module, outside)
// A seat the module's trunk no longer defines leaves the state, unless it is handed over: a seat that is not its
// holder's domain's waits for the domain's module (domain.go).
keys, err := ix.SeatDefinitions.Keys(ctx)
@@ -430,7 +322,7 @@ func (ix *Indexer) keepTrunk(ctx context.Context, repository, commit string, c c
gone := false
err := write(ctx, ix.SeatDefinitions, key, func(value []byte, found bool) ([]byte, bool, error) {
var e SeatDefinitionEntry
gone = found && json.Unmarshal(value, &e) == nil && e.Repository == repository && e.Module == c.module && !handoverPending(e)
gone = found && json.Unmarshal(value, &e) == nil && e.Repository == repository && e.Module == c.Module && !handoverPending(e)
return nil, gone, nil
})
errs = append(errs, err)
@@ -451,10 +343,10 @@ func (ix *Indexer) keepPull(ctx context.Context, repository string, pull int, he
if err != nil {
unparsed = map[string]string{"": err.Error()}
}
base := SeatDefinitionEntry{Source: SourcePull, Repository: repository, Pull: pull, Module: c.module, Directory: c.dir, Commit: head}
base := SeatDefinitionEntry{Source: SourcePull, Repository: repository, Pull: pull, Module: c.Module, Directory: c.Dir, Commit: head}
var errs []error
for seat, d := range defs {
if why := domainRefusal(c.module, d); why != "" {
if why := seatdefs.Refusal(c.Module, d); why != "" {
unparsed[seat] = why // its check fails on it, as on a definition that does not parse
continue
}
@@ -467,7 +359,7 @@ func (ix *Indexer) keepPull(ctx context.Context, repository string, pull int, he
e := base
e.Seat, e.Unparsed, e.At = seat, why, ix.now().UTC()
if seat == "" {
e.Seat = c.module // the module's index did not parse: no seat is known, the entry is named by the module
e.Seat = c.Module // the module's index did not parse: no seat is known, the entry is named by the module
}
data, err := json.Marshal(e)
if err == nil {
@@ -5,13 +5,15 @@ package index
// on top of the seat list the indexer holds, since the manifest the revision names may serve or invoke what the revision's
// definitions add (a verb of a seat, an event). The revision's merges are not taken in yet, and a try has no pull request,
// so their definitions are not on the bus: they are read from the merges' trees as the indexer reads a merge's (readDefinitions
// of seatdefs.go, the same resolution of the parts), never by a reader of their own.
// of seatdefs.go, the same resolution of the parts and the same rule of which module may define a seat), never by a reader
// of their own.
import (
"context"
"maps"
"slices"
"git.novox.be/novox/mesh-delivery/sdk/seatdefs"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
@@ -35,9 +37,15 @@ func (ix *Indexer) revisionSeats(ctx context.Context, ready []PendingEntry) ([]c
return nil, err
}
for seat, why := range unparsed {
ix.logf("the revision's definition of %s (%s) does not parse and is left out: %s", seat, c.module, why)
ix.logf("the revision's definition of %s (%s) does not parse and is left out: %s", seat, c.Module, why)
}
for seat, d := range got {
if why := seatdefs.Refusal(c.Module, d); why != "" {
ix.logf("the revision's definition of %s is left out: %s", seat, why) // as keepTrunk refuses it (ADR 0636 §5)
continue
}
defs[seat] = d
}
maps.Copy(defs, got)
}
}
return catalogue.SeatsFromDefinitions(slices.Collect(maps.Values(defs))), nil
+64
View File
@@ -0,0 +1,64 @@
package seatdefs
// Where a seat of the mesh's own contracts may be defined (novox/hq ADR 0636 §5, ADR 0634 §7): a definition names its
// glossary domain; a seat of the mesh's own contracts is taken only from that domain's contracts module
// (mesh-<domain>-contracts), or from mesh-contracts for a domain with none. A definition moving from mesh-contracts to
// its domain's contracts module is a handover: the new definer takes the seat over, and a removal from the module that is
// not the seat's domain's keeps the last trunk definition until the domain's module defines it.
import (
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// Fallback is the mesh's contracts module for a domain with no contracts module of its own.
const Fallback = "mesh-contracts"
// domainsWithContracts are the domains whose seats have a contracts module of their own (ADR 0634 §7). Every other
// domain's seats are defined in Fallback.
var domainsWithContracts = []string{"Delivery", "Provisioning", "Identity and access", "Health and repair",
"Connectivity", "Operator and conversation"}
// DomainContracts is the module that defines a domain's seats: mesh-<domain>-contracts, the heading lowercased with its
// spaces as dashes, or Fallback for a domain with none.
func DomainContracts(domain string) string {
if !slices.Contains(domainsWithContracts, domain) {
return Fallback
}
return "mesh-" + strings.ReplaceAll(strings.ToLower(domain), " ", "-") + "-contracts"
}
// Own says a contracts module is one of the mesh's own: Fallback or a domain's.
func Own(module string) bool {
return module == Fallback || slices.ContainsFunc(seatdef.Domains, func(d string) bool {
return module == DomainContracts(d) && module != Fallback
})
}
// Refusal is, for a definition a contracts module of the mesh's own holds, why it may not: the words naming the module
// and the domain's. Empty when the module may define the seat (a module outside the mesh's own contracts defines what it
// likes, ADR 0625 §7).
func Refusal(module string, d seatdef.Definition) string {
if !Own(module) {
return ""
}
if want := DomainContracts(d.Domain); want != module {
return fmt.Sprintf("it is a seat of the domain %q, whose seats %s defines, and %s defines it", d.Domain, want, module)
}
return ""
}
// HandsOver says a seat its holder defines is handed over to a definer: the holder is a module of the mesh's own that is
// not the definer, and the definer is the seat's domain's contracts module.
func HandsOver(holder, definer string, d seatdef.Definition) bool {
return holder != definer && Own(holder) && DomainContracts(d.Domain) == definer
}
// HandoverPending says a seat a holder of the mesh's own no longer defines waits for its domain's module: the holder is
// not the domain's (named by the definition it held), so its last definition stays until that module defines it.
func HandoverPending(holder, domain string) bool {
return Own(holder) && DomainContracts(domain) != holder
}
+172
View File
@@ -0,0 +1,172 @@
// Package seatdefs is how a seat definition is read from a contracts module at a commit, and which contracts module may
// define a seat (novox/hq ADR 0608 §2-§3, ADR 0635 §2, ADR 0636 §5, ADR 0634 §1-§2): the one reading the indexer keeps
// in its state seat-definitions and the checker lays over the seat list for a case's heads, so both take the same
// definitions from the same modules. It names no transport: the caller's Forge binds the forge's listing and its
// get-file to its own calls.
package seatdefs
import (
"context"
"errors"
"fmt"
"path"
"slices"
"strings"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seatdef"
)
// ErrUnanswered wraps a read the forge did not answer (never a file or a directory it does not hold): the caller tries
// again rather than taking the module's definitions as gone.
var ErrUnanswered = errors.New("the forge did not answer")
// Forge is what the reading asks of the forge.
type Forge struct {
// Files lists every file under each directory at a commit, by directory then path relative to it; a directory the
// commit lacks is absent from the answer (no error).
Files func(ctx context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error)
// File reads one file at a commit; a file the commit does not hold is nil with no error.
File func(ctx context.Context, repository, commit, path string) ([]byte, error)
}
// Module is a contracts module: its name, its directory in the repository and its definitions' directory.
type Module struct{ Module, Dir, Defs string }
// Of is the contracts module a manifest in a directory is (a package artifact naming `generated-from`), and whether it
// is one at all.
func Of(dir string, m catalogue.Manifest) (Module, bool) {
if m.Build == nil {
return Module{}, false
}
for _, a := range m.Build.Artifacts {
if a.GeneratedFrom == "" {
continue
}
base := strings.Trim(dir, "/")
if base == meshcatalogclient.RootPath {
base = ""
}
return Module{Module: m.Module, Dir: dir, Defs: path.Clean(path.Join(base, a.GeneratedFrom))}, true
}
return Module{}, false
}
// ContractsModules are the contracts modules among raw manifests by directory, in directory order. A manifest that does
// not decode is no contracts module here: the manifest validator says why it is refused.
func ContractsModules(manifests map[string]string) []Module {
var out []Module
for dir, raw := range manifests {
m, err := catalogue.Decode([]byte(raw))
if err != nil {
continue
}
if c, ok := Of(dir, m); ok {
out = append(out, c)
}
}
slices.SortFunc(out, func(a, b Module) int { return strings.Compare(a.Dir, b.Dir) })
return out
}
// under is a path of the module's definitions' directory.
func (c Module) under(rel string) string {
if c.Defs == "." || c.Defs == "" {
return rel
}
return c.Defs + "/" + rel
}
// Seats names the seats a contracts module defines at a commit: every directory of its definitions' `seats/` that holds
// a `seat.json`, whatever the seat's grain (ADR 0635 §2, §6), from the forge's one listing of that directory. No index
// file names them.
func (f Forge) Seats(ctx context.Context, repository, commit string, c Module) ([]string, error) {
if f.Files == nil {
return nil, fmt.Errorf("%w: no listing of %s's seats", ErrUnanswered, c.Module)
}
dir := c.under("seats")
got, err := f.Files(ctx, repository, commit, []string{dir})
if err != nil {
return nil, fmt.Errorf("%w: the seats of %s at %.12s: %w", ErrUnanswered, c.Module, commit, err)
}
var seats []string
for p := range got[dir] {
if seat, ok := strings.CutSuffix(p, "/seat.json"); ok && seat != "" && !strings.Contains(seat, "/") {
seats = append(seats, seat)
}
}
slices.Sort(seats)
return seats, nil
}
// Read reads a contracts module's definitions at a commit: each seat's definition (Seats) and the parts it includes,
// each resolved. A seat whose definition is missing, does not parse, names another seat than its directory's, or whose
// part does not parse or resolve, is answered in unparsed by its words; a read the forge did not answer is an error
// wrapping ErrUnanswered.
func (f Forge) Read(ctx context.Context, repository, commit string, c Module) (defs map[string]seatdef.Definition, unparsed map[string]string, err error) {
if f.File == nil {
return nil, nil, fmt.Errorf("%w: no reader of %s's definitions", ErrUnanswered, c.Module)
}
file := func(rel string) ([]byte, error) {
p := c.under(rel)
raw, err := f.File(ctx, repository, commit, p)
if err != nil {
return nil, fmt.Errorf("%w: %s at %.12s: %w", ErrUnanswered, p, commit, err)
}
if raw == nil {
return nil, fmt.Errorf("%s at %.12s: the forge holds no such file", p, commit)
}
return raw, nil
}
seats, err := f.Seats(ctx, repository, commit, c)
if err != nil {
return nil, nil, err
}
defs, unparsed = map[string]seatdef.Definition{}, map[string]string{}
parts := map[string]seatdef.Part{}
for _, seat := range seats {
raw, err := file("seats/" + seat + "/seat.json")
if err != nil {
if errors.Is(err, ErrUnanswered) {
return nil, nil, err
}
unparsed[seat] = err.Error()
continue
}
d, err := seatdef.Parse(raw)
if err == nil && d.Seat != seat {
err = fmt.Errorf("it names the seat %q, not %q", d.Seat, seat)
}
if err == nil {
for _, name := range d.Includes {
if _, ok := parts[name]; ok {
continue
}
praw, perr := file("parts/" + name + ".json")
if perr == nil {
var p seatdef.Part
if p, perr = seatdef.ParsePart(praw); perr == nil {
parts[name] = p
}
}
if errors.Is(perr, ErrUnanswered) {
return nil, nil, perr
}
if perr != nil {
err = fmt.Errorf("its part %s: %w", name, perr)
break
}
}
}
if err == nil {
d, err = seatdef.Resolve(d, parts)
}
if err != nil {
unparsed[seat] = err.Error()
continue
}
defs[seat] = d
}
return defs, unparsed, nil
}