catalogue: announce a same-node provider at the port it is published on

A co-located consumer of a `from: mesh` provision was told the port the
provider module DECLARED, not the host port the mesh assigned and published
it on. The same-node served facts are settled while resolving (servedHere,
here()), before a bare `ports` mapping is assigned its host port, so they
carried the declared number; only the cross-node path re-derived them after
assignment. So the provider was published on <node>.internal:<assigned> while
its own-machine consumer dialled <node>.internal:<declared>, where nothing
listens — the ordinary small-mesh case, and the one the fix for issue 018
(announce the same-node provider at all) left one promise short of kept.

Redirect same-node needs to the machine's assignment in Declaration, where the
port map is known, exactly as plan.go already does cross-node. The publish bind
is unchanged (all interfaces, scoped to the mesh by the listen's firewall rule);
only the announced port is corrected.

novox/hq 04-ISSUES/038

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-09 23:02:50 +02:00
parent 9e29772ed4
commit c147a26138
2 changed files with 130 additions and 0 deletions
+66
View File
@@ -141,6 +141,21 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
return nil, err
}
// A provider answered on this same machine is announced at the port it *declared*, because the
// need was settled while resolving — before this machine assigned the port a bare `ports`
// mapping is published on (novox/hq 04-ISSUES/038). Redirect those same-node needs to where the
// machine actually publishes the provider, exactly as the cross-node path already does with the
// provider's own assignment, so a co-located consumer is told the port that is really listening.
servedBy := servedByModule(r.Modules)
for i := range r.Needs {
if r.Needs[i].From != r.Node {
continue
}
if module, ok := servedBy[r.Needs[i].Name]; ok {
r.Needs[i].Serves = atMachinePort(r.Needs[i].Serves, module, with.Ports)
}
}
// Once, from every module's listens -- not per module. A module receiving only its own ports
// would write a rule set that closed every other module on the machine. Each module's per-node
// exposure settings override its listens' source first (novox/hq ADR 0046).
@@ -311,6 +326,11 @@ func (r Resolution) Declaration(with Rendering) ([]map[string]any, error) {
// requirement met by the module itself.
continue
}
// Answered here, so its served port needs the same redirect the resolver's
// same-node needs got above — this fallback builds one afresh, outside r.Needs.
if provider, ok := servedBy[to]; ok {
here.Serves = atMachinePort(here.Serves, provider, with.Ports)
}
found = here
}
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)))
@@ -838,3 +858,49 @@ func asPort(v any) (int, bool) {
}
return 0, false
}
// servedByModule maps each provision offered on this node to the module that offers it.
//
// The same node may both provide and consume a provision, and a consumer's binding names the
// provision, not the module behind it — so redirecting a served port to where the machine put it
// needs this reverse lookup, from the provision back to the module whose assignment moved it.
func servedByModule(mods []Manifest) map[string]string {
out := map[string]string{}
for _, m := range mods {
for provision := range m.Serves {
out[provision] = m.Module
}
}
return out
}
// atMachinePort redirects a served `port` fact to where this machine actually published the
// provider (novox/hq 04-ISSUES/038).
//
// **Same-node served facts are settled before the machine's port is assigned.** The resolver names
// what a consumer must know (resolve.go's servedHere, declaration.go's here()) while resolving,
// which happens before a bare `ports` declaration is assigned a host port — so it can only carry
// the port the provider *declared*. The cross-node path re-derives the fact after assignment, with
// the provider node's port map (cmd plan.go), and so already tells the truth. This closes the gap
// for the co-located case: without it a consumer is announced `<node>.internal:<declared>` while
// the provider is published on `<node>.internal:<assigned>`, and dials a port nothing listens on.
//
// A fresh map when it changes anything, so the manifest-derived value the resolver handed back is
// never mutated under a caller that still holds it. Keyed by the *declared* port, so applying it a
// second time is a no-op: once redirected the value is the machine port, which is not itself a key.
func atMachinePort(serves map[string]any, module string, ports map[string]map[int]int) map[string]any {
number, ok := asPort(serves["port"])
if !ok {
return serves
}
at, known := ports[module][number]
if !known || at == number {
return serves
}
copied := make(map[string]any, len(serves))
for k, v := range serves {
copied[k] = v
}
copied["port"] = at
return copied
}