A claim problem or a refused credential holds its module, not the whole pass (issue 1966, S1 and S5)

The node-engine refused the whole pass when two modules claimed a seat held once, or when a consumer's credential was
refused. Now the composer leaves that module out of the declaration and composes the rest: the module is returned held
(Resolved.Held, resolution.ResolveHolding), reaches the declaration as left out through the filter and the declaration
#218 already keep (Rendering.NotPlaced), and Declared.LeftOut names it. The second claimant of a seat is the one held;
its claim is not this node's holding. The rest-of-mesh view and the network use the same, so a node with a held module
stays on the network. Resolve stays strict for callers that cannot leave a module out.

Quick fix: hold only the credential refusal (resolve.go planForNow), about 20 minutes. Thorough fix, chosen: every
claim problem is attributed to its module (claimsOf) and both kinds go through the one left-out path of ADR 0163 rule 6,
in the declaration, the filter and the network, so no per-kind refusal of a pass remains in the composer. Left for the
next step: a declared-and-not-made own secret (NotMadeError) and the Foreseeing-mode refusal still refuse the pass; the
frombus candidate path holds those by stub already.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
This commit is contained in:
2026-11-09 12:52:54 +01:00
parent 7c483da93a
commit 868ee6beb0
14 changed files with 182 additions and 83 deletions
@@ -1253,6 +1253,11 @@ func Rules(r mcat.Resolution, with mcat.Rendering) ([]mcat.Rule, error) {
// cannot be read for it, its listens open no wider than the mesh, never at the manifest's
// default, which could reopen a port an operator had narrowed.
left := LeftOut(r, with.Settings, with.Adopted)
for module, why := range with.NotPlaced {
if _, already := left[module]; !already {
left[module] = why
}
}
exposure := map[string]map[int]string{}
for _, m := range r.Modules {
e, err := exposureOf(m, with.Settings[m.Module])
@@ -37,6 +37,11 @@ func TestWithNoHolderOnRecordTheSoleClaimantHoldsAndTwoAreRefused(t *testing.T)
if len(problems) != 1 || !strings.Contains(problems[0], "both claim") {
t.Fatalf("two claimants with nothing on record were not refused: %v", problems)
}
// Held for the module that cannot hold, which then holds nothing; the other still does (issue 1966).
held, holding := holdClaims([]mcat.Manifest{oldBroker(), newBroker()}, node, nil, nil)
if len(holding) != 1 || !strings.Contains(holding["new-broker"], "both claim") || len(held) != 1 || held[0].Module != "old-broker" {
t.Fatalf("the second claimant is not the one held: held=%v holding=%v", held, holding)
}
}
// With a holder on record, the other eligible assignment is silent: not refused, and not holding.
+76 -13
View File
@@ -32,6 +32,20 @@ func (r *Refusal) Error() string {
// What comes back is the closure — assigned modules plus everything they require — or a refusal
// naming every reason it could not be closed.
func Resolve(catalogue map[string]mcat.Manifest, assigned []string, node mcat.Node, world mcat.World) (mcat.Resolution, error) {
resolution, _, err := resolve(catalogue, assigned, node, world, true)
return resolution, err
}
// ResolveHolding is Resolve where a claim problem costs the module that has it, not the machine (issue 1966, ADR 0636 §3: a
// refused claim blocks that claimant only). The modules that cannot hold what they claim come back with why, each still in
// the resolution's modules and none of them in its claims; the caller leaves them out of what it declares (LeftOut), so the
// machine composes everything else. Every other refusal is Resolve's.
func ResolveHolding(catalogue map[string]mcat.Manifest, assigned []string, node mcat.Node, world mcat.World) (mcat.Resolution, map[string]string, error) {
return resolve(catalogue, assigned, node, world, false)
}
// resolve is Resolve; with strict, a claim problem is a refusal beside every other, and without it a module held.
func resolve(catalogue map[string]mcat.Manifest, assigned []string, node mcat.Node, world mcat.World, strict bool) (mcat.Resolution, map[string]string, error) {
elsewhere := world.Held
var problems []string
@@ -593,8 +607,13 @@ func Resolve(catalogue map[string]mcat.Manifest, assigned []string, node mcat.No
}
problems = append(problems, checkCapabilities(resolution.Modules, node)...)
claims, claimProblems := checkClaims(resolution.Modules, node, elsewhere, world.Holdings)
problems = append(problems, claimProblems...)
claims, held := holdClaims(resolution.Modules, node, elsewhere, world.Holdings)
if strict {
for _, why := range held {
problems = append(problems, why)
}
held = nil
}
problems = append(problems, checkResources(resolution.Modules)...)
resolution.Claims = claims
@@ -616,9 +635,9 @@ func Resolve(catalogue map[string]mcat.Manifest, assigned []string, node mcat.No
if len(problems) > 0 {
sort.Strings(problems)
return mcat.Resolution{}, &Refusal{Problems: problems}
return mcat.Resolution{}, nil, &Refusal{Problems: problems}
}
return resolution, nil
return resolution, held, nil
}
// isModule reports whether a name is a module in its own right rather than only something
@@ -677,7 +696,37 @@ func checkCapabilities(modules []mcat.Manifest, node mcat.Node) []string {
// Within this node's own set, and against what is already held elsewhere for the wider scopes. A
// claim at mesh scope is the same idea as the mesh's one hub, said once instead of hard-coded.
func checkClaims(modules []mcat.Manifest, node mcat.Node, elsewhere []mcat.Held, holdings []mcat.Held) ([]mcat.Held, []string) {
var problems []string
held, problems := claimsOf(modules, node, elsewhere, holdings)
var out []string
for _, p := range problems {
out = append(out, p.why)
}
return held, out
}
// holdClaims is checkClaims with each problem put on the module that has it: the claims that hold, and for each module that
// cannot hold what it claims, why (every reason, joined).
func holdClaims(modules []mcat.Manifest, node mcat.Node, elsewhere []mcat.Held, holdings []mcat.Held) ([]mcat.Held, map[string]string) {
held, problems := claimsOf(modules, node, elsewhere, holdings)
var out map[string]string
for _, p := range problems {
if out == nil {
out = map[string]string{}
}
if prior, ok := out[p.module]; ok {
out[p.module] = prior + "; " + p.why
} else {
out[p.module] = p.why
}
}
return held, out
}
// claimProblem is a refusal of one module's claim.
type claimProblem struct{ module, why string }
func claimsOf(modules []mcat.Manifest, node mcat.Node, elsewhere []mcat.Held, holdings []mcat.Held) ([]mcat.Held, []claimProblem) {
var problems []claimProblem
var held []mcat.Held
// onRecord is every recorded holder of a seat, if a handover ever named one: one for most seats,
@@ -717,9 +766,9 @@ func checkClaims(modules []mcat.Manifest, node mcat.Node, elsewhere []mcat.Held,
// disagrees with the mesh's definition of the role, and it is refused, named, rather
// than letting two machines answer for what the mesh has one of.
if s, known := mcat.SeatNamed(c.Name); known && !s.Replicated && len(recs) > 1 {
problems = append(problems, fmt.Sprintf(
problems = append(problems, claimProblem{m.Module, fmt.Sprintf(
"%q is on record as held by %d assignments, and it is held once per %s — "+
"`seat %s --to <node>/<module>` records one", c.Name, len(recs), scope, c.Name))
"`seat %s --to <node>/<module>` records one", c.Name, len(recs), scope, c.Name)})
continue
}
if !recordedHere(c.Name, scope, m.Module) {
@@ -734,9 +783,9 @@ func checkClaims(modules []mcat.Manifest, node mcat.Node, elsewhere []mcat.Held,
// one seat, compared by the seat they resolve to and not by how each spelled it.
seat := heldKey(c.Name, c.Kind)
if other, taken := byScope[scope][seat]; taken {
problems = append(problems, fmt.Sprintf(
problems = append(problems, claimProblem{m.Module, fmt.Sprintf(
"%s and %s both claim %q, and only one thing may hold it per %s",
other, m.Module, seat, scope))
other, m.Module, seat, scope)})
continue
}
byScope[scope][seat] = m.Module
@@ -762,20 +811,34 @@ func checkClaims(modules []mcat.Manifest, node mcat.Node, elsewhere []mcat.Held,
// Both claim and nobody is on record, or this refusal could not have happened.
// The remedy is the handover that records the holder (novox/hq ADR 0131,
// 04-ISSUES/170), so it is named here rather than left to be found.
problems = append(problems, fmt.Sprintf(
problems = append(problems, claimProblem{h.Module, fmt.Sprintf(
"%s on %s claims %q, which %s on %s already holds — one per mesh. Nothing is "+
"on record for it; `seat %s --to %s/%s` records the holder, and the other "+
"assignment then stands beside it, eligible and silent",
h.Module, node.Name, h.Claim, e.Module, e.Node, h.Claim, e.Node, e.Module))
h.Module, node.Name, h.Claim, e.Module, e.Node, h.Claim, e.Node, e.Module)})
case mcat.ScopeSite:
if node.Site != "" && node.Site == e.Site {
problems = append(problems, fmt.Sprintf(
problems = append(problems, claimProblem{h.Module, fmt.Sprintf(
"%s on %s claims %q, which %s on %s already holds at %s — one per site",
h.Module, node.Name, h.Claim, e.Module, e.Node, node.Site))
h.Module, node.Name, h.Claim, e.Module, e.Node, node.Site)})
}
}
}
}
// A module refused here holds nothing of what it claims: its claims are not this node's holdings.
refused := map[string]bool{}
for _, p := range problems {
refused[p.module] = true
}
if len(refused) > 0 {
kept := held[:0]
for _, h := range held {
if !refused[h.Module] {
kept = append(kept, h)
}
}
held = kept
}
return held, problems
}