detached 70034fb

feat(broker): serve a local-only observer through the authority

An authority's connections expose immutable membership: they never add or remove
hosts. That is why NewAuthority refused any registry that observes, since an
observer would probe configured hosts the service refuses to manage.

That reasoning does not hold for an observer limited to the broker's own
daemon. A local-only observer has no remote membership to manage, and its local
entry is configured authority supplied by the composition rather than
membership, so an authority can publish and serve it. Accept exactly that case,
and keep refusing an observer that owns remote membership.

This is what an offline composition needs to observe this machine's daemon and
serve clients from the same publication.
commit
70034fb09e350e5dbe27e90088a4aa0b8c3b0b8f
author
brice · · 2026-09-19 12:26 UTC
parent
eef2c8e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +42 -6
M internal/usecase/broker/registry_local.go +11 -0
@@ -75,6 +75,17 @@ func (l *LocalObservation) validate() error {
7575 return nil
7676 }
7777
78+// observesLocalOnly reports whether this registry observes only the broker's
79+// own daemon: observation is enabled, a local binding is configured, and no
80+// remote membership is projected. It is the registry-side answer an authority
81+// needs, because an authority whose connections expose immutable membership may
82+// serve an observer only when there is no remote membership to manage.
83+func (r *Registry) observesLocalOnly() bool {
84+ r.mu.Lock()
85+ defer r.mu.Unlock()
86+ return !r.observationDisabled && r.local != nil && len(r.hosts) == 0
87+}
88+
7889 // dispatchLocalLocked admits at most one local probe attempt when its schedule
7990 // says it is due. Callers must hold r.mu. It reports whether an attempt
8091 // started, so dispatch publishes the transient Checking state exactly once.
@@ -75,6 +75,17 @@ func (l *LocalObservation) validate() error {
75 return nil75 return nil
76 }76 }
77 77
78+// observesLocalOnly reports whether this registry observes only the broker's
79+// own daemon: observation is enabled, a local binding is configured, and no
80+// remote membership is projected. It is the registry-side answer an authority
81+// needs, because an authority whose connections expose immutable membership may
82+// serve an observer only when there is no remote membership to manage.
83+func (r *Registry) observesLocalOnly() bool {
84+ r.mu.Lock()
85+ defer r.mu.Unlock()
86+ return !r.observationDisabled && r.local != nil && len(r.hosts) == 0
87+}
88+
78 // dispatchLocalLocked admits at most one local probe attempt when its schedule89 // dispatchLocalLocked admits at most one local probe attempt when its schedule
79 // says it is due. Callers must hold r.mu. It reports whether an attempt90 // says it is due. Callers must hold r.mu. It reports whether an attempt
80 // started, so dispatch publishes the transient Checking state exactly once.91 // started, so dispatch publishes the transient Checking state exactly once.
M internal/usecase/broker/service.go +10 -6
@@ -58,10 +58,14 @@ type Authority struct {
5858 var _ ports.BrokerAuthority = (*Authority)(nil)
5959
6060 // NewAuthority composes one connection-scoped broker authority over an existing
61-// registry, pool, and supervisor. All three must be live and share epoch. The
62-// registry must be observation-disabled: this authority's sessions expose
63-// immutable offline membership, so an observing registry would probe hosts the
64-// service refuses to manage.
61+// registry, pool, and supervisor. All three must be live and share epoch.
62+//
63+// The authority's connections expose immutable membership: they neither add nor
64+// remove hosts. It therefore accepts an observation-disabled registry, or an
65+// observer whose observation is limited to the broker's own daemon, and refuses
66+// a registry that observes configured hosts the service would refuse to manage.
67+// A local-only observer has no remote membership to manage: its local entry is
68+// configured authority supplied by the composition, not membership.
6569 //
6670 // The caller owns the dependencies' lifecycles. In particular it must start the
6771 // registry's single Registry.Run to own and drain the durable writer, and must
@@ -76,8 +80,8 @@ func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, super
7680 if registry.epoch != epoch || pool.epoch != epoch {
7781 return nil, errors.New("broker: authority epoch mismatch")
7882 }
79- if !registry.observationDisabled {
80- return nil, errors.New("broker: authority requires an observation-disabled registry")
83+ if !registry.observationDisabled && !registry.observesLocalOnly() {
84+ return nil, errors.New("broker: authority requires an observation-disabled registry or a local-only observer")
8185 }
8286 return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil
8387 }
@@ -58,10 +58,14 @@ type Authority struct {
58 var _ ports.BrokerAuthority = (*Authority)(nil)58 var _ ports.BrokerAuthority = (*Authority)(nil)
59 59
60 // NewAuthority composes one connection-scoped broker authority over an existing60 // NewAuthority composes one connection-scoped broker authority over an existing
61-// registry, pool, and supervisor. All three must be live and share epoch. The61+// registry, pool, and supervisor. All three must be live and share epoch.
62-// registry must be observation-disabled: this authority's sessions expose62+//
63-// immutable offline membership, so an observing registry would probe hosts the63+// The authority's connections expose immutable membership: they neither add nor
64-// service refuses to manage.64+// remove hosts. It therefore accepts an observation-disabled registry, or an
65+// observer whose observation is limited to the broker's own daemon, and refuses
66+// a registry that observes configured hosts the service would refuse to manage.
67+// A local-only observer has no remote membership to manage: its local entry is
68+// configured authority supplied by the composition, not membership.
65 //69 //
66 // The caller owns the dependencies' lifecycles. In particular it must start the70 // The caller owns the dependencies' lifecycles. In particular it must start the
67 // registry's single Registry.Run to own and drain the durable writer, and must71 // registry's single Registry.Run to own and drain the durable writer, and must
@@ -76,8 +80,8 @@ func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, super
76 if registry.epoch != epoch || pool.epoch != epoch {80 if registry.epoch != epoch || pool.epoch != epoch {
77 return nil, errors.New("broker: authority epoch mismatch")81 return nil, errors.New("broker: authority epoch mismatch")
78 }82 }
79- if !registry.observationDisabled {83+ if !registry.observationDisabled && !registry.observesLocalOnly() {
80- return nil, errors.New("broker: authority requires an observation-disabled registry")84+ return nil, errors.New("broker: authority requires an observation-disabled registry or a local-only observer")
81 }85 }
82 return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil86 return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil
83 }87 }
M internal/usecase/broker/service_test.go +21 -0
@@ -126,6 +126,27 @@ func TestAuthorityValidation(t *testing.T) {
126126 _, err = NewAuthority(1, observing, pool, supervisor)
127127 require.Error(t, err, "an observing registry must be refused by an offline authority")
128128
129+ // An observer that is limited to the broker's own daemon is served: it has
130+ // no remote membership for the authority's immutable-membership connections
131+ // to manage, and its local entry is configured authority rather than
132+ // membership.
133+ localOnly, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: &LocalObservation{
134+ DisplayOrigin: "local",
135+ Policy: poolPolicy(),
136+ Probe: newScriptedLocalProbe(1),
137+ }})
138+ require.NoError(t, err)
139+ _, err = NewAuthority(1, localOnly, pool, supervisor)
140+ require.NoError(t, err, "a local-only observer must be served")
141+
142+ // The same observer stops being served as soon as it owns remote membership
143+ // the authority's connections could never manage.
144+ observerWithMembership, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil)
145+ require.NoError(t, err)
146+ require.NoError(t, observerWithMembership.setHosts(hostRecords(registration(t, "user@host:22", 1))))
147+ _, err = NewAuthority(1, observerWithMembership, pool, supervisor)
148+ require.Error(t, err, "an observer that owns remote membership must be refused")
149+
129150 disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true})
130151 require.NoError(t, err)
131152 _, err = NewAuthority(2, disabled, pool, supervisor)
@@ -126,6 +126,27 @@ func TestAuthorityValidation(t *testing.T) {
126 _, err = NewAuthority(1, observing, pool, supervisor)126 _, err = NewAuthority(1, observing, pool, supervisor)
127 require.Error(t, err, "an observing registry must be refused by an offline authority")127 require.Error(t, err, "an observing registry must be refused by an offline authority")
128 128
129+ // An observer that is limited to the broker's own daemon is served: it has
130+ // no remote membership for the authority's immutable-membership connections
131+ // to manage, and its local entry is configured authority rather than
132+ // membership.
133+ localOnly, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: &LocalObservation{
134+ DisplayOrigin: "local",
135+ Policy: poolPolicy(),
136+ Probe: newScriptedLocalProbe(1),
137+ }})
138+ require.NoError(t, err)
139+ _, err = NewAuthority(1, localOnly, pool, supervisor)
140+ require.NoError(t, err, "a local-only observer must be served")
141+
142+ // The same observer stops being served as soon as it owns remote membership
143+ // the authority's connections could never manage.
144+ observerWithMembership, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil)
145+ require.NoError(t, err)
146+ require.NoError(t, observerWithMembership.setHosts(hostRecords(registration(t, "user@host:22", 1))))
147+ _, err = NewAuthority(1, observerWithMembership, pool, supervisor)
148+ require.Error(t, err, "an observer that owns remote membership must be refused")
149+
129 disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true})150 disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true})
130 require.NoError(t, err)151 require.NoError(t, err)
131 _, err = NewAuthority(2, disabled, pool, supervisor)152 _, err = NewAuthority(2, disabled, pool, supervisor)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close