From 666dcff79bc7a7c06ca0cd6036a72a4f86fb9805 Mon Sep 17 00:00:00 2001 From: mesh-tools Date: Mon, 19 Oct 2026 21:21:32 +0000 Subject: [PATCH] Pass a seat held once to its only claimant when its recorded holder is assigned nowhere (issue 1317, ADR 0449) --- cmd/mesh-controller/plan.go | 14 ++- cmd/mesh-controller/seat_pass.go | 104 ++++++++++++++++++++++ cmd/mesh-controller/seat_pass_test.go | 122 ++++++++++++++++++++++++++ internal/catalogue/seat_pass.go | 85 ++++++++++++++++++ internal/catalogue/seat_pass_test.go | 81 +++++++++++++++++ internal/inventory/holdings_test.go | 21 +++++ internal/inventory/seats.go | 42 +++++++++ 7 files changed, 467 insertions(+), 2 deletions(-) create mode 100644 cmd/mesh-controller/seat_pass.go create mode 100644 cmd/mesh-controller/seat_pass_test.go create mode 100644 internal/catalogue/seat_pass.go create mode 100644 internal/catalogue/seat_pass_test.go diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index a0efccf0..6507b208 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -285,12 +285,22 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, assigned []string } var others []candidate + // **Every machine's assignments, before the holdings are read by any resolution** (novox/hq ADR 0449 §4): a + // seat held once whose holder on record is assigned on no machine needs the whole mesh to be known, and it + // passes to its only claimant here, in the composition that finds it. + everyAssigned := map[string][]string{} + for _, n := range nodes { + if theirs, err := inv.Assigned(ctx, n.Name); err == nil && len(theirs) > 0 { + everyAssigned[n.Name] = theirs + } + } + holdings = theMeshPassesSeatsOn(ctx, inv, shelf, everyAssigned, holdings) for _, n := range nodes { if n.Name == exclude { continue } - theirs, err := inv.Assigned(ctx, n.Name) - if err != nil || len(theirs) == 0 { + theirs := everyAssigned[n.Name] + if len(theirs) == 0 { continue } caps, _ := inv.ProfileOf(ctx, n.Name) diff --git a/cmd/mesh-controller/seat_pass.go b/cmd/mesh-controller/seat_pass.go new file mode 100644 index 00000000..fe9ccf29 --- /dev/null +++ b/cmd/mesh-controller/seat_pass.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/nats-io/nats.go" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/inventory" + "github.com/novox/mesh-controller/internal/link" +) + +// seatPassing is what passing seats on needs of the outside: the record's compare-and-set, and the hand act. +type seatPassing struct { + pass func(ctx context.Context, p catalogue.SeatPass) (bool, error) + act func(ctx context.Context, p catalogue.SeatPass) error + say io.Writer +} + +// passSeatsOn records, in the composition that finds it, the new holder of each seat held once whose holder on +// record is assigned nowhere and which has exactly one claimant able to hold it, and writes the controller's own +// hand act for it (novox/hq ADR 0449 §4, issue 1317). It answers the holdings as they are after it, so the +// resolution that follows reads the record it just moved. A failed write is said and leaves the record as it was: +// a composition is never refused for it. +func passSeatsOn(ctx context.Context, sp seatPassing, holdings []catalogue.Held, + assignments []catalogue.Assignment) []catalogue.Held { + passes, said := catalogue.SeatsToPassOn(holdings, assignments) + for _, line := range said { + fmt.Fprintln(sp.say, line) + } + for _, p := range passes { + moved, err := sp.pass(ctx, p) + if err != nil { + fmt.Fprintf(sp.say, "%s could not be passed from %s on %s to %s on %s: %v\n", p.Seat.Name, + p.From.Module, p.From.Node, p.To.Module, p.To.Node, err) + continue + } + if !moved { + continue // another composition passed it first, and wrote the act + } + if err := sp.act(ctx, p); err != nil { + fmt.Fprintf(sp.say, "%s passed from %s on %s to %s on %s, and the hand act could NOT be recorded: %v\n", + p.Seat.Name, p.From.Module, p.From.Node, p.To.Module, p.To.Node, err) + } + var next []catalogue.Held + for _, h := range holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == p.Seat.Name { + continue + } + next = append(next, h) + } + p.To.Site = p.From.Site + holdings = append(next, p.To) + } + return holdings +} + +// seatPassHandAct is the controller's own entry for a pass: it is the act that puts the holder on record, +// where a person's `seat --to` would have, so the record of who held what stays complete (ADR 0449 §4). +func seatPassHandAct(p catalogue.SeatPass) link.HandAct { + return link.HandAct{ + By: "the controller", + Verb: "seat", + Args: []string{p.Seat.Name, "--to", p.To.Node + "/" + p.To.Module, "--was", p.From.Node + "/" + p.From.Module}, + Why: fmt.Sprintf("%s on %s, the holder on record of %s, is assigned on no machine, and %s on %s is the only "+ + "assignment that claims it and can hold it (ADR 0449 §4)", p.From.Module, p.From.Node, p.Seat.Name, + p.To.Module, p.To.Node), + Cause: "seat-passed", + Outcome: "done", + } +} + +// theMeshPassesSeatsOn is passSeatsOn as the composition runs it: against the store's record, with the hand act +// on the bus. Said to where the composition says its other lines (sayLeftOut). +func theMeshPassesSeatsOn(ctx context.Context, inv *inventory.Inventory, shelf map[string]catalogue.Manifest, + assigned map[string][]string, holdings []catalogue.Held) []catalogue.Held { + var assignments []catalogue.Assignment + for node, modules := range assigned { + for _, module := range modules { + if m, ok := shelf[module]; ok { + assignments = append(assignments, catalogue.Assignment{Node: node, Manifest: m}) + } + } + } + var w io.Writer = os.Stderr + if lw, ok := ctx.Value(leftOutKey{}).(io.Writer); ok { + w = lw + } + return passSeatsOn(ctx, seatPassing{ + say: w, + pass: func(ctx context.Context, p catalogue.SeatPass) (bool, error) { + return inv.PassSeat(ctx, p.Seat.Name, p.Seat.Scope, p.From, p.To) + }, + act: func(ctx context.Context, p catalogue.SeatPass) error { + return onTheBus(func(conn *nats.Conn) error { + _, err := link.RecordHandAct(ctx, conn, seatPassHandAct(p)) + return err + }) + }, + }, holdings, assignments) +} diff --git a/cmd/mesh-controller/seat_pass_test.go b/cmd/mesh-controller/seat_pass_test.go new file mode 100644 index 00000000..b4649044 --- /dev/null +++ b/cmd/mesh-controller/seat_pass_test.go @@ -0,0 +1,122 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" + "github.com/novox/mesh-controller/internal/link" +) + +func aBrokerClaiming(module string) catalogue.Manifest { + return catalogue.Manifest{Module: module, + Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} +} + +func withBusSeat(t *testing.T) { + t.Helper() + was := catalogue.Seats() + t.Cleanup(func() { catalogue.UseSeats(was) }) + catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) +} + +// 1317/AC1: the composition records the claimant as the holder and writes one hand act by the controller naming +// the seat, the former holder and the new one; the holdings it answers name the new holder. +func TestACompositionPassesTheSeatAndWritesOneHandActByTheController(t *testing.T) { + withBusSeat(t) + var passed []catalogue.SeatPass + var acts []link.HandAct + var said bytes.Buffer + sp := seatPassing{say: &said, + pass: func(_ context.Context, p catalogue.SeatPass) (bool, error) { + passed = append(passed, p) + return true, nil + }, + act: func(_ context.Context, p catalogue.SeatPass) error { + acts = append(acts, seatPassHandAct(p)) + return nil + }, + } + holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}} + got := passSeatsOn(t.Context(), sp, holdings, []catalogue.Assignment{{Node: "novox", Manifest: aBrokerClaiming("gate")}}) + if len(got) != 1 || got[0].Module != "gate" || got[0].Node != "novox" { + t.Fatalf("the holdings after the composition do not name the gate: %+v", got) + } + if len(passed) != 1 || len(acts) != 1 { + t.Fatalf("passes=%d acts=%d, want one each", len(passed), len(acts)) + } + a := acts[0] + text := strings.Join(append([]string{a.By, a.Verb, a.Why}, a.Args...), " ") + for _, want := range []string{"the controller", "mesh-broker", "novox/old-broker", "novox/gate"} { + if !strings.Contains(text, want) { + t.Fatalf("the hand act does not name %q: %+v", want, a) + } + } +} + +// 1317/AC2: two claimants, nothing recorded, the refusal said. +func TestACompositionWithTwoClaimantsRecordsNoneAndSaysSo(t *testing.T) { + withBusSeat(t) + sp := seatPassing{say: &bytes.Buffer{}, + pass: func(context.Context, catalogue.SeatPass) (bool, error) { + t.Fatal("a seat with two claimants was passed") + return false, nil + }, + act: func(context.Context, catalogue.SeatPass) error { t.Fatal("an act was written"); return nil }, + } + var said bytes.Buffer + sp.say = &said + holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}} + got := passSeatsOn(t.Context(), sp, holdings, []catalogue.Assignment{ + {Node: "novox", Manifest: aBrokerClaiming("gate")}, {Node: "ace", Manifest: aBrokerClaiming("other")}}) + if len(got) != 1 || got[0].Module != "old-broker" { + t.Fatalf("the record changed: %+v", got) + } + if !strings.Contains(said.String(), "gate") || !strings.Contains(said.String(), "other") || + !strings.Contains(said.String(), "seat mesh-broker --to") { + t.Fatalf("the refusal is not said with both and the remedy: %q", said.String()) + } +} + +// 1317/AC3 and AC4: nothing changes while the holder is assigned, nor with no claimant. +func TestACompositionChangesNothingWhileTheHolderIsAssignedOrNobodyClaims(t *testing.T) { + withBusSeat(t) + sp := seatPassing{say: &bytes.Buffer{}, + pass: func(context.Context, catalogue.SeatPass) (bool, error) { t.Fatal("passed"); return false, nil }, + act: func(context.Context, catalogue.SeatPass) error { t.Fatal("act"); return nil }, + } + holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}} + got := passSeatsOn(t.Context(), sp, holdings, []catalogue.Assignment{ + {Node: "novox", Manifest: aBrokerClaiming("old-broker")}, {Node: "novox", Manifest: aBrokerClaiming("gate")}}) + if len(got) != 1 || got[0].Module != "old-broker" { + t.Fatalf("holder assigned: record changed: %+v", got) + } + got = passSeatsOn(t.Context(), sp, holdings, nil) + if len(got) != 1 || got[0].Module != "old-broker" { + t.Fatalf("no claimant: record changed: %+v", got) + } +} + +// A composition that lost the compare-and-set writes no act, and a failed write leaves the record. +func TestALostPassWritesNoActAndAFailedOneLeavesTheRecord(t *testing.T) { + withBusSeat(t) + holdings := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}} + as := []catalogue.Assignment{{Node: "novox", Manifest: aBrokerClaiming("gate")}} + lost := seatPassing{say: &bytes.Buffer{}, + pass: func(context.Context, catalogue.SeatPass) (bool, error) { return false, nil }, + act: func(context.Context, catalogue.SeatPass) error { t.Fatal("act without a pass"); return nil }, + } + passSeatsOn(t.Context(), lost, holdings, as) + var said bytes.Buffer + sp := seatPassing{say: &said, + pass: func(context.Context, catalogue.SeatPass) (bool, error) { return false, context.DeadlineExceeded }, + act: func(context.Context, catalogue.SeatPass) error { t.Fatal("act"); return nil }, + } + got := passSeatsOn(t.Context(), sp, holdings, as) + if got[0].Module != "old-broker" || !strings.Contains(said.String(), "could not be passed") { + t.Fatalf("got %+v said %q", got, said.String()) + } +} diff --git a/internal/catalogue/seat_pass.go b/internal/catalogue/seat_pass.go new file mode 100644 index 00000000..336c8f67 --- /dev/null +++ b/internal/catalogue/seat_pass.go @@ -0,0 +1,85 @@ +package catalogue + +import ( + "fmt" + "sort" +) + +// An assignment as the passing of a seat reads it: a module on a machine, with its manifest. +type Assignment struct { + Node string + Manifest Manifest +} + +// SeatPass is one seat whose holder on record is assigned nowhere, and the only assignment that can take it. +type SeatPass struct { + Seat Seat + From Held + To Held +} + +// SeatsToPassOn reads the mesh for seats held once whose holder on record has left (novox/hq ADR 0449 §4). +// +// **A holder on record that is assigned on no machine holds nothing, and a seat that answers nothing is +// the day a delivery removed its module.** Of such a seat, exactly one assignment in the mesh able to hold +// it (it claims the seat at its scope and CanHold accepts it) takes it over: that is a pass, and the caller +// records it and writes the hand act. With no such assignment the record stays; with two or more nothing is +// recorded and the answer says so, naming both and the `seat --to` that settles it. A holder still assigned +// anywhere changes nothing: the other claimants stand beside it, eligible and silent (ADR 0131). Only mesh +// seats held once are read: a replicated seat's holders are added by an act (ADR 0223), a kinded bench is held +// by its claims, and a seat with several holders on record is a store that disagrees with the mesh (refused +// by the resolver). +func SeatsToPassOn(holdings []Held, assignments []Assignment) ([]SeatPass, []string) { + assigned := map[string]bool{} + for _, a := range assignments { + assigned[a.Manifest.Module] = true + } + bySeat := map[string][]Held{} + var order []string + for _, h := range holdings { + s, ok := SeatNamed(h.Claim) + if !ok || s.Replicated || s.Scope != ScopeMesh || h.Scope != ScopeMesh || KindedBenches[s.Name] { + continue + } + if _, seen := bySeat[s.Name]; !seen { + order = append(order, s.Name) + } + bySeat[s.Name] = append(bySeat[s.Name], h) + } + sort.Strings(order) + + var passes []SeatPass + var said []string + for _, name := range order { + recs := bySeat[name] + if len(recs) != 1 || assigned[recs[0].Module] { + continue + } + seat, _ := SeatNamed(name) + var claimants []Held + for _, a := range assignments { + if CanHold(a.Manifest, seat) != nil { + continue + } + claimants = append(claimants, Held{Claim: seat.Name, Scope: seat.Scope, Node: a.Node, Module: a.Manifest.Module}) + } + sort.Slice(claimants, func(i, j int) bool { + if claimants[i].Node != claimants[j].Node { + return claimants[i].Node < claimants[j].Node + } + return claimants[i].Module < claimants[j].Module + }) + switch len(claimants) { + case 0: + case 1: + passes = append(passes, SeatPass{Seat: seat, From: recs[0], To: claimants[0]}) + default: + said = append(said, fmt.Sprintf( + "%s on %s is on record as the holder of %q and is assigned nowhere, and %s on %s and %s on %s both claim it "+ + "— nothing is recorded; `seat %s --to /` records the one that holds it", + recs[0].Module, recs[0].Node, seat.Name, claimants[0].Module, claimants[0].Node, + claimants[1].Module, claimants[1].Node, seat.Name)) + } + } + return passes, said +} diff --git a/internal/catalogue/seat_pass_test.go b/internal/catalogue/seat_pass_test.go new file mode 100644 index 00000000..d1bc8e06 --- /dev/null +++ b/internal/catalogue/seat_pass_test.go @@ -0,0 +1,81 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// **A seat held once whose holder on record is assigned nowhere passes to its only claimant** (novox/hq ADR +// 0449 §4, issue 1317). + +func onRecord(node, module string) []Held { + return []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: node, Module: module}} +} + +// 1317/AC1 +func TestASeatWhoseHolderIsAssignedNowhereIsPassedToItsOnlyClaimant(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + passes, said := SeatsToPassOn(onRecord("novox", "old-broker"), + []Assignment{{Node: "novox", Manifest: newBroker()}}) + if len(said) != 0 || len(passes) != 1 { + t.Fatalf("the only claimant was not given the seat: passes=%v said=%v", passes, said) + } + p := passes[0] + if p.Seat.Name != "mesh-broker" || p.From.Module != "old-broker" || p.From.Node != "novox" || + p.To.Module != "new-broker" || p.To.Node != "novox" { + t.Fatalf("the pass does not name the seat, the former holder and the new one: %+v", p) + } +} + +// 1317/AC2 +func TestWithTwoClaimantsAndTheHolderGoneNothingIsPassedAndTheRefusalNamesBoth(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + passes, said := SeatsToPassOn(onRecord("novox", "gone"), []Assignment{ + {Node: "novox", Manifest: newBroker()}, {Node: "ace", Manifest: oldBroker()}}) + if len(passes) != 0 || len(said) != 1 { + t.Fatalf("two claimants: passes=%v said=%v", passes, said) + } + for _, want := range []string{"old-broker", "new-broker", "seat mesh-broker --to"} { + if !strings.Contains(said[0], want) { + t.Fatalf("the refusal does not say %q: %s", want, said[0]) + } + } +} + +// 1317/AC3 +func TestAHolderStillAssignedSomewhereChangesNothing(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + // Assigned on another machine than the record names: still assigned somewhere. + passes, said := SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{ + {Node: "ace", Manifest: oldBroker()}, {Node: "novox", Manifest: newBroker()}}) + if len(passes) != 0 || len(said) != 0 { + t.Fatalf("a holder still assigned lost its seat: passes=%v said=%v", passes, said) + } +} + +// 1317/AC4 +func TestWithNoClaimantTheRecordStays(t *testing.T) { + busSeatDelivering(t, "mesh-bus") + passes, said := SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{ + {Node: "novox", Manifest: Manifest{Module: "other"}}}) + if len(passes) != 0 || len(said) != 0 { + t.Fatalf("with no claimant: passes=%v said=%v", passes, said) + } + // A claimant that cannot hold the seat (it does not provide what the seat delivers) is no claimant. + bad := Manifest{Module: "no-bus", Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} + passes, said = SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{{Node: "novox", Manifest: bad}}) + if len(passes) != 0 || len(said) != 0 { + t.Fatalf("a module that cannot hold the seat was passed it: passes=%v said=%v", passes, said) + } +} + +// A replicated seat is never passed: its holders are added by an act (ADR 0223). +func TestAReplicatedSeatIsNeverPassed(t *testing.T) { + record := []Held{{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "novox", Module: "gone"}} + resolver := Manifest{Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}, + Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}} + passes, _ := SeatsToPassOn(record, []Assignment{{Node: "novox", Manifest: resolver}}) + if len(passes) != 0 { + t.Fatalf("a replicated seat was passed: %v", passes) + } +} diff --git a/internal/inventory/holdings_test.go b/internal/inventory/holdings_test.go index b298a59d..e129c141 100644 --- a/internal/inventory/holdings_test.go +++ b/internal/inventory/holdings_test.go @@ -153,3 +153,24 @@ func TestAReplicatedSeatHasSeveralHoldersOnRecord(t *testing.T) { t.Fatalf("a handover left other holders on record: %+v", held) } } + +// A pass is a compare-and-set: it moves the record only while the holder it was read with is still on it. +func TestPassingASeatMovesTheRecordOnceAndOnlyFromTheHolderRead(t *testing.T) { + inv, ctx := twoBrokersOnTwoNodes(t) + if err := inv.HoldSeat(ctx, "mesh-broker", catalogue.ScopeMesh, "anchor", "old-broker"); err != nil { + t.Fatal(err) + } + from := catalogue.Held{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "old-broker"} + to := catalogue.Held{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "new-broker"} + passed, err := inv.PassSeat(ctx, "mesh-broker", catalogue.ScopeMesh, from, to) + if err != nil || !passed { + t.Fatalf("the seat was not passed: %v %v", passed, err) + } + held, _ := inv.Holdings(ctx) + if len(held) != 1 || held[0].Node != "laptop" || held[0].Module != "new-broker" { + t.Fatalf("the record does not name the new holder alone: %+v", held) + } + if passed, err = inv.PassSeat(ctx, "mesh-broker", catalogue.ScopeMesh, from, to); err != nil || passed { + t.Fatalf("a second pass from the same read moved the record again: %v %v", passed, err) + } +} diff --git a/internal/inventory/seats.go b/internal/inventory/seats.go index 33dc4706..972e3e2c 100644 --- a/internal/inventory/seats.go +++ b/internal/inventory/seats.go @@ -284,6 +284,48 @@ func (i *Inventory) HoldSeat(ctx context.Context, seat, scope, nodeName, module return tx.Commit(ctx) } +// PassSeat records `to` as the holder of a seat held once in place of `from`, only while `from` is still the +// one on record: a compare-and-set in one transaction, so two compositions reading the same record cannot both +// hand the seat over (novox/hq ADR 0449 §4). It answers false, with no error, when the record no longer names +// `from` (another composition passed it first). The new holder must be an assignment. +func (i *Inventory) PassSeat(ctx context.Context, seat, scope string, from, to catalogue.Held) (bool, error) { + fromNode, err := i.NodeByName(ctx, from.Node) + if err != nil { + return false, err + } + toNode, err := i.NodeByName(ctx, to.Node) + if err != nil { + return false, err + } + if err := i.mustBeAssigned(ctx, seat, to.Node, to.Module); err != nil { + return false, err + } + tx, err := i.store.Pool().Begin(ctx) + if err != nil { + return false, err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + gone, err := tx.Exec(ctx, + `delete from seat_holding where seat = $1 and node = $2 and module = $3`, + seat, fromNode.ID, from.Module) + if err != nil { + return false, fmt.Errorf("passing %s from %s on %s: %w", seat, from.Module, from.Node, err) + } + if gone.RowsAffected() == 0 { + return false, nil + } + if _, err := tx.Exec(ctx, + `delete from seat_holding where seat = $1`, seat); err != nil { + return false, fmt.Errorf("passing %s: %w", seat, err) + } + if _, err := tx.Exec(ctx, + `insert into seat_holding (seat, scope, node, module) values ($1, $2, $3, $4)`, + seat, scope, toNode.ID, to.Module); err != nil { + return false, fmt.Errorf("recording %s on %s as the holder of %s: %w", to.Module, to.Node, seat, err) + } + return true, tx.Commit(ctx) +} + // AddSeatHolder records one more assignment holding a replicated seat, beside those already on // record (novox/hq ADR 0223). Whether the seat may have several holders is the caller's to judge — // the seat's definition is compiled, and the store holds no column for it. Recording a holder