detached a734bf5

feat(broker): project broker-native daemon observations

Close the P5.2a broker catalogue contract: a publication carries one
broker-native observation per daemon instead of a remote-host projection.

- ports: BrokerDaemonObservation keeps configured authority (local flag,
  endpoint, registration, policy, display origin, rank) separate from observed
  state (identity, incarnation, protocol version, capabilities, availability,
  freshness, failure, exact session catalogue). BrokerSnapshot publishes
  daemons with the local entry first, and the durable projection rule enforces
  the observed-identity rule the live contract already enforced.
- registry: stamps authority from durable membership only, orders remotes by
  registration order, re-stamps rank when that order changes, refuses
  membership it cannot publish, and never publishes a projection the wire
  would refuse.
- brokerstore: persists and reloads remote observations only, re-stamping
  policy from membership on load.
- brokerwire: SnapshotBegin/SnapshotDaemon/SnapshotSession carry the new
  projection; protocol version 55.
- daemonmux: narrow the admission field before the cast so an out-of-range
  wire value cannot alias a valid admission.
commit
a734bf58d00fb1df48368ce422b8b3dc33b2f3f6
author
brice · · 2026-09-19 09:22 UTC
parent
ca3d194
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
43 files · +3185 -970
M docs/architecture.md +25 -1
@@ -193,7 +193,7 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
193193 `go tool buf generate`, and handle the new variant in `sessionwire`.
194194 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
195195 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
196-- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `54`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
196+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `55`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
197197
198198 ## Broker wire core (Plan 001 P3.1, not activated)
199199
@@ -265,6 +265,30 @@ nonblocking drain on false precedes Reset, supporting buffered timer adapters.
265265 Policy identities have independent field-labelled validation and a named bound.
266266 These are P2.2 port contracts, not a wire migration or production activation.
267267
268+## Broker registry and daemon observation (Plan 001 P5.2a, not activated)
269+
270+`internal/usecase/broker.Registry` owns configured hosts and their immutable
271+observation projections. `ports.BrokerDaemonObservation` is the broker-native
272+projection of one daemon, local or remote: configured authority (local flag,
273+endpoint, registration, policy, display origin, rank) is stamped by the
274+registry from durable membership and never taken from a probe, while observed
275+identity, process incarnation, protocol version, capability bitmask,
276+availability, failure/freshness state, and the exact session catalogue come
277+only from probe results. Unknown identity or version is zero, never invented;
278+an observed identity always carries a protocol version, and availability is
279+never zero (an unobserved daemon carries `RemoteAvailabilityUnknown`). A
280+`ports.BrokerSnapshot` publishes these observations (`Daemons`, local entry
281+first when present, then remotes in registration order) plus the tombstone
282+set; `brokerwire` mirrors them through `SnapshotBegin`/`SnapshotDaemon`/
283+`SnapshotSession` parts. Local observations are never durable: the snapshot
284+store persists and reloads remote observations only, with policy re-stamped
285+from membership on load, so membership stays the single policy authority.
286+Observing a daemon never attaches to it and never starts a stopped one.
287+Attachment streams declare one closed admission variant
288+(`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral
289+creation) validated identically at the ports, broker IPC, and daemonmux
290+boundaries.
291+
268292 ## Broker local IPC (Plan 001 P3.3, not activated)
269293
270294 `internal/adapters/brokeripc` carries the P3.1 broker conversation over one
M internal/adapters/brokeripc/client.go +2 -1
@@ -486,7 +486,8 @@ func (c *client) OpenStream(ctx context.Context, request ports.BrokerOpenStreamR
486486 // dispatched, because the local identity is already admitted either way.
487487 if _, err := c.sendContext(ctx, brokerwire.OpenStream{
488488 Epoch: scoped.Epoch, Connection: scoped.Connection, Stream: scoped.Stream,
489- Purpose: scoped.Purpose, Local: scoped.Local,
489+ Purpose: scoped.Purpose, Admission: scoped.Admission, Name: scoped.Name,
490+ Local: scoped.Local,
490491 Endpoint: scoped.Endpoint, Registration: scoped.Registration,
491492 Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy,
492493 }); err != nil {
M internal/adapters/brokeripc/emit.go +24 -20
@@ -11,12 +11,13 @@ import (
1111 // Snapshot publication (P3.3).
1212 //
1313 // A broker publication travels as the exact multipart transfer the P3.1
14-// assembler accepts: Begin(index 0, element counts), then every host part in
15-// host-index order immediately followed by that host's session parts in
16-// session-index order, then the tombstones, then End at the final index. Host
17-// parts carry no inline sessions; sessions travel as their own parts. Both
18-// sides agree on one deterministic layout, so the receiving assembler commits
19-// only a transfer whose indexes, counts, and order are exact.
14+// assembler accepts: Begin(index 0, element counts), then every daemon part in
15+// daemon-index order immediately followed by that daemon's session parts in
16+// session-index order, then the tombstones, then End at the final index. The
17+// local daemon, when present, is daemon index 0 and its sessions carry
18+// Local=true. Daemon parts carry no inline sessions; sessions travel as their
19+// own parts. Both sides agree on one deterministic layout, so the receiving
20+// assembler commits only a transfer whose indexes, counts, and order are exact.
2021 //
2122 // The emitter refuses a transfer the peer's assembler would refuse: more parts
2223 // than MaxSnapshotParts, or a total encoded payload above the staged ceiling.
@@ -33,20 +34,21 @@ func snapshotParts(snapshot ports.BrokerSnapshot, epoch ports.BrokerEpoch, conne
3334 if snapshot.Revision == 0 {
3435 return nil, errors.New("brokeripc: snapshot publication has no revision")
3536 }
36- if len(snapshot.Hosts) > ports.BrokerMaxHosts || len(snapshot.Removed) > ports.BrokerMaxTombstones {
37- return nil, errors.New("brokeripc: snapshot publication exceeds host bound")
37+ if len(snapshot.Daemons) > ports.BrokerMaxDaemonsPerSnapshot || len(snapshot.Removed) > ports.BrokerMaxTombstones {
38+ return nil, errors.New("brokeripc: snapshot publication exceeds daemon bound")
3839 }
3940 sessions := 0
40- for _, host := range snapshot.Hosts {
41- if len(host.Sessions) > ports.BrokerMaxSessionsPerHost {
41+ for _, daemon := range snapshot.Daemons {
42+ if len(daemon.Sessions) > ports.BrokerMaxSessionsPerHost {
4243 return nil, errors.New("brokeripc: snapshot publication exceeds session bound")
4344 }
44- sessions += len(host.Sessions)
45+ sessions += len(daemon.Sessions)
4546 }
46- total := 1 + len(snapshot.Hosts) + sessions + len(snapshot.Removed) + 1
47+ total := 1 + len(snapshot.Daemons) + sessions + len(snapshot.Removed) + 1
4748 if total > brokerwire.MaxSnapshotParts {
4849 return nil, errors.New("brokeripc: snapshot publication exceeds part bound")
4950 }
51+ localPresent := len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local
5052
5153 parts := make([]brokerwire.SnapshotPart, 0, total)
5254 base := func(part brokerwire.SnapshotPartPayload) brokerwire.SnapshotPart {
@@ -56,23 +58,25 @@ func snapshotParts(snapshot ports.BrokerSnapshot, epoch ports.BrokerEpoch, conne
5658 }
5759 }
5860 parts = append(parts, base(brokerwire.SnapshotBegin{
59- HostCount: uint32(len(snapshot.Hosts)),
61+ HostCount: uint32(len(snapshot.Daemons)),
6062 SessionCount: uint32(sessions),
6163 TombstoneCount: uint32(len(snapshot.Removed)),
64+ LocalPresent: localPresent,
6265 }))
63- for hostIndex, host := range snapshot.Hosts {
64- projection := host.Clone()
66+ for daemonIndex, daemon := range snapshot.Daemons {
67+ projection := daemon.Clone()
6568 sessionCount := len(projection.Sessions)
6669 projection.Sessions = nil
67- parts = append(parts, base(brokerwire.SnapshotHostPart{
68- HostIndex: uint32(hostIndex),
69- Host: projection,
70+ parts = append(parts, base(brokerwire.SnapshotDaemonPart{
71+ HostIndex: uint32(daemonIndex),
72+ Daemon: projection,
7073 SessionCount: uint32(sessionCount),
7174 }))
72- for sessionIndex, session := range host.Sessions {
75+ for sessionIndex, session := range daemon.Sessions {
7376 parts = append(parts, base(brokerwire.SnapshotSessionPart{
74- HostIndex: uint32(hostIndex),
77+ HostIndex: uint32(daemonIndex),
7578 SessionIndex: uint32(sessionIndex),
79+ Local: daemon.Local,
7680 Session: cloneCatalogSession(session),
7781 }))
7882 }
M internal/adapters/brokeripc/helpers_test.go +23 -6
@@ -45,13 +45,14 @@ func testPolicy() ports.BrokerPolicy {
4545 }
4646 }
4747
48-// testHostAt builds one catalogue-valid host projection for index i.
49-func testHostAt(i int) ports.RemoteHostSnapshot {
48+// testDaemonAt builds one catalogue-valid remote daemon projection for index i.
49+func testDaemonAt(i int) ports.BrokerDaemonObservation {
5050 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)
51- return ports.RemoteHostSnapshot{
51+ return ports.BrokerDaemonObservation{
5252 Endpoint: endpoint,
5353 DisplayOrigin: endpoint,
5454 Registration: domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{0x10, byte(i + 1), 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xf0, 0x11}, Generation: domain.RemoteGeneration(i + 1)},
55+ Policy: testPolicy(),
5556 Availability: domain.RemoteAvailabilityReachable,
5657 LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(),
5758 InventoryKnown: true,
@@ -71,9 +72,25 @@ func testSessionAt(host, index int) catalogue.RemoteCatalogSession {
7172
7273 // testSnapshot builds one valid publication for an epoch and revision.
7374 func testSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot {
74- host := testHostAt(0)
75- host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
76- return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Hosts: []ports.RemoteHostSnapshot{host}}
75+ daemon := testDaemonAt(0)
76+ daemon.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
77+ return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{daemon}}
78+}
79+
80+// testLocalSnapshot builds one valid publication whose first daemon is local:
81+// the local daemon with one session followed by one remote daemon with one.
82+func testLocalSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot {
83+ local := ports.BrokerDaemonObservation{
84+ Local: true,
85+ DisplayOrigin: "local",
86+ Policy: testPolicy(),
87+ Availability: domain.RemoteAvailabilityReachable,
88+ InventoryKnown: true,
89+ Sessions: []catalogue.RemoteCatalogSession{testSessionAt(0, 0)},
90+ }
91+ remote := testDaemonAt(0)
92+ remote.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)}
93+ return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{local, remote}}
7794 }
7895
7996 // snapshotHub is a multi-subscriber publication hub standing in for the broker
M internal/adapters/brokeripc/limits_test.go +47 -6
@@ -24,10 +24,10 @@ import (
2424 // session-less hosts.
2525 func TestSnapshotPartsLayoutRoundTrips(t *testing.T) {
2626 snapshot := testSnapshot(9, 12)
27- snapshot.Hosts = append(snapshot.Hosts, testHostAt(1))
27+ snapshot.Daemons = append(snapshot.Daemons, testDaemonAt(1))
2828 snapshot.Removed = []ports.BrokerHostTombstone{{
2929 Endpoint: "retired@old:22",
30- Registration: testHostAt(0).Registration,
30+ Registration: testDaemonAt(0).Registration,
3131 RetiredRevision: 3,
3232 }}
3333 snapshot.Removed[0].Registration.Endpoint = "retired@old:22"
@@ -61,9 +61,9 @@ func TestSnapshotPartsLayoutRoundTrips(t *testing.T) {
6161 }
6262 require.True(t, found, "the transfer must commit")
6363 require.Equal(t, snapshot.Revision, committed.Revision)
64- require.Len(t, committed.Hosts, 2)
65- require.Len(t, committed.Hosts[0].Sessions, 2)
66- for _, host := range committed.Hosts {
64+ require.Len(t, committed.Daemons, 2)
65+ require.Len(t, committed.Daemons[0].Sessions, 2)
66+ for _, host := range committed.Daemons {
6767 for _, session := range host.Sessions {
6868 require.Equal(t, []catalogue.RemoteCatalogTab{}, session.Tabs)
6969 }
@@ -72,13 +72,54 @@ func TestSnapshotPartsLayoutRoundTrips(t *testing.T) {
7272 require.Equal(t, "retired@old:22", committed.Removed[0].Endpoint)
7373 }
7474
75+// TestSnapshotPartsLocalDaemonLayoutRoundTrips proves the emitter and the
76+// assembler agree on a local-first transfer: Begin advertises local_present,
77+// the local daemon is index 0 with its session bound locally, and the committed
78+// snapshot reproduces the local-first publication.
79+func TestSnapshotPartsLocalDaemonLayoutRoundTrips(t *testing.T) {
80+ snapshot := testLocalSnapshot(4, 2)
81+ require.NoError(t, snapshot.Validate())
82+
83+ parts, err := snapshotParts(snapshot, 4, ports.BrokerConnectionID{4, 4}, 2)
84+ require.NoError(t, err)
85+ require.Len(t, parts, 1+2+2+1)
86+
87+ begin, ok := parts[0].Part.(brokerwire.SnapshotBegin)
88+ require.True(t, ok)
89+ require.True(t, begin.LocalPresent)
90+ require.Equal(t, uint32(2), begin.HostCount)
91+ require.Equal(t, uint32(2), begin.SessionCount)
92+
93+ assembler := brokerwire.NewSnapshotAssembler(4, ports.BrokerConnectionID{4, 4}, brokerwire.WithGeneration(2))
94+ var committed ports.BrokerSnapshot
95+ found := false
96+ for index, part := range parts {
97+ require.Equal(t, uint32(index), part.Index)
98+ done, assembled, err := assembler.Add(part)
99+ require.NoError(t, err, "part %d", index)
100+ if done {
101+ committed = assembled
102+ found = true
103+ }
104+ }
105+ require.True(t, found, "the transfer must commit")
106+ require.Equal(t, snapshot.Revision, committed.Revision)
107+ require.Len(t, committed.Daemons, 2)
108+ require.True(t, committed.Daemons[0].Local)
109+ require.Empty(t, committed.Daemons[0].Endpoint)
110+ require.Len(t, committed.Daemons[0].Sessions, 1)
111+ require.Equal(t, snapshot.Daemons[0].Sessions[0].Name, committed.Daemons[0].Sessions[0].Name)
112+ require.Equal(t, "user0@host0:22", committed.Daemons[1].Endpoint)
113+ require.Len(t, committed.Daemons[1].Sessions, 1)
114+}
115+
75116 // TestSnapshotPartsRefusesOverBoundInventory proves the emitter fails closed on
76117 // an inventory the peer's assembler would refuse, instead of silently sending a
77118 // transfer that can never commit.
78119 func TestSnapshotPartsRefusesOverBoundInventory(t *testing.T) {
79120 snapshot := testSnapshot(1, 1)
80121 over := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost+1)
81- snapshot.Hosts[0].Sessions = over
122+ snapshot.Daemons[0].Sessions = over
82123 _, err := snapshotParts(snapshot, 1, ports.BrokerConnectionID{1}, 1)
83124 require.Error(t, err)
84125
M internal/adapters/brokeripc/session_test.go +35 -8
@@ -132,6 +132,33 @@ func TestSessionCloseStreamFillsConnectionScope(t *testing.T) {
132132 require.ErrorIs(t, session.CloseStream(ports.BrokerConnectionID{0x99}, 2), ports.BrokerAdmissionStale)
133133 }
134134
135+// TestOpenStreamAdmissionRoundTrips proves the attachment-admission contract
136+// travels the IPC wire: a create-named request reaches the core with exactly
137+// its admission and validated name.
138+func TestOpenStreamAdmissionRoundTrips(t *testing.T) {
139+ e := startEndpoint(t, Config{})
140+ client, _ := e.pair()
141+ core := e.authority.last()
142+ require.NotNil(t, core)
143+
144+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
145+ defer cancel()
146+ stream, err := client.OpenStream(ctx, ports.BrokerOpenStreamRequest{
147+ Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionCreateNamed, Name: "work",
148+ Local: true, Policy: testPolicy(),
149+ })
150+ require.NoError(t, err)
151+ require.NotNil(t, stream)
152+
153+ core.mu.Lock()
154+ defer core.mu.Unlock()
155+ require.Len(t, core.opens, 1)
156+ require.Equal(t, ports.BrokerAdmissionCreateNamed, core.opens[0].Admission)
157+ require.Equal(t, "work", core.opens[0].Name)
158+ require.Equal(t, ports.BrokerStreamAttachment, core.opens[0].Purpose)
159+ require.True(t, core.opens[0].Local)
160+}
161+
135162 // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly
136163 // the scope the listener assigned at accept.
137164 func TestRegistrationAssignsScopeAtAccept(t *testing.T) {
@@ -158,10 +185,10 @@ func TestSubscribePublishesSnapshot(t *testing.T) {
158185
159186 got := client.Snapshot()
160187 require.Equal(t, e.epoch, got.Epoch)
161- require.Len(t, got.Hosts, 1)
162- require.Equal(t, snapshot.Hosts[0].Endpoint, got.Hosts[0].Endpoint)
163- require.Len(t, got.Hosts[0].Sessions, 2)
164- require.Equal(t, snapshot.Hosts[0].Sessions[0].Name, got.Hosts[0].Sessions[0].Name)
188+ require.Len(t, got.Daemons, 1)
189+ require.Equal(t, snapshot.Daemons[0].Endpoint, got.Daemons[0].Endpoint)
190+ require.Len(t, got.Daemons[0].Sessions, 2)
191+ require.Equal(t, snapshot.Daemons[0].Sessions[0].Name, got.Daemons[0].Sessions[0].Name)
165192
166193 select {
167194 case <-sub.Changed():
@@ -619,11 +646,11 @@ func TestRequestReconcileIsBoundedHint(t *testing.T) {
619646 snapshot := e.publish(e.epoch, 4)
620647 require.Eventually(t, func() bool { return client.Snapshot().Revision == 4 }, 5*time.Second, 10*time.Millisecond)
621648
622- client.RequestReconcile(snapshot.Hosts[0].Endpoint)
649+ client.RequestReconcile(snapshot.Daemons[0].Endpoint)
623650 require.Eventually(t, func() bool {
624651 core.mu.Lock()
625652 defer core.mu.Unlock()
626- return len(core.reconcile) == 1 && core.reconcile[0] == snapshot.Hosts[0].Endpoint
653+ return len(core.reconcile) == 1 && core.reconcile[0] == snapshot.Daemons[0].Endpoint
627654 }, 5*time.Second, 10*time.Millisecond)
628655 }
629656
@@ -689,12 +716,12 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) {
689716 func TestDomainFixturesAreValid(t *testing.T) {
690717 snapshot := testSnapshot(1, 1)
691718 require.NoError(t, snapshot.Validate())
692- for _, host := range snapshot.Hosts {
719+ for _, host := range snapshot.Daemons {
693720 require.NoError(t, ports.ValidateDurableHostProjection(host))
694721 }
695722 require.NoError(t, testPolicy().Validate())
696723 catalogueSession := testSessionAt(0, 0)
697724 require.False(t, strings.HasPrefix(catalogueSession.Name, " "))
698- require.NotEqual(t, domain.RemoteAvailabilityUnknown, testHostAt(0).Availability)
725+ require.NotEqual(t, domain.RemoteAvailabilityUnknown, testDaemonAt(0).Availability)
699726 require.True(t, errors.Is(errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale), ports.BrokerAdmissionStale))
700727 }
M internal/adapters/brokeripc/stream.go +2 -1
@@ -203,7 +203,8 @@ func (s *serverSession) startStream(m brokerwire.OpenStream) {
203203 return
204204 }
205205 request := ports.BrokerOpenStreamRequest{
206- Epoch: m.Epoch, Purpose: m.Purpose, Local: m.Local,
206+ Epoch: m.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name,
207+ Local: m.Local,
207208 Connection: m.Connection, Stream: m.Stream,
208209 Endpoint: m.Endpoint, Registration: m.Registration,
209210 Target: m.Target, Env: m.Env, Policy: m.Policy,
M internal/adapters/brokerstore/README.md +6 -1
@@ -82,7 +82,12 @@ record remains authoritative for generation. Transient checking/error objects
8282 are never persisted. Retirement tombstones are process-local fencing state and
8383 are never durable: the registry's persistable copy and the store both clear
8484 `Snapshot.Removed` before writing, so reopening sees membership without stale
85-tombstones. No environment, launch, or trust policy is guessed.
85+tombstones. Policy is membership authority, not observation state, so the
86+durable observation format omits it: the loader re-stamps every restored
87+observation from the matching `ports.BrokerHostRecord`, keeping membership the
88+single policy authority. A local daemon observation is process-local and never
89+durable: the store rejects one outright. No environment, launch, or trust policy
90+is guessed.
8691
8792 Production cutover, lifecycle supervision, and IPC composition remain later
8893 phases. Close ordering must drain registry snapshot writers before releasing the
M internal/adapters/brokerstore/legacy.go +14 -2
@@ -131,12 +131,24 @@ func migrate(o Options) (recovery, error) {
131131 // Unbound v2/v3 cache data is validated and backed up, never promoted
132132 // to authority. v4 can seed only its exact surviving incarnation.
133133 if e.Host == h.Registration.Endpoint && e.Incarnation == h.Registration.Incarnation {
134- r.State.Snapshot.Hosts = append(r.State.Snapshot.Hosts, ports.RemoteHostSnapshot{Endpoint: e.Host, Registration: h.Registration, Availability: domain.RemoteAvailabilityUnknown, InventoryKnown: true, LastSuccess: e.FetchedAt, Sessions: e.Sessions})
134+ // A legacy cache entry carries only observed sessions: daemon
135+ // identity, incarnation, protocol version, and capabilities stay
136+ // zero (never invented), and policy stays absent because the
137+ // loader re-stamps it from membership.
138+ r.State.Snapshot.Daemons = append(r.State.Snapshot.Daemons, ports.BrokerDaemonObservation{
139+ Endpoint: e.Host,
140+ DisplayOrigin: domain.RemoteDisplayOrigin(e.Host),
141+ Registration: h.Registration,
142+ Availability: domain.RemoteAvailabilityUnknown,
143+ InventoryKnown: true,
144+ LastSuccess: e.FetchedAt,
145+ Sessions: e.Sessions,
146+ })
135147 }
136148 }
137149 }
138150 }
139- if len(r.State.Snapshot.Hosts) > 0 {
151+ if len(r.State.Snapshot.Daemons) > 0 {
140152 var epoch [8]byte
141153 if _, err = rand.Read(epoch[:]); err != nil {
142154 return r, err
M internal/adapters/brokerstore/legacy_test.go +2 -2
@@ -60,10 +60,10 @@ func TestDefensiveCopiesAndConcurrentAccess(t *testing.T) {
6060 h.Hosts[0].Policy.Trust = "changed"
6161 snap, err := s.Load()
6262 require.NoError(t, err)
63- snap.Hosts[0].Sessions[0].Tabs[0].Name = "changed"
63+ snap.Daemons[0].Sessions[0].Tabs[0].Name = "changed"
6464 again, err := s.Load()
6565 require.NoError(t, err)
66- require.Equal(t, "shell", again.Hosts[0].Sessions[0].Tabs[0].Name)
66+ require.Equal(t, "shell", again.Daemons[0].Sessions[0].Tabs[0].Name)
6767 hosts, err := s.LoadHosts()
6868 require.NoError(t, err)
6969 require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust)
M internal/adapters/brokerstore/store.go +90 -20
@@ -17,6 +17,7 @@ import (
1717 "sync"
1818 "syscall"
1919
20+ "github.com/bnema/vev/internal/domain"
2021 "github.com/bnema/vev/internal/ports"
2122 "github.com/bnema/vev/pkg/safedir"
2223 )
@@ -286,14 +287,18 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error {
286287 }
287288 // Removed tombstones and in-flight checking/failure detail are process-local
288289 // fencing and observation state; the durable snapshot never carries them, so
289- // sanitize the caller's copy before validating the durable shape.
290+ // sanitize the caller's copy before validating the durable shape. Policy is
291+ // membership authority, not observation state, so the durable format omits
292+ // it too: membership is the single policy authority and the loader re-stamps
293+ // every restored observation from the matching record.
290294 snapshot = snapshot.Clone()
291295 snapshot.Removed = nil
292- for i := range snapshot.Hosts {
293- snapshot.Hosts[i].Checking = false
294- snapshot.Hosts[i].LastFailure.Err = nil
296+ for i := range snapshot.Daemons {
297+ snapshot.Daemons[i].Checking = false
298+ snapshot.Daemons[i].LastFailure.Err = nil
299+ snapshot.Daemons[i].Policy = ports.BrokerPolicy{}
295300 }
296- if err := snapshot.Validate(); err != nil {
301+ if err := validateDurableSnapshot(snapshot); err != nil {
297302 return err
298303 }
299304 if s.writeEpoch != 0 && s.writeEpoch != snapshot.Epoch {
@@ -304,10 +309,10 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error {
304309 return ErrStale
305310 }
306311 // Reject rather than partially adopt a queued publication from retired hosts.
307- for _, h := range snapshot.Hosts {
312+ for _, daemon := range snapshot.Daemons {
308313 found := false
309314 for _, a := range s.state.Hosts.Hosts {
310- if h.Registration.Equal(a.Registration) {
315+ if daemon.Registration.Equal(a.Registration) {
311316 found = true
312317 }
313318 }
@@ -323,14 +328,15 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error {
323328 s.writeEpoch = snapshot.Epoch
324329 return nil
325330 }
331+
326332 func filter(snapshot ports.BrokerSnapshot, hosts ports.BrokerHosts) ports.BrokerSnapshot {
327333 out := snapshot.Clone()
328- out.Hosts = nil
334+ out.Daemons = nil
329335 out.Removed = nil
330- for _, h := range snapshot.Hosts {
336+ for _, daemon := range snapshot.Daemons {
331337 for _, a := range hosts.Hosts {
332- if h.Registration.Equal(a.Registration) {
333- out.Hosts = append(out.Hosts, h.Clone())
338+ if daemon.Registration.Equal(a.Registration) {
339+ out.Daemons = append(out.Daemons, daemon.Clone())
334340 }
335341 }
336342 }
@@ -360,32 +366,96 @@ func validate(st state) error {
360366 return invalid("%v", err)
361367 }
362368 if st.Snapshot.Epoch == 0 {
363- if st.Snapshot.Revision != 0 || len(st.Snapshot.Hosts) != 0 || len(st.Snapshot.Removed) != 0 {
369+ if st.Snapshot.Revision != 0 || len(st.Snapshot.Daemons) != 0 || len(st.Snapshot.Removed) != 0 {
364370 return invalid("invalid empty snapshot")
365371 }
366372 return nil
367373 }
368- if err := st.Snapshot.Validate(); err != nil {
374+ if err := validateDurableSnapshot(st.Snapshot); err != nil {
369375 return invalid("%v", err)
370376 }
371- for _, h := range st.Snapshot.Hosts {
377+ for _, daemon := range st.Snapshot.Daemons {
372378 found := false
373379 for _, a := range st.Hosts.Hosts {
374- if h.Registration.Equal(a.Registration) {
380+ if daemon.Registration.Equal(a.Registration) {
375381 found = true
376382 }
377383 }
378- if !found || h.Checking || h.LastFailure.Err != nil {
384+ if !found || daemon.Checking || daemon.LastFailure.Err != nil {
379385 return invalid("non-authoritative snapshot")
380386 }
381- // The store enforces the same durable projection rules the registry
382- // checks before publishing, so an accepted publication always persists.
383- if err := ports.ValidateDurableHostProjection(h); err != nil {
384- return invalid("%v", err)
387+ }
388+ return nil
389+}
390+
391+// validateDurableSnapshot reports whether a snapshot satisfies the durable
392+// shape: an empty placeholder stays empty, and a populated snapshot carries
393+// only non-local daemon observations bound to an exact valid registration with
394+// a closed availability/failure range and a catalogue-valid inventory. Policy
395+// and display authority are deliberately excluded: the durable format omits
396+// policy (membership is the single policy authority and the loader re-stamps
397+// it) and display hints are derived presentation state, never durable
398+// authority. Transient checking and live failure causes are sanitized away by
399+// the callers before this rule runs.
400+func validateDurableSnapshot(snapshot ports.BrokerSnapshot) error {
401+ if snapshot.Epoch == 0 {
402+ if snapshot.Revision != 0 || len(snapshot.Daemons) != 0 || len(snapshot.Removed) != 0 {
403+ return errors.New("invalid empty snapshot")
404+ }
405+ return nil
406+ }
407+ if snapshot.Revision == 0 {
408+ return errors.New("snapshot has no revision")
409+ }
410+ if len(snapshot.Daemons) > ports.BrokerMaxDaemonsPerSnapshot {
411+ return errors.New("snapshot has too many daemons")
412+ }
413+ seen := make(map[string]struct{}, len(snapshot.Daemons))
414+ for _, daemon := range snapshot.Daemons {
415+ if err := validateDurableObservation(daemon); err != nil {
416+ return err
417+ }
418+ if _, duplicate := seen[daemon.Endpoint]; duplicate {
419+ return errors.New("snapshot has duplicate host")
420+ }
421+ seen[daemon.Endpoint] = struct{}{}
422+ }
423+ if len(snapshot.Removed) > ports.BrokerMaxTombstones {
424+ return errors.New("snapshot has too many tombstones")
425+ }
426+ for _, tombstone := range snapshot.Removed {
427+ if err := tombstone.Validate(); err != nil {
428+ return err
429+ }
430+ if _, live := seen[tombstone.Endpoint]; live {
431+ return errors.New("snapshot carries a live host as tombstone")
385432 }
386433 }
387434 return nil
388435 }
436+
437+// validateDurableObservation reports whether one daemon observation may be
438+// durable: it is remote-only (a local daemon observation is never durable), it
439+// carries an exact valid registration matching its endpoint, and it satisfies
440+// the ports durable projection rules shared with the registry.
441+func validateDurableObservation(daemon ports.BrokerDaemonObservation) error {
442+ if daemon.Local {
443+ return errors.New("local daemon observation is never durable")
444+ }
445+ if err := domain.ValidateRemoteHostTarget(daemon.Endpoint); err != nil {
446+ return err
447+ }
448+ if err := daemon.Registration.Validate(); err != nil {
449+ return err
450+ }
451+ if daemon.Endpoint != daemon.Registration.Endpoint {
452+ return errors.New("observation endpoint does not match registration")
453+ }
454+ if len(daemon.Sessions) > 0 && !daemon.InventoryKnown {
455+ return errors.New("observation carries sessions without known inventory")
456+ }
457+ return ports.ValidateDurableHostProjection(daemon)
458+}
389459 func (s *Store) commit(next state) error {
390460 if err := validate(next); err != nil {
391461 return err
M internal/adapters/brokerstore/store_test.go +41 -7
@@ -17,9 +17,26 @@ import (
1717 "github.com/stretchr/testify/require"
1818 )
1919
20+func testPolicy() ports.BrokerPolicy {
21+ return ports.BrokerPolicy{ProtocolVersion: 1, CatalogSchemaVersion: 1, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "known-hosts", Launch: "explicit", Isolation: "user"}
22+}
23+
24+// observation builds a remote daemon observation that satisfies the durable
25+// shape: an exact registration, a derived display origin, and a reachable
26+// availability. Policy is intentionally left absent: the loader stamps it from
27+// membership, never from the durable snapshot.
28+func observation(endpoint string, registration domain.RemoteRegistration) ports.BrokerDaemonObservation {
29+ return ports.BrokerDaemonObservation{
30+ Endpoint: endpoint,
31+ DisplayOrigin: domain.RemoteDisplayOrigin(endpoint),
32+ Registration: registration,
33+ Availability: domain.RemoteAvailabilityReachable,
34+ }
35+}
36+
2037 func options(t *testing.T) Options {
2138 t.Helper()
22- return Options{Dir: filepath.Join(t.TempDir(), "broker"), LegacyHosts: "testdata/hosts-v3.json", LegacyCache: "testdata/cache-v4.json", Policies: map[string]ports.BrokerPolicy{"user@arch": {ProtocolVersion: 1, CatalogSchemaVersion: 1, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, Transport: "quic", Trust: "known-hosts", Launch: "explicit", Isolation: "user"}}}
39+ return Options{Dir: filepath.Join(t.TempDir(), "broker"), LegacyHosts: "testdata/hosts-v3.json", LegacyCache: "testdata/cache-v4.json", Policies: map[string]ports.BrokerPolicy{"user@arch": testPolicy()}}
2340 }
2441 func TestPriorFixtures(t *testing.T) {
2542 for h := 1; h <= 3; h++ {
@@ -46,11 +63,11 @@ func TestPriorFixtures(t *testing.T) {
4663 snap, err := s.Load()
4764 require.NoError(t, err)
4865 if h == 3 && c == 4 {
49- require.Len(t, snap.Hosts, 1)
50- require.Len(t, snap.Hosts[0].Sessions, 1)
51- require.Equal(t, "t_work", snap.Hosts[0].Sessions[0].Tabs[0].ID)
66+ require.Len(t, snap.Daemons, 1)
67+ require.Len(t, snap.Daemons[0].Sessions, 1)
68+ require.Equal(t, "t_work", snap.Daemons[0].Sessions[0].Tabs[0].ID)
5269 } else {
53- require.Empty(t, snap.Hosts)
70+ require.Empty(t, snap.Daemons)
5471 }
5572 require.NoError(t, s.Close())
5673 o.LegacyHosts = "missing"
@@ -453,7 +470,7 @@ func TestAuthorityGenerationPolicyAndRemoveReAdd(t *testing.T) {
453470
454471 // A queued publication from the retired incarnation is refused even though
455472 // its revision is newer.
456- require.ErrorIs(t, s.Store(ports.BrokerSnapshot{Epoch: 9, Revision: 1, Hosts: []ports.RemoteHostSnapshot{{Endpoint: base.Registration.Endpoint, Registration: base.Registration, Availability: domain.RemoteAvailabilityReachable}}}), ErrStale)
473+ require.ErrorIs(t, s.Store(ports.BrokerSnapshot{Epoch: 9, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation(base.Registration.Endpoint, base.Registration)}}), ErrStale)
457474
458475 // Re-adding the endpoint needs a fresh incarnation; the retired
459476 // observation never comes back for it.
@@ -620,9 +637,26 @@ func TestFencingAndCommitFaults(t *testing.T) {
620637 defer s.Close()
621638 got, err := s.Load()
622639 require.NoError(t, err)
623- require.Empty(t, got.Hosts)
640+ require.Empty(t, got.Daemons)
624641 gotH, err := s.LoadHosts()
625642 require.NoError(t, err)
626643 require.Equal(t, h.Hosts, gotH.Hosts)
627644 })
628645 }
646+
647+// TestLocalDaemonObservationIsNeverDurable pins the durable boundary: a local
648+// daemon observation is process-local state and the store refuses it outright,
649+// so it can never become durable authority.
650+func TestLocalDaemonObservationIsNeverDurable(t *testing.T) {
651+ s, err := OpenOffline(options(t))
652+ require.NoError(t, err)
653+ defer s.Close()
654+ err = s.Store(ports.BrokerSnapshot{Epoch: 9, Revision: 1, Daemons: []ports.BrokerDaemonObservation{{
655+ Local: true,
656+ DisplayOrigin: "local",
657+ Policy: testPolicy(),
658+ Availability: domain.RemoteAvailabilityReachable,
659+ }}})
660+ require.Error(t, err)
661+ require.Contains(t, err.Error(), "never durable")
662+}
M internal/adapters/brokerwire/codec.go +195 -80
@@ -419,7 +419,8 @@ func unsubscribeFromWire(message *wire.Unsubscribe) (Unsubscribe, error) {
419419
420420 func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
421421 request := ports.BrokerOpenStreamRequest{
422- Epoch: m.Epoch, Purpose: m.Purpose, Local: m.Local,
422+ Epoch: m.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name,
423+ Local: m.Local,
423424 Connection: m.Connection, Stream: m.Stream,
424425 Endpoint: m.Endpoint, Registration: m.Registration,
425426 Target: m.Target, Env: m.Env, Policy: m.Policy,
@@ -450,22 +451,64 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
450451 default:
451452 return nil, ErrInvalidMessage
452453 }
454+ admission, err := admissionToWire(m.Admission)
455+ if err != nil {
456+ return nil, err
457+ }
453458 out := &wire.OpenStream{
454459 Ref: refToWire(m.Epoch, m.Connection, m.Stream),
455460 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,
456461 Env: append([]string(nil), m.Env...),
457462 Policy: policyToWire(m.Policy),
463+ // The admission taxonomy mirrors the port values (0 none, 1 exact,
464+ // 2 create named, 3 create ephemeral); the name travels only for the
465+ // create-named variant.
466+ Admission: admission, Name: m.Name,
458467 }
459468 if !m.Local {
460469 out.Registration = registrationToWire(m.Registration)
461470 }
462- if m.Purpose == ports.BrokerStreamAttachment {
463- target := m.Target
464- out.Target = exactTargetToWire(target)
471+ // Only exact attach/resume carries a session target; a creation
472+ // admission carries a zero target that must never be encoded as a
473+ // present message.
474+ if m.Purpose == ports.BrokerStreamAttachment && m.Admission == ports.BrokerAdmissionExact {
475+ out.Target = exactTargetToWire(m.Target)
465476 }
466477 return out, nil
467478 }
468479
480+// admissionToWire maps the closed admission taxonomy onto its wire code.
481+func admissionToWire(admission ports.BrokerStreamAdmission) (uint32, error) {
482+ switch admission {
483+ case 0:
484+ return 0, nil
485+ case ports.BrokerAdmissionExact:
486+ return 1, nil
487+ case ports.BrokerAdmissionCreateNamed:
488+ return 2, nil
489+ case ports.BrokerAdmissionCreateEphemeral:
490+ return 3, nil
491+ default:
492+ return 0, errConvertRange
493+ }
494+}
495+
496+// admissionFromWire maps a wire admission code onto the closed taxonomy.
497+func admissionFromWire(value uint32) (ports.BrokerStreamAdmission, error) {
498+ switch value {
499+ case 0:
500+ return 0, nil
501+ case 1:
502+ return ports.BrokerAdmissionExact, nil
503+ case 2:
504+ return ports.BrokerAdmissionCreateNamed, nil
505+ case 3:
506+ return ports.BrokerAdmissionCreateEphemeral, nil
507+ default:
508+ return 0, errConvertRange
509+ }
510+}
511+
469512 func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
470513 var out OpenStream
471514 if message == nil {
@@ -486,6 +529,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
486529 default:
487530 return OpenStream{}, ErrInvalidMessage
488531 }
532+ admission, err := admissionFromWire(message.GetAdmission())
533+ if err != nil {
534+ return OpenStream{}, ErrInvalidMessage
535+ }
489536 local := message.GetLocal()
490537 var registration domain.RemoteRegistration
491538 if !local {
@@ -496,8 +543,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
496543 } else if message.GetRegistration() != nil {
497544 return OpenStream{}, ErrInvalidMessage
498545 }
546+ // Only exact attach/resume carries a session target. A present target on
547+ // any other admission or purpose is malformed.
499548 var target protocol.ExactSessionTarget
500- if purpose == ports.BrokerStreamAttachment {
549+ if purpose == ports.BrokerStreamAttachment && admission == ports.BrokerAdmissionExact {
501550 target, err = exactTargetFromWire(message.GetTarget())
502551 if err != nil {
503552 return OpenStream{}, ErrInvalidMessage
@@ -519,11 +568,14 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
519568 endpoint := message.GetEndpoint()
520569 candidate := OpenStream{
521570 Epoch: epoch, Connection: connection, Stream: stream,
522- Purpose: purpose, Local: local, Endpoint: endpoint,
571+ Purpose: purpose, Admission: admission, Name: message.GetName(),
572+ Local: local,
573+ Endpoint: endpoint,
523574 Registration: registration, Target: target, Env: env, Policy: policy,
524575 }
525576 request := ports.BrokerOpenStreamRequest{
526- Epoch: candidate.Epoch, Purpose: candidate.Purpose, Local: candidate.Local,
577+ Epoch: candidate.Epoch, Purpose: candidate.Purpose,
578+ Admission: candidate.Admission, Name: candidate.Name, Local: candidate.Local,
527579 Connection: candidate.Connection, Stream: candidate.Stream,
528580 Endpoint: candidate.Endpoint, Registration: candidate.Registration,
529581 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,
@@ -780,26 +832,30 @@ func snapshotPartToWire(m SnapshotPart) (*wire.SnapshotPart, error) {
780832 }
781833 switch part := m.Part.(type) {
782834 case SnapshotBegin:
783- if part.HostCount > ports.BrokerMaxHosts ||
784- part.SessionCount > ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost ||
835+ if part.HostCount > ports.BrokerMaxDaemonsPerSnapshot ||
836+ part.SessionCount > ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost ||
785837 part.TombstoneCount > ports.BrokerMaxTombstones {
786838 return nil, ErrTooLarge
787839 }
840+ if part.LocalPresent && part.HostCount == 0 {
841+ return nil, ErrInvalidMessage
842+ }
788843 out.Part = &wire.SnapshotPart_Begin{Begin: &wire.SnapshotBegin{
789844 HostCount: part.HostCount, SessionCount: part.SessionCount, TombstoneCount: part.TombstoneCount,
845+ LocalPresent: part.LocalPresent,
790846 }}
791847 case *SnapshotBegin:
792848 if part == nil {
793849 return nil, ErrInvalidMessage
794850 }
795851 return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part})
796- case SnapshotHostPart:
797- converted, err := snapshotHostToWire(part)
852+ case SnapshotDaemonPart:
853+ converted, err := snapshotDaemonToWire(part)
798854 if err != nil {
799855 return nil, err
800856 }
801- out.Part = &wire.SnapshotPart_Host{Host: converted}
802- case *SnapshotHostPart:
857+ out.Part = &wire.SnapshotPart_Daemon{Daemon: converted}
858+ case *SnapshotDaemonPart:
803859 if part == nil {
804860 return nil, ErrInvalidMessage
805861 }
@@ -867,34 +923,38 @@ func snapshotPartFromWire(message *wire.SnapshotPart) (SnapshotPart, error) {
867923 if payload.Begin == nil {
868924 return SnapshotPart{}, ErrInvalidMessage
869925 }
870- if payload.Begin.GetHostCount() > ports.BrokerMaxHosts ||
871- payload.Begin.GetSessionCount() > ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost ||
926+ if payload.Begin.GetHostCount() > ports.BrokerMaxDaemonsPerSnapshot ||
927+ payload.Begin.GetSessionCount() > ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost ||
872928 payload.Begin.GetTombstoneCount() > ports.BrokerMaxTombstones {
873929 return SnapshotPart{}, ErrTooLarge
874930 }
931+ if payload.Begin.GetLocalPresent() && payload.Begin.GetHostCount() == 0 {
932+ return SnapshotPart{}, ErrInvalidMessage
933+ }
875934 part.Part = SnapshotBegin{
876935 HostCount: payload.Begin.GetHostCount(),
877936 SessionCount: payload.Begin.GetSessionCount(),
878937 TombstoneCount: payload.Begin.GetTombstoneCount(),
938+ LocalPresent: payload.Begin.GetLocalPresent(),
879939 }
880- case *wire.SnapshotPart_Host:
881- if payload.Host == nil {
940+ case *wire.SnapshotPart_Daemon:
941+ if payload.Daemon == nil {
882942 return SnapshotPart{}, ErrInvalidMessage
883943 }
884- host, index, sessionCount, err := snapshotHostFromWire(payload.Host)
944+ daemon, index, sessionCount, err := snapshotDaemonFromWire(payload.Daemon)
885945 if err != nil {
886946 return SnapshotPart{}, err
887947 }
888- part.Part = SnapshotHostPart{HostIndex: index, Host: host, SessionCount: sessionCount}
948+ part.Part = SnapshotDaemonPart{HostIndex: index, Daemon: daemon, SessionCount: sessionCount}
889949 case *wire.SnapshotPart_Session:
890950 if payload.Session == nil {
891951 return SnapshotPart{}, ErrInvalidMessage
892952 }
893- session, hostIndex, sessionIndex, err := snapshotSessionFromWire(payload.Session)
953+ session, hostIndex, sessionIndex, local, err := snapshotSessionFromWire(payload.Session)
894954 if err != nil {
895955 return SnapshotPart{}, err
896956 }
897- part.Part = SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: session}
957+ part.Part = SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Local: local, Session: session}
898958 case *wire.SnapshotPart_Tombstone:
899959 if payload.Tombstone == nil {
900960 return SnapshotPart{}, ErrInvalidMessage
@@ -922,118 +982,167 @@ func snapshotPartFromWire(message *wire.SnapshotPart) (SnapshotPart, error) {
922982 return part, nil
923983 }
924984
925-func snapshotHostToWire(part SnapshotHostPart) (*wire.SnapshotHost, error) {
926- if part.HostIndex >= ports.BrokerMaxHosts {
985+// snapshotDaemonToWire converts one daemon part losslessly. It refuses a
986+// malformed local/remote authority pairing, a partial identity, an
987+// out-of-range enum or rank, a session inventory that could not be durable,
988+// inline sessions (they travel separately), and any index or count above
989+// the snapshot bounds.
990+func snapshotDaemonToWire(part SnapshotDaemonPart) (*wire.SnapshotDaemon, error) {
991+ if part.HostIndex >= ports.BrokerMaxDaemonsPerSnapshot {
927992 return nil, ErrTooLarge
928993 }
929- host := part.Host
930- if err := validateSnapshotHostFields(host); err != nil {
994+ daemon := part.Daemon
995+ if err := validateSnapshotDaemonFields(daemon); err != nil {
931996 return nil, err
932997 }
933- if err := host.Registration.Validate(); err != nil {
934- return nil, ErrInvalidMessage
935- }
936- if host.Endpoint != host.Registration.Endpoint {
998+ if err := daemon.Validate(); err != nil {
937999 return nil, ErrInvalidMessage
9381000 }
939- if err := ports.ValidateDurableHostProjection(host); err != nil {
940- return nil, ErrInvalidMessage
1001+ if !daemon.Local {
1002+ if err := ports.ValidateDurableHostProjection(daemon); err != nil {
1003+ return nil, ErrInvalidMessage
1004+ }
9411005 }
942- if len(host.Sessions) != 0 {
1006+ if len(daemon.Sessions) != 0 {
9431007 return nil, ErrInvalidMessage
9441008 }
9451009 if part.SessionCount > ports.BrokerMaxSessionsPerHost {
9461010 return nil, ErrTooLarge
9471011 }
948- lastAttemptSeconds, lastAttemptNanos := brokerTimeToWire(host.LastAttempt)
949- lastSuccessSeconds, lastSuccessNanos := brokerTimeToWire(host.LastSuccess)
950- nextDueSeconds, nextDueNanos := brokerTimeToWire(host.NextDue)
951- return &wire.SnapshotHost{
952- HostIndex: part.HostIndex, Endpoint: host.Endpoint, DisplayOrigin: host.DisplayOrigin,
953- Rank: uint32(host.Rank), Registration: registrationToWire(host.Registration),
954- Availability: uint32(host.Availability), Checking: host.Checking,
1012+ lastAttemptSeconds, lastAttemptNanos := brokerTimeToWire(daemon.LastAttempt)
1013+ lastSuccessSeconds, lastSuccessNanos := brokerTimeToWire(daemon.LastSuccess)
1014+ nextDueSeconds, nextDueNanos := brokerTimeToWire(daemon.NextDue)
1015+ var registration *wire.RemoteRegistration
1016+ if !daemon.Local {
1017+ registration = registrationToWire(daemon.Registration)
1018+ }
1019+ var incarnation []byte
1020+ if !daemon.Incarnation.IsZero() {
1021+ incarnation = append([]byte(nil), daemon.Incarnation[:]...)
1022+ }
1023+ return &wire.SnapshotDaemon{
1024+ HostIndex: part.HostIndex, Local: daemon.Local,
1025+ Endpoint: daemon.Endpoint, DisplayOrigin: daemon.DisplayOrigin,
1026+ Rank: uint32(daemon.Rank), Registration: registration,
1027+ Policy: policyToWire(daemon.Policy),
1028+ DaemonIdentity: string(daemon.Identity), DaemonIncarnation: incarnation,
1029+ ProtocolVersion: uint32(daemon.ProtocolVersion), Capabilities: daemon.Capabilities,
1030+ Availability: uint32(daemon.Availability), Checking: daemon.Checking,
9551031 LastAttempt: &wire.BrokerTimestamp{Seconds: lastAttemptSeconds, Nanos: lastAttemptNanos},
9561032 LastSuccess: &wire.BrokerTimestamp{Seconds: lastSuccessSeconds, Nanos: lastSuccessNanos},
9571033 NextDue: &wire.BrokerTimestamp{Seconds: nextDueSeconds, Nanos: nextDueNanos},
958- ConsecutiveFailures: uint64(host.ConsecutiveFailures), FailureEpisode: host.FailureEpisode,
959- FailureKind: uint32(host.LastFailure.Kind),
960- InventoryKnown: host.InventoryKnown, SessionCount: part.SessionCount,
1034+ ConsecutiveFailures: uint64(daemon.ConsecutiveFailures), FailureEpisode: daemon.FailureEpisode,
1035+ FailureKind: uint32(daemon.LastFailure.Kind),
1036+ InventoryKnown: daemon.InventoryKnown, SessionCount: part.SessionCount,
9611037 }, nil
9621038 }
9631039
964-func snapshotHostFromWire(message *wire.SnapshotHost) (ports.RemoteHostSnapshot, uint32, uint32, error) {
965- var host ports.RemoteHostSnapshot
1040+func snapshotDaemonFromWire(message *wire.SnapshotDaemon) (ports.BrokerDaemonObservation, uint32, uint32, error) {
1041+ var daemon ports.BrokerDaemonObservation
9661042 if message == nil {
967- return host, 0, 0, ErrInvalidMessage
1043+ return daemon, 0, 0, ErrInvalidMessage
9681044 }
969- if message.GetHostIndex() >= ports.BrokerMaxHosts {
970- return ports.RemoteHostSnapshot{}, 0, 0, ErrTooLarge
1045+ if message.GetHostIndex() >= ports.BrokerMaxDaemonsPerSnapshot {
1046+ return daemon, 0, 0, ErrTooLarge
9711047 }
1048+ local := message.GetLocal()
9721049 endpoint := message.GetEndpoint()
973- if err := validateBrokerEndpoint(endpoint); err != nil {
974- return ports.RemoteHostSnapshot{}, 0, 0, err
975- }
9761050 origin := message.GetDisplayOrigin()
9771051 if err := validateBrokerOrigin(origin); err != nil {
978- return ports.RemoteHostSnapshot{}, 0, 0, err
1052+ return daemon, 0, 0, err
9791053 }
980- registration, err := registrationFromWire(message.GetRegistration())
1054+ var registration domain.RemoteRegistration
1055+ if local {
1056+ if message.GetRegistration() != nil || endpoint != "" {
1057+ return daemon, 0, 0, ErrInvalidMessage
1058+ }
1059+ } else {
1060+ if err := validateBrokerEndpoint(endpoint); err != nil {
1061+ return daemon, 0, 0, err
1062+ }
1063+ converted, err := registrationFromWire(message.GetRegistration())
1064+ if err != nil {
1065+ return daemon, 0, 0, ErrInvalidMessage
1066+ }
1067+ if endpoint != converted.Endpoint {
1068+ return daemon, 0, 0, ErrInvalidMessage
1069+ }
1070+ registration = converted
1071+ }
1072+ policy, err := policyFromWire(message.GetPolicy())
9811073 if err != nil {
982- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1074+ return daemon, 0, 0, ErrInvalidMessage
9831075 }
984- if endpoint != registration.Endpoint {
985- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1076+ raw := message.GetDaemonIncarnation()
1077+ if len(raw) != 0 && len(raw) != len(daemon.Incarnation) {
1078+ return daemon, 0, 0, errConvertRange
1079+ }
1080+ if len(raw) == len(daemon.Incarnation) {
1081+ copy(daemon.Incarnation[:], raw)
1082+ }
1083+ protocolVersion, err := brokerEnum16[uint16](message.GetProtocolVersion())
1084+ if err != nil {
1085+ return daemon, 0, 0, ErrInvalidMessage
9861086 }
9871087 availability, err := brokerEnum8[domain.RemoteAvailability](message.GetAvailability())
9881088 if err != nil {
989- return ports.RemoteHostSnapshot{}, 0, 0, err
1089+ return daemon, 0, 0, ErrInvalidMessage
9901090 }
9911091 if availability < domain.RemoteAvailabilityUnknown || availability > domain.RemoteAvailabilityInvalidResponse {
992- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1092+ return daemon, 0, 0, ErrInvalidMessage
9931093 }
9941094 failureKind, err := brokerEnum8[domain.RemoteFailureKind](message.GetFailureKind())
9951095 if err != nil {
996- return ports.RemoteHostSnapshot{}, 0, 0, err
1096+ return daemon, 0, 0, ErrInvalidMessage
9971097 }
9981098 if failureKind > domain.RemoteFailureInvalidResponse {
999- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1099+ return daemon, 0, 0, ErrInvalidMessage
10001100 }
10011101 lastAttempt, err := brokerTimeFromWire(message.GetLastAttempt().GetSeconds(), message.GetLastAttempt().GetNanos())
10021102 if err != nil {
1003- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1103+ return daemon, 0, 0, ErrInvalidMessage
10041104 }
10051105 lastSuccess, err := brokerTimeFromWire(message.GetLastSuccess().GetSeconds(), message.GetLastSuccess().GetNanos())
10061106 if err != nil {
1007- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1107+ return daemon, 0, 0, ErrInvalidMessage
10081108 }
10091109 nextDue, err := brokerTimeFromWire(message.GetNextDue().GetSeconds(), message.GetNextDue().GetNanos())
10101110 if err != nil {
1011- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage
1111+ return daemon, 0, 0, ErrInvalidMessage
10121112 }
10131113 if message.GetConsecutiveFailures() > math.MaxUint32 {
1014- return ports.RemoteHostSnapshot{}, 0, 0, errConvertRange
1114+ return daemon, 0, 0, errConvertRange
10151115 }
1016- sessionCount := message.GetSessionCount()
1017- if sessionCount > ports.BrokerMaxSessionsPerHost {
1018- return ports.RemoteHostSnapshot{}, 0, 0, ErrTooLarge
1116+ if message.GetSessionCount() > ports.BrokerMaxSessionsPerHost {
1117+ return daemon, 0, 0, ErrTooLarge
10191118 }
1020- host = ports.RemoteHostSnapshot{
1021- Endpoint: endpoint, DisplayOrigin: origin, Rank: int(message.GetRank()),
1022- Registration: registration, Availability: availability, Checking: message.GetChecking(),
1119+ daemon = ports.BrokerDaemonObservation{
1120+ Local: local, Endpoint: endpoint, DisplayOrigin: origin, Rank: int(message.GetRank()),
1121+ Registration: registration, Policy: policy,
1122+ Identity: ports.BrokerDaemonIdentity(message.GetDaemonIdentity()), Incarnation: daemon.Incarnation,
1123+ ProtocolVersion: protocolVersion, Capabilities: message.GetCapabilities(),
1124+ Availability: availability, Checking: message.GetChecking(),
10231125 LastAttempt: lastAttempt, LastSuccess: lastSuccess, NextDue: nextDue,
10241126 ConsecutiveFailures: uint(message.GetConsecutiveFailures()), FailureEpisode: message.GetFailureEpisode(),
10251127 LastFailure: domain.RemoteFailure{Kind: failureKind},
10261128 InventoryKnown: message.GetInventoryKnown(),
10271129 Sessions: []catalogue.RemoteCatalogSession{},
10281130 }
1029- if err := validateSnapshotHostFields(host); err != nil {
1030- return ports.RemoteHostSnapshot{}, 0, 0, err
1131+ if err := validateSnapshotDaemonFields(daemon); err != nil {
1132+ return daemon, 0, 0, err
10311133 }
1032- return host, message.GetHostIndex(), message.GetSessionCount(), nil
1134+ if err := daemon.Validate(); err != nil {
1135+ return daemon, 0, 0, ErrInvalidMessage
1136+ }
1137+ return daemon, message.GetHostIndex(), message.GetSessionCount(), nil
10331138 }
10341139
10351140 func snapshotSessionToWire(part SnapshotSessionPart) (*wire.SnapshotSession, error) {
1036- if part.HostIndex >= ports.BrokerMaxHosts {
1141+ if part.Local {
1142+ if part.HostIndex != 0 {
1143+ return nil, ErrInvalidMessage
1144+ }
1145+ } else if part.HostIndex >= ports.BrokerMaxDaemonsPerSnapshot {
10371146 return nil, ErrTooLarge
10381147 }
10391148 if part.SessionIndex >= ports.BrokerMaxSessionsPerHost {
@@ -1044,26 +1153,32 @@ func snapshotSessionToWire(part SnapshotSessionPart) (*wire.SnapshotSession, err
10441153 }
10451154 return &wire.SnapshotSession{
10461155 HostIndex: part.HostIndex, SessionIndex: part.SessionIndex,
1156+ Local: part.Local,
10471157 Session: catalogSessionToWire(part.Session),
10481158 }, nil
10491159 }
10501160
1051-func snapshotSessionFromWire(message *wire.SnapshotSession) (catalogue.RemoteCatalogSession, uint32, uint32, error) {
1161+func snapshotSessionFromWire(message *wire.SnapshotSession) (catalogue.RemoteCatalogSession, uint32, uint32, bool, error) {
10521162 var session catalogue.RemoteCatalogSession
10531163 if message == nil {
1054- return session, 0, 0, ErrInvalidMessage
1164+ return session, 0, 0, false, ErrInvalidMessage
10551165 }
1056- if message.GetHostIndex() >= ports.BrokerMaxHosts {
1057- return catalogue.RemoteCatalogSession{}, 0, 0, ErrTooLarge
1166+ local := message.GetLocal()
1167+ if local {
1168+ if message.GetHostIndex() != 0 {
1169+ return session, 0, 0, false, ErrInvalidMessage
1170+ }
1171+ } else if message.GetHostIndex() >= ports.BrokerMaxDaemonsPerSnapshot {
1172+ return session, 0, 0, false, ErrTooLarge
10581173 }
10591174 if message.GetSessionIndex() >= ports.BrokerMaxSessionsPerHost {
1060- return catalogue.RemoteCatalogSession{}, 0, 0, ErrTooLarge
1175+ return session, 0, 0, false, ErrTooLarge
10611176 }
10621177 converted, err := catalogSessionFromWire(message.GetSession())
10631178 if err != nil {
1064- return catalogue.RemoteCatalogSession{}, 0, 0, err
1179+ return session, 0, 0, false, err
10651180 }
1066- return converted, message.GetHostIndex(), message.GetSessionIndex(), nil
1181+ return converted, message.GetHostIndex(), message.GetSessionIndex(), local, nil
10671182 }
10681183
10691184 func operationResultToWire(m OperationResult) (*wire.OperationResult, error) {
M internal/adapters/brokerwire/codec_test.go +205 -47
@@ -74,19 +74,39 @@ func testCatalogSession() catalogue.RemoteCatalogSession {
7474 }
7575 }
7676
77-func testHostSnapshot() ports.RemoteHostSnapshot {
78- return ports.RemoteHostSnapshot{
79- Endpoint: "dev@host:22",
80- DisplayOrigin: "dev@host",
81- Rank: 2,
82- Registration: testRegistration(),
77+// testDaemonObservation builds one observed, catalogue-valid remote daemon
78+// projection. Sessions are empty: they travel as separate parts.
79+func testDaemonObservation() ports.BrokerDaemonObservation {
80+ return ports.BrokerDaemonObservation{
81+ Endpoint: "dev@host:22",
82+ DisplayOrigin: "dev@host",
83+ Rank: 2,
84+ Registration: testRegistration(),
85+ Policy: testPolicy(),
86+ Identity: ports.BrokerDaemonIdentity("authed-daemon"),
87+ Incarnation: ports.BrokerDaemonIncarnation{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
88+ ProtocolVersion: protocol.Version,
89+ Capabilities: protocol.CapabilityResume | protocol.CapabilityUDP,
90+ Availability: domain.RemoteAvailabilityReachable,
91+ Checking: true,
92+ LastAttempt: time.Unix(1700000000, 1).UTC(),
93+ LastSuccess: time.Unix(1700000001, 2).UTC(),
94+ NextDue: time.Unix(1700000002, 3).UTC(),
95+ FailureEpisode: 6,
96+ LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureTransport},
97+ InventoryKnown: true,
98+ Sessions: []catalogue.RemoteCatalogSession{},
99+ }
100+}
101+
102+// testLocalDaemonObservation builds the local daemon projection: no endpoint,
103+// no registration, always a valid policy, and an explicit availability.
104+func testLocalDaemonObservation() ports.BrokerDaemonObservation {
105+ return ports.BrokerDaemonObservation{
106+ Local: true,
107+ DisplayOrigin: "local",
108+ Policy: testPolicy(),
83109 Availability: domain.RemoteAvailabilityReachable,
84- Checking: true,
85- LastAttempt: time.Unix(1700000000, 1).UTC(),
86- LastSuccess: time.Unix(1700000001, 2).UTC(),
87- NextDue: time.Unix(1700000002, 3).UTC(),
88- FailureEpisode: 6,
89- LastFailure: domain.RemoteFailure{Kind: domain.RemoteFailureTransport},
90110 InventoryKnown: true,
91111 Sessions: []catalogue.RemoteCatalogSession{},
92112 }
@@ -334,7 +354,7 @@ func TestBrokerClientVariants(t *testing.T) {
334354 {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 105},
335355 {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 106},
336356 {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107},
337- {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108},
357+ {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108},
338358 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},
339359 {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},
340360 }
@@ -353,6 +373,141 @@ func TestBrokerClientVariants(t *testing.T) {
353373 }
354374 }
355375
376+// TestBrokerOpenStreamAdmissionVariants pins the attachment-admission
377+// contract: exact, create-named, and create-ephemeral round-trip, control
378+// carries none, and wrong admission/purpose/name/target combinations are
379+// refused exactly like ports.BrokerOpenStreamRequest.Validate.
380+func TestBrokerOpenStreamAdmissionVariants(t *testing.T) {
381+ connection := testConnectionID(0x11)
382+ base := func() OpenStream {
383+ return OpenStream{
384+ Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment,
385+ Endpoint: "dev@host:22", Registration: testRegistration(), Policy: testPolicy(),
386+ }
387+ }
388+ roundTrips := []struct {
389+ name string
390+ message OpenStream
391+ }{
392+ {"exact", func() OpenStream {
393+ message := base()
394+ message.Admission = ports.BrokerAdmissionExact
395+ message.Target = testTarget()
396+ return message
397+ }()},
398+ {"create named", func() OpenStream {
399+ message := base()
400+ message.Admission = ports.BrokerAdmissionCreateNamed
401+ message.Name = "work"
402+ return message
403+ }()},
404+ {"create ephemeral", func() OpenStream {
405+ message := base()
406+ message.Admission = ports.BrokerAdmissionCreateEphemeral
407+ return message
408+ }()},
409+ {"control carries none", OpenStream{
410+ Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,
411+ Local: true, Policy: testPolicy(),
412+ }},
413+ }
414+ for _, tc := range roundTrips {
415+ t.Run(tc.name, func(t *testing.T) {
416+ raw := mustEncodeClient(t, tc.message)
417+ decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
418+ require.NoError(t, err)
419+ require.Equal(t, tc.message, decoded)
420+ })
421+ }
422+
423+ t.Run("wrong combinations refused", func(t *testing.T) {
424+ exact := func() OpenStream {
425+ message := base()
426+ message.Admission = ports.BrokerAdmissionExact
427+ message.Target = testTarget()
428+ return message
429+ }
430+ cases := map[string]OpenStream{
431+ "attachment without admission": func() OpenStream { m := base(); return m }(),
432+ "exact with name": func() OpenStream {
433+ m := exact()
434+ m.Name = "work"
435+ return m
436+ }(),
437+ "named with target": func() OpenStream {
438+ m := base()
439+ m.Admission = ports.BrokerAdmissionCreateNamed
440+ m.Name = "work"
441+ m.Target = testTarget()
442+ return m
443+ }(),
444+ "named without name": func() OpenStream {
445+ m := base()
446+ m.Admission = ports.BrokerAdmissionCreateNamed
447+ return m
448+ }(),
449+ "ephemeral with name": func() OpenStream {
450+ m := base()
451+ m.Admission = ports.BrokerAdmissionCreateEphemeral
452+ m.Name = "work"
453+ return m
454+ }(),
455+ "control with admission": OpenStream{
456+ Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamControl,
457+ Local: true, Policy: testPolicy(), Admission: ports.BrokerAdmissionExact,
458+ },
459+ }
460+ for name, message := range cases {
461+ t.Run(name, func(t *testing.T) {
462+ _, err := EncodeClient(message, testEnvelopeCeiling, testChunkCeiling)
463+ require.ErrorIs(t, err, ErrInvalidMessage)
464+ })
465+ }
466+ })
467+
468+ t.Run("unknown admission code refused on decode", func(t *testing.T) {
469+ message := base()
470+ message.Admission = ports.BrokerAdmissionExact
471+ message.Target = testTarget()
472+ raw := mustEncodeClient(t, message)
473+ envelope := &wire.BrokerClientEnvelope{}
474+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
475+ require.NoError(t, proto.Unmarshal(raw, envelope))
476+ envelope.GetOpenStream().Admission = 9
477+ mutated, err := proto.Marshal(envelope)
478+ require.NoError(t, err)
479+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
480+ require.ErrorIs(t, err, ErrInvalidMessage)
481+ })
482+}
483+
484+// TestBrokerSnapshotDaemonByteForByte proves the daemon and session parts are
485+// byte-for-byte stable across an encode/decode/re-encode cycle, including an
486+// observed identity/incarnation/version/capability set, the local daemon, and a
487+// locally-bound session.
488+func TestBrokerSnapshotDaemonByteForByte(t *testing.T) {
489+ connection := testConnectionID(0x11)
490+ messages := []struct {
491+ name string
492+ message SnapshotPart
493+ }{
494+ {"observed remote daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: testDaemonObservation(), SessionCount: 1}}},
495+ {"local daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonObservation(), SessionCount: 1}}},
496+ {"local session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}},
497+ }
498+ for _, tc := range messages {
499+ t.Run(tc.name, func(t *testing.T) {
500+ raw := mustEncodeServer(t, tc.message)
501+ decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
502+ require.NoError(t, err)
503+ require.Equal(t, tc.message, decoded)
504+ again, err := EncodeServer(decoded, testEnvelopeCeiling, testChunkCeiling)
505+ require.NoError(t, err)
506+ require.Equal(t, raw, again, "re-encode must be byte-for-byte identical")
507+ })
508+ }
509+}
510+
356511 // TestBrokerServerVariants inventories every server variant tag.
357512 func TestBrokerServerVariants(t *testing.T) {
358513 connection := testConnectionID(0x11)
@@ -363,11 +518,13 @@ func TestBrokerServerVariants(t *testing.T) {
363518 tag protowire.Number
364519 }{
365520 {"registered", Registered{Epoch: 7, Connection: connection}, 201},
366- {"snapshot_begin", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 0}}, 202},
367- {"snapshot_host", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotHostPart{HostIndex: 0, Host: testHostSnapshot(), SessionCount: 1}}, 202},
368- {"snapshot_session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}}, 202},
369- {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 3, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202},
370- {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotEnd{}}, 202},
521+ {"snapshot_begin", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 0, Part: SnapshotBegin{HostCount: 2, SessionCount: 2, TombstoneCount: 0, LocalPresent: true}}, 202},
522+ {"snapshot_daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonObservation(), SessionCount: 0}}, 202},
523+ {"snapshot_daemon_remote", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotDaemonPart{HostIndex: 1, Daemon: testDaemonObservation(), SessionCount: 1}}, 202},
524+ {"snapshot_session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 3, Part: SnapshotSessionPart{HostIndex: 1, SessionIndex: 0, Session: testCatalogSession()}}, 202},
525+ {"snapshot_session_local", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}, 202},
526+ {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 5, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202},
527+ {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 6, Part: SnapshotEnd{}}, 202},
371528 {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true}, 203},
372529 {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204},
373530 {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205},
@@ -445,15 +602,16 @@ func TestBrokerTabIndexBound(t *testing.T) {
445602 require.ErrorIs(t, err, errConvertRange)
446603 }
447604
448-// mutateHostIndex re-encodes one host snapshot part envelope with its host
449-// index overwritten, so decode can be fed a value encode would never emit.
450-func mutateHostIndex(t *testing.T, part SnapshotPart, hostIndex uint32) []byte {
605+// mutateDaemonIndex re-encodes one daemon snapshot part envelope with its
606+// daemon index overwritten, so decode can be fed a value encode would never
607+// emit.
608+func mutateDaemonIndex(t *testing.T, part SnapshotPart, hostIndex uint32) []byte {
451609 t.Helper()
452610 raw := mustEncodeServer(t, part)
453611 envelope := &wire.BrokerServerEnvelope{}
454612 require.NoError(t, wire.ScanEnvelope(envelope, raw))
455613 require.NoError(t, proto.Unmarshal(raw, envelope))
456- envelope.GetSnapshotPart().GetHost().HostIndex = hostIndex
614+ envelope.GetSnapshotPart().GetDaemon().HostIndex = hostIndex
457615 mutated, err := proto.Marshal(envelope)
458616 require.NoError(t, err)
459617 return mutated
@@ -476,57 +634,57 @@ func mutateSessionIndexes(t *testing.T, part SnapshotPart, hostIndex, sessionInd
476634
477635 // TestBrokerSnapshotIndexBounds proves snapshot part indexes are strict
478636 // ranges: max-1 is accepted on both encode and decode, and max is refused as a
479-// range refusal on both paths. Host indexes are bounded by BrokerMaxHosts and
480-// session indexes by BrokerMaxSessionsPerHost.
637+// range refusal on both paths. Daemon indexes are bounded by
638+// BrokerMaxDaemonsPerSnapshot and session indexes by BrokerMaxSessionsPerHost.
481639 func TestBrokerSnapshotIndexBounds(t *testing.T) {
482640 connection := testConnectionID(0x11)
483- hostPart := func(hostIndex uint32) SnapshotPart {
484- return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotHostPart{HostIndex: hostIndex, Host: testHostSnapshot()}}
641+ daemonPart := func(hostIndex uint32) SnapshotPart {
642+ return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: hostIndex, Daemon: testDaemonObservation()}}
485643 }
486644 sessionPart := func(hostIndex, sessionIndex uint32) SnapshotPart {
487645 return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: testCatalogSession()}}
488646 }
489647
490- t.Run("host part at max-1 accepted", func(t *testing.T) {
491- message := hostPart(ports.BrokerMaxHosts - 1)
648+ t.Run("daemon part at max-1 accepted", func(t *testing.T) {
649+ message := daemonPart(ports.BrokerMaxDaemonsPerSnapshot - 1)
492650 raw := mustEncodeServer(t, message)
493651 decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
494652 require.NoError(t, err)
495653 require.Equal(t, message, decoded)
496- decoded, err = DecodeServer(mutateHostIndex(t, message, ports.BrokerMaxHosts-1), testEnvelopeCeiling, testChunkCeiling)
654+ decoded, err = DecodeServer(mutateDaemonIndex(t, message, ports.BrokerMaxDaemonsPerSnapshot-1), testEnvelopeCeiling, testChunkCeiling)
497655 require.NoError(t, err)
498656 require.Equal(t, message, decoded)
499657 })
500658
501- t.Run("host part at max refused", func(t *testing.T) {
502- _, err := EncodeServer(hostPart(ports.BrokerMaxHosts), testEnvelopeCeiling, testChunkCeiling)
659+ t.Run("daemon part at max refused", func(t *testing.T) {
660+ _, err := EncodeServer(daemonPart(ports.BrokerMaxDaemonsPerSnapshot), testEnvelopeCeiling, testChunkCeiling)
503661 require.ErrorIs(t, err, ErrTooLarge)
504- _, err = DecodeServer(mutateHostIndex(t, hostPart(0), ports.BrokerMaxHosts), testEnvelopeCeiling, testChunkCeiling)
662+ _, err = DecodeServer(mutateDaemonIndex(t, daemonPart(0), ports.BrokerMaxDaemonsPerSnapshot), testEnvelopeCeiling, testChunkCeiling)
505663 require.ErrorIs(t, err, ErrTooLarge)
506664 })
507665
508- t.Run("host part at uint32 maximum refused", func(t *testing.T) {
509- _, err := EncodeServer(hostPart(^uint32(0)), testEnvelopeCeiling, testChunkCeiling)
666+ t.Run("daemon part at uint32 maximum refused", func(t *testing.T) {
667+ _, err := EncodeServer(daemonPart(^uint32(0)), testEnvelopeCeiling, testChunkCeiling)
510668 require.ErrorIs(t, err, ErrTooLarge)
511- _, err = DecodeServer(mutateHostIndex(t, hostPart(0), ^uint32(0)), testEnvelopeCeiling, testChunkCeiling)
669+ _, err = DecodeServer(mutateDaemonIndex(t, daemonPart(0), ^uint32(0)), testEnvelopeCeiling, testChunkCeiling)
512670 require.ErrorIs(t, err, ErrTooLarge)
513671 })
514672
515673 t.Run("session part at max-1 accepted", func(t *testing.T) {
516- message := sessionPart(ports.BrokerMaxHosts-1, ports.BrokerMaxSessionsPerHost-1)
674+ message := sessionPart(ports.BrokerMaxDaemonsPerSnapshot-1, ports.BrokerMaxSessionsPerHost-1)
517675 raw := mustEncodeServer(t, message)
518676 decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
519677 require.NoError(t, err)
520678 require.Equal(t, message, decoded)
521- decoded, err = DecodeServer(mutateSessionIndexes(t, message, ports.BrokerMaxHosts-1, ports.BrokerMaxSessionsPerHost-1), testEnvelopeCeiling, testChunkCeiling)
679+ decoded, err = DecodeServer(mutateSessionIndexes(t, message, ports.BrokerMaxDaemonsPerSnapshot-1, ports.BrokerMaxSessionsPerHost-1), testEnvelopeCeiling, testChunkCeiling)
522680 require.NoError(t, err)
523681 require.Equal(t, message, decoded)
524682 })
525683
526684 t.Run("session part host index at max refused", func(t *testing.T) {
527- _, err := EncodeServer(sessionPart(ports.BrokerMaxHosts, 0), testEnvelopeCeiling, testChunkCeiling)
685+ _, err := EncodeServer(sessionPart(ports.BrokerMaxDaemonsPerSnapshot, 0), testEnvelopeCeiling, testChunkCeiling)
528686 require.ErrorIs(t, err, ErrTooLarge)
529- _, err = DecodeServer(mutateSessionIndexes(t, sessionPart(0, 0), ports.BrokerMaxHosts, 0), testEnvelopeCeiling, testChunkCeiling)
687+ _, err = DecodeServer(mutateSessionIndexes(t, sessionPart(0, 0), ports.BrokerMaxDaemonsPerSnapshot, 0), testEnvelopeCeiling, testChunkCeiling)
530688 require.ErrorIs(t, err, ErrTooLarge)
531689 })
532690
@@ -591,10 +749,10 @@ func TestBrokerBounds(t *testing.T) {
591749 for i := range many {
592750 many[i] = "A=B"
593751 }
594- _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: many, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
752+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: many, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
595753 require.ErrorIs(t, err, ErrTooLarge)
596754 oversize := []string{"K=" + strings.Repeat("x", ports.BrokerMaxEnvEntryBytes)}
597- _, err = EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: oversize, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
755+ _, err = EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: oversize, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
598756 require.ErrorIs(t, err, ErrTooLarge)
599757 })
600758 t.Run("error text", func(t *testing.T) {
@@ -604,7 +762,7 @@ func TestBrokerBounds(t *testing.T) {
604762 require.ErrorIs(t, err, ErrTooLarge)
605763 })
606764 t.Run("snapshot counts", func(t *testing.T) {
607- _, err := EncodeServer(SnapshotPart{Epoch: 7, Connection: connection, Generation: 1, Revision: 1, Index: 0, Part: SnapshotBegin{HostCount: ports.BrokerMaxHosts + 1}}, testEnvelopeCeiling, testChunkCeiling)
765+ _, err := EncodeServer(SnapshotPart{Epoch: 7, Connection: connection, Generation: 1, Revision: 1, Index: 0, Part: SnapshotBegin{HostCount: ports.BrokerMaxDaemonsPerSnapshot + 1}}, testEnvelopeCeiling, testChunkCeiling)
608766 require.ErrorIs(t, err, ErrTooLarge)
609767 })
610768 t.Run("control env carries no attachment state", func(t *testing.T) {
@@ -693,7 +851,7 @@ func TestBrokerValidationNegatives(t *testing.T) {
693851 require.ErrorIs(t, err, ErrInvalidMessage)
694852 })
695853 t.Run("taxonomy switches", func(t *testing.T) {
696- raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()})
854+ raw := mustEncodeClient(t, OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()})
697855 envelope := &wire.BrokerClientEnvelope{}
698856 require.NoError(t, wire.ScanEnvelope(envelope, raw))
699857 require.NoError(t, proto.Unmarshal(raw, envelope))
@@ -725,11 +883,11 @@ func TestBrokerValidationNegatives(t *testing.T) {
725883 require.ErrorIs(t, err, ErrInvalidMessage)
726884 })
727885 t.Run("env without equals refused", func(t *testing.T) {
728- _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
886+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"NOEQUALS"}, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
729887 require.ErrorIs(t, err, ErrInvalidMessage)
730888 })
731889 t.Run("invalid policy refused", func(t *testing.T) {
732- _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling)
890+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling)
733891 require.ErrorIs(t, err, ErrInvalidMessage)
734892 })
735893 t.Run("snapshot revision zero refused", func(t *testing.T) {
@@ -741,7 +899,7 @@ func TestBrokerValidationNegatives(t *testing.T) {
741899 require.ErrorIs(t, err, ErrInvalidMessage)
742900 })
743901 t.Run("local open with registration refused", func(t *testing.T) {
744- _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Local: true, Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
902+ _, err := EncodeClient(OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: true, Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
745903 require.ErrorIs(t, err, ErrInvalidMessage)
746904 })
747905 t.Run("bad shutdown reason refused", func(t *testing.T) {
@@ -845,7 +1003,7 @@ func fuzzClientSeeds(t testing.TB) [][]byte {
8451003 for _, message := range []ClientMessage{
8461004 Register{},
8471005 Subscribe{Epoch: 7, Connection: testConnectionID(0x11), Generation: 1},
848- OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()},
1006+ OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()},
8491007 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},
8501008 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},
8511009 } {
M internal/adapters/brokerwire/convert.go +16 -9
@@ -435,24 +435,31 @@ func validateCatalogSession(session catalogue.RemoteCatalogSession) error {
435435 return nil
436436 }
437437
438-// validateSnapshotHostBounds enforces the per-host snapshot contract:
438+// validateSnapshotDaemonFields enforces the per-daemon snapshot contract:
439439 // endpoint grammar, presentation origin, rank fit, closed availability and
440-// failure taxonomies, and time values that survive the wire exactly.
441-func validateSnapshotHostFields(host ports.RemoteHostSnapshot) error {
442- if err := validateBrokerEndpoint(host.Endpoint); err != nil {
440+// failure taxonomies, and a syntactically present policy. The full port
441+// projection rules (identity/incarnation pairing, observed version,
442+// inventory, local authority) are re-checked by BrokerDaemonObservation
443+// validation and, for remotes, the durable projection rule.
444+func validateSnapshotDaemonFields(daemon ports.BrokerDaemonObservation) error {
445+ if daemon.Local {
446+ if daemon.Endpoint != "" {
447+ return ErrInvalidMessage
448+ }
449+ } else if err := validateBrokerEndpoint(daemon.Endpoint); err != nil {
443450 return err
444451 }
445- if err := validateBrokerOrigin(host.DisplayOrigin); err != nil {
452+ if err := validateBrokerOrigin(daemon.DisplayOrigin); err != nil {
446453 return err
447454 }
448- if host.Rank < 0 || host.Rank > math.MaxUint32 {
455+ if daemon.Rank < 0 || daemon.Rank > math.MaxUint32 {
449456 return errConvertRange
450457 }
451- if host.Availability < domain.RemoteAvailabilityUnknown ||
452- host.Availability > domain.RemoteAvailabilityInvalidResponse {
458+ if daemon.Availability < domain.RemoteAvailabilityUnknown ||
459+ daemon.Availability > domain.RemoteAvailabilityInvalidResponse {
453460 return ErrInvalidMessage
454461 }
455- if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
462+ if daemon.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
456463 return ErrInvalidMessage
457464 }
458465 return nil
M internal/adapters/brokerwire/messages.go +17 -8
@@ -99,12 +99,16 @@ func (Reconcile) brokerClientMessage() {}
9999
100100 // OpenStream asks the broker to open one independently cancellable logical
101101 // stream to the owning daemon. Env is the per-request session environment
102-// and is never inherited from the broker process environment.
102+// and is never inherited from the broker process environment. Admission
103+// selects the attachment admission variant; Name is the validated session
104+// name for create-named and is empty otherwise.
103105 type OpenStream struct {
104106 Epoch ports.BrokerEpoch
105107 Connection ports.BrokerConnectionID
106108 Stream ports.BrokerStreamID
107109 Purpose ports.BrokerStreamPurpose
110+ Admission ports.BrokerStreamAdmission
111+ Name string
108112 Local bool
109113 Endpoint string
110114 Registration domain.RemoteRegistration
@@ -163,29 +167,34 @@ func (SnapshotPart) brokerServerMessage() {}
163167 type SnapshotPartPayload interface{ snapshotPartPayload() }
164168
165169 // SnapshotBegin opens a snapshot publication with its element counts.
170+// HostCount counts every daemon part in the transfer (the local daemon plus
171+// every remote host); LocalPresent reports whether daemon index 0 is local.
166172 type SnapshotBegin struct {
167173 HostCount uint32
168174 SessionCount uint32
169175 TombstoneCount uint32
176+ LocalPresent bool
170177 }
171178
172179 func (SnapshotBegin) snapshotPartPayload() {}
173180
174-// SnapshotHostPart is one host projection. Sessions travel as separate
175-// SnapshotSessionPart parts bound by host index; SessionCount advertises
176-// how many follow. The embedded Host must carry no inline sessions.
177-type SnapshotHostPart struct {
181+// SnapshotDaemonPart is one daemon projection. Sessions travel as separate
182+// SnapshotSessionPart parts bound by daemon index; SessionCount advertises
183+// how many follow. The embedded Daemon must carry no inline sessions.
184+type SnapshotDaemonPart struct {
178185 HostIndex uint32
179- Host ports.RemoteHostSnapshot
186+ Daemon ports.BrokerDaemonObservation
180187 SessionCount uint32
181188 }
182189
183-func (SnapshotHostPart) snapshotPartPayload() {}
190+func (SnapshotDaemonPart) snapshotPartPayload() {}
184191
185-// SnapshotSessionPart is one catalogue session bound to its host by index.
192+// SnapshotSessionPart is one catalogue session bound to its daemon by index.
193+// Local marks a session of the local daemon: HostIndex is then exactly 0.
186194 type SnapshotSessionPart struct {
187195 HostIndex uint32
188196 SessionIndex uint32
197+ Local bool
189198 Session catalogue.RemoteCatalogSession
190199 }
191200
M internal/adapters/brokerwire/snapshot.go +132 -108
@@ -9,36 +9,40 @@
99 //
1010 // Transfer layout (indexes are exact positions):
1111 //
12-// Begin(0) Host/Session... Tombstone... End(N-1)
12+// Begin(0) Daemon/Session... Tombstone... End(N-1)
1313 //
14-// Begin carries the total host, session, and tombstone counts. Host parts
15-// arrive in host_index order; each Host advertises how many Session parts
16-// follow it immediately, with per-host session_index order. Tombstones
17-// arrive in tombstone_index order after all hosts and sessions. End closes
18-// the transfer. Total parts are 1 + hosts + sessions + tombstones + 1,
19-// bounded by MaxSnapshotParts (64 hosts, 64*256 sessions, 64 tombstones).
14+// Begin carries the total daemon, session, and tombstone counts plus whether
15+// the local daemon is present. Daemon parts arrive in host_index order, with
16+// the local daemon (when present) first at index 0 and remotes following in
17+// registration order; each Daemon advertises how many Session parts follow it
18+// immediately, with per-daemon session_index order. A session of the local
19+// daemon carries Local=true and host_index 0. Tombstones arrive in
20+// tombstone_index order after all daemons and sessions. End closes the
21+// transfer. Total parts are 1 + daemons + sessions + tombstones + 1, bounded
22+// by MaxSnapshotParts (65 daemons, 65*256 sessions, 64 tombstones).
2023 //
2124 // Bounds: at most MaxSnapshotParts parts and MaxSnapshotStagedBytes staged
2225 // estimate per transfer. The staged estimate is a deterministic function of
23-// endpoint, origin, session, tab, and tombstone text lengths plus fixed
24-// per-part overhead; it bounds hostile memory without measuring wire bytes.
26+// endpoint, origin, policy, identity, session, tab, and tombstone text lengths
27+// plus fixed per-part overhead; it bounds hostile memory without measuring
28+// wire bytes.
2529 //
2630 // Publication is atomic: the committed snapshot is replaced only when End
2731 // arrives with exact indexes and the assembled BrokerSnapshot passes
28-// ports.BrokerSnapshot.Validate plus per-host
29-// ports.ValidateDurableHostProjection. Any malformed index, count, order,
30-// or validation failure aborts staging and retains the old snapshot. A new
31-// Begin for the same generation with a newer revision coalesces by
32-// replacing staging; an older revision is rejected and retains both the
33-// committed snapshot and any staging in flight. Equal revisions are allowed
34-// so a resync may republish the committed revision.
32+// ports.BrokerSnapshot.Validate plus, for every remote daemon,
33+// ports.ValidateDurableHostProjection. Any malformed index, count, order, or
34+// validation failure aborts staging and retains the old snapshot. A new Begin
35+// for the same generation with a newer revision coalesces by replacing
36+// staging; an older revision is rejected and retains both the committed
37+// snapshot and any staging in flight. Equal revisions are allowed so a resync
38+// may republish the committed revision.
3539 //
3640 // Generation fencing: the assembler belongs to exactly one nonzero
3741 // subscription generation, adopted from the first Begin or pinned by
38-// WithGeneration. Parts with a stale generation are ignored without
39-// touching staging; parts with a future generation are rejected, whether or
40-// not a transfer is in flight. Scope fencing is exact: every part must carry
41-// the assembler's epoch and connection.
42+// WithGeneration. Parts with a stale generation are ignored without touching
43+// staging; parts with a future generation are rejected, whether or not a
44+// transfer is in flight. Scope fencing is exact: every part must carry the
45+// assembler's epoch and connection.
4246 //
4347 // There is no I/O, socket, pump, or P3.2 transport here. The owning
4448 // Connection discards staging on generation advance, unsubscribe, or close.
@@ -53,9 +57,9 @@ import (
5357 )
5458
5559 const (
56- // MaxSnapshotParts bounds one multipart publication: 1 Begin + 64
57- // hosts + 64*256 sessions + 64 tombstones + 1 End.
58- MaxSnapshotParts = 1 + ports.BrokerMaxHosts + ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost + ports.BrokerMaxTombstones + 1
60+ // MaxSnapshotParts bounds one multipart publication: 1 Begin + 65 daemons
61+ // + 65*256 sessions + 64 tombstones + 1 End.
62+ MaxSnapshotParts = 1 + ports.BrokerMaxDaemonsPerSnapshot + ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost + ports.BrokerMaxTombstones + 1
5963 // MaxSnapshotStagedBytes bounds the deterministic staged estimate of
6064 // one transfer (80 MiB).
6165 MaxSnapshotStagedBytes = uint64(80 << 20)
@@ -121,18 +125,38 @@ type snapshotStaging struct {
121125 hostCount uint32
122126 sessionTotal uint32
123127 tombstoneCount uint32
128+ localPresent bool
124129 totalParts uint32
125130 nextIndex uint32
126- hosts []ports.RemoteHostSnapshot
127- hostExpected []uint32
128- hostSeen []uint32
129- hostsSeen uint32
131+ daemons []ports.BrokerDaemonObservation
132+ daemonExpected []uint32
133+ daemonSeen []uint32
134+ daemonsSeen uint32
130135 sessionsSeen uint32
131136 tombstones []ports.BrokerHostTombstone
132137 tombstonesSeen uint32
133138 stagedBytes uint64
134139 }
135140
141+// newSnapshotStaging allocates one transfer's staging from a validated Begin.
142+func newSnapshotStaging(generation SubscriptionGeneration, revision ports.BrokerRevision, begin SnapshotBegin) *snapshotStaging {
143+ total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1
144+ return &snapshotStaging{
145+ generation: generation,
146+ revision: revision,
147+ hostCount: begin.HostCount,
148+ sessionTotal: begin.SessionCount,
149+ tombstoneCount: begin.TombstoneCount,
150+ localPresent: begin.LocalPresent,
151+ totalParts: uint32(total),
152+ nextIndex: 1,
153+ daemons: make([]ports.BrokerDaemonObservation, begin.HostCount),
154+ daemonExpected: make([]uint32, begin.HostCount),
155+ daemonSeen: make([]uint32, begin.HostCount),
156+ tombstones: make([]ports.BrokerHostTombstone, begin.TombstoneCount),
157+ }
158+}
159+
136160 // NewSnapshotAssembler binds an assembler to one exact scope assigned by
137161 // Registered. Every part must carry that scope.
138162 func NewSnapshotAssembler(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, opts ...SnapshotOption) *SnapshotAssembler {
@@ -242,23 +266,7 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot,
242266 if a.hasCommitted && part.Revision < a.committed.Revision {
243267 return false, ports.BrokerSnapshot{}, ErrSnapshotStale
244268 }
245- total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1
246- if total > uint64(MaxSnapshotParts) {
247- return false, ports.BrokerSnapshot{}, ErrTooLarge
248- }
249- staging := &snapshotStaging{
250- generation: part.Generation,
251- revision: part.Revision,
252- hostCount: begin.HostCount,
253- sessionTotal: begin.SessionCount,
254- tombstoneCount: begin.TombstoneCount,
255- totalParts: uint32(total),
256- nextIndex: 1,
257- hosts: make([]ports.RemoteHostSnapshot, begin.HostCount),
258- hostExpected: make([]uint32, begin.HostCount),
259- hostSeen: make([]uint32, begin.HostCount),
260- tombstones: make([]ports.BrokerHostTombstone, begin.TombstoneCount),
261- }
269+ staging := newSnapshotStaging(part.Generation, part.Revision, begin)
262270 staged := estimatePartBytes(part)
263271 if staged > a.maxStagedBytes {
264272 return false, ports.BrokerSnapshot{}, ErrTooLarge
@@ -284,11 +292,6 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot,
284292 a.staging = nil
285293 return false, ports.BrokerSnapshot{}, err
286294 }
287- total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1
288- if total > uint64(MaxSnapshotParts) {
289- a.staging = nil
290- return false, ports.BrokerSnapshot{}, ErrTooLarge
291- }
292295 switch {
293296 case part.Revision == staging.revision:
294297 // Restart of the same transfer: discard progress.
@@ -299,19 +302,7 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot,
299302 default:
300303 return false, ports.BrokerSnapshot{}, ErrSnapshotStale
301304 }
302- fresh := &snapshotStaging{
303- generation: part.Generation,
304- revision: part.Revision,
305- hostCount: begin.HostCount,
306- sessionTotal: begin.SessionCount,
307- tombstoneCount: begin.TombstoneCount,
308- totalParts: uint32(total),
309- nextIndex: 1,
310- hosts: make([]ports.RemoteHostSnapshot, begin.HostCount),
311- hostExpected: make([]uint32, begin.HostCount),
312- hostSeen: make([]uint32, begin.HostCount),
313- tombstones: make([]ports.BrokerHostTombstone, begin.TombstoneCount),
314- }
305+ fresh := newSnapshotStaging(part.Generation, part.Revision, begin)
315306 staged := estimatePartBytes(part)
316307 if staged > a.maxStagedBytes {
317308 a.staging = nil
@@ -342,9 +333,9 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot,
342333 return false, ports.BrokerSnapshot{}, ErrTooLarge
343334 }
344335 switch payload := part.Part.(type) {
345- case SnapshotHostPart, *SnapshotHostPart:
346- hostPart := normalizeHostPart(payload)
347- if err := a.stageHost(staging, hostPart); err != nil {
336+ case SnapshotDaemonPart, *SnapshotDaemonPart:
337+ daemonPart := normalizeDaemonPart(payload)
338+ if err := a.stageDaemon(staging, daemonPart); err != nil {
348339 a.staging = nil
349340 return false, ports.BrokerSnapshot{}, err
350341 }
@@ -394,17 +385,17 @@ func normalizeBegin(payload SnapshotPartPayload) (SnapshotBegin, bool) {
394385 }
395386 }
396387
397-func normalizeHostPart(payload SnapshotPartPayload) SnapshotHostPart {
388+func normalizeDaemonPart(payload SnapshotPartPayload) SnapshotDaemonPart {
398389 switch p := payload.(type) {
399- case SnapshotHostPart:
390+ case SnapshotDaemonPart:
400391 return p
401- case *SnapshotHostPart:
392+ case *SnapshotDaemonPart:
402393 if p == nil {
403- return SnapshotHostPart{HostIndex: ^uint32(0)}
394+ return SnapshotDaemonPart{HostIndex: ^uint32(0)}
404395 }
405396 return *p
406397 default:
407- return SnapshotHostPart{HostIndex: ^uint32(0)}
398+ return SnapshotDaemonPart{HostIndex: ^uint32(0)}
408399 }
409400 }
410401
@@ -437,19 +428,24 @@ func normalizeTombstonePart(payload SnapshotPartPayload) SnapshotTombstonePart {
437428 }
438429
439430 func checkBeginCounts(begin SnapshotBegin) error {
440- if begin.HostCount > uint32(ports.BrokerMaxHosts) ||
441- begin.SessionCount > uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) ||
431+ if begin.HostCount > uint32(ports.BrokerMaxDaemonsPerSnapshot) ||
432+ begin.SessionCount > uint32(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost) ||
442433 begin.TombstoneCount > uint32(ports.BrokerMaxTombstones) {
443434 return ErrTooLarge
444435 }
436+ // A local daemon occupies daemon index 0, so it can only be present when
437+ // at least one daemon part follows.
438+ if begin.LocalPresent && begin.HostCount == 0 {
439+ return ErrSnapshotInvalid
440+ }
445441 return nil
446442 }
447443
448-func (a *SnapshotAssembler) stageHost(staging *snapshotStaging, part SnapshotHostPart) error {
449- if staging.hostsSeen >= staging.hostCount {
444+func (a *SnapshotAssembler) stageDaemon(staging *snapshotStaging, part SnapshotDaemonPart) error {
445+ if staging.daemonsSeen >= staging.hostCount {
450446 return ErrSnapshotInvalid
451447 }
452- if part.HostIndex != staging.hostsSeen {
448+ if part.HostIndex != staging.daemonsSeen {
453449 return ErrSnapshotInvalid
454450 }
455451 if part.HostIndex >= staging.hostCount {
@@ -458,46 +454,60 @@ func (a *SnapshotAssembler) stageHost(staging *snapshotStaging, part SnapshotHos
458454 if part.SessionCount > uint32(ports.BrokerMaxSessionsPerHost) {
459455 return ErrTooLarge
460456 }
461- if len(part.Host.Sessions) != 0 {
457+ if len(part.Daemon.Sessions) != 0 {
458+ return ErrSnapshotInvalid
459+ }
460+ // The local daemon is always first: daemon index 0 is local exactly when
461+ // Begin advertised it, and no later daemon may be local.
462+ if part.HostIndex == 0 {
463+ if part.Daemon.Local != staging.localPresent {
464+ return ErrSnapshotInvalid
465+ }
466+ } else if part.Daemon.Local {
462467 return ErrSnapshotInvalid
463468 }
464469 var seen uint64
465- for _, n := range staging.hostExpected[:staging.hostsSeen] {
470+ for _, n := range staging.daemonExpected[:staging.daemonsSeen] {
466471 seen += uint64(n)
467472 }
468473 if seen+uint64(part.SessionCount) > uint64(staging.sessionTotal) {
469474 return ErrSnapshotInvalid
470475 }
471- // Remaining hosts must still be able to fill the advertised total:
472- // even if every remaining host carried the per-host maximum, the
476+ // Remaining daemons must still be able to fill the advertised total:
477+ // even if every remaining daemon carried the per-daemon maximum, the
473478 // total must stay reachable. This is checked exactly at End; here we
474479 // only refuse an already-exceeded sum.
475- staging.hosts[part.HostIndex] = part.Host.Clone()
476- staging.hostExpected[part.HostIndex] = part.SessionCount
477- staging.hostSeen[part.HostIndex] = 0
478- staging.hostsSeen++
480+ staging.daemons[part.HostIndex] = part.Daemon.Clone()
481+ staging.daemonExpected[part.HostIndex] = part.SessionCount
482+ staging.daemonSeen[part.HostIndex] = 0
483+ staging.daemonsSeen++
479484 return nil
480485 }
481486
482487 func (a *SnapshotAssembler) stageSession(staging *snapshotStaging, part SnapshotSessionPart) error {
483- if staging.hostsSeen == 0 || staging.hostsSeen > staging.hostCount {
488+ if staging.daemonsSeen == 0 || staging.daemonsSeen > staging.hostCount {
484489 return ErrSnapshotInvalid
485490 }
486- current := staging.hostsSeen - 1
491+ current := staging.daemonsSeen - 1
487492 if part.HostIndex != current {
488493 return ErrSnapshotInvalid
489494 }
490495 if current >= staging.hostCount {
491496 return ErrSnapshotInvalid
492497 }
493- expected := staging.hostExpected[current]
494- if staging.hostSeen[current] >= expected {
498+ expected := staging.daemonExpected[current]
499+ if staging.daemonSeen[current] >= expected {
495500 return ErrSnapshotInvalid
496501 }
497- if part.SessionIndex != staging.hostSeen[current] {
502+ if part.SessionIndex != staging.daemonSeen[current] {
498503 return ErrSnapshotInvalid
499504 }
500- if part.HostIndex >= uint32(ports.BrokerMaxHosts) || part.SessionIndex >= uint32(ports.BrokerMaxSessionsPerHost) {
505+ // A session belongs to the daemon it follows: a local session binds to
506+ // the local daemon at index 0, and a remote session to a remote daemon.
507+ if part.Local != staging.daemons[current].Local {
508+ return ErrSnapshotInvalid
509+ }
510+ if part.HostIndex >= uint32(ports.BrokerMaxDaemonsPerSnapshot) || part.SessionIndex >= uint32(ports.BrokerMaxSessionsPerHost) {
501511 return ErrTooLarge
502512 }
503513 if staging.sessionsSeen >= staging.sessionTotal {
@@ -510,16 +520,16 @@ func (a *SnapshotAssembler) stageSession(staging *snapshotStaging, part Snapshot
510520 tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs))
511521 copy(tabs, session.Tabs)
512522 session.Tabs = tabs
513- host := staging.hosts[current]
514- host.Sessions = append(host.Sessions, session)
515- staging.hosts[current] = host
516- staging.hostSeen[current]++
523+ daemon := staging.daemons[current]
524+ daemon.Sessions = append(daemon.Sessions, session)
525+ staging.daemons[current] = daemon
526+ staging.daemonSeen[current]++
517527 staging.sessionsSeen++
518528 return nil
519529 }
520530
521531 func (a *SnapshotAssembler) stageTombstone(staging *snapshotStaging, part SnapshotTombstonePart) error {
522- if staging.hostsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal {
532+ if staging.daemonsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal {
523533 return ErrSnapshotInvalid
524534 }
525535 if staging.tombstonesSeen >= staging.tombstoneCount {
@@ -541,33 +551,37 @@ func (a *SnapshotAssembler) stageTombstone(staging *snapshotStaging, part Snapsh
541551 }
542552
543553 func (a *SnapshotAssembler) commitStaging(staging *snapshotStaging) (ports.BrokerSnapshot, error) {
544- if staging.hostsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal || staging.tombstonesSeen != staging.tombstoneCount {
554+ if staging.daemonsSeen != staging.hostCount || staging.sessionsSeen != staging.sessionTotal || staging.tombstonesSeen != staging.tombstoneCount {
545555 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
546556 }
547557 if staging.nextIndex != staging.totalParts-1 {
548558 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
549559 }
550560 var sum uint64
551- for _, n := range staging.hostExpected {
561+ for _, n := range staging.daemonExpected {
552562 sum += uint64(n)
553563 }
554564 if sum != uint64(staging.sessionTotal) {
555565 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
556566 }
557- hosts := make([]ports.RemoteHostSnapshot, len(staging.hosts))
558- for i, host := range staging.hosts {
559- hosts[i] = host.Clone()
560- if hosts[i].Sessions == nil {
561- hosts[i].Sessions = []catalogue.RemoteCatalogSession{}
567+ daemons := make([]ports.BrokerDaemonObservation, len(staging.daemons))
568+ for i, daemon := range staging.daemons {
569+ daemons[i] = daemon.Clone()
570+ if daemons[i].Sessions == nil {
571+ daemons[i].Sessions = []catalogue.RemoteCatalogSession{}
562572 }
563573 }
564574 removed := append([]ports.BrokerHostTombstone(nil), staging.tombstones...)
565- snapshot := ports.BrokerSnapshot{Epoch: a.epoch, Revision: staging.revision, Hosts: hosts, Removed: removed}
575+ snapshot := ports.BrokerSnapshot{Epoch: a.epoch, Revision: staging.revision, Daemons: daemons, Removed: removed}
566576 if err := snapshot.Validate(); err != nil {
567577 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
568578 }
569- for _, host := range hosts {
570- if err := ports.ValidateDurableHostProjection(host); err != nil {
579+ for _, daemon := range daemons {
580+ // The local daemon is process-local state and is never durable.
581+ if daemon.Local {
582+ continue
583+ }
584+ if err := ports.ValidateDurableHostProjection(daemon); err != nil {
571585 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
572586 }
573587 }
@@ -579,13 +593,13 @@ func estimatePartBytes(part SnapshotPart) uint64 {
579593 switch payload := part.Part.(type) {
580594 case SnapshotBegin, *SnapshotBegin:
581595 return overhead
582- case SnapshotHostPart:
583- return uint64(len(payload.Host.Endpoint)+len(payload.Host.DisplayOrigin)+len(payload.Host.Registration.Endpoint)+64) + overhead
584- case *SnapshotHostPart:
596+ case SnapshotDaemonPart:
597+ return estimateDaemonBytes(payload.Daemon) + overhead
598+ case *SnapshotDaemonPart:
585599 if payload == nil {
586600 return overhead
587601 }
588- return uint64(len(payload.Host.Endpoint)+len(payload.Host.DisplayOrigin)+len(payload.Host.Registration.Endpoint)+64) + overhead
602+ return estimateDaemonBytes(payload.Daemon) + overhead
589603 case SnapshotSessionPart:
590604 return estimateCatalogSessionBytes(payload.Session) + overhead
591605 case *SnapshotSessionPart:
@@ -607,6 +621,16 @@ func estimatePartBytes(part SnapshotPart) uint64 {
607621 }
608622 }
609623
624+// estimateDaemonBytes bounds one daemon projection's staged memory: every
625+// configured-authority and observed-identity string plus a fixed per-daemon
626+// charge.
627+func estimateDaemonBytes(daemon ports.BrokerDaemonObservation) uint64 {
628+ text := len(daemon.Endpoint) + len(daemon.DisplayOrigin) + len(daemon.Registration.Endpoint) +
629+ len(daemon.Policy.Transport) + len(daemon.Policy.Trust) + len(daemon.Policy.Launch) + len(daemon.Policy.Isolation) +
630+ len(daemon.Identity)
631+ return uint64(text + 64)
632+}
633+
610634 // estimateCatalogSessionBytes bounds one session's staged memory. It charges
611635 // every tab at least estimateTabBytes (covering the tab struct, its string
612636 // headers, and its slice slot) plus the variable-length text, and adds one
M internal/adapters/brokerwire/snapshot_test.go +202 -92
@@ -25,13 +25,16 @@ func testSnapshotAssembler(t *testing.T, opts ...SnapshotOption) *SnapshotAssemb
2525 return NewSnapshotAssembler(epoch, connection, opts...)
2626 }
2727
28-// testHostAt builds one catalogue-valid host projection for host index i.
29-func testHostAt(i int) ports.RemoteHostSnapshot {
28+// testDaemonAt builds one catalogue-valid remote daemon projection for daemon
29+// index i. Sessions are attached by the sample helpers; the emitter strips
30+// them before a daemon part travels.
31+func testDaemonAt(i int) ports.BrokerDaemonObservation {
3032 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)
31- return ports.RemoteHostSnapshot{
33+ return ports.BrokerDaemonObservation{
3234 Endpoint: endpoint,
3335 DisplayOrigin: endpoint,
3436 Registration: domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{0x10, byte(i + 1), 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xf0, 0x11}, Generation: domain.RemoteGeneration(i + 1)},
37+ Policy: testPolicy(),
3538 Availability: domain.RemoteAvailabilityReachable,
3639 LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(),
3740 InventoryKnown: true,
@@ -39,6 +42,18 @@ func testHostAt(i int) ports.RemoteHostSnapshot {
3942 }
4043 }
4144
45+// testLocalDaemonAt builds the local daemon projection.
46+func testLocalDaemonAt() ports.BrokerDaemonObservation {
47+ return ports.BrokerDaemonObservation{
48+ Local: true,
49+ DisplayOrigin: "local",
50+ Policy: testPolicy(),
51+ Availability: domain.RemoteAvailabilityReachable,
52+ InventoryKnown: true,
53+ Sessions: []catalogue.RemoteCatalogSession{},
54+ }
55+}
56+
4257 // testSessionAt builds one catalogue-valid session for a host and index.
4358 func testSessionAt(host, index int) catalogue.RemoteCatalogSession {
4459 return catalogue.RemoteCatalogSession{
@@ -59,29 +74,39 @@ func testTombstoneAt(i int) ports.BrokerHostTombstone {
5974 }
6075 }
6176
62-func sampleSnapshotHosts() []ports.RemoteHostSnapshot {
63- host0 := testHostAt(0)
64- host0.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
65- host1 := testHostAt(1)
66- host1.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)}
67- return []ports.RemoteHostSnapshot{host0, host1}
77+func sampleSnapshotDaemons() []ports.BrokerDaemonObservation {
78+ daemon0 := testDaemonAt(0)
79+ daemon0.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
80+ daemon1 := testDaemonAt(1)
81+ daemon1.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)}
82+ return []ports.BrokerDaemonObservation{daemon0, daemon1}
83+}
84+
85+// sampleLocalSnapshotDaemons builds a local-first publication: the local daemon
86+// at index 0 with two sessions, then one remote daemon with one session.
87+func sampleLocalSnapshotDaemons() []ports.BrokerDaemonObservation {
88+ local := testLocalDaemonAt()
89+ local.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
90+ remote := testDaemonAt(0)
91+ remote.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)}
92+ return []ports.BrokerDaemonObservation{local, remote}
6893 }
6994
7095 func sampleSnapshotTombstones() []ports.BrokerHostTombstone {
7196 return []ports.BrokerHostTombstone{testTombstoneAt(0)}
7297 }
7398
74-func snapshotSessionTotal(hosts []ports.RemoteHostSnapshot) int {
99+func snapshotSessionTotal(daemons []ports.BrokerDaemonObservation) int {
75100 total := 0
76- for _, host := range hosts {
77- total += len(host.Sessions)
101+ for _, daemon := range daemons {
102+ total += len(daemon.Sessions)
78103 }
79104 return total
80105 }
81106
82-// snapshotPartsFor lays out one exact transfer: Begin(0), hosts with their
107+// snapshotPartsFor lays out one exact transfer: Begin(0), daemons with their
83108 // sessions, tombstones, End. Indexes are exact sequential positions.
84-func snapshotPartsFor(gen uint64, rev ports.BrokerRevision, hosts []ports.RemoteHostSnapshot, tombstones []ports.BrokerHostTombstone) []SnapshotPart {
109+func snapshotPartsFor(gen uint64, rev ports.BrokerRevision, daemons []ports.BrokerDaemonObservation, tombstones []ports.BrokerHostTombstone) []SnapshotPart {
85110 epoch, connection := testSnapshotScope()
86111 next := uint32(0)
87112 part := func(payload SnapshotPartPayload) SnapshotPart {
@@ -89,19 +114,21 @@ func snapshotPartsFor(gen uint64, rev ports.BrokerRevision, hosts []ports.Remote
89114 next++
90115 return out
91116 }
117+ localPresent := len(daemons) > 0 && daemons[0].Local
92118 parts := []SnapshotPart{part(SnapshotBegin{
93- HostCount: uint32(len(hosts)),
94- SessionCount: uint32(snapshotSessionTotal(hosts)),
119+ HostCount: uint32(len(daemons)),
120+ SessionCount: uint32(snapshotSessionTotal(daemons)),
95121 TombstoneCount: uint32(len(tombstones)),
122+ LocalPresent: localPresent,
96123 })}
97- for hostIndex, host := range hosts {
98- // Host parts carry no inline sessions: the advertised count is
124+ for daemonIndex, daemon := range daemons {
125+ // Daemon parts carry no inline sessions: the advertised count is
99126 // followed by that many session parts.
100- projection := host.Clone()
127+ projection := daemon.Clone()
101128 projection.Sessions = []catalogue.RemoteCatalogSession{}
102- parts = append(parts, part(SnapshotHostPart{HostIndex: uint32(hostIndex), Host: projection, SessionCount: uint32(len(host.Sessions))}))
103- for sessionIndex, session := range host.Sessions {
104- parts = append(parts, part(SnapshotSessionPart{HostIndex: uint32(hostIndex), SessionIndex: uint32(sessionIndex), Session: session}))
129+ parts = append(parts, part(SnapshotDaemonPart{HostIndex: uint32(daemonIndex), Daemon: projection, SessionCount: uint32(len(daemon.Sessions))}))
130+ for sessionIndex, session := range daemon.Sessions {
131+ parts = append(parts, part(SnapshotSessionPart{HostIndex: uint32(daemonIndex), SessionIndex: uint32(sessionIndex), Local: daemon.Local, Session: session}))
105132 }
106133 }
107134 for tombstoneIndex, tombstone := range tombstones {
@@ -136,15 +163,15 @@ func runSnapshotParts(a *SnapshotAssembler, parts []SnapshotPart) error {
136163 }
137164
138165 func TestSnapshotAssemblerBoundsConstants(t *testing.T) {
139- require.Equal(t, 16514, MaxSnapshotParts)
140- require.Equal(t, 1+64+64*256+64+1, MaxSnapshotParts)
166+ require.Equal(t, 16771, MaxSnapshotParts)
167+ require.Equal(t, 1+ports.BrokerMaxDaemonsPerSnapshot+ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost+ports.BrokerMaxTombstones+1, MaxSnapshotParts)
141168 require.Equal(t, uint64(80<<20), MaxSnapshotStagedBytes)
142169 }
143170
144171 // TestSnapshotAssemblerExactTransfer proves exact order, count, and index
145172 // acceptance, and that committed order matches the staged order.
146173 func TestSnapshotAssemblerExactTransfer(t *testing.T) {
147- hosts := sampleSnapshotHosts()
174+ hosts := sampleSnapshotDaemons()
148175 tombstones := sampleSnapshotTombstones()
149176 parts := snapshotPartsFor(5, 3, hosts, tombstones)
150177 require.Len(t, parts, 1+2+3+1+1)
@@ -169,13 +196,13 @@ func TestSnapshotAssemblerExactTransfer(t *testing.T) {
169196 epoch, connection := testSnapshotScope()
170197 require.Equal(t, epoch, committed.Epoch)
171198 require.Equal(t, ports.BrokerRevision(3), committed.Revision)
172- require.Len(t, committed.Hosts, 2)
173- require.Equal(t, "user0@host0:22", committed.Hosts[0].Endpoint)
174- require.Equal(t, "user1@host1:22", committed.Hosts[1].Endpoint)
175- require.Equal(t, 2, len(committed.Hosts[0].Sessions))
176- require.Equal(t, "s00-000", committed.Hosts[0].Sessions[0].Name)
177- require.Equal(t, "s00-001", committed.Hosts[0].Sessions[1].Name)
178- require.Equal(t, "s01-000", committed.Hosts[1].Sessions[0].Name)
199+ require.Len(t, committed.Daemons, 2)
200+ require.Equal(t, "user0@host0:22", committed.Daemons[0].Endpoint)
201+ require.Equal(t, "user1@host1:22", committed.Daemons[1].Endpoint)
202+ require.Equal(t, 2, len(committed.Daemons[0].Sessions))
203+ require.Equal(t, "s00-000", committed.Daemons[0].Sessions[0].Name)
204+ require.Equal(t, "s00-001", committed.Daemons[0].Sessions[1].Name)
205+ require.Equal(t, "s01-000", committed.Daemons[1].Sessions[0].Name)
179206 require.Len(t, committed.Removed, 1)
180207 require.Equal(t, "retired0@old0:22", committed.Removed[0].Endpoint)
181208 _ = connection
@@ -192,16 +219,95 @@ func TestSnapshotAssemblerEmptyTransfer(t *testing.T) {
192219 completed, snapshot, err := a.Add(parts[1])
193220 require.NoError(t, err)
194221 require.True(t, completed)
195- require.Empty(t, snapshot.Hosts)
222+ require.Empty(t, snapshot.Daemons)
196223 require.Empty(t, snapshot.Removed)
197224 }
198225
226+// TestSnapshotAssemblerLocalDaemonTransfer proves a local-first transfer with a
227+// local daemon and its sessions commits with the local entry first, and that
228+// local sessions bind to index 0 while a mismatched local flag is refused.
229+func TestSnapshotAssemblerLocalDaemonTransfer(t *testing.T) {
230+ daemons := sampleLocalSnapshotDaemons()
231+ parts := snapshotPartsFor(5, 1, daemons, nil)
232+ require.Len(t, parts, 1+2+3+1)
233+ begin, ok := parts[0].Part.(SnapshotBegin)
234+ require.True(t, ok)
235+ require.True(t, begin.LocalPresent)
236+ require.Equal(t, uint32(2), begin.HostCount)
237+ require.Equal(t, uint32(3), begin.SessionCount)
238+
239+ a := testSnapshotAssembler(t)
240+ completed, snapshot, err := addAll(a, parts)
241+ require.NoError(t, err)
242+ require.True(t, completed)
243+ require.Len(t, snapshot.Daemons, 2)
244+ require.True(t, snapshot.Daemons[0].Local)
245+ require.Empty(t, snapshot.Daemons[0].Endpoint)
246+ require.Equal(t, "user0@host0:22", snapshot.Daemons[1].Endpoint)
247+ require.Len(t, snapshot.Daemons[0].Sessions, 2)
248+ require.Equal(t, "s00-000", snapshot.Daemons[0].Sessions[0].Name)
249+ require.Len(t, snapshot.Daemons[1].Sessions, 1)
250+ requireValidBrokerSnapshot(t, snapshot)
251+
252+ t.Run("local session marked remote refused", func(t *testing.T) {
253+ a := testSnapshotAssembler(t)
254+ require.NoError(t, runSnapshotParts(a, parts[:2]))
255+ require.True(t, a.StagingActive())
256+ mismatched := parts[2]
257+ mismatched.Part = SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: false, Session: testSessionAt(0, 0)}
258+ _, _, err := a.Add(mismatched)
259+ require.ErrorIs(t, err, ErrSnapshotInvalid)
260+ require.False(t, a.StagingActive())
261+ })
262+
263+ t.Run("non-local daemon under local_present refused", func(t *testing.T) {
264+ a := testSnapshotAssembler(t)
265+ begin := parts[0]
266+ begin.Part = SnapshotBegin{HostCount: 1, LocalPresent: true}
267+ require.NoError(t, runSnapshotParts(a, []SnapshotPart{begin}))
268+ daemonPart := parts[1]
269+ daemonPart.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testDaemonAt(0)}
270+ _, _, err := a.Add(daemonPart)
271+ require.ErrorIs(t, err, ErrSnapshotInvalid)
272+ require.False(t, a.StagingActive())
273+ })
274+
275+ t.Run("local daemon without local_present refused", func(t *testing.T) {
276+ a := testSnapshotAssembler(t)
277+ begin := parts[0]
278+ begin.Part = SnapshotBegin{HostCount: 1}
279+ require.NoError(t, runSnapshotParts(a, []SnapshotPart{begin}))
280+ daemonPart := parts[1]
281+ daemonPart.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonAt()}
282+ _, _, err := a.Add(daemonPart)
283+ require.ErrorIs(t, err, ErrSnapshotInvalid)
284+ require.False(t, a.StagingActive())
285+ })
286+}
287+
288+// addAll stages every part in order and returns the final commit result.
289+func addAll(a *SnapshotAssembler, parts []SnapshotPart) (bool, ports.BrokerSnapshot, error) {
290+ completed := false
291+ var snapshot ports.BrokerSnapshot
292+ for _, part := range parts {
293+ published, committed, err := a.Add(part)
294+ if err != nil {
295+ return false, ports.BrokerSnapshot{}, err
296+ }
297+ if published {
298+ completed = true
299+ snapshot = committed
300+ }
301+ }
302+ return completed, snapshot, nil
303+}
304+
199305 // TestSnapshotAssemblerTransferRejections proves malformed indexes, counts,
200306 // order, payloads, and assembled validation failures abort staging and
201307 // retain the committed snapshot.
202308 func TestSnapshotAssemblerTransferRejections(t *testing.T) {
203309 base := func() []SnapshotPart {
204- return snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones())
310+ return snapshotPartsFor(5, 3, sampleSnapshotDaemons(), sampleSnapshotTombstones())
205311 }
206312 mutate := func(parts []SnapshotPart, index int, apply func(*SnapshotPart)) []SnapshotPart {
207313 out := cloneSnapshotParts(parts)
@@ -217,10 +323,10 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) {
217323 {"non-begin first", base()[1:], ErrSnapshotInvalid},
218324 {"missing session part", append(cloneSnapshotParts(base()[:3]), base()[4:]...), ErrSnapshotInvalid},
219325 {"duplicate host index", mutate(base(), 4, func(p *SnapshotPart) {
220- p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(1), SessionCount: 1}
326+ p.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testDaemonAt(1), SessionCount: 1}
221327 }), ErrSnapshotInvalid},
222328 {"host index skips ahead", mutate(base(), 4, func(p *SnapshotPart) {
223- p.Part = SnapshotHostPart{HostIndex: 5, Host: testHostAt(1), SessionCount: 1}
329+ p.Part = SnapshotDaemonPart{HostIndex: 5, Daemon: testDaemonAt(1), SessionCount: 1}
224330 }), ErrSnapshotInvalid},
225331 {"session host index mismatch", mutate(base(), 3, func(p *SnapshotPart) {
226332 p.Part = SnapshotSessionPart{HostIndex: 1, SessionIndex: 1, Session: testSessionAt(0, 1)}
@@ -234,15 +340,15 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) {
234340 p.Part = SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: session}
235341 }), ErrSnapshotInvalid},
236342 {"host carries inline sessions", mutate(base(), 1, func(p *SnapshotPart) {
237- host := testHostAt(0)
343+ host := testDaemonAt(0)
238344 host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0)}
239- p.Part = SnapshotHostPart{HostIndex: 0, Host: host, SessionCount: 1}
345+ p.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: host, SessionCount: 1}
240346 }), ErrSnapshotInvalid},
241347 {"host session count over maximum", mutate(base(), 1, func(p *SnapshotPart) {
242- p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(0), SessionCount: uint32(ports.BrokerMaxSessionsPerHost) + 1}
348+ p.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testDaemonAt(0), SessionCount: uint32(ports.BrokerMaxSessionsPerHost) + 1}
243349 }), ErrTooLarge},
244350 {"host session count exceeds advertised total", mutate(base(), 1, func(p *SnapshotPart) {
245- p.Part = SnapshotHostPart{HostIndex: 0, Host: testHostAt(0), SessionCount: 3}
351+ p.Part = SnapshotDaemonPart{HostIndex: 0, Daemon: testDaemonAt(0), SessionCount: 3}
246352 }), ErrSnapshotInvalid},
247353 {"tombstone before all sessions", func() []SnapshotPart {
248354 parts := base()
@@ -265,37 +371,37 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) {
265371 p.Part = SnapshotBegin{HostCount: 2, SessionCount: 4, TombstoneCount: 1}
266372 }), ErrSnapshotInvalid},
267373 {"begin host count over maximum", mutate(base(), 0, func(p *SnapshotPart) {
268- p.Part = SnapshotBegin{HostCount: uint32(ports.BrokerMaxHosts) + 1}
374+ p.Part = SnapshotBegin{HostCount: uint32(ports.BrokerMaxDaemonsPerSnapshot) + 1}
269375 }), ErrTooLarge},
270376 {"begin session count over maximum", mutate(base(), 0, func(p *SnapshotPart) {
271- p.Part = SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) + 1}
377+ p.Part = SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost) + 1}
272378 }), ErrTooLarge},
273379 {"begin tombstone count over maximum", mutate(base(), 0, func(p *SnapshotPart) {
274380 p.Part = SnapshotBegin{HostCount: 1, TombstoneCount: uint32(ports.BrokerMaxTombstones) + 1}
275381 }), ErrTooLarge},
276382 {"commit duplicate host endpoint", func() []SnapshotPart {
277- hosts := sampleSnapshotHosts()
383+ hosts := sampleSnapshotDaemons()
278384 hosts[1].Endpoint = hosts[0].Endpoint
279385 hosts[1].Registration.Endpoint = hosts[0].Endpoint
280386 return snapshotPartsFor(5, 3, hosts, nil)
281387 }(), ErrSnapshotInvalid},
282388 {"commit host endpoint registration mismatch", func() []SnapshotPart {
283- hosts := sampleSnapshotHosts()
389+ hosts := sampleSnapshotDaemons()
284390 hosts[0].Endpoint = "other@elsewhere:22"
285391 return snapshotPartsFor(5, 3, hosts, nil)
286392 }(), ErrSnapshotInvalid},
287393 {"commit invalid projection", func() []SnapshotPart {
288- hosts := sampleSnapshotHosts()
394+ hosts := sampleSnapshotDaemons()
289395 hosts[0].Availability = 0
290396 return snapshotPartsFor(5, 3, hosts, nil)
291397 }(), ErrSnapshotInvalid},
292398 {"commit duplicate session name", func() []SnapshotPart {
293- hosts := sampleSnapshotHosts()
399+ hosts := sampleSnapshotDaemons()
294400 hosts[0].Sessions[1].Name = hosts[0].Sessions[0].Name
295401 return snapshotPartsFor(5, 3, hosts, nil)
296402 }(), ErrSnapshotInvalid},
297403 {"commit tombstone for live host", func() []SnapshotPart {
298- hosts := sampleSnapshotHosts()
404+ hosts := sampleSnapshotDaemons()
299405 tombstone := testTombstoneAt(0)
300406 tombstone.Endpoint = hosts[0].Endpoint
301407 tombstone.Registration.Endpoint = hosts[0].Endpoint
@@ -319,7 +425,7 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) {
319425 // survives.
320426 func TestSnapshotAssemblerLatePartAfterCommit(t *testing.T) {
321427 a := testSnapshotAssembler(t)
322- parts := snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones())
428+ parts := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), sampleSnapshotTombstones())
323429 require.NoError(t, runSnapshotParts(a, parts))
324430 late := parts[len(parts)-1]
325431 late.Index = uint32(len(parts))
@@ -331,13 +437,13 @@ func TestSnapshotAssemblerLatePartAfterCommit(t *testing.T) {
331437 require.Equal(t, ports.BrokerRevision(3), committed.Revision)
332438 }
333439
334-// TestSnapshotAssemblerBounds proves the 16,514-part and 80 MiB staged
440+// TestSnapshotAssemblerBounds proves the 16,771-part and 80 MiB staged
335441 // ceilings: maximum counts commit, one over is refused.
336442 func TestSnapshotAssemblerBounds(t *testing.T) {
337443 t.Run("maximum counts commit", func(t *testing.T) {
338- hosts := make([]ports.RemoteHostSnapshot, ports.BrokerMaxHosts)
444+ hosts := make([]ports.BrokerDaemonObservation, ports.BrokerMaxDaemonsPerSnapshot)
339445 for i := range hosts {
340- host := testHostAt(i)
446+ host := testDaemonAt(i)
341447 sessions := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost)
342448 for j := range sessions {
343449 sessions[j] = testSessionAt(i, j)
@@ -363,9 +469,9 @@ func TestSnapshotAssemblerBounds(t *testing.T) {
363469 }
364470 }
365471 require.True(t, completed)
366- require.Len(t, snapshot.Hosts, ports.BrokerMaxHosts)
472+ require.Len(t, snapshot.Daemons, ports.BrokerMaxDaemonsPerSnapshot)
367473 require.Len(t, snapshot.Removed, ports.BrokerMaxTombstones)
368- for _, host := range snapshot.Hosts {
474+ for _, host := range snapshot.Daemons {
369475 require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost)
370476 }
371477 require.Equal(t, ports.BrokerRevision(12), snapshot.Revision)
@@ -375,7 +481,7 @@ func TestSnapshotAssemblerBounds(t *testing.T) {
375481 a := testSnapshotAssembler(t)
376482 _, _, err := a.Add(SnapshotPart{
377483 Epoch: 7, Connection: testConnectionID(0x21), Generation: 1, Revision: 1, Index: 0,
378- Part: SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) + 1},
484+ Part: SnapshotBegin{HostCount: 1, SessionCount: uint32(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost) + 1},
379485 })
380486 require.ErrorIs(t, err, ErrTooLarge)
381487 require.False(t, a.StagingActive())
@@ -383,7 +489,7 @@ func TestSnapshotAssemblerBounds(t *testing.T) {
383489
384490 t.Run("staged byte ceiling aborts and retains", func(t *testing.T) {
385491 a := testSnapshotAssembler(t, WithMaxStagedBytes(200))
386- parts := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)
492+ parts := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)
387493 // Begin (32) + host part stay within the cap; the first session part
388494 // crosses it.
389495 require.NoError(t, runSnapshotParts(a, parts[:2]))
@@ -441,12 +547,12 @@ func TestSnapshotHostileMaxTabsBound(t *testing.T) {
441547 session := testSessionAt(0, 0)
442548 session.Tabs = maxTabs
443549 perSession := estimateCatalogSessionBytes(session)
444- worst := perSession * uint64(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost)
550+ worst := perSession * uint64(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost)
445551 require.Greater(t, worst, MaxSnapshotStagedBytes, "hostile max tabs must cross the 80 MiB ceiling")
446552
447- host := testHostAt(0)
553+ host := testDaemonAt(0)
448554 host.Sessions = []catalogue.RemoteCatalogSession{session}
449- parts := snapshotPartsFor(9, 1, []ports.RemoteHostSnapshot{host}, nil)
555+ parts := snapshotPartsFor(9, 1, []ports.BrokerDaemonObservation{host}, nil)
450556
451557 tight := testSnapshotAssembler(t, WithMaxStagedBytes(perSession))
452558 require.NoError(t, runSnapshotParts(tight, parts[:2]))
@@ -459,22 +565,22 @@ func TestSnapshotHostileMaxTabsBound(t *testing.T) {
459565 require.NoError(t, runSnapshotParts(generous, parts))
460566 committed, ok := generous.Snapshot()
461567 require.True(t, ok)
462- require.Len(t, committed.Hosts, 1)
463- require.Len(t, committed.Hosts[0].Sessions, 1)
464- require.Len(t, committed.Hosts[0].Sessions[0].Tabs, catalogue.RemoteCatalogMaxTabsPerSess)
568+ require.Len(t, committed.Daemons, 1)
569+ require.Len(t, committed.Daemons[0].Sessions, 1)
570+ require.Len(t, committed.Daemons[0].Sessions[0].Tabs, catalogue.RemoteCatalogMaxTabsPerSess)
465571 }
466572
467573 // TestSnapshotAssemblerAtomicCommit proves publication happens only at End
468574 // and that the returned snapshot is a defensive clone.
469575 func TestSnapshotAssemblerAtomicCommit(t *testing.T) {
470576 a := testSnapshotAssembler(t)
471- first := snapshotPartsFor(5, 1, sampleSnapshotHosts(), sampleSnapshotTombstones())
577+ first := snapshotPartsFor(5, 1, sampleSnapshotDaemons(), sampleSnapshotTombstones())
472578 require.NoError(t, runSnapshotParts(a, first))
473579 committed, ok := a.Snapshot()
474580 require.True(t, ok)
475581 require.Equal(t, ports.BrokerRevision(1), committed.Revision)
476582
477- second := snapshotPartsFor(5, 2, sampleSnapshotHosts(), nil)
583+ second := snapshotPartsFor(5, 2, sampleSnapshotDaemons(), nil)
478584 for _, part := range second[:len(second)-1] {
479585 completed, _, err := a.Add(part)
480586 require.NoError(t, err)
@@ -494,18 +600,18 @@ func TestSnapshotAssemblerAtomicCommit(t *testing.T) {
494600
495601 // The committed snapshot is immutable to the caller: mutating a returned
496602 // value never reaches the assembler.
497- published.Hosts[0].Endpoint = "mutated@nowhere:22"
498- published.Hosts[0].Sessions[0].Tabs = []catalogue.RemoteCatalogTab{{ID: "injected", Index: 0, Name: "injected"}}
499- published.Hosts[0].Sessions[0].Name = "mutated"
603+ published.Daemons[0].Endpoint = "mutated@nowhere:22"
604+ published.Daemons[0].Sessions[0].Tabs = []catalogue.RemoteCatalogTab{{ID: "injected", Index: 0, Name: "injected"}}
605+ published.Daemons[0].Sessions[0].Name = "mutated"
500606 again, ok := a.Snapshot()
501607 require.True(t, ok)
502- require.Equal(t, "user0@host0:22", again.Hosts[0].Endpoint)
503- require.Equal(t, "s00-000", again.Hosts[0].Sessions[0].Name)
504- require.Empty(t, again.Hosts[0].Sessions[0].Tabs)
608+ require.Equal(t, "user0@host0:22", again.Daemons[0].Endpoint)
609+ require.Equal(t, "s00-000", again.Daemons[0].Sessions[0].Name)
610+ require.Empty(t, again.Daemons[0].Sessions[0].Tabs)
505611
506612 // An aborted transfer retains the committed snapshot.
507- require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[:2]))
508- _, _, err = a.Add(snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[4])
613+ require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[:2]))
614+ _, _, err = a.Add(snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[4])
509615 require.ErrorIs(t, err, ErrSnapshotInvalid)
510616 again, ok = a.Snapshot()
511617 require.True(t, ok)
@@ -517,13 +623,13 @@ func TestSnapshotAssemblerAtomicCommit(t *testing.T) {
517623 // transfer starts from index 0.
518624 func TestSnapshotAssemblerInterruptedRetention(t *testing.T) {
519625 a := testSnapshotAssembler(t)
520- parts := snapshotPartsFor(5, 1, sampleSnapshotHosts(), sampleSnapshotTombstones())
626+ parts := snapshotPartsFor(5, 1, sampleSnapshotDaemons(), sampleSnapshotTombstones())
521627 require.NoError(t, runSnapshotParts(a, parts))
522628 committed, ok := a.Snapshot()
523629 require.True(t, ok)
524630 require.Equal(t, ports.BrokerRevision(1), committed.Revision)
525631
526- restart := snapshotPartsFor(5, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())
632+ restart := snapshotPartsFor(5, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones())
527633 require.NoError(t, runSnapshotParts(a, restart[:3]))
528634 require.True(t, a.StagingActive())
529635 a.DiscardStaging()
@@ -555,7 +661,7 @@ func TestSnapshotAssemblerInterruptedRetention(t *testing.T) {
555661 // staging.
556662 func TestSnapshotAssemblerRevisionFencing(t *testing.T) {
557663 a := testSnapshotAssembler(t)
558- hosts := sampleSnapshotHosts()
664+ hosts := sampleSnapshotDaemons()
559665 require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 5, hosts, nil)))
560666
561667 t.Run("older revision refused", func(t *testing.T) {
@@ -607,7 +713,7 @@ func TestSnapshotAssemblerRevisionFencing(t *testing.T) {
607713 // replaces staging: equal revisions restart progress, newer revisions
608714 // coalesce.
609715 func TestSnapshotAssemblerCoalescing(t *testing.T) {
610- hosts := sampleSnapshotHosts()
716+ hosts := sampleSnapshotDaemons()
611717 base := snapshotPartsFor(5, 3, hosts, nil)
612718
613719 a := testSnapshotAssembler(t)
@@ -644,7 +750,7 @@ func TestSnapshotAssemblerCoalescing(t *testing.T) {
644750 // one generation, ignores stale parts, rejects future parts, and refuses a
645751 // zero generation.
646752 func TestSnapshotAssemblerGenerationFencing(t *testing.T) {
647- hosts := sampleSnapshotHosts()
753+ hosts := sampleSnapshotDaemons()
648754 base := snapshotPartsFor(5, 3, hosts, nil)
649755
650756 t.Run("zero generation refused", func(t *testing.T) {
@@ -703,7 +809,7 @@ func TestSnapshotAssemblerGenerationFencing(t *testing.T) {
703809 // TestSnapshotAssemblerScopeFencing proves every part must carry the exact
704810 // assigned epoch and connection, whether or not a transfer is in flight.
705811 func TestSnapshotAssemblerScopeFencing(t *testing.T) {
706- base := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)
812+ base := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)
707813 cases := []struct {
708814 name string
709815 apply func(*SnapshotPart)
@@ -737,7 +843,7 @@ func TestSnapshotAssemblerScopeFencing(t *testing.T) {
737843 // TestSnapshotAssemblerZeroRevision proves a zero revision is refused.
738844 func TestSnapshotAssemblerZeroRevision(t *testing.T) {
739845 a := testSnapshotAssembler(t)
740- part := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[0]
846+ part := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[0]
741847 part.Revision = 0
742848 _, _, err := a.Add(part)
743849 require.ErrorIs(t, err, ErrSnapshotInvalid)
@@ -769,13 +875,13 @@ func TestSnapshotAssemblerNilAndEmptySafety(t *testing.T) {
769875 // part from the first one.
770876 func TestSnapshotAssemblerPinnedGeneration(t *testing.T) {
771877 a := testSnapshotAssembler(t, WithGeneration(5))
772- stale := snapshotPartsFor(4, 1, sampleSnapshotHosts(), nil)[0]
878+ stale := snapshotPartsFor(4, 1, sampleSnapshotDaemons(), nil)[0]
773879 _, _, err := a.Add(stale)
774880 require.ErrorIs(t, err, ErrStaleGeneration)
775- future := snapshotPartsFor(6, 1, sampleSnapshotHosts(), nil)[0]
881+ future := snapshotPartsFor(6, 1, sampleSnapshotDaemons(), nil)[0]
776882 _, _, err = a.Add(future)
777883 require.ErrorIs(t, err, ErrFutureGeneration)
778- require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 1, sampleSnapshotHosts(), nil)))
884+ require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 1, sampleSnapshotDaemons(), nil)))
779885 committed, ok := a.Snapshot()
780886 require.True(t, ok)
781887 require.Equal(t, ports.BrokerRevision(1), committed.Revision)
@@ -786,7 +892,7 @@ func TestSnapshotAssemblerPinnedGeneration(t *testing.T) {
786892 // keeps publishing it until a newer transfer commits.
787893 func TestSnapshotAssemblerAdoptCommitted(t *testing.T) {
788894 source := testSnapshotAssembler(t)
789- require.NoError(t, runSnapshotParts(source, snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones())))
895+ require.NoError(t, runSnapshotParts(source, snapshotPartsFor(5, 3, sampleSnapshotDaemons(), sampleSnapshotTombstones())))
790896 committed, ok := source.Snapshot()
791897 require.True(t, ok)
792898
@@ -801,24 +907,24 @@ func TestSnapshotAssemblerAdoptCommitted(t *testing.T) {
801907 require.Len(t, adopted.Removed, 1)
802908
803909 // An older revision and a foreign epoch are ignored.
804- target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch, Revision: 2, Hosts: committed.Hosts})
805- target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch + 1, Revision: 9, Hosts: committed.Hosts})
910+ target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch, Revision: 2, Daemons: committed.Daemons})
911+ target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch + 1, Revision: 9, Daemons: committed.Daemons})
806912 adopted, ok = target.Snapshot()
807913 require.True(t, ok)
808914 require.Equal(t, ports.BrokerRevision(3), adopted.Revision)
809915
810916 // A newer transfer commits over the adopted snapshot.
811- require.NoError(t, runSnapshotParts(target, snapshotPartsFor(6, 4, sampleSnapshotHosts(), nil)))
917+ require.NoError(t, runSnapshotParts(target, snapshotPartsFor(6, 4, sampleSnapshotDaemons(), nil)))
812918 adopted, ok = target.Snapshot()
813919 require.True(t, ok)
814920 require.Equal(t, ports.BrokerRevision(4), adopted.Revision)
815921 require.Empty(t, adopted.Removed)
816922
817923 // The adopted value is independent of the source.
818- adopted.Hosts[0].Endpoint = "mutated@nowhere:22"
924+ adopted.Daemons[0].Endpoint = "mutated@nowhere:22"
819925 again, ok := source.Snapshot()
820926 require.True(t, ok)
821- require.Equal(t, "user0@host0:22", again.Hosts[0].Endpoint)
927+ require.Equal(t, "user0@host0:22", again.Daemons[0].Endpoint)
822928 }
823929
824930 // TestSnapshotAssemblerConcurrentReaders is a deterministic race test: one
@@ -849,7 +955,7 @@ func TestSnapshotAssemblerConcurrentReaders(t *testing.T) {
849955 }()
850956 }
851957 for revision := 1; revision <= revisions; revision++ {
852- parts := snapshotPartsFor(5, ports.BrokerRevision(revision), sampleSnapshotHosts(), nil)
958+ parts := snapshotPartsFor(5, ports.BrokerRevision(revision), sampleSnapshotDaemons(), nil)
853959 for _, part := range parts {
854960 _, _, err := a.Add(part)
855961 require.NoError(t, err)
@@ -886,7 +992,7 @@ func fuzzSnapshotAssemblerSeeds(tb testing.TB) [][]byte {
886992 require.NoError(tb, err)
887993 return raw
888994 }
889- parts := snapshotPartsFor(4, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())
995+ parts := snapshotPartsFor(4, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones())
890996 frames := make([][]byte, 0, len(parts))
891997 for _, part := range parts {
892998 frames = append(frames, encode(part))
@@ -946,11 +1052,15 @@ func FuzzSnapshotAssembler(f *testing.F) {
9461052 }
9471053
9481054 // requireValidBrokerSnapshot asserts one published snapshot is exactly what the
949-// assembler promises: a validated snapshot with durable host projections.
1055+// assembler promises: a validated snapshot with durable remote daemon
1056+// projections (the local daemon is process-local and never durable).
9501057 func requireValidBrokerSnapshot(t *testing.T, snapshot ports.BrokerSnapshot) {
9511058 t.Helper()
9521059 require.NoError(t, snapshot.Validate())
953- for _, host := range snapshot.Hosts {
954- require.NoError(t, ports.ValidateDurableHostProjection(host))
1060+ for _, daemon := range snapshot.Daemons {
1061+ if daemon.Local {
1062+ continue
1063+ }
1064+ require.NoError(t, ports.ValidateDurableHostProjection(daemon))
9551065 }
9561066 }
M internal/adapters/brokerwire/state_test.go +11 -11
@@ -123,7 +123,7 @@ func TestConnStateString(t *testing.T) {
123123 // Register -> Closing -> Closed.
124124 func TestConnectionLifecycle(t *testing.T) {
125125 op := operationIDAt(1)
126- part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]
126+ part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0]
127127 cases := []struct {
128128 name string
129129 setup func(*testing.T) *Connection
@@ -285,7 +285,7 @@ func stageConnectionPart(t *testing.T, conn *Connection, part SnapshotPart) {
285285 func TestConnectionSubscriptionStagingRetention(t *testing.T) {
286286 conn := readyTestConnection(t)
287287 require.NoError(t, conn.Subscribe(1))
288- for _, part := range snapshotPartsFor(1, 4, sampleSnapshotHosts(), sampleSnapshotTombstones()) {
288+ for _, part := range snapshotPartsFor(1, 4, sampleSnapshotDaemons(), sampleSnapshotTombstones()) {
289289 stageConnectionPart(t, conn, part)
290290 }
291291 committed, ok := conn.Snapshot()
@@ -293,7 +293,7 @@ func TestConnectionSubscriptionStagingRetention(t *testing.T) {
293293 require.Equal(t, ports.BrokerRevision(4), committed.Revision)
294294
295295 // In-flight staging of the active generation is discarded on advance.
296- inFlight := snapshotPartsFor(1, 6, sampleSnapshotHosts(), nil)
296+ inFlight := snapshotPartsFor(1, 6, sampleSnapshotDaemons(), nil)
297297 stageConnectionPart(t, conn, inFlight[0])
298298 require.True(t, conn.StagingActive())
299299 require.NoError(t, conn.Subscribe(2))
@@ -312,7 +312,7 @@ func TestConnectionSubscriptionStagingRetention(t *testing.T) {
312312 future.Generation = 3
313313 _, _, err = conn.AddSnapshotPart(future)
314314 require.ErrorIs(t, err, ErrFutureGeneration)
315- next := snapshotPartsFor(2, 5, sampleSnapshotHosts(), nil)
315+ next := snapshotPartsFor(2, 5, sampleSnapshotDaemons(), nil)
316316 _, _, err = conn.AddSnapshotPart(next[1])
317317 require.ErrorIs(t, err, ErrSnapshotInvalid)
318318 require.False(t, conn.StagingActive())
@@ -352,7 +352,7 @@ func TestConnectionSnapshotParts(t *testing.T) {
352352 t.Run("future generation rejected", func(t *testing.T) {
353353 conn := readyTestConnection(t)
354354 require.NoError(t, conn.Subscribe(2))
355- part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]
355+ part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0]
356356 part.Generation = 3
357357 _, _, err := conn.AddSnapshotPart(part)
358358 require.ErrorIs(t, err, ErrFutureGeneration)
@@ -364,11 +364,11 @@ func TestConnectionSnapshotParts(t *testing.T) {
364364 t.Run("foreign scope rejected", func(t *testing.T) {
365365 conn := readyTestConnection(t)
366366 require.NoError(t, conn.Subscribe(2))
367- part := snapshotPartsFor(2, 1, sampleSnapshotHosts(), nil)[0]
367+ part := snapshotPartsFor(2, 1, sampleSnapshotDaemons(), nil)[0]
368368 part.Epoch = 8
369369 _, _, err := conn.AddSnapshotPart(part)
370370 require.ErrorIs(t, err, ErrScopeMismatch)
371- part = snapshotPartsFor(2, 1, sampleSnapshotHosts(), nil)[0]
371+ part = snapshotPartsFor(2, 1, sampleSnapshotDaemons(), nil)[0]
372372 part.Connection = testConnectionID(0x77)
373373 _, _, err = conn.AddSnapshotPart(part)
374374 require.ErrorIs(t, err, ErrScopeMismatch)
@@ -377,7 +377,7 @@ func TestConnectionSnapshotParts(t *testing.T) {
377377 t.Run("complete transfer through the connection", func(t *testing.T) {
378378 conn := readyTestConnection(t)
379379 require.NoError(t, conn.Subscribe(9))
380- parts := snapshotPartsFor(9, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())
380+ parts := snapshotPartsFor(9, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones())
381381 var published bool
382382 for _, part := range parts {
383383 completed, _, err := conn.AddSnapshotPart(part)
@@ -410,7 +410,7 @@ func TestConnectionExactScopeRegistration(t *testing.T) {
410410 _, err = connectionStreams(conn).Close(other, 1)
411411 require.ErrorIs(t, err, ErrScopeMismatch)
412412
413- part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]
413+ part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0]
414414 part.Connection = other.Connection
415415 part.Epoch = other.Epoch
416416 require.NoError(t, conn.Subscribe(1))
@@ -914,7 +914,7 @@ func TestStreamTrackerConcurrentFrames(t *testing.T) {
914914 func TestConnectionConcurrentWork(t *testing.T) {
915915 conn := readyTestConnection(t)
916916 require.NoError(t, conn.Subscribe(1))
917- hosts := sampleSnapshotHosts()
917+ hosts := sampleSnapshotDaemons()
918918 const revisions = 20
919919 const operations = 32
920920 for i := 0; i < operations; i++ {
@@ -1069,7 +1069,7 @@ func TestConnectionCloseRaceNoPostCloseSnapshotCommit(t *testing.T) {
10691069 for i := 0; i < attempts; i++ {
10701070 conn := readyTestConnection(t)
10711071 require.NoError(t, conn.Subscribe(2))
1072- parts := snapshotPartsFor(2, revision, sampleSnapshotHosts(), nil)
1072+ parts := snapshotPartsFor(2, revision, sampleSnapshotDaemons(), nil)
10731073 end := parts[len(parts)-1]
10741074 for _, part := range parts[:len(parts)-1] {
10751075 stageConnectionPart(t, conn, part)
M internal/adapters/daemonmux/codec.go +24 -6
@@ -270,7 +270,7 @@ func openToWire(m Open) (*wire.MuxOpen, error) {
270270 return nil, ErrInvalidMessage
271271 }
272272 request := ports.BrokerOpenStreamRequest{
273- Epoch: m.Ref.Epoch, Purpose: m.Purpose, Local: m.Local,
273+ Epoch: m.Ref.Epoch, Purpose: m.Purpose, Admission: m.Admission, Name: m.Name, Local: m.Local,
274274 Connection: m.Ref.Connection, Stream: m.Ref.Client,
275275 Endpoint: m.Endpoint, Registration: m.Registration,
276276 Target: m.Target, Env: m.Env, Policy: m.Policy,
@@ -304,7 +304,11 @@ func openToWire(m Open) (*wire.MuxOpen, error) {
304304 out.Registration = registrationToWire(m.Registration)
305305 }
306306 if m.Purpose == ports.BrokerStreamAttachment {
307- out.Target = exactTargetToWire(m.Target)
307+ out.Admission = uint32(m.Admission)
308+ out.Name = m.Name
309+ if m.Admission == ports.BrokerAdmissionExact {
310+ out.Target = exactTargetToWire(m.Target)
311+ }
308312 }
309313 return out, nil
310314 }
@@ -322,6 +326,16 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
322326 if err != nil {
323327 return Open{}, err
324328 }
329+ // Narrow the wire uint32 before the cast: a value whose high bits
330+ // truncate onto a valid admission code would otherwise alias silently.
331+ admission, err := muxEnum8[ports.BrokerStreamAdmission](message.GetAdmission())
332+ if err != nil {
333+ return Open{}, ErrInvalidMessage
334+ }
335+ name := message.GetName()
336+ if purpose != ports.BrokerStreamAttachment && (admission != 0 || name != "") {
337+ return Open{}, ErrInvalidMessage
338+ }
325339 local := message.GetLocal()
326340 var registration domain.RemoteRegistration
327341 if !local {
@@ -334,8 +348,12 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
334348 }
335349 var target protocol.ExactSessionTarget
336350 if purpose == ports.BrokerStreamAttachment {
337- target, err = exactTargetFromWire(message.GetTarget())
338- if err != nil {
351+ if admission == ports.BrokerAdmissionExact {
352+ target, err = exactTargetFromWire(message.GetTarget())
353+ if err != nil {
354+ return Open{}, ErrInvalidMessage
355+ }
356+ } else if message.GetTarget() != nil {
339357 return Open{}, ErrInvalidMessage
340358 }
341359 } else if message.GetTarget() != nil {
@@ -354,11 +372,11 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
354372 }
355373 endpoint := message.GetEndpoint()
356374 candidate := Open{
357- Ref: ref, Purpose: purpose, Local: local, Endpoint: endpoint,
375+ Ref: ref, Purpose: purpose, Admission: admission, Name: name, Local: local, Endpoint: endpoint,
358376 Registration: registration, Target: target, Env: env, Policy: policy,
359377 }
360378 request := ports.BrokerOpenStreamRequest{
361- Epoch: candidate.Ref.Epoch, Purpose: candidate.Purpose, Local: candidate.Local,
379+ Epoch: candidate.Ref.Epoch, Purpose: candidate.Purpose, Admission: candidate.Admission, Name: candidate.Name, Local: candidate.Local,
362380 Connection: candidate.Ref.Connection, Stream: candidate.Ref.Client,
363381 Endpoint: candidate.Endpoint, Registration: candidate.Registration,
364382 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,
M internal/adapters/daemonmux/codec_test.go +119 -1
@@ -94,7 +94,7 @@ func mustEncodeServer(t *testing.T, message ServerMessage) []byte {
9494
9595 func testOpen() Open {
9696 return Open{
97- Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Local: false,
97+ Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: false,
9898 Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(),
9999 Env: []string{"TERM=xterm-256color"}, Policy: testPolicy(),
100100 }
@@ -662,3 +662,121 @@ func FuzzDecodeServer(f *testing.F) {
662662 _, _ = DecodeServer(payload, testEnvelopeCeiling, testChunkCeiling)
663663 })
664664 }
665+
666+// TestMuxAdmissionVariants proves the attachment-admission contract travels
667+// the mux wire losslessly and rejects every incoherent combination at the
668+// same boundary the ports contract rejects it.
669+func TestMuxAdmissionVariants(t *testing.T) {
670+ roundTrip := func(t *testing.T, open Open) Open {
671+ t.Helper()
672+ raw, err := EncodeClient(open, testEnvelopeCeiling, testChunkCeiling)
673+ require.NoError(t, err)
674+ decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
675+ require.NoError(t, err)
676+ open, ok := decoded.(Open)
677+ require.True(t, ok)
678+ return open
679+ }
680+ t.Run("exact attach round-trips", func(t *testing.T) {
681+ require.Equal(t, testOpen(), roundTrip(t, testOpen()))
682+ })
683+ t.Run("named creation round-trips", func(t *testing.T) {
684+ named := testOpen()
685+ named.Admission = ports.BrokerAdmissionCreateNamed
686+ named.Name = "work"
687+ named.Target = protocol.ExactSessionTarget{}
688+ require.Equal(t, named, roundTrip(t, named))
689+ })
690+ t.Run("ephemeral creation round-trips", func(t *testing.T) {
691+ ephemeral := testOpen()
692+ ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral
693+ ephemeral.Target = protocol.ExactSessionTarget{}
694+ require.Equal(t, ephemeral, roundTrip(t, ephemeral))
695+ })
696+ t.Run("control never carries admission", func(t *testing.T) {
697+ control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}
698+ require.Equal(t, control, roundTrip(t, control))
699+ mutated := mustEncodeClient(t, control)
700+ envelope := &wire.MuxClientEnvelope{}
701+ require.NoError(t, proto.Unmarshal(mutated, envelope))
702+ envelope.GetOpen().Admission = 2
703+ envelope.GetOpen().Name = "work"
704+ mutated, err := proto.Marshal(envelope)
705+ require.NoError(t, err)
706+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
707+ require.ErrorIs(t, err, ErrInvalidMessage)
708+ })
709+ t.Run("exact with a creation name is refused", func(t *testing.T) {
710+ open := testOpen()
711+ open.Name = "work"
712+ _, err := EncodeClient(open, testEnvelopeCeiling, testChunkCeiling)
713+ require.ErrorIs(t, err, ErrInvalidMessage)
714+ })
715+ t.Run("named creation with a target is refused", func(t *testing.T) {
716+ named := testOpen()
717+ named.Admission = ports.BrokerAdmissionCreateNamed
718+ named.Name = "work"
719+ _, err := EncodeClient(named, testEnvelopeCeiling, testChunkCeiling)
720+ require.ErrorIs(t, err, ErrInvalidMessage)
721+ })
722+ t.Run("unknown admission code is refused", func(t *testing.T) {
723+ raw := mustEncodeClient(t, testOpen())
724+ envelope := &wire.MuxClientEnvelope{}
725+ require.NoError(t, proto.Unmarshal(raw, envelope))
726+ envelope.GetOpen().Admission = 9
727+ mutated, err := proto.Marshal(envelope)
728+ require.NoError(t, err)
729+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
730+ require.ErrorIs(t, err, ErrInvalidMessage)
731+ })
732+}
733+
734+// TestMuxAdmissionWireRange proves the MuxOpen decoder narrows the wire
735+// admission field before any cast: the accepted semantic set (no admission, or
736+// one of the three closed admission codes) is unchanged, while every wire value
737+// whose high bits would alias a valid code is refused instead of silently
738+// truncating onto that code.
739+func TestMuxAdmissionWireRange(t *testing.T) {
740+ control := Open{Ref: testRef(6), Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}
741+ named := testOpen()
742+ named.Admission = ports.BrokerAdmissionCreateNamed
743+ named.Name = "work"
744+ named.Target = protocol.ExactSessionTarget{}
745+ ephemeral := testOpen()
746+ ephemeral.Admission = ports.BrokerAdmissionCreateEphemeral
747+ ephemeral.Target = protocol.ExactSessionTarget{}
748+
749+ tests := []struct {
750+ name string
751+ base Open
752+ admission uint32
753+ wantErr bool
754+ }{
755+ {"no admission on a control stream", control, 0, false},
756+ {"exact admission", testOpen(), uint32(ports.BrokerAdmissionExact), false},
757+ {"create-named admission", named, uint32(ports.BrokerAdmissionCreateNamed), false},
758+ {"create-ephemeral admission", ephemeral, uint32(ports.BrokerAdmissionCreateEphemeral), false},
759+ {"wire value 257 aliasing exact", testOpen(), 257, true},
760+ {"wire value 258 aliasing create-named", named, 258, true},
761+ {"wire value 1024 aliasing no admission", control, 1024, true},
762+ {"wire value max uint32", control, 1<<32 - 1, true},
763+ }
764+ for _, tc := range tests {
765+ t.Run(tc.name, func(t *testing.T) {
766+ raw := mustEncodeClient(t, tc.base)
767+ envelope := &wire.MuxClientEnvelope{}
768+ require.NoError(t, proto.Unmarshal(raw, envelope))
769+ envelope.GetOpen().Admission = tc.admission
770+ mutated, err := proto.Marshal(envelope)
771+ require.NoError(t, err)
772+ decoded, err := DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
773+ if tc.wantErr {
774+ require.ErrorIs(t, err, ErrInvalidMessage)
775+ require.Zero(t, decoded, "an out-of-range wire value is never accepted as an alias")
776+ return
777+ }
778+ require.NoError(t, err)
779+ require.Equal(t, tc.base, decoded)
780+ })
781+ }
782+}
M internal/adapters/daemonmux/messages.go +2 -0
@@ -80,6 +80,8 @@ func (r StreamRef) Validate() error {
8080 type Open struct {
8181 Ref StreamRef
8282 Purpose ports.BrokerStreamPurpose
83+ Admission ports.BrokerStreamAdmission
84+ Name string
8385 Local bool
8486 Endpoint string
8587 Registration domain.RemoteRegistration
M internal/app/broker_offline_status.go +9 -1
@@ -627,13 +627,21 @@ func probeBrokerStatus(ctx context.Context, socketPath string) (brokerStatusRepo
627627 }
628628
629629 // readyBrokerStatus projects one committed snapshot into the ready report.
630+// HostCount counts configured remote hosts; the local daemon observation is
631+// not a configured host.
630632 func readyBrokerStatus(socketPath string, snapshot ports.BrokerSnapshot) brokerStatusReport {
633+ hosts := 0
634+ for _, daemon := range snapshot.Daemons {
635+ if !daemon.Local {
636+ hosts++
637+ }
638+ }
631639 return brokerStatusReport{
632640 Status: "ready",
633641 Endpoint: socketPath,
634642 Epoch: snapshot.Epoch,
635643 Revision: snapshot.Revision,
636- HostCount: len(snapshot.Hosts),
644+ HostCount: hosts,
637645 }
638646 }
639647
M internal/ports/broker.go +167 -57
@@ -233,16 +233,18 @@ func (t BrokerHostTombstone) Fences(candidate domain.RemoteRegistration) bool {
233233 t.Registration.Generation >= candidate.Generation
234234 }
235235
236-// BrokerSnapshot is an immutable, fully defensive broker publication. Hosts
237-// reuse the ordered RemoteHostSnapshot projection; Removed carries the
238-// bounded tombstone set that fences stale authority. Nested slices are
239-// never mutated after publication. Removed is process-local fencing state and
240-// is never durable: a BrokerSnapshotStore persists and reloads hosts without
241-// tombstones.
236+// BrokerSnapshot is an immutable, fully defensive broker publication.
237+// Daemons carries the broker-native daemon observations (Plan 001 P5.2a):
238+// the local daemon entry first when present, then remote hosts in
239+// registration order. Removed carries the bounded tombstone set that fences
240+// stale authority. Nested slices are never mutated after publication.
241+// Tombstones and the local daemon observation are process-local state and
242+// are never durable: a BrokerSnapshotStore persists and reloads remote
243+// daemon observations only.
242244 type BrokerSnapshot struct {
243245 Epoch BrokerEpoch
244246 Revision BrokerRevision
245- Hosts []RemoteHostSnapshot
247+ Daemons []BrokerDaemonObservation
246248 Removed []BrokerHostTombstone
247249 }
248250
@@ -253,30 +255,35 @@ func (s BrokerSnapshot) Validate() error {
253255 if s.Revision == 0 {
254256 return errors.New("ports: broker snapshot has no revision")
255257 }
256- if len(s.Hosts) > BrokerMaxHosts {
257- return errors.New("ports: broker snapshot has too many hosts")
258+ if len(s.Daemons) > BrokerMaxDaemonsPerSnapshot {
259+ return errors.New("ports: broker snapshot has too many daemons")
258260 }
259- if len(s.Removed) > BrokerMaxTombstones {
260- return errors.New("ports: broker snapshot has too many tombstones")
261- }
262- seen := make(map[string]struct{}, len(s.Hosts))
263- for _, host := range s.Hosts {
264- if err := domain.ValidateRemoteHostTarget(host.Endpoint); err != nil {
265- return fmt.Errorf("ports: broker snapshot: %w", err)
266- }
267- if err := host.Registration.Validate(); err != nil {
268- return fmt.Errorf("ports: broker snapshot: %w", err)
261+ local := false
262+ seen := make(map[string]struct{}, len(s.Daemons))
263+ for i, daemon := range s.Daemons {
264+ if err := daemon.Validate(); err != nil {
265+ return fmt.Errorf("ports: broker snapshot daemon %d: %w", i, err)
269266 }
270- if host.Endpoint != host.Registration.Endpoint {
271- return errors.New("ports: broker snapshot host endpoint does not match registration")
267+ if daemon.Local {
268+ if local {
269+ return errors.New("ports: broker snapshot has more than one local daemon")
270+ }
271+ // The wire layout carries the local daemon at index zero only, so a
272+ // local observation anywhere else is a publication the peer's
273+ // assembler refuses. Refuse it at the contract gate instead.
274+ if i != 0 {
275+ return fmt.Errorf("ports: broker snapshot carries its local daemon at index %d", i)
276+ }
277+ local = true
278+ continue
272279 }
273- if len(host.Sessions) > BrokerMaxSessionsPerHost {
274- return errors.New("ports: broker snapshot host has too many sessions")
280+ if _, dup := seen[daemon.Endpoint]; dup {
281+ return fmt.Errorf("ports: broker snapshot has duplicate host %q", daemon.Endpoint)
275282 }
276- if _, dup := seen[host.Endpoint]; dup {
277- return fmt.Errorf("ports: broker snapshot has duplicate host %q", host.Endpoint)
278- }
279- seen[host.Endpoint] = struct{}{}
283+ seen[daemon.Endpoint] = struct{}{}
284+ }
285+ if len(s.Removed) > BrokerMaxTombstones {
286+ return errors.New("ports: broker snapshot has too many tombstones")
280287 }
281288 for _, tombstone := range s.Removed {
282289 if err := tombstone.Validate(); err != nil {
@@ -289,26 +296,27 @@ func (s BrokerSnapshot) Validate() error {
289296 return nil
290297 }
291298
292-// Clone returns a defensive copy with independent host, session, tab, and
299+// Clone returns a defensive copy with independent daemon, session, and
293300 // tombstone slices.
294301 func (s BrokerSnapshot) Clone() BrokerSnapshot {
295302 out := s
296- out.Hosts = make([]RemoteHostSnapshot, len(s.Hosts))
297- for i, host := range s.Hosts {
298- out.Hosts[i] = host.Clone()
303+ out.Daemons = make([]BrokerDaemonObservation, len(s.Daemons))
304+ for i, daemon := range s.Daemons {
305+ out.Daemons[i] = daemon.Clone()
299306 }
300307 out.Removed = append([]BrokerHostTombstone(nil), s.Removed...)
301308 return out
302309 }
303310
304-// Find returns the snapshot for an exact endpoint identity.
305-func (s BrokerSnapshot) Find(endpoint string) (RemoteHostSnapshot, bool) {
306- for _, host := range s.Hosts {
307- if host.Endpoint == endpoint {
308- return host, true
311+// Find returns the observation for an exact remote endpoint identity. The
312+// local daemon has no endpoint and is located by its Local flag.
313+func (s BrokerSnapshot) Find(endpoint string) (BrokerDaemonObservation, bool) {
314+ for _, daemon := range s.Daemons {
315+ if !daemon.Local && daemon.Endpoint == endpoint {
316+ return daemon, true
309317 }
310318 }
311- return RemoteHostSnapshot{}, false
319+ return BrokerDaemonObservation{}, false
312320 }
313321
314322 // Supersedes reports whether s is newer than other. Any epoch change
@@ -342,14 +350,58 @@ func BrokerCompletionIsStale(currentRegistration domain.RemoteRegistration, curr
342350 return !currentRegistration.Equal(completionRegistration)
343351 }
344352
353+// BrokerStreamAdmission is the closed attachment-admission taxonomy
354+// (Plan 001 P5.3a, contract-only until the admission slice wires it):
355+// an attachment stream names exactly how the daemon must admit it.
356+// Control and observation streams carry no admission.
357+type BrokerStreamAdmission uint8
358+
359+const (
360+ // BrokerAdmissionExact attaches to one exact session lifecycle identity;
361+ // the daemon revalidates it before attachment.
362+ BrokerAdmissionExact BrokerStreamAdmission = iota + 1
363+ // BrokerAdmissionCreateNamed creates one named session when the stream
364+ // attaches; the name is validated session-name authority.
365+ BrokerAdmissionCreateNamed
366+ // BrokerAdmissionCreateEphemeral creates one ephemeral session when the
367+ // stream attaches.
368+ BrokerAdmissionCreateEphemeral
369+)
370+
371+func (a BrokerStreamAdmission) String() string {
372+ switch a {
373+ case BrokerAdmissionExact:
374+ return "exact"
375+ case BrokerAdmissionCreateNamed:
376+ return "create_named"
377+ case BrokerAdmissionCreateEphemeral:
378+ return "create_ephemeral"
379+ default:
380+ return fmt.Sprintf("invalid(%d)", uint8(a))
381+ }
382+}
383+
384+func (a BrokerStreamAdmission) Validate() error {
385+ switch a {
386+ case BrokerAdmissionExact, BrokerAdmissionCreateNamed, BrokerAdmissionCreateEphemeral:
387+ return nil
388+ default:
389+ return errors.New("ports: invalid broker stream admission")
390+ }
391+}
392+
345393 // BrokerOpenStreamRequest asks the broker to open one independently
346394 // cancellable logical stream to the owning daemon. The daemon revalidates
347-// the exact session identity before attachment. Env is the per-request
348-// session environment; it is never inherited from the broker process
349-// environment.
395+// the exact session identity before attachment. Admission selects the
396+// attachment admission variant; Name is the validated session name for
397+// BrokerAdmissionCreateNamed and is empty for every other variant. Env is
398+// the per-request session environment; it is never inherited from the
399+// broker process environment.
350400 type BrokerOpenStreamRequest struct {
351401 Epoch BrokerEpoch
352402 Purpose BrokerStreamPurpose
403+ Admission BrokerStreamAdmission
404+ Name string
353405 Local bool
354406 Connection BrokerConnectionID
355407 Stream BrokerStreamID
@@ -384,11 +436,34 @@ func (r BrokerOpenStreamRequest) Validate() error {
384436 }
385437 switch r.Purpose {
386438 case BrokerStreamAttachment:
387- if err := r.Target.Validate(); err != nil {
439+ if err := r.Admission.Validate(); err != nil {
388440 return err
389441 }
442+ switch r.Admission {
443+ case BrokerAdmissionExact:
444+ if err := r.Target.Validate(); err != nil {
445+ return err
446+ }
447+ if r.Name != "" {
448+ return errors.New("ports: exact admission carries a creation name")
449+ }
450+ case BrokerAdmissionCreateNamed:
451+ if r.Target != (protocol.ExactSessionTarget{}) {
452+ return errors.New("ports: named creation carries an exact target")
453+ }
454+ if err := domain.ValidateSessionName(r.Name); err != nil {
455+ return fmt.Errorf("ports: invalid creation session name: %w", err)
456+ }
457+ case BrokerAdmissionCreateEphemeral:
458+ if r.Target != (protocol.ExactSessionTarget{}) {
459+ return errors.New("ports: ephemeral creation carries an exact target")
460+ }
461+ if r.Name != "" {
462+ return errors.New("ports: ephemeral creation carries a session name")
463+ }
464+ }
390465 case BrokerStreamControl, BrokerStreamObservation:
391- if r.Target != (protocol.ExactSessionTarget{}) || len(r.Env) != 0 {
466+ if r.Admission != 0 || r.Name != "" || r.Target != (protocol.ExactSessionTarget{}) || len(r.Env) != 0 {
392467 return errors.New("ports: control/observation carries attachment state")
393468 }
394469 default:
@@ -644,21 +719,27 @@ type BrokerSnapshotStore interface {
644719 }
645720
646721 // BrokerHostProbe observes one exact host registration without creating an
647-// attachment. The returned projection must describe the same registration and
648-// must satisfy the durable projection rules (see
649-// ValidateDurableHostProjection): the registry re-checks exactly those rules,
650-// so an over-bound or catalogue-invalid session inventory is classified as an
651-// invalid response and never published. Implementations should therefore
652-// validate the projection with catalogue.ValidateRemoteCatalog against the
653-// RemoteCatalogMax* bounds before returning it rather than relying on that
654-// rejection. Probe must return promptly once ctx is cancelled: the registry
655-// cancels the attempt context when a registration is replaced or removed, and
656-// when the run settles. A probe that
657-// ignores cancellation delays that retirement and can overlap a replacement
658-// attempt for the same endpoint. Stale completions that arrive anyway are
659-// fenced by epoch and registration identity before publication.
722+// attachment. The probe reports only observed state in a
723+// BrokerDaemonObservation: the observed daemon identity, incarnation,
724+// protocol version, capabilities, availability, failure, freshness, and
725+// session inventory. Configured authority (endpoint, registration, policy,
726+// display origin, rank) is stamped by the registry from its own records and
727+// never trusted from the probe; unknown identity, version, or inventory is
728+// zero and is never invented. The returned observation must satisfy the
729+// durable projection rules (see ValidateDurableHostProjection): the registry
730+// re-checks exactly those rules, so an over-bound or catalogue-invalid session
731+// inventory is classified as an invalid response and never published.
732+// Implementations should therefore validate the observation with
733+// catalogue.ValidateRemoteCatalog against the RemoteCatalogMax* bounds
734+// before returning it rather than relying on that rejection. Probe must
735+// return promptly once ctx is cancelled: the registry cancels the attempt
736+// context when a registration is replaced or removed, and when the run
737+// settles. A probe that ignores cancellation delays that retirement and can
738+// overlap a replacement attempt for the same endpoint. Stale completions
739+// that arrive anyway are fenced by epoch and registration identity before
740+// publication.
660741 type BrokerHostProbe interface {
661- Probe(ctx context.Context, registration domain.RemoteRegistration) (RemoteHostSnapshot, error)
742+ Probe(ctx context.Context, registration domain.RemoteRegistration) (BrokerDaemonObservation, error)
662743 }
663744
664745 // BrokerPhysicalConnection is one pooled physical transport to an
@@ -773,9 +854,38 @@ func validateBrokerDisplayText(value string, maxBytes int, label string) error {
773854 return fmt.Errorf("ports: broker %s is not valid UTF-8", label)
774855 }
775856 for _, r := range value {
776- if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {
857+ if !brokerDisplayRuneAllowed(r) {
777858 return fmt.Errorf("ports: broker %s contains disallowed characters", label)
778859 }
779860 }
780861 return nil
781862 }
863+
864+// brokerDisplayRuneAllowed reports whether one rune may appear in broker
865+// display text. Controls, bidi controls, and the Unicode line separators are
866+// refused: none of them carries presentation value, and each lets a value
867+// reorder or split the text rendered around it.
868+func brokerDisplayRuneAllowed(r rune) bool {
869+ return !unicode.IsControl(r) && !unicode.Is(unicode.Bidi_Control, r) && r != '\u2028' && r != '\u2029'
870+}
871+
872+// SanitizeBrokerDisplayText returns value with every rune broker display text
873+// refuses removed, and always as valid UTF-8. Producers apply it to the
874+// presentation hints they derive rather than receive: a hint derived from
875+// routing authority is not itself authority, so a configured target the
876+// routing validator accepts still yields text the display rule accepts instead
877+// of a publication the wire refuses. The result is not length-checked; the
878+// caller applies the bound for its own field.
879+func SanitizeBrokerDisplayText(value string) string {
880+ if utf8.ValidString(value) && strings.IndexFunc(value, func(r rune) bool { return !brokerDisplayRuneAllowed(r) }) < 0 {
881+ return value
882+ }
883+ var safe strings.Builder
884+ safe.Grow(len(value))
885+ for _, r := range value {
886+ if brokerDisplayRuneAllowed(r) {
887+ safe.WriteRune(r)
888+ }
889+ }
890+ return safe.String()
891+}
A internal/ports/broker_observation.go +167 -0
@@ -0,0 +1,167 @@
1+package ports
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/protocol/catalogue"
10+)
11+
12+// Broker-native daemon observation (Plan 001 P5.2a).
13+//
14+// BrokerDaemonObservation is the broker's own projection of one daemon, local
15+// or remote. It deliberately does not reuse RemoteHostSnapshot: that type
16+// belongs to the legacy remote monitor and carries no daemon identity,
17+// incarnation, version, capability, or policy authority. The broker
18+// projection keeps configured authority separate from observed state:
19+//
20+// - configured authority: Local flag, Endpoint, Registration, Policy, and
21+// the presentation hints DisplayOrigin and Rank. The registry stamps
22+// them from its own host records (or the local composition) and never
23+// trusts them from a probe.
24+// - observed identity: Identity and Incarnation are zero until a probe
25+// actually authenticated the daemon. Unknown identity is never invented.
26+// - observed capability: ProtocolVersion and Capabilities mirror what the
27+// daemon reported. ProtocolVersion is the daemon's own version and may
28+// differ from Policy.ProtocolVersion; that difference is exactly how an
29+// incompatible daemon is observable. Zero means unobserved.
30+// - observation state: Availability, Checking, timestamps, failure
31+// counters, and the exact session catalogue. Availability zero is
32+// invalid: an unobserved daemon carries RemoteAvailabilityUnknown.
33+//
34+// Observing a daemon never attaches to it and never starts a stopped one.
35+
36+const (
37+ // BrokerMaxDaemonsPerSnapshot bounds one snapshot publication: the local
38+ // daemon plus every configured remote host.
39+ BrokerMaxDaemonsPerSnapshot = BrokerMaxHosts + 1
40+ // BrokerMaxDisplayOriginBytes bounds the presentation origin hint.
41+ BrokerMaxDisplayOriginBytes = 128
42+)
43+
44+// BrokerDaemonObservation is one immutable daemon projection in one broker
45+// snapshot. Nested slices are never mutated after publication.
46+type BrokerDaemonObservation struct {
47+ // Local marks the broker's own machine daemon. A local observation has
48+ // no endpoint and no registration: the local daemon is not a configured
49+ // host. At most one local observation exists per snapshot.
50+ Local bool
51+
52+ // Configured authority (registry-stamped, never probe-supplied).
53+ Endpoint string // remote only; matches Registration.Endpoint
54+ DisplayOrigin string
55+ Rank int
56+ Registration domain.RemoteRegistration
57+ Policy BrokerPolicy
58+
59+ // Observed identity/incarnation (zero until observed).
60+ Identity BrokerDaemonIdentity
61+ Incarnation BrokerDaemonIncarnation
62+
63+ // Observed version/capability (zero until observed).
64+ ProtocolVersion uint16
65+ Capabilities uint32
66+
67+ // Availability, checking, failure, and freshness state.
68+ Availability domain.RemoteAvailability
69+ Checking bool
70+ LastAttempt time.Time
71+ LastSuccess time.Time
72+ NextDue time.Time
73+ ConsecutiveFailures uint
74+ FailureEpisode uint64
75+ LastFailure domain.RemoteFailure
76+ InventoryKnown bool
77+
78+ // Exact session catalogue observed from the daemon.
79+ Sessions []catalogue.RemoteCatalogSession
80+}
81+
82+// Validate enforces the closed projection rules.
83+func (o BrokerDaemonObservation) Validate() error {
84+ // Policy is required authority for every daemon, observed or not.
85+ if err := o.Policy.Validate(); err != nil {
86+ return fmt.Errorf("ports: broker daemon observation: %w", err)
87+ }
88+ if err := validateBrokerDisplayText(o.DisplayOrigin, BrokerMaxDisplayOriginBytes, "display origin"); err != nil {
89+ return fmt.Errorf("ports: broker daemon observation: %w", err)
90+ }
91+ if o.DisplayOrigin == "" {
92+ return errors.New("ports: broker daemon observation has no display origin")
93+ }
94+ if o.Local {
95+ if o.Endpoint != "" || o.Registration != (domain.RemoteRegistration{}) {
96+ return errors.New("ports: local daemon observation carries remote authority")
97+ }
98+ } else {
99+ if err := domain.ValidateRemoteHostTarget(o.Endpoint); err != nil {
100+ return fmt.Errorf("ports: broker daemon observation: %w", err)
101+ }
102+ if err := o.Registration.Validate(); err != nil {
103+ return fmt.Errorf("ports: broker daemon observation: %w", err)
104+ }
105+ if o.Endpoint != o.Registration.Endpoint {
106+ return errors.New("ports: broker daemon observation endpoint does not match registration")
107+ }
108+ }
109+ if err := validateObservedDaemonState(o); err != nil {
110+ return err
111+ }
112+ if o.Availability < domain.RemoteAvailabilityUnknown || o.Availability > domain.RemoteAvailabilityInvalidResponse {
113+ return errors.New("ports: broker daemon observation availability is out of range")
114+ }
115+ if o.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
116+ return errors.New("ports: broker daemon observation failure kind is out of range")
117+ }
118+ if len(o.Sessions) > BrokerMaxSessionsPerHost {
119+ return errors.New("ports: broker daemon observation has too many sessions")
120+ }
121+ if len(o.Sessions) > 0 && !o.InventoryKnown {
122+ return errors.New("ports: broker daemon observation carries sessions without known inventory")
123+ }
124+ return nil
125+}
126+
127+// validateObservedDaemonState applies the observed-state rules shared by a live
128+// publication and a durable projection. Observed identity is all-or-nothing and
129+// never invented: identity and incarnation appear together, both validate, and
130+// an observed daemon always carries the protocol version its identity was
131+// authenticated with. The offline store, a restored durable record, and the
132+// registry all apply this one rule, so a projection either layer accepts is
133+// never published as an observation the wire refuses.
134+func validateObservedDaemonState(observation BrokerDaemonObservation) error {
135+ observed := observation.Identity != ""
136+ if observed != !observation.Incarnation.IsZero() {
137+ return errors.New("ports: broker daemon observation has partial identity")
138+ }
139+ if !observed {
140+ return nil
141+ }
142+ if err := observation.Identity.Validate(); err != nil {
143+ return err
144+ }
145+ if err := observation.Incarnation.Validate(); err != nil {
146+ return err
147+ }
148+ if observation.ProtocolVersion == 0 {
149+ return errors.New("ports: observed daemon identity carries no protocol version")
150+ }
151+ return nil
152+}
153+
154+// Clone returns a defensive copy with an independent session slice. Each
155+// session keeps the exact nil-ness of its tab list: an empty but present list
156+// is catalogue-valid and must not become absent through a copy.
157+func (o BrokerDaemonObservation) Clone() BrokerDaemonObservation {
158+ out := o
159+ out.Sessions = append([]catalogue.RemoteCatalogSession(nil), o.Sessions...)
160+ for i := range out.Sessions {
161+ if o.Sessions[i].Tabs == nil {
162+ continue
163+ }
164+ out.Sessions[i].Tabs = append(make([]catalogue.RemoteCatalogTab, 0, len(o.Sessions[i].Tabs)), o.Sessions[i].Tabs...)
165+ }
166+ return out
167+}
A internal/ports/broker_observation_test.go +170 -0
@@ -0,0 +1,170 @@
1+package ports
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol"
9+ "github.com/bnema/vev/internal/protocol/catalogue"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestBrokerDaemonObservationValidate(t *testing.T) {
14+ observed := func(o BrokerDaemonObservation) BrokerDaemonObservation {
15+ o.Identity = BrokerDaemonIdentity("authed-daemon")
16+ o.Incarnation = BrokerDaemonIncarnation{9}
17+ o.ProtocolVersion = protocol.Version
18+ o.Capabilities = protocol.CapabilityResume | protocol.CapabilityUDP
19+ return o
20+ }
21+ tests := []struct {
22+ name string
23+ mutate func(*BrokerDaemonObservation)
24+ valid bool
25+ }{
26+ {name: "unobserved remote daemon", mutate: func(*BrokerDaemonObservation) {}, valid: true},
27+ {name: "unknown availability", mutate: func(o *BrokerDaemonObservation) {
28+ o.Availability = domain.RemoteAvailabilityUnknown
29+ }, valid: true},
30+ {name: "observed daemon", mutate: func(o *BrokerDaemonObservation) { *o = observed(*o) }, valid: true},
31+ {name: "observed daemon without capabilities", mutate: func(o *BrokerDaemonObservation) {
32+ *o = observed(*o)
33+ o.Capabilities = 0
34+ }, valid: true},
35+ {name: "observed daemon reports a different protocol version", mutate: func(o *BrokerDaemonObservation) {
36+ *o = observed(*o)
37+ o.ProtocolVersion = protocol.Version + 1
38+ o.Availability = domain.RemoteAvailabilityIncompatible
39+ }, valid: true},
40+ {name: "observed identity without protocol version", mutate: func(o *BrokerDaemonObservation) {
41+ *o = observed(*o)
42+ o.ProtocolVersion = 0
43+ }, valid: false},
44+ {name: "identity without incarnation", mutate: func(o *BrokerDaemonObservation) {
45+ o.Identity = BrokerDaemonIdentity("authed-daemon")
46+ }, valid: false},
47+ {name: "incarnation without identity", mutate: func(o *BrokerDaemonObservation) {
48+ o.Incarnation = BrokerDaemonIncarnation{9}
49+ }, valid: false},
50+ {name: "empty display origin", mutate: func(o *BrokerDaemonObservation) {
51+ o.DisplayOrigin = ""
52+ }, valid: false},
53+ {name: "control characters in display origin", mutate: func(o *BrokerDaemonObservation) {
54+ o.DisplayOrigin = "bad\x1border"
55+ }, valid: false},
56+ {name: "oversized display origin", mutate: func(o *BrokerDaemonObservation) {
57+ o.DisplayOrigin = strings.Repeat("a", BrokerMaxDisplayOriginBytes+1)
58+ }, valid: false},
59+ {name: "missing policy", mutate: func(o *BrokerDaemonObservation) {
60+ o.Policy = BrokerPolicy{}
61+ }, valid: false},
62+ {name: "zero availability is never invented", mutate: func(o *BrokerDaemonObservation) {
63+ o.Availability = 0
64+ }, valid: false},
65+ {name: "failure kind past the closed range", mutate: func(o *BrokerDaemonObservation) {
66+ o.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1
67+ }, valid: false},
68+ {name: "empty inventory is known inventory", mutate: func(o *BrokerDaemonObservation) {
69+ o.InventoryKnown = true
70+ }, valid: true},
71+ {name: "sessions without known inventory", mutate: func(o *BrokerDaemonObservation) {
72+ o.Sessions = []catalogue.RemoteCatalogSession{{
73+ LifecycleID: testBrokerLifecycle(),
74+ Name: "work",
75+ State: catalogue.RemoteCatalogSessionUp,
76+ Tabs: []catalogue.RemoteCatalogTab{},
77+ }}
78+ }, valid: false},
79+ {name: "local daemon without remote authority", mutate: func(o *BrokerDaemonObservation) {
80+ *o = testLocalBrokerObservation()
81+ }, valid: true},
82+ {name: "local daemon carrying an endpoint", mutate: func(o *BrokerDaemonObservation) {
83+ *o = testLocalBrokerObservation()
84+ o.Endpoint = "user@arch"
85+ }, valid: false},
86+ {name: "local daemon carrying a registration", mutate: func(o *BrokerDaemonObservation) {
87+ *o = testLocalBrokerObservation()
88+ o.Registration = testBrokerRegistration("user@arch", 1, 1)
89+ }, valid: false},
90+ {name: "remote daemon missing registration", mutate: func(o *BrokerDaemonObservation) {
91+ o.Registration = domain.RemoteRegistration{}
92+ }, valid: false},
93+ {name: "endpoint registration mismatch", mutate: func(o *BrokerDaemonObservation) {
94+ o.Registration = testBrokerRegistration("user@other", 1, 1)
95+ }, valid: false},
96+ }
97+ for _, tc := range tests {
98+ t.Run(tc.name, func(t *testing.T) {
99+ obs := testBrokerObservation("user@arch")
100+ tc.mutate(&obs)
101+ if (obs.Validate() == nil) != tc.valid {
102+ t.Fatalf("Validate() = %v, want valid %t", obs.Validate(), tc.valid)
103+ }
104+ })
105+ }
106+}
107+
108+// TestBrokerDaemonObservationValidateDisplayOrigin pins the display-origin
109+// bound and character rules with exact refusals: a bidi control or a Unicode
110+// line separator is refused as disallowed text, an origin at the byte bound is
111+// accepted, and one byte over is refused.
112+func TestBrokerDaemonObservationValidateDisplayOrigin(t *testing.T) {
113+ tests := []struct {
114+ name string
115+ origin string
116+ wantErrContains string
117+ }{
118+ {
119+ name: "bidi control is refused",
120+ origin: "user\u202earch",
121+ wantErrContains: "display origin contains disallowed characters",
122+ },
123+ {
124+ name: "line separator is refused",
125+ origin: "user@arch\u2028",
126+ wantErrContains: "display origin contains disallowed characters",
127+ },
128+ {
129+ name: "origin at the byte bound is accepted",
130+ origin: strings.Repeat("a", BrokerMaxDisplayOriginBytes),
131+ },
132+ {
133+ name: "origin one byte over the bound is refused",
134+ origin: strings.Repeat("a", BrokerMaxDisplayOriginBytes+1),
135+ wantErrContains: "display origin too long",
136+ },
137+ }
138+ for _, tc := range tests {
139+ t.Run(tc.name, func(t *testing.T) {
140+ obs := testBrokerObservation("user@arch")
141+ obs.DisplayOrigin = tc.origin
142+ err := obs.Validate()
143+ if tc.wantErrContains != "" {
144+ require.ErrorContains(t, err, tc.wantErrContains)
145+ return
146+ }
147+ require.NoError(t, err)
148+ })
149+ }
150+}
151+
152+func TestBrokerDaemonObservationCloneIsDefensive(t *testing.T) {
153+ original := testBrokerObservation("user@arch")
154+ original.InventoryKnown = true
155+ original.Sessions = []catalogue.RemoteCatalogSession{{
156+ LifecycleID: testBrokerLifecycle(),
157+ Name: "work",
158+ State: catalogue.RemoteCatalogSessionUp,
159+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}},
160+ }}
161+ cloned := original.Clone()
162+ cloned.Sessions[0].Name = "mutated"
163+ cloned.Sessions[0].Tabs[0].ID = "mutated"
164+ if original.Sessions[0].Name != "work" || original.Sessions[0].Tabs[0].ID != "tab-1" {
165+ t.Fatal("Clone() shares memory with the original")
166+ }
167+ if (BrokerDaemonObservation{}).Clone().Sessions != nil {
168+ t.Fatal("Clone() of an empty observation must keep a nil session slice")
169+ }
170+}
M internal/ports/broker_store.go +28 -16
@@ -94,28 +94,40 @@ type BrokerHostStore interface {
9494 // one seam. This guard keeps that composition compile-checked.
9595 var _ BrokerSnapshotStore = (BrokerHostStore)(nil)
9696
97-// ValidateDurableHostProjection reports whether one host projection satisfies
98-// the semantic rules of durable broker state: a published availability and
99-// failure kind inside their closed ranges, and a session inventory the
100-// catalogue accepts for the host's exact incarnation and success time. The
101-// offline store and the registry both apply this rule, so a projection the
102-// registry publishes is never rejected (and therefore silently never
103-// persisted) by the store. Transient state (Checking and the live failure
104-// cause) is deliberately not part of the durable rule.
105-func ValidateDurableHostProjection(host RemoteHostSnapshot) error {
106- if host.Availability < domain.RemoteAvailabilityUnknown || host.Availability > domain.RemoteAvailabilityInvalidResponse {
97+// ValidateDurableHostProjection reports whether one daemon observation
98+// satisfies the rules the offline store and wire accept: a local observation
99+// is never durable, availability and failure kinds stay in their closed
100+// ranges, and a present session inventory is a valid catalogue cache entry
101+// against the exact registration incarnation. The offline store and the
102+// registry both apply this rule, so a projection the registry publishes is
103+// never rejected (and therefore silently never persisted) by the store.
104+// Transient state (Checking and the live failure cause) is deliberately not
105+// part of the durable rule.
106+func ValidateDurableHostProjection(obs BrokerDaemonObservation) error {
107+ if obs.Local {
108+ return errors.New("ports: local daemon observation is never durable")
109+ }
110+ if obs.Availability < domain.RemoteAvailabilityUnknown || obs.Availability > domain.RemoteAvailabilityInvalidResponse {
107111 return errors.New("ports: broker host availability is out of range")
108112 }
109- if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
113+ if obs.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
110114 return errors.New("ports: broker host failure kind is out of range")
111115 }
112- if len(host.Sessions) == 0 {
116+ // Observed identity is part of the durable rule, not only of the live one: a
117+ // restored record with partial identity or without the protocol version its
118+ // identity was authenticated with would otherwise reach a publication the
119+ // wire refuses, taking down every client connection instead of failing
120+ // closed here.
121+ if err := validateObservedDaemonState(obs); err != nil {
122+ return err
123+ }
124+ if len(obs.Sessions) == 0 {
113125 return nil
114126 }
115127 return catalogue.ValidateRemoteCatalogCacheEntries([]catalogue.RemoteCatalogCacheEntry{{
116- Host: host.Endpoint,
117- Incarnation: host.Registration.Incarnation,
118- FetchedAt: host.LastSuccess,
119- Sessions: host.Sessions,
128+ Host: obs.Endpoint,
129+ Incarnation: obs.Registration.Incarnation,
130+ FetchedAt: obs.LastSuccess,
131+ Sessions: obs.Sessions,
120132 }})
121133 }
A internal/ports/broker_store_test.go +70 -0
@@ -0,0 +1,70 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/protocol"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+// TestValidateDurableHostProjectionIdentity pins the durable rule to the live
11+// observation rule: an identity and incarnation must appear together, and an
12+// observed identity must carry the protocol version it was authenticated with.
13+// A restored durable record that violates either rule would otherwise reach a
14+// publication the wire refuses.
15+func TestValidateDurableHostProjectionIdentity(t *testing.T) {
16+ observed := func(o BrokerDaemonObservation) BrokerDaemonObservation {
17+ o.Identity = BrokerDaemonIdentity("authed-daemon")
18+ o.Incarnation = BrokerDaemonIncarnation{9}
19+ o.ProtocolVersion = protocol.Version
20+ return o
21+ }
22+ tests := []struct {
23+ name string
24+ mutate func(*BrokerDaemonObservation)
25+ wantErrContains string
26+ }{
27+ {
28+ name: "no identity is accepted",
29+ mutate: func(*BrokerDaemonObservation) {},
30+ },
31+ {
32+ name: "identity and incarnation with a protocol version are accepted",
33+ mutate: func(o *BrokerDaemonObservation) { *o = observed(*o) },
34+ },
35+ {
36+ name: "identity with zero protocol version is refused",
37+ mutate: func(o *BrokerDaemonObservation) {
38+ *o = observed(*o)
39+ o.ProtocolVersion = 0
40+ },
41+ wantErrContains: "carries no protocol version",
42+ },
43+ {
44+ name: "identity without incarnation is refused",
45+ mutate: func(o *BrokerDaemonObservation) {
46+ o.Identity = BrokerDaemonIdentity("authed-daemon")
47+ },
48+ wantErrContains: "partial identity",
49+ },
50+ {
51+ name: "incarnation without identity is refused",
52+ mutate: func(o *BrokerDaemonObservation) {
53+ o.Incarnation = BrokerDaemonIncarnation{9}
54+ },
55+ wantErrContains: "partial identity",
56+ },
57+ }
58+ for _, tc := range tests {
59+ t.Run(tc.name, func(t *testing.T) {
60+ obs := testBrokerObservation("user@arch")
61+ tc.mutate(&obs)
62+ err := ValidateDurableHostProjection(obs)
63+ if tc.wantErrContains != "" {
64+ require.ErrorContains(t, err, tc.wantErrContains)
65+ return
66+ }
67+ require.NoError(t, err)
68+ })
69+ }
70+}
M internal/ports/broker_test.go +189 -47
@@ -5,10 +5,12 @@ import (
55 "strings"
66 "testing"
77 "time"
8+ "unicode/utf8"
89
910 "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/protocol"
1112 "github.com/bnema/vev/internal/protocol/catalogue"
13+ "github.com/stretchr/testify/require"
1214 )
1315
1416 func testBrokerRegistration(endpoint string, incarnation byte, generation domain.RemoteGeneration) domain.RemoteRegistration {
@@ -37,11 +39,21 @@ func testBrokerPolicy() BrokerPolicy {
3739 }
3840 }
3941
40-func testBrokerHost(endpoint string) RemoteHostSnapshot {
41- return RemoteHostSnapshot{
42+func testBrokerObservation(endpoint string) BrokerDaemonObservation {
43+ return BrokerDaemonObservation{
4244 Endpoint: endpoint,
4345 DisplayOrigin: endpoint,
4446 Registration: testBrokerRegistration(endpoint, 1, 1),
47+ Policy: testBrokerPolicy(),
48+ Availability: domain.RemoteAvailabilityReachable,
49+ }
50+}
51+
52+func testLocalBrokerObservation() BrokerDaemonObservation {
53+ return BrokerDaemonObservation{
54+ Local: true,
55+ DisplayOrigin: "local",
56+ Policy: testBrokerPolicy(),
4557 Availability: domain.RemoteAvailabilityReachable,
4658 }
4759 }
@@ -50,7 +62,7 @@ func validBrokerSnapshot() BrokerSnapshot {
5062 return BrokerSnapshot{
5163 Epoch: 3,
5264 Revision: 9,
53- Hosts: []RemoteHostSnapshot{testBrokerHost("user@arch")},
65+ Daemons: []BrokerDaemonObservation{testBrokerObservation("user@arch")},
5466 }
5567 }
5668
@@ -76,36 +88,45 @@ func TestValidateDurableHostProjection(t *testing.T) {
7688 }
7789 tests := []struct {
7890 name string
79- mutate func(*RemoteHostSnapshot)
91+ mutate func(*BrokerDaemonObservation)
8092 wantErr bool
8193 }{
82- {name: "empty inventory", mutate: func(*RemoteHostSnapshot) {}},
83- {name: "valid inventory", mutate: func(h *RemoteHostSnapshot) { h.Sessions = []catalogue.RemoteCatalogSession{session} }},
84- {name: "zero availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = 0 }, wantErr: true},
85- {name: "unknown availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityUnknown }},
86- {name: "availability past the closed range", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true},
87- {name: "failure kind past the closed range", mutate: func(h *RemoteHostSnapshot) { h.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true},
88- {name: "missing lifecycle identity", mutate: func(h *RemoteHostSnapshot) {
89- h.Sessions = []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{}}}
94+ {name: "empty inventory", mutate: func(*BrokerDaemonObservation) {}},
95+ {name: "valid inventory", mutate: func(o *BrokerDaemonObservation) {
96+ o.InventoryKnown = true
97+ o.Sessions = []catalogue.RemoteCatalogSession{session}
98+ }},
99+ {name: "local observation", mutate: func(o *BrokerDaemonObservation) {
100+ *o = testLocalBrokerObservation()
101+ }, wantErr: true},
102+ {name: "zero availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = 0 }, wantErr: true},
103+ {name: "unknown availability", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnknown }},
104+ {name: "availability past the closed range", mutate: func(o *BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true},
105+ {name: "failure kind past the closed range", mutate: func(o *BrokerDaemonObservation) { o.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true},
106+ {name: "missing lifecycle identity", mutate: func(o *BrokerDaemonObservation) {
107+ o.InventoryKnown = true
108+ o.Sessions = []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{}}}
90109 }, wantErr: true},
91- {name: "absent tab list", mutate: func(h *RemoteHostSnapshot) {
110+ {name: "absent tab list", mutate: func(o *BrokerDaemonObservation) {
92111 broken := session
93112 broken.Tabs = nil
94- h.Sessions = []catalogue.RemoteCatalogSession{broken}
113+ o.InventoryKnown = true
114+ o.Sessions = []catalogue.RemoteCatalogSession{broken}
95115 }, wantErr: true},
96- {name: "sessions past the catalogue bound", mutate: func(h *RemoteHostSnapshot) {
97- h.Sessions = make([]catalogue.RemoteCatalogSession, catalogue.RemoteCatalogMaxSessions+1)
98- for i := range h.Sessions {
99- h.Sessions[i] = session
116+ {name: "sessions past the catalogue bound", mutate: func(o *BrokerDaemonObservation) {
117+ o.InventoryKnown = true
118+ o.Sessions = make([]catalogue.RemoteCatalogSession, catalogue.RemoteCatalogMaxSessions+1)
119+ for i := range o.Sessions {
120+ o.Sessions[i] = session
100121 }
101122 }, wantErr: true},
102123 }
103124 for _, tc := range tests {
104125 t.Run(tc.name, func(t *testing.T) {
105- host := testBrokerHost("user@arch")
106- host.LastSuccess = time.Unix(50, 0)
107- tc.mutate(&host)
108- if gotErr := ValidateDurableHostProjection(host); (gotErr != nil) != tc.wantErr {
126+ obs := testBrokerObservation("user@arch")
127+ obs.LastSuccess = time.Unix(50, 0)
128+ tc.mutate(&obs)
129+ if gotErr := ValidateDurableHostProjection(obs); (gotErr != nil) != tc.wantErr {
109130 t.Fatalf("ValidateDurableHostProjection() = %v, wantErr %t", gotErr, tc.wantErr)
110131 }
111132 })
@@ -121,21 +142,27 @@ func TestBrokerSnapshotValidateBounds(t *testing.T) {
121142 {name: "valid", mutate: func(*BrokerSnapshot) {}, wantErr: false},
122143 {name: "zero epoch", mutate: func(s *BrokerSnapshot) { s.Epoch = 0 }, wantErr: true},
123144 {name: "zero revision", mutate: func(s *BrokerSnapshot) { s.Revision = 0 }, wantErr: true},
124- {name: "too many hosts", mutate: func(s *BrokerSnapshot) {
125- s.Hosts = make([]RemoteHostSnapshot, BrokerMaxHosts+1)
126- for i := range s.Hosts {
127- s.Hosts[i] = testBrokerHost("user@arch")
128- s.Hosts[i].Registration = testBrokerRegistration("user@arch", 1, 1)
145+ {name: "too many daemons", mutate: func(s *BrokerSnapshot) {
146+ s.Daemons = make([]BrokerDaemonObservation, BrokerMaxDaemonsPerSnapshot+1)
147+ for i := range s.Daemons {
148+ s.Daemons[i] = testBrokerObservation("user@arch")
149+ s.Daemons[i].Registration = testBrokerRegistration("user@arch", 1, 1)
129150 }
130151 }, wantErr: true},
152+ {name: "local daemon plus remote hosts is valid", mutate: func(s *BrokerSnapshot) {
153+ s.Daemons = append([]BrokerDaemonObservation{testLocalBrokerObservation()}, s.Daemons...)
154+ }},
155+ {name: "two local daemons", mutate: func(s *BrokerSnapshot) {
156+ s.Daemons = append(s.Daemons, testLocalBrokerObservation(), testLocalBrokerObservation())
157+ }, wantErr: true},
131158 {name: "duplicate host", mutate: func(s *BrokerSnapshot) {
132- s.Hosts = append(s.Hosts, testBrokerHost("user@arch"))
159+ s.Daemons = append(s.Daemons, testBrokerObservation("user@arch"))
133160 }, wantErr: true},
134161 {name: "endpoint registration mismatch", mutate: func(s *BrokerSnapshot) {
135- s.Hosts[0].Registration = testBrokerRegistration("user@other", 1, 1)
162+ s.Daemons[0].Registration = testBrokerRegistration("user@other", 1, 1)
136163 }, wantErr: true},
137164 {name: "zero registration incarnation", mutate: func(s *BrokerSnapshot) {
138- s.Hosts[0].Registration = domain.RemoteRegistration{Endpoint: "user@arch", Generation: 1}
165+ s.Daemons[0].Registration = domain.RemoteRegistration{Endpoint: "user@arch", Generation: 1}
139166 }, wantErr: true},
140167 {name: "live host as tombstone", mutate: func(s *BrokerSnapshot) {
141168 s.Removed = []BrokerHostTombstone{{
@@ -228,7 +255,9 @@ func TestBrokerSnapshotEpochRevisionOrdering(t *testing.T) {
228255
229256 func TestBrokerSnapshotCloneAndFind(t *testing.T) {
230257 original := validBrokerSnapshot()
231- original.Hosts[0].Sessions = []catalogue.RemoteCatalogSession{{
258+ original.Daemons = append([]BrokerDaemonObservation{testLocalBrokerObservation()}, original.Daemons...)
259+ original.Daemons[1].InventoryKnown = true
260+ original.Daemons[1].Sessions = []catalogue.RemoteCatalogSession{{
232261 LifecycleID: testBrokerLifecycle(),
233262 Name: "work",
234263 State: catalogue.RemoteCatalogSessionUp,
@@ -241,9 +270,18 @@ func TestBrokerSnapshotCloneAndFind(t *testing.T) {
241270 if _, ok := cloned.Find("user@mule"); ok {
242271 t.Fatal("Find() must miss unknown endpoints")
243272 }
244- cloned.Hosts[0].Sessions[0].Name = "mutated"
245- cloned.Hosts[0].Sessions[0].Tabs[0].ID = "mutated"
246- if original.Hosts[0].Sessions[0].Name != "work" || original.Hosts[0].Sessions[0].Tabs[0].ID != "tab-1" {
273+ local := 0
274+ for _, daemon := range cloned.Daemons {
275+ if daemon.Local {
276+ local++
277+ }
278+ }
279+ if local != 1 {
280+ t.Fatalf("Clone() lost the local daemon entry: %d local entries", local)
281+ }
282+ cloned.Daemons[1].Sessions[0].Name = "mutated"
283+ cloned.Daemons[1].Sessions[0].Tabs[0].ID = "mutated"
284+ if original.Daemons[1].Sessions[0].Name != "work" || original.Daemons[1].Sessions[0].Tabs[0].ID != "tab-1" {
247285 t.Fatal("Clone() shares memory with the original")
248286 }
249287 }
@@ -352,6 +390,7 @@ func TestBrokerOpenStreamRequestValidate(t *testing.T) {
352390 valid := func() BrokerOpenStreamRequest {
353391 return BrokerOpenStreamRequest{
354392 Epoch: 1, Purpose: BrokerStreamAttachment,
393+ Admission: BrokerAdmissionExact,
355394 Connection: BrokerConnectionID{1},
356395 Stream: 2,
357396 Endpoint: "user@arch",
@@ -379,6 +418,26 @@ func TestBrokerOpenStreamRequestValidate(t *testing.T) {
379418 {name: "bad session name", mutate: func(r *BrokerOpenStreamRequest) {
380419 r.Target = protocol.ExactSessionTarget{LifecycleID: testBrokerLifecycle(), SessionName: "bad name!"}
381420 }},
421+ {name: "exact admission carries a creation name", mutate: func(r *BrokerOpenStreamRequest) {
422+ r.Name = "work"
423+ }},
424+ {name: "named creation carries an exact target", mutate: func(r *BrokerOpenStreamRequest) {
425+ r.Admission = BrokerAdmissionCreateNamed
426+ r.Name = "work"
427+ }},
428+ {name: "named creation with invalid name", mutate: func(r *BrokerOpenStreamRequest) {
429+ r.Admission = BrokerAdmissionCreateNamed
430+ r.Name = "bad name!"
431+ r.Target = protocol.ExactSessionTarget{}
432+ }},
433+ {name: "ephemeral creation carries a name", mutate: func(r *BrokerOpenStreamRequest) {
434+ r.Admission = BrokerAdmissionCreateEphemeral
435+ r.Target = protocol.ExactSessionTarget{}
436+ r.Name = "work"
437+ }},
438+ {name: "ephemeral creation carries a target", mutate: func(r *BrokerOpenStreamRequest) {
439+ r.Admission = BrokerAdmissionCreateEphemeral
440+ }},
382441 {name: "too many env entries", mutate: func(r *BrokerOpenStreamRequest) {
383442 r.Env = make([]string, BrokerMaxEnvEntries+1)
384443 }},
@@ -546,24 +605,107 @@ func TestBrokerInterfacesArePorts(t *testing.T) {
546605 }
547606 }
548607
608+// TestSanitizeBrokerDisplayText pins the sanitizer to the display validator:
609+// every result is valid UTF-8 and carries no rune validateBrokerDisplayText
610+// refuses, so a producer can sanitize a derived presentation hint and never
611+// publish a value its own contract rejects.
612+func TestSanitizeBrokerDisplayText(t *testing.T) {
613+ tests := []struct {
614+ name string
615+ input string
616+ want string
617+ }{
618+ {name: "ordinary text is returned unchanged", input: "user@arch", want: "user@arch"},
619+ {name: "login-prefixed origin is returned unchanged", input: "user@host.example:2222", want: "user@host.example:2222"},
620+ {name: "accented and emoji text is kept intact", input: "caf\u00e9 \U0001f5a5", want: "caf\u00e9 \U0001f5a5"},
621+ {name: "right-to-left override is dropped", input: "sh\u202eell", want: "shell"},
622+ {name: "left-to-right embedding is dropped", input: "a\u202ab", want: "ab"},
623+ {name: "C0 control is dropped", input: "a\x00b\x1bc", want: "abc"},
624+ {name: "line separator is dropped", input: "a\u2028b", want: "ab"},
625+ {name: "paragraph separator is dropped", input: "a\u2029b", want: "ab"},
626+ {name: "invalid utf-8 is replaced", input: "ok\xff\xfe", want: "ok\ufffd\ufffd"},
627+ {name: "every rune disallowed yields empty", input: "\u202e\u2028\x00", want: ""},
628+ }
629+ for _, tc := range tests {
630+ t.Run(tc.name, func(t *testing.T) {
631+ got := SanitizeBrokerDisplayText(tc.input)
632+ require.Equal(t, tc.want, got)
633+ require.True(t, utf8.ValidString(got), "sanitized text must always be valid UTF-8")
634+ require.Equal(t, -1, strings.IndexFunc(got, func(r rune) bool { return !brokerDisplayRuneAllowed(r) }), "sanitized text must carry no disallowed rune")
635+ require.NoError(t, validateBrokerDisplayText(got, BrokerMaxDisplayOriginBytes, "display origin"))
636+ })
637+ }
638+}
639+
640+// TestBrokerSnapshotValidateLocalDaemonPlacement pins the wire placement rule:
641+// the local daemon observation is carried at index zero only, a local
642+// observation anywhere else is refused, and a local daemon is still refused
643+// when it appears twice.
644+func TestBrokerSnapshotValidateLocalDaemonPlacement(t *testing.T) {
645+ tests := []struct {
646+ name string
647+ daemons []BrokerDaemonObservation
648+ wantErrContains string
649+ }{
650+ {
651+ name: "local daemon first",
652+ daemons: []BrokerDaemonObservation{testLocalBrokerObservation(), testBrokerObservation("user@arch")},
653+ },
654+ {
655+ name: "local daemon at index one is refused",
656+ daemons: []BrokerDaemonObservation{testBrokerObservation("user@arch"), testLocalBrokerObservation()},
657+ wantErrContains: "local daemon at index 1",
658+ },
659+ {
660+ name: "local daemon after two remotes is refused",
661+ daemons: []BrokerDaemonObservation{testBrokerObservation("user@arch"), testBrokerObservation("user@other"), testLocalBrokerObservation()},
662+ wantErrContains: "local daemon at index 2",
663+ },
664+ {
665+ name: "two local daemons are refused",
666+ daemons: []BrokerDaemonObservation{testLocalBrokerObservation(), testLocalBrokerObservation()},
667+ wantErrContains: "more than one local daemon",
668+ },
669+ }
670+ for _, tc := range tests {
671+ t.Run(tc.name, func(t *testing.T) {
672+ snapshot := validBrokerSnapshot()
673+ snapshot.Daemons = tc.daemons
674+ err := snapshot.Validate()
675+ if tc.wantErrContains != "" {
676+ require.ErrorContains(t, err, tc.wantErrContains)
677+ return
678+ }
679+ require.NoError(t, err)
680+ })
681+ }
682+}
683+
549684 func TestBrokerStreamPurposeShapes(t *testing.T) {
550685 for _, tc := range []struct {
551- name string
552- purpose BrokerStreamPurpose
553- target bool
554- env bool
555- valid bool
686+ name string
687+ purpose BrokerStreamPurpose
688+ admission BrokerStreamAdmission
689+ createName string
690+ target bool
691+ env bool
692+ valid bool
556693 }{
557- {"attachment", BrokerStreamAttachment, true, true, true},
558- {"attachment missing target", BrokerStreamAttachment, false, false, false},
559- {"control", BrokerStreamControl, false, false, true},
560- {"observation", BrokerStreamObservation, false, false, true},
561- {"control target", BrokerStreamControl, true, false, false},
562- {"observation env", BrokerStreamObservation, false, true, false},
563- {"unknown", 0, false, false, false},
694+ {"attachment exact", BrokerStreamAttachment, BrokerAdmissionExact, "", true, true, true},
695+ {"attachment missing target", BrokerStreamAttachment, BrokerAdmissionExact, "", false, false, false},
696+ {"attachment missing admission", BrokerStreamAttachment, 0, "", true, false, false},
697+ {"attachment named creation", BrokerStreamAttachment, BrokerAdmissionCreateNamed, "work", false, true, true},
698+ {"attachment ephemeral creation", BrokerStreamAttachment, BrokerAdmissionCreateEphemeral, "", false, true, true},
699+ {"control", BrokerStreamControl, 0, "", false, false, true},
700+ {"observation", BrokerStreamObservation, 0, "", false, false, true},
701+ {"control target", BrokerStreamControl, 0, "", true, false, false},
702+ {"control admission", BrokerStreamControl, BrokerAdmissionExact, "", false, false, false},
703+ {"observation env", BrokerStreamObservation, 0, "", false, true, false},
704+ {"observation admission", BrokerStreamObservation, BrokerAdmissionCreateEphemeral, "", false, false, false},
705+ {"unknown", 0, 0, "", false, false, false},
564706 } {
565707 t.Run(tc.name, func(t *testing.T) {
566- r := BrokerOpenStreamRequest{Epoch: 1, Connection: BrokerConnectionID{1}, Stream: 1, Local: true, Purpose: tc.purpose, Policy: testBrokerPolicy()}
708+ r := BrokerOpenStreamRequest{Epoch: 1, Connection: BrokerConnectionID{1}, Stream: 1, Local: true, Purpose: tc.purpose, Admission: tc.admission, Name: tc.createName, Policy: testBrokerPolicy()}
567709 if tc.target {
568710 r.Target = testBrokerTarget()
569711 }
M internal/ports/mocks/broker_mock.go +8 -8
@@ -871,22 +871,22 @@ func (_m *MockBrokerHostProbe) EXPECT() *MockBrokerHostProbe_Expecter {
871871 }
872872
873873 // Probe provides a mock function for the type MockBrokerHostProbe
874-func (_mock *MockBrokerHostProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.RemoteHostSnapshot, error) {
874+func (_mock *MockBrokerHostProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
875875 ret := _mock.Called(ctx, registration)
876876
877877 if len(ret) == 0 {
878878 panic("no return value specified for Probe")
879879 }
880880
881- var r0 ports.RemoteHostSnapshot
881+ var r0 ports.BrokerDaemonObservation
882882 var r1 error
883- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) (ports.RemoteHostSnapshot, error)); ok {
883+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) (ports.BrokerDaemonObservation, error)); ok {
884884 return returnFunc(ctx, registration)
885885 }
886- if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) ports.RemoteHostSnapshot); ok {
886+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) ports.BrokerDaemonObservation); ok {
887887 r0 = returnFunc(ctx, registration)
888888 } else {
889- r0 = ret.Get(0).(ports.RemoteHostSnapshot)
889+ r0 = ret.Get(0).(ports.BrokerDaemonObservation)
890890 }
891891 if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration) error); ok {
892892 r1 = returnFunc(ctx, registration)
@@ -926,12 +926,12 @@ func (_c *MockBrokerHostProbe_Probe_Call) Run(run func(ctx context.Context, regi
926926 return _c
927927 }
928928
929-func (_c *MockBrokerHostProbe_Probe_Call) Return(remoteHostSnapshot ports.RemoteHostSnapshot, err error) *MockBrokerHostProbe_Probe_Call {
930- _c.Call.Return(remoteHostSnapshot, err)
929+func (_c *MockBrokerHostProbe_Probe_Call) Return(brokerDaemonObservation ports.BrokerDaemonObservation, err error) *MockBrokerHostProbe_Probe_Call {
930+ _c.Call.Return(brokerDaemonObservation, err)
931931 return _c
932932 }
933933
934-func (_c *MockBrokerHostProbe_Probe_Call) RunAndReturn(run func(ctx context.Context, registration domain.RemoteRegistration) (ports.RemoteHostSnapshot, error)) *MockBrokerHostProbe_Probe_Call {
934+func (_c *MockBrokerHostProbe_Probe_Call) RunAndReturn(run func(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error)) *MockBrokerHostProbe_Probe_Call {
935935 _c.Call.Return(run)
936936 return _c
937937 }
M internal/protocol/session.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 // daemonmux physical preamble conversation (multiplex.proto), whose
1717 // negotiated ceilings and accepted daemon binding ride the same wire
1818 // version as the session and broker conversations.
19-const Version uint16 = 54
19+const Version uint16 = 55
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(54), Version)
13+ require.Equal(t, uint16(55), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
M internal/protocol/wire/broker.pb.go +223 -106 generated file: collapsed
M internal/protocol/wire/broker_test.go +16 -13
@@ -178,9 +178,12 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope {
178178 "registered": {Payload: &BrokerServerEnvelope_Registered{Registered: &Registered{Scope: brokerScope()}}},
179179 "snapshot_part": {Payload: &BrokerServerEnvelope_SnapshotPart{SnapshotPart: &SnapshotPart{
180180 Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9,
181- Part: &SnapshotPart_Host{Host: &SnapshotHost{
181+ Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{
182182 HostIndex: 1, Endpoint: "dev@host:22", DisplayOrigin: "dev@host",
183- Rank: 2, Registration: brokerRegistration(), Availability: 1,
183+ Rank: 2, Registration: brokerRegistration(), Policy: &BrokerWirePolicy{ProtocolVersion: 55},
184+ DaemonIdentity: "authed-daemon", DaemonIncarnation: bytes.Repeat([]byte{0x9}, 16),
185+ ProtocolVersion: 55, Capabilities: 1,
186+ Availability: 1,
184187 Checking: true,
185188 LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1},
186189 LastSuccess: &BrokerTimestamp{Seconds: 1700000001, Nanos: 2},
@@ -249,11 +252,11 @@ func TestBrokerEnvelopeRoundTrip(t *testing.T) {
249252 })
250253 }
251254
252- // The snapshot nested unions round-trip their non-host parts too, so
255+ // The snapshot nested unions round-trip their non-daemon parts too, so
253256 // every part tag is exercised.
254257 for name, part := range map[string]*SnapshotPart{
255- "begin": {Part: &SnapshotPart_Begin{Begin: &SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 3}}},
256- "session": {Part: &SnapshotPart_Session{Session: &SnapshotSession{HostIndex: 1, SessionIndex: 2, Session: brokerCatalogSession()}}},
258+ "begin": {Part: &SnapshotPart_Begin{Begin: &SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 3, LocalPresent: true}}},
259+ "session": {Part: &SnapshotPart_Session{Session: &SnapshotSession{HostIndex: 1, SessionIndex: 2, Session: brokerCatalogSession(), Local: true}}},
257260 "tombstone": {Part: &SnapshotPart_Tombstone{Tombstone: &SnapshotTombstone{TombstoneIndex: 3, Registration: brokerRegistration(), RetiredRevision: 4}}},
258261 "end": {Part: &SnapshotPart_End{End: &SnapshotEnd{}}},
259262 } {
@@ -318,9 +321,9 @@ func TestBrokerEnvelopeDuplicateAlternatives(t *testing.T) {
318321 }
319322
320323 // The nested snapshot part union is exclusive as well.
321- host := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_Host{Host: &SnapshotHost{Endpoint: "dev@host:22"}}})
324+ daemon := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{Endpoint: "dev@host:22"}}})
322325 end := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_End{End: &SnapshotEnd{}}})
323- if err := ScanEnvelope(&SnapshotPart{}, append(append([]byte(nil), host...), end...)); !errors.Is(err, ErrScanDuplicate) {
326+ if err := ScanEnvelope(&SnapshotPart{}, append(append([]byte(nil), daemon...), end...)); !errors.Is(err, ErrScanDuplicate) {
324327 t.Fatalf("duplicate snapshot part = %v, want ErrScanDuplicate", err)
325328 }
326329 }
@@ -353,12 +356,12 @@ func TestBrokerEnvelopeRejectsUnknownAndWrongTypes(t *testing.T) {
353356 }
354357
355358 // Unknown field inside a nested snapshot part message.
356- part := protowire.AppendTag(nil, 11, protowire.BytesType) // SnapshotPart.host
357- host := protowire.AppendTag(nil, 2, protowire.BytesType) // SnapshotHost.endpoint
358- host = protowire.AppendBytes(host, []byte("dev@host:22"))
359- host = protowire.AppendTag(host, 99, protowire.VarintType)
360- host = protowire.AppendVarint(host, 1)
361- part = protowire.AppendBytes(part, host)
359+ part := protowire.AppendTag(nil, 15, protowire.BytesType) // SnapshotPart.daemon
360+ daemon := protowire.AppendTag(nil, 3, protowire.BytesType) // SnapshotDaemon.endpoint
361+ daemon = protowire.AppendBytes(daemon, []byte("dev@host:22"))
362+ daemon = protowire.AppendTag(daemon, 99, protowire.VarintType)
363+ daemon = protowire.AppendVarint(daemon, 1)
364+ part = protowire.AppendBytes(part, daemon)
362365 raw = protowire.AppendTag(nil, 202, protowire.BytesType) // BrokerServerEnvelope.snapshot_part
363366 raw = protowire.AppendBytes(raw, part)
364367 if err := ScanEnvelope(&BrokerServerEnvelope{}, raw); !errors.Is(err, ErrScanUnknown) {
M internal/protocol/wire/envelope_inventory_test.go +2 -2
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 54 {
19- t.Fatalf("protocol.Version = %d, want 54", protocol.Version)
18+ if protocol.Version != 55 {
19+ t.Fatalf("protocol.Version = %d, want 55", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
M internal/protocol/wire/multiplex.pb.go +31 -9
@@ -120,13 +120,18 @@ type MuxOpen struct {
120120 state protoimpl.MessageState `protogen:"open.v1"`
121121 Ref *MuxStreamRef `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"`
122122 // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation.
123- Purpose uint32 `protobuf:"varint,2,opt,name=purpose,proto3" json:"purpose,omitempty"`
124- Local bool `protobuf:"varint,3,opt,name=local,proto3" json:"local,omitempty"`
125- Endpoint string `protobuf:"bytes,4,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
126- Registration *RemoteRegistration `protobuf:"bytes,5,opt,name=registration,proto3" json:"registration,omitempty"`
127- Target *ExactTarget `protobuf:"bytes,6,opt,name=target,proto3" json:"target,omitempty"`
128- Env []string `protobuf:"bytes,7,rep,name=env,proto3" json:"env,omitempty"`
129- Policy *BrokerWirePolicy `protobuf:"bytes,8,opt,name=policy,proto3" json:"policy,omitempty"`
123+ Purpose uint32 `protobuf:"varint,2,opt,name=purpose,proto3" json:"purpose,omitempty"`
124+ Local bool `protobuf:"varint,3,opt,name=local,proto3" json:"local,omitempty"`
125+ Endpoint string `protobuf:"bytes,4,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
126+ Registration *RemoteRegistration `protobuf:"bytes,5,opt,name=registration,proto3" json:"registration,omitempty"`
127+ Target *ExactTarget `protobuf:"bytes,6,opt,name=target,proto3" json:"target,omitempty"`
128+ Env []string `protobuf:"bytes,7,rep,name=env,proto3" json:"env,omitempty"`
129+ Policy *BrokerWirePolicy `protobuf:"bytes,8,opt,name=policy,proto3" json:"policy,omitempty"`
130+ // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission):
131+ // 0 = none (control/observation), 1 = exact attach/resume carrying
132+ // target, 2 = create named carrying name, 3 = create ephemeral.
133+ Admission uint32 `protobuf:"varint,9,opt,name=admission,proto3" json:"admission,omitempty"`
134+ Name string `protobuf:"bytes,10,opt,name=name,proto3" json:"name,omitempty"`
130135 unknownFields protoimpl.UnknownFields
131136 sizeCache protoimpl.SizeCache
132137 }
@@ -217,6 +222,20 @@ func (x *MuxOpen) GetPolicy() *BrokerWirePolicy {
217222 return nil
218223 }
219224
225+func (x *MuxOpen) GetAdmission() uint32 {
226+ if x != nil {
227+ return x.Admission
228+ }
229+ return 0
230+}
231+
232+func (x *MuxOpen) GetName() string {
233+ if x != nil {
234+ return x.Name
235+ }
236+ return ""
237+}
238+
220239 // MuxOpened confirms one logical stream is established.
221240 type MuxOpened struct {
222241 state protoimpl.MessageState `protogen:"open.v1"`
@@ -1005,7 +1024,7 @@ const file_multiplex_proto_rawDesc = "" +
10051024 "\x12physical_stream_id\x18\x01 \x01(\x04R\x10physicalStreamId\x12!\n" +
10061025 "\fbroker_epoch\x18\x02 \x01(\x04R\vbrokerEpoch\x12#\n" +
10071026 "\rconnection_id\x18\x03 \x01(\fR\fconnectionId\x12(\n" +
1008- "\x10client_stream_id\x18\x04 \x01(\x04R\x0eclientStreamId\"\xc2\x02\n" +
1027+ "\x10client_stream_id\x18\x04 \x01(\x04R\x0eclientStreamId\"\xf4\x02\n" +
10091028 "\aMuxOpen\x12+\n" +
10101029 "\x03ref\x18\x01 \x01(\v2\x19.vev.wire.v1.MuxStreamRefR\x03ref\x12\x18\n" +
10111030 "\apurpose\x18\x02 \x01(\rR\apurpose\x12\x14\n" +
@@ -1014,7 +1033,10 @@ const file_multiplex_proto_rawDesc = "" +
10141033 "\fregistration\x18\x05 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\x120\n" +
10151034 "\x06target\x18\x06 \x01(\v2\x18.vev.wire.v1.ExactTargetR\x06target\x12\x10\n" +
10161035 "\x03env\x18\a \x03(\tR\x03env\x125\n" +
1017- "\x06policy\x18\b \x01(\v2\x1d.vev.wire.v1.BrokerWirePolicyR\x06policy\"8\n" +
1036+ "\x06policy\x18\b \x01(\v2\x1d.vev.wire.v1.BrokerWirePolicyR\x06policy\x12\x1c\n" +
1037+ "\tadmission\x18\t \x01(\rR\tadmission\x12\x12\n" +
1038+ "\x04name\x18\n" +
1039+ " \x01(\tR\x04name\"8\n" +
10181040 "\tMuxOpened\x12+\n" +
10191041 "\x03ref\x18\x01 \x01(\v2\x19.vev.wire.v1.MuxStreamRefR\x03ref\"o\n" +
10201042 "\n" +
M internal/protocol/wire/schema/broker.proto +56 -21
@@ -150,6 +150,11 @@ message Reconcile {
150150 // OpenStream asks the broker to open one independently cancellable logical
151151 // stream to the owning daemon. env is the per-request session environment
152152 // and is never inherited from the broker process environment.
153+//
154+// admission is the closed attachment-admission taxonomy: an attachment
155+// stream names exactly how the daemon must admit it, while control and
156+// observation streams carry none (0). name is validated session-name
157+// authority for create-named and is empty for every other variant.
153158 message OpenStream {
154159 BrokerStreamRef ref = 1;
155160 // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation.
@@ -157,9 +162,16 @@ message OpenStream {
157162 bool local = 3;
158163 string endpoint = 4;
159164 RemoteRegistration registration = 5;
165+ // Present only for exact attach/resume; the zero-value message is never
166+ // sent for a creation admission.
160167 ExactTarget target = 6;
161168 repeated string env = 7;
162169 BrokerWirePolicy policy = 8;
170+ // Closed admission taxonomy: 0 = none (control/observation), 1 = exact
171+ // attach/resume, 2 = create named, 3 = create ephemeral.
172+ uint32 admission = 9;
173+ // Validated session name for create-named; empty otherwise.
174+ string name = 10;
163175 }
164176
165177 // ClientStreamData carries one opaque client-to-daemon stream frame.
@@ -188,7 +200,7 @@ message SnapshotPart {
188200 uint32 index = 4;
189201 oneof part {
190202 SnapshotBegin begin = 10;
191- SnapshotHost host = 11;
203+ SnapshotDaemon daemon = 15;
192204 SnapshotSession session = 12;
193205 SnapshotTombstone tombstone = 13;
194206 SnapshotEnd end = 14;
@@ -196,40 +208,63 @@ message SnapshotPart {
196208 }
197209
198210 // SnapshotBegin opens a snapshot publication with its element counts.
211+// host_count counts every daemon part in the transfer (the local daemon plus
212+// every remote host), and local_present reports whether daemon index 0 is the
213+// local daemon. A publication carries at most one local daemon, always first.
199214 message SnapshotBegin {
200215 uint32 host_count = 1;
201216 uint32 session_count = 2;
202217 uint32 tombstone_count = 3;
218+ bool local_present = 4;
203219 }
204220
205-// SnapshotHost is one host projection (ports.RemoteHostSnapshot). The
206-// endpoint and display origin stay separate: the latter is
207-// presentation-only. Sessions travel as separate SnapshotSession parts.
208-message SnapshotHost {
221+// SnapshotDaemon is one daemon projection (ports.BrokerDaemonObservation),
222+// local or remote. Configured authority (endpoint, registration, policy,
223+// display origin, rank) stays separate from observed state (identity,
224+// incarnation, version, capabilities, availability, failure, freshness),
225+// and observation never invents unknown values. registration is absent for
226+// the local daemon; policy is always present. Sessions travel as separate
227+// SnapshotSession parts.
228+message SnapshotDaemon {
209229 uint32 host_index = 1;
210- string endpoint = 2;
211- string display_origin = 3;
212- uint32 rank = 4;
213- RemoteRegistration registration = 5;
214- // Closed availability taxonomy; 0 is the zero value.
215- uint32 availability = 6;
216- bool checking = 7;
217- BrokerTimestamp last_attempt = 8;
218- BrokerTimestamp last_success = 9;
219- BrokerTimestamp next_due = 10;
220- uint64 consecutive_failures = 11;
221- uint64 failure_episode = 12;
230+ bool local = 2;
231+ string endpoint = 3;
232+ string display_origin = 4;
233+ uint32 rank = 5;
234+ RemoteRegistration registration = 6;
235+ BrokerWirePolicy policy = 7;
236+ // Authenticated daemon identity; empty until observed.
237+ string daemon_identity = 8;
238+ // Daemon process incarnation; exactly 16 bytes when an identity is
239+ // present and absent otherwise.
240+ bytes daemon_incarnation = 9;
241+ // Observed daemon protocol version; 0 = unobserved.
242+ uint32 protocol_version = 10;
243+ // Observed capability bitmask (mirrors protocol.Capability* bits).
244+ uint32 capabilities = 11;
245+ // Closed availability taxonomy (domain.RemoteAvailability); 0 is
246+ // invalid, an unobserved daemon carries RemoteAvailabilityUnknown.
247+ uint32 availability = 12;
248+ bool checking = 13;
249+ BrokerTimestamp last_attempt = 14;
250+ BrokerTimestamp last_success = 15;
251+ BrokerTimestamp next_due = 16;
252+ uint64 consecutive_failures = 17;
253+ uint64 failure_episode = 18;
222254 // Closed cause taxonomy (domain.RemoteFailureKind); 0 = none.
223- uint32 failure_kind = 13;
224- bool inventory_known = 14;
225- uint32 session_count = 15;
255+ uint32 failure_kind = 19;
256+ bool inventory_known = 20;
257+ uint32 session_count = 21;
226258 }
227259
228-// SnapshotSession is one catalogue session bound to its host by index.
260+// SnapshotSession is one catalogue session bound to its daemon by index.
261+// local marks a session of the local daemon; its host_index is then exactly
262+// 0 and follows the local daemon part rather than a remote host index.
229263 message SnapshotSession {
230264 uint32 host_index = 1;
231265 uint32 session_index = 2;
232266 BrokerCatalogSession session = 3;
267+ bool local = 4;
233268 }
234269
235270 // SnapshotTombstone fences one retired host registration so a stale probe
M internal/protocol/wire/schema/multiplex.proto +5 -0
@@ -51,6 +51,11 @@ message MuxOpen {
5151 ExactTarget target = 6;
5252 repeated string env = 7;
5353 BrokerWirePolicy policy = 8;
54+ // Closed attachment-admission taxonomy (ports.BrokerStreamAdmission):
55+ // 0 = none (control/observation), 1 = exact attach/resume carrying
56+ // target, 2 = create named carrying name, 3 = create ephemeral.
57+ uint32 admission = 9;
58+ string name = 10;
5459 }
5560
5661 // MuxOpened confirms one logical stream is established.
M internal/usecase/broker/registry.go +260 -51
@@ -8,10 +8,12 @@ import (
88 "hash/fnv"
99 "log/slog"
1010 "reflect"
11+ "slices"
1112 "sort"
1213 "sync"
1314 "sync/atomic"
1415 "time"
16+ "unicode/utf8"
1517
1618 "github.com/bnema/vev/internal/domain"
1719 "github.com/bnema/vev/internal/ports"
@@ -79,7 +81,15 @@ type Registry struct {
7981 // completion is fenced even when the endpoint is removed and re-added with
8082 // the identical registration.
8183 attempts uint64
82- hosts map[string]ports.RemoteHostSnapshot
84+ // hosts is the broker-native projection per endpoint. Configured
85+ // authority (endpoint, registration, policy, display origin, rank) is
86+ // stamped here from durable membership; probe results only supply observed
87+ // state and can never override it.
88+ hosts map[string]ports.BrokerDaemonObservation
89+ // order is the publication order of remote hosts: registration order from
90+ // durable membership. A snapshot carries the local daemon first (when a
91+ // producer exists) and then these remotes in this order.
92+ order []string
8393 // inflight records the one admitted attempt per endpoint. The record
8494 // carries the attempt's derived context cancel so a retired attempt is
8595 // released the moment its registration is replaced, removed, or the run
@@ -128,7 +138,7 @@ func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore,
128138 r := &Registry{
129139 epoch: epoch, probe: probe, clock: clock, log: log,
130140 observationDisabled: cfg.ObservationDisabled,
131- hosts: make(map[string]ports.RemoteHostSnapshot), inflight: make(map[string]*probeAttempt),
141+ hosts: make(map[string]ports.BrokerDaemonObservation), inflight: make(map[string]*probeAttempt),
132142 pending: make(map[string]bool), tombstones: make(map[string]ports.BrokerHostTombstone),
133143 subs: make(map[*subscription]struct{}), wake: make(chan struct{}, 1),
134144 freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit,
@@ -183,19 +193,87 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error {
183193 if err := hosts.Validate(); err != nil {
184194 return err
185195 }
186- next := make(map[string]ports.RemoteHostSnapshot, len(hosts.Hosts))
187- for _, record := range hosts.Hosts {
196+ next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts))
197+ order := make([]string, 0, len(hosts.Hosts))
198+ for rank, record := range hosts.Hosts {
188199 registration := record.Registration
189200 restored, present := r.hosts[registration.Endpoint]
190201 if !present || !restored.Registration.Equal(registration) {
191- restored = ports.RemoteHostSnapshot{Endpoint: registration.Endpoint, Registration: registration, Availability: domain.RemoteAvailabilityUnknown}
202+ restored = ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnknown}
192203 }
193- next[registration.Endpoint] = restored
204+ projection := stampHostAuthority(restored, record, rank)
205+ // A durable membership whose projection cannot be published is refused
206+ // here, at the boundary that owns authority, instead of silently
207+ // leaving the registry unable to publish anything.
208+ if err := projection.Validate(); err != nil {
209+ return fmt.Errorf("broker: host record %q is not publishable: %w", registration.Endpoint, err)
210+ }
211+ next[registration.Endpoint] = projection
212+ order = append(order, registration.Endpoint)
194213 }
195214 r.hosts = next
215+ r.order = order
196216 return nil
197217 }
198218
219+// stampHostAuthority overwrites the configured authority fields of a daemon
220+// projection with durable membership authority. Membership is the single
221+// authority for endpoint, registration, policy, and the presentation hints
222+// derived from them; a probe, a restored durable observation, or any other
223+// producer can never supply or override them. The durable observation format
224+// deliberately carries no policy (see durable), so this stamp is also what
225+// gives every restored observation a valid policy.
226+func stampHostAuthority(observation ports.BrokerDaemonObservation, record ports.BrokerHostRecord, rank int) ports.BrokerDaemonObservation {
227+ observation.Local = false
228+ observation.Endpoint = record.Registration.Endpoint
229+ observation.DisplayOrigin = hostDisplayOrigin(record.Registration.Endpoint)
230+ observation.Rank = rank
231+ observation.Registration = record.Registration
232+ observation.Policy = record.Policy
233+ if observation.Availability == 0 {
234+ // An unobserved daemon reports the explicit unknown availability; zero
235+ // is never a valid publication.
236+ observation.Availability = domain.RemoteAvailabilityUnknown
237+ }
238+ return observation
239+}
240+
241+// stampObservationAuthority copies the configured authority already stamped on
242+// a live projection onto a fresh probe result. Probe results supply only
243+// observed state, so their endpoint, registration, policy, display origin, and
244+// rank fields are discarded wholesale.
245+func stampObservationAuthority(observation, authority ports.BrokerDaemonObservation) ports.BrokerDaemonObservation {
246+ observation.Local = false
247+ observation.Endpoint = authority.Endpoint
248+ observation.DisplayOrigin = authority.DisplayOrigin
249+ observation.Rank = authority.Rank
250+ observation.Registration = authority.Registration
251+ observation.Policy = authority.Policy
252+ return observation
253+}
254+
255+// hostDisplayOrigin derives the presentation hint for a configured host. The
256+// routing endpoint stays authoritative; the origin is only the login prefix
257+// stripped for display, and an endpoint that carries none is its own origin.
258+// The hint is sanitized to the display-text rule before it is clamped rune- and
259+// byte-safely to the ports bound: an endpoint the routing validator accepts may
260+// still carry bidi or control runes, and rendering those would let a derived
261+// hint reorder the picker text around it. A derived hint carries no routing
262+// authority, so the refused runes are dropped rather than the host rejected. A
263+// target left with no displayable rune at all yields no origin, which its
264+// caller refuses as unpublishable membership instead of inventing display text.
265+func hostDisplayOrigin(endpoint string) string {
266+ origin := ports.SanitizeBrokerDisplayText(domain.RemoteDisplayOrigin(endpoint))
267+ if len(origin) <= ports.BrokerMaxDisplayOriginBytes {
268+ return origin
269+ }
270+ truncated := origin[:ports.BrokerMaxDisplayOriginBytes]
271+ for len(truncated) > 0 && !utf8.ValidString(truncated) {
272+ truncated = truncated[:len(truncated)-1]
273+ }
274+ return truncated
275+}
276+
199277 // restore adopts a validated durable snapshot. A zero-epoch snapshot is only
200278 // the empty placeholder a store returns before its first write: any record
201279 // without an epoch is rejected instead of silently dropped. A persisted
@@ -207,7 +285,7 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error {
207285 // fencing state and never survive a restart.
208286 func (r *Registry) restore(snapshot ports.BrokerSnapshot) error {
209287 if snapshot.Epoch == 0 {
210- if len(snapshot.Hosts) > 0 || len(snapshot.Removed) > 0 || snapshot.Revision != 0 {
288+ if len(snapshot.Daemons) > 0 || len(snapshot.Removed) > 0 || snapshot.Revision != 0 {
211289 return errors.New("broker: persisted snapshot has no epoch")
212290 }
213291 return nil
@@ -215,17 +293,78 @@ func (r *Registry) restore(snapshot ports.BrokerSnapshot) error {
215293 if snapshot.Epoch == r.epoch {
216294 return errors.New("broker: persisted snapshot epoch matches the new registry epoch")
217295 }
218- if err := snapshot.Validate(); err != nil {
296+ if err := validateRestoredSnapshot(snapshot); err != nil {
219297 return err
220298 }
221- for _, host := range snapshot.Hosts {
222- host = host.Clone()
223- host.Checking = false
224- r.hosts[host.Endpoint] = host
299+ for _, daemon := range snapshot.Daemons {
300+ daemon = daemon.Clone()
301+ daemon.Checking = false
302+ r.hosts[daemon.Endpoint] = daemon
225303 }
226304 return nil
227305 }
228306
307+// validateRestoredSnapshot applies the durable shape rules to a snapshot read
308+// back from a store: every observation must be a remote, non-local projection
309+// of an exact valid registration whose endpoint matches, with a closed
310+// availability/failure range and a catalogue-valid inventory. Policy and
311+// display authority are deliberately not checked here because the durable
312+// format omits policy and projectMembership stamps both from membership, so a
313+// restored observation is always remote-only and always carries authority once
314+// membership is projected.
315+func validateRestoredSnapshot(snapshot ports.BrokerSnapshot) error {
316+ if snapshot.Revision == 0 {
317+ return errors.New("broker: persisted snapshot has no revision")
318+ }
319+ if len(snapshot.Daemons) > ports.BrokerMaxDaemonsPerSnapshot {
320+ return errors.New("broker: persisted snapshot has too many daemons")
321+ }
322+ seen := make(map[string]struct{}, len(snapshot.Daemons))
323+ for _, daemon := range snapshot.Daemons {
324+ if err := validateDurableObservation(daemon); err != nil {
325+ return err
326+ }
327+ if _, duplicate := seen[daemon.Endpoint]; duplicate {
328+ return fmt.Errorf("broker: persisted snapshot has duplicate host %q", daemon.Endpoint)
329+ }
330+ seen[daemon.Endpoint] = struct{}{}
331+ }
332+ if len(snapshot.Removed) > ports.BrokerMaxTombstones {
333+ return errors.New("broker: persisted snapshot has too many tombstones")
334+ }
335+ for _, tombstone := range snapshot.Removed {
336+ if err := tombstone.Validate(); err != nil {
337+ return err
338+ }
339+ if _, live := seen[tombstone.Endpoint]; live {
340+ return fmt.Errorf("broker: persisted snapshot carries live host %q as tombstone", tombstone.Endpoint)
341+ }
342+ }
343+ return nil
344+}
345+
346+// validateDurableObservation reports whether one observation satisfies the
347+// durable shape shared with the offline store: remote-only, an exact valid
348+// registration matching its endpoint, and the ports durable projection rules.
349+func validateDurableObservation(daemon ports.BrokerDaemonObservation) error {
350+ if daemon.Local {
351+ return errors.New("broker: persisted local daemon observation")
352+ }
353+ if err := domain.ValidateRemoteHostTarget(daemon.Endpoint); err != nil {
354+ return fmt.Errorf("broker: persisted observation: %w", err)
355+ }
356+ if err := daemon.Registration.Validate(); err != nil {
357+ return fmt.Errorf("broker: persisted observation: %w", err)
358+ }
359+ if daemon.Endpoint != daemon.Registration.Endpoint {
360+ return errors.New("broker: persisted observation endpoint does not match registration")
361+ }
362+ if len(daemon.Sessions) > 0 && !daemon.InventoryKnown {
363+ return errors.New("broker: persisted observation carries sessions without known inventory")
364+ }
365+ return ports.ValidateDurableHostProjection(daemon)
366+}
367+
229368 // Snapshot returns a defensive immutable copy without I/O.
230369 func (r *Registry) Snapshot() ports.BrokerSnapshot { return r.current.Load().Clone() }
231370
@@ -262,10 +401,12 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
262401 if err := ports.ValidateBrokerHostRecords(records); err != nil {
263402 return err
264403 }
265- next := make(map[string]ports.RemoteHostSnapshot, len(records))
266- for _, record := range records {
404+ next := make(map[string]ports.BrokerDaemonObservation, len(records))
405+ order := make([]string, 0, len(records))
406+ for rank, record := range records {
267407 reg := record.Registration
268- next[reg.Endpoint] = ports.RemoteHostSnapshot{Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityUnknown}
408+ next[reg.Endpoint] = stampHostAuthority(ports.BrokerDaemonObservation{}, record, rank)
409+ order = append(order, reg.Endpoint)
269410 }
270411 r.mu.Lock()
271412 defer r.mu.Unlock()
@@ -279,7 +420,7 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
279420 return fmt.Errorf("broker: replace hosts: %w", err)
280421 }
281422 r.authority = ports.BrokerHosts{Revision: r.authority.Revision + 1, Hosts: records}
282- r.setHostsLocked(next)
423+ r.setHostsLocked(next, order)
283424 return nil
284425 }
285426
@@ -291,19 +432,33 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
291432 // Removed registrations retire bounded tombstones so a stale completion can
292433 // never revive them; replaced or re-added endpoints are fenced by the
293434 // registry attempt token instead.
294-func (r *Registry) setHosts(registrations []domain.RemoteRegistration) error {
295- if len(registrations) > ports.BrokerMaxHosts {
296- return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(registrations), ports.BrokerMaxHosts)
297- }
298- next := make(map[string]ports.RemoteHostSnapshot, len(registrations))
299- for _, registration := range registrations {
300- if err := registration.Validate(); err != nil {
435+func (r *Registry) setHosts(records []ports.BrokerHostRecord) error {
436+ if len(records) > ports.BrokerMaxHosts {
437+ return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts)
438+ }
439+ next := make(map[string]ports.BrokerDaemonObservation, len(records))
440+ order := make([]string, 0, len(records))
441+ for rank, record := range records {
442+ if err := record.Registration.Validate(); err != nil {
301443 return err
302444 }
445+ if err := record.Policy.Validate(); err != nil {
446+ return err
447+ }
448+ registration := record.Registration
303449 if _, duplicate := next[registration.Endpoint]; duplicate {
304450 return errors.New("broker: duplicate host registration")
305451 }
306- next[registration.Endpoint] = ports.RemoteHostSnapshot{Endpoint: registration.Endpoint, Registration: registration, Availability: domain.RemoteAvailabilityUnknown}
452+ observation := stampHostAuthority(ports.BrokerDaemonObservation{}, record, rank)
453+ // Membership the registry cannot publish is a caller error, refused here
454+ // rather than left to freeze every later publication: an endpoint whose
455+ // derived display hint is empty after the display rule drops its runes
456+ // makes every snapshot carrying it invalid.
457+ if err := observation.Validate(); err != nil {
458+ return fmt.Errorf("broker: host record %q is not publishable: %w", registration.Endpoint, err)
459+ }
460+ next[registration.Endpoint] = observation
461+ order = append(order, registration.Endpoint)
307462 }
308463
309464 r.mu.Lock()
@@ -311,21 +466,27 @@ func (r *Registry) setHosts(registrations []domain.RemoteRegistration) error {
311466 if r.revisionExhaustedLocked() {
312467 return fmt.Errorf("broker: set hosts: %w", ports.ErrBrokerRevisionExhausted)
313468 }
314- r.setHostsLocked(next)
469+ r.setHostsLocked(next, order)
315470 return nil
316471 }
317472
318473 // setHostsLocked applies only a projection; the public path commits authority
319474 // first. The caller holds mu through both steps, fencing probe completions.
320-func (r *Registry) setHostsLocked(next map[string]ports.RemoteHostSnapshot) {
321- if registrationsUnchanged(r.hosts, next) {
475+// order is the registration order the projection was built from; it becomes
476+// the publication order of remote daemons.
477+func (r *Registry) setHostsLocked(next map[string]ports.BrokerDaemonObservation, order []string) {
478+ if registrationsUnchanged(r.hosts, next, r.order, order) {
322479 return
323480 }
324481 for endpoint, current := range r.hosts {
325482 candidate, present := next[endpoint]
326- if present && current.Registration.Equal(candidate.Registration) {
327- // Unchanged registration: keep the live observation as is.
328- next[endpoint] = current
483+ if present && current.Registration.Equal(candidate.Registration) && current.Policy == candidate.Policy {
484+ // Unchanged authority: keep the live observation as is. Rank is the
485+ // one authority field that follows membership order rather than the
486+ // record, so it is re-stamped from the new projection.
487+ kept := current
488+ kept.Rank = candidate.Rank
489+ next[endpoint] = kept
329490 continue
330491 }
331492 // A retired registration owns no observation: cancelling its attempt
@@ -351,6 +512,7 @@ func (r *Registry) setHostsLocked(next map[string]ports.RemoteHostSnapshot) {
351512 delete(r.tombstones, endpoint)
352513 }
353514 r.hosts = next
515+ r.order = order
354516 r.publishLocked(true)
355517 r.hint()
356518 }
@@ -480,7 +642,7 @@ type probeResult struct {
480642 endpoint string
481643 registration domain.RemoteRegistration
482644 attempt uint64
483- snapshot ports.RemoteHostSnapshot
645+ snapshot ports.BrokerDaemonObservation
484646 err error
485647 at time.Time
486648 }
@@ -547,9 +709,10 @@ func (r *Registry) apply(result probeResult) {
547709
548710 kind, availability, failed := observationOutcome(result)
549711 if !failed {
550- observed := result.snapshot.Clone()
551- observed.Endpoint = result.endpoint
552- observed.Registration = result.registration
712+ // Configured authority comes only from the registry's own projection:
713+ // a probe result supplies observed state, never endpoint, registration,
714+ // policy, display origin, or rank.
715+ observed := stampObservationAuthority(result.snapshot.Clone(), current)
553716 observed.Checking = false
554717 observed.LastAttempt = result.at
555718 observed.LastSuccess = result.at
@@ -557,10 +720,16 @@ func (r *Registry) apply(result probeResult) {
557720 observed.ConsecutiveFailures = 0
558721 observed.LastFailure = domain.RemoteFailure{}
559722 observed.FailureEpisode = current.FailureEpisode
560- // A projection the durable store would reject is an invalid response,
561- // not an observation: publishing it would leave the registry valid but
562- // permanently unpersisted. The store applies the same rule.
563- if err := ports.ValidateDurableHostProjection(observed); err != nil {
723+ // The published observation must be fully valid once authority is
724+ // stamped: a probe that invents partial identity (identity without a
725+ // protocol version) or an inventory without a known catalogue is an
726+ // invalid response, never a published observation. The durable rule is
727+ // re-checked too: a projection the offline store would reject is never
728+ // published as if it had persisted. The store applies the same rule.
729+ if err := observed.Validate(); err != nil {
730+ r.log.Debug("broker: observation is invalid", "endpoint", result.endpoint, "err", err)
731+ kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
732+ } else if err := ports.ValidateDurableHostProjection(observed); err != nil {
564733 r.log.Debug("broker: observation is not persistable", "endpoint", result.endpoint, "err", err)
565734 kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
566735 } else {
@@ -687,16 +856,23 @@ func (r *Registry) nextDelay(now time.Time) time.Duration {
687856 return 0
688857 }
689858
690-// registrationsUnchanged reports exact membership equality: the same
691-// endpoints bound to the same registration identities. Projection fields such
692-// as availability, checking, or inventory never participate.
693-func registrationsUnchanged(current, next map[string]ports.RemoteHostSnapshot) bool {
859+// registrationsUnchanged reports exact membership equality: the same endpoints
860+// in the same registration order, bound to the same registration identities and
861+// policy. Publication order and presentation rank are functions of durable
862+// membership order, so a reordering of identical records is a membership change
863+// that must republish; otherwise the same durable state yields different
864+// publications depending on process history. Projection fields such as
865+// availability, checking, or inventory never participate.
866+func registrationsUnchanged(current, next map[string]ports.BrokerDaemonObservation, currentOrder, nextOrder []string) bool {
694867 if len(current) != len(next) {
695868 return false
696869 }
870+ if !slices.Equal(currentOrder, nextOrder) {
871+ return false
872+ }
697873 for endpoint, candidate := range next {
698874 existing, ok := current[endpoint]
699- if !ok || !existing.Registration.Equal(candidate.Registration) {
875+ if !ok || !existing.Registration.Equal(candidate.Registration) || existing.Policy != candidate.Policy {
700876 return false
701877 }
702878 }
@@ -782,13 +958,28 @@ func (r *Registry) publishLocked(persist bool) {
782958 if r.revisionExhaustedLocked() {
783959 return
784960 }
785- r.revision++
786- hosts := make([]ports.RemoteHostSnapshot, 0, len(r.hosts))
787- for _, host := range r.hosts {
788- hosts = append(hosts, host.Clone())
961+ // Remote daemons are published in registration order. The local daemon,
962+ // when a producer exists (Plan 001 P5.3a), is prepended ahead of these;
963+ // the registry produces no local observation yet and never invents one.
964+ daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts))
965+ for _, endpoint := range r.order {
966+ host, ok := r.hosts[endpoint]
967+ if !ok {
968+ continue
969+ }
970+ daemons = append(daemons, host.Clone())
789971 }
790- sort.Slice(hosts, func(i, j int) bool { return hosts[i].Endpoint < hosts[j].Endpoint })
791- snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision, Hosts: hosts, Removed: r.tombstonesLocked()}
972+ snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision + 1, Daemons: daemons, Removed: r.tombstonesLocked()}
973+ if err := snapshot.Validate(); err != nil {
974+ // Fail closed: a publication the wire refuses aborts every client
975+ // connection on subscribe, so an invalid projection is never published.
976+ // The last valid snapshot stays current, nothing is persisted, and the
977+ // revision series does not advance past a publication that never
978+ // happened.
979+ r.log.Error("broker: refused invalid snapshot publication", "revision", r.revision+1, "err", err)
980+ return
981+ }
982+ r.revision = snapshot.Revision
792983 r.current.Store(&snapshot)
793984 if persist {
794985 // enqueue only stages an immutable publication: rendering the durable
@@ -807,12 +998,30 @@ func (r *Registry) publishLocked(persist bool) {
807998 // they retire registrations only for this process lifetime and a restart never
808999 // adopts them, so persisting them would leak stale authority into durable
8091000 // state. Removed is therefore cleared before persistence.
1001+//
1002+// Policy is membership authority, not observation state, so the durable format
1003+// deliberately omits it: membership is the single policy authority, and the
1004+// loader re-stamps every restored observation from the matching
1005+// ports.BrokerHostRecord (see stampHostAuthority). Persisting it would create a
1006+// second, stale authority that a later membership change could contradict.
1007+//
1008+// Only remote observations are durable: a local daemon observation is
1009+// process-local state and is dropped here (the store also rejects one), so a
1010+// future local producer can never break durable persistence.
8101011 func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
8111012 out := snapshot.Clone()
8121013 out.Removed = nil
813- for i := range out.Hosts {
814- out.Hosts[i].Checking = false
1014+ daemons := out.Daemons[:0]
1015+ for _, daemon := range out.Daemons {
1016+ if daemon.Local {
1017+ continue
1018+ }
1019+ daemon.Checking = false
1020+ daemon.LastFailure.Err = nil
1021+ daemon.Policy = ports.BrokerPolicy{}
1022+ daemons = append(daemons, daemon)
8151023 }
1024+ out.Daemons = daemons
8161025 return out
8171026 }
8181027
M internal/usecase/broker/registry_durable_test.go +68 -6
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/vev/internal/domain"
1111 "github.com/bnema/vev/internal/ports"
1212 portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/bnema/vev/internal/protocol"
1314 "github.com/stretchr/testify/require"
1415 )
1516
@@ -38,22 +39,22 @@ func TestRegistryDurableReplaceHostsRestart(t *testing.T) {
3839 require.NoError(t, err)
3940 defer r.settle()
4041 require.Equal(t, previous, r.authority.Hosts)
41- require.Len(t, r.Snapshot().Hosts, len(previous))
42+ require.Len(t, r.Snapshot().Daemons, len(previous))
4243 if len(previous) > 0 {
43- require.True(t, r.Snapshot().Hosts[0].InventoryKnown, "advisory observation must also survive restart")
44+ require.True(t, r.Snapshot().Daemons[0].InventoryKnown, "advisory observation must also survive restart")
4445 }
4546 require.NoError(t, r.ReplaceHosts(stage.records))
4647 authority, err := store.LoadHosts()
4748 require.NoError(t, err)
4849 require.Equal(t, r.authority, authority)
4950 require.Equal(t, stage.records, authority.Hosts)
50- require.Len(t, r.Snapshot().Hosts, len(stage.records))
51+ require.Len(t, r.Snapshot().Daemons, len(stage.records))
5152 if len(stage.records) > 0 {
5253 reg := stage.records[0].Registration
5354 ctx, cancel := context.WithCancel(context.Background())
5455 defer cancel()
5556 r.inflight[reg.Endpoint] = &probeAttempt{token: 1, cancel: cancel}
56- r.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: 1, at: r.clock.Now(), snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}})
57+ r.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: 1, at: r.clock.Now(), snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}})
5758 require.ErrorIs(t, ctx.Err(), context.Canceled)
5859 }
5960 r.settle() // flush before inspecting persisted projection
@@ -121,7 +122,7 @@ func TestRegistryReplaceHostsAfterShutdownIsTyped(t *testing.T) {
121122 store := newTestStore()
122123 r := newTestRegistry(t, 1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)))
123124 record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()}
124- require.NoError(t, r.setHosts([]domain.RemoteRegistration{record.Registration}))
125+ require.NoError(t, r.setHosts(hostRecords(record.Registration)))
125126 r.settle()
126127
127128 before := r.Snapshot()
@@ -150,7 +151,7 @@ func TestRegistryDurableChurnFlushesLatestMembership(t *testing.T) {
150151 record.Registration.Generation++
151152 record.Policy.Trust = "replacement-trust"
152153 require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record}))
153- require.Equal(t, record.Registration, r.Snapshot().Hosts[0].Registration)
154+ require.Equal(t, record.Registration, r.Snapshot().Daemons[0].Registration)
154155 if i < 20 {
155156 require.NoError(t, r.ReplaceHosts(nil))
156157 }
@@ -163,3 +164,64 @@ func TestRegistryDurableChurnFlushesLatestMembership(t *testing.T) {
163164 require.NoError(t, err)
164165 require.Equal(t, r.authority, authority)
165166 }
167+
168+// TestRegistryDurableRoundTripObservedIdentity proves the durable format carries
169+// observed daemon identity, incarnation, protocol version, and capabilities
170+// across a restart. The durable copy deliberately omits policy; the loader
171+// re-stamps it from the matching membership record, which remains the single
172+// policy authority.
173+func TestRegistryDurableRoundTripObservedIdentity(t *testing.T) {
174+ dir := filepath.Join(t.TempDir(), "broker")
175+ record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()}
176+
177+ store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: dir})
178+ require.NoError(t, err)
179+ r, err := NewRegistry(1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)), nil)
180+ require.NoError(t, err)
181+ require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record}))
182+
183+ reg := record.Registration
184+ ctx, cancel := context.WithCancel(context.Background())
185+ defer cancel()
186+ r.inflight[reg.Endpoint] = &probeAttempt{token: 1, cancel: cancel}
187+ r.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: 1, at: r.clock.Now(), snapshot: ports.BrokerDaemonObservation{
188+ Identity: "authed-daemon",
189+ Incarnation: ports.BrokerDaemonIncarnation{4, 5},
190+ ProtocolVersion: protocol.Version,
191+ Capabilities: protocol.CapabilityResume,
192+ Availability: domain.RemoteAvailabilityReachable,
193+ }})
194+ observed, ok := r.Snapshot().Find(reg.Endpoint)
195+ require.True(t, ok)
196+ require.Equal(t, ports.BrokerDaemonIdentity("authed-daemon"), observed.Identity)
197+ require.ErrorIs(t, ctx.Err(), context.Canceled, "applying the result retires the attempt context")
198+ // Policy is membership authority, never durable observation state.
199+ require.Equal(t, ports.BrokerPolicy{}, durable(r.Snapshot()).Daemons[0].Policy)
200+
201+ r.settle() // flush before reopening
202+ require.NoError(t, store.Close())
203+
204+ reopened, err := brokerstore.OpenOffline(brokerstore.Options{Dir: dir})
205+ require.NoError(t, err)
206+ defer reopened.Close()
207+ persisted, err := reopened.Load()
208+ require.NoError(t, err)
209+ require.Len(t, persisted.Daemons, 1)
210+ require.Equal(t, ports.BrokerPolicy{}, persisted.Daemons[0].Policy, "the durable format omits policy")
211+ require.Equal(t, ports.BrokerDaemonIdentity("authed-daemon"), persisted.Daemons[0].Identity)
212+ require.Equal(t, ports.BrokerDaemonIncarnation{4, 5}, persisted.Daemons[0].Incarnation)
213+ require.Equal(t, protocol.Version, persisted.Daemons[0].ProtocolVersion)
214+ require.Equal(t, protocol.CapabilityResume, persisted.Daemons[0].Capabilities)
215+ r2, err := NewRegistry(2, reopened, newTestProbe(1), newManualClock(time.Unix(200, 0)), nil)
216+ require.NoError(t, err)
217+ defer r2.settle()
218+
219+ restored, ok := r2.Snapshot().Find(reg.Endpoint)
220+ require.True(t, ok)
221+ require.Equal(t, observed.Identity, restored.Identity)
222+ require.Equal(t, observed.Incarnation, restored.Incarnation)
223+ require.Equal(t, observed.ProtocolVersion, restored.ProtocolVersion)
224+ require.Equal(t, observed.Capabilities, restored.Capabilities)
225+ require.Equal(t, domain.RemoteAvailabilityReachable, restored.Availability)
226+ require.Equal(t, poolPolicy(), restored.Policy, "the loader re-stamps policy from membership")
227+}
M internal/usecase/broker/registry_store_test.go +9 -9
@@ -17,7 +17,7 @@ func TestRegistryRestoresAuthoritativeMembership(t *testing.T) {
1717 old := registration(t, "host", 1)
1818 current := old
1919 store := portsmocks.NewMockBrokerHostStore(t)
20- snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 9, Hosts: []ports.RemoteHostSnapshot{{Endpoint: "host", Registration: old, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}}
20+ snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 9, Daemons: []ports.BrokerDaemonObservation{{Endpoint: "host", Registration: old, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true}}}
2121 hosts := ports.BrokerHosts{Revision: 4}
2222 if mode == "replaced" {
2323 current.Generation++
@@ -36,12 +36,12 @@ func TestRegistryRestoresAuthoritativeMembership(t *testing.T) {
3636 require.Equal(t, ports.BrokerEpoch(2), got.Epoch)
3737 require.Equal(t, ports.BrokerRevision(1), got.Revision)
3838 if mode == "removed" {
39- require.Empty(t, got.Hosts)
39+ require.Empty(t, got.Daemons)
4040 return
4141 }
42- require.Len(t, got.Hosts, 1)
43- require.Equal(t, current, got.Hosts[0].Registration)
44- require.Equal(t, mode == "matching", got.Hosts[0].InventoryKnown)
42+ require.Len(t, got.Daemons, 1)
43+ require.Equal(t, current, got.Daemons[0].Registration)
44+ require.Equal(t, mode == "matching", got.Daemons[0].InventoryKnown)
4545 })
4646 }
4747 }
@@ -98,7 +98,7 @@ func TestRegistryRestoreIsReadOnlyAndPublicationLocal(t *testing.T) {
9898 clock := newManualClock(time.Unix(100, 0))
9999 reg := registration(t, "host", 1)
100100 store := portsmocks.NewMockBrokerHostStore(t)
101- store.EXPECT().Load().Return(ports.BrokerSnapshot{Epoch: 5, Revision: 4096, Hosts: []ports.RemoteHostSnapshot{{
101+ store.EXPECT().Load().Return(ports.BrokerSnapshot{Epoch: 5, Revision: 4096, Daemons: []ports.BrokerDaemonObservation{{
102102 Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true,
103103 }}}, nil).Once()
104104 store.EXPECT().LoadHosts().Return(ports.BrokerHosts{Revision: 99, Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}}, nil).Once()
@@ -110,7 +110,7 @@ func TestRegistryRestoreIsReadOnlyAndPublicationLocal(t *testing.T) {
110110 require.Equal(t, ports.BrokerEpoch(6), snapshot.Epoch)
111111 // Restoring reuses the matching observation but never republishes it: a
112112 // fresh process adopts authority and rewrites only when it observes.
113- require.Len(t, snapshot.Hosts, 1)
114- require.True(t, snapshot.Hosts[0].InventoryKnown)
115- require.Equal(t, domain.RemoteAvailabilityReachable, snapshot.Hosts[0].Availability)
113+ require.Len(t, snapshot.Daemons, 1)
114+ require.True(t, snapshot.Daemons[0].InventoryKnown)
115+ require.Equal(t, domain.RemoteAvailabilityReachable, snapshot.Daemons[0].Availability)
116116 }
M internal/usecase/broker/registry_test.go +464 -178
@@ -12,9 +12,11 @@ import (
1212 "sync"
1313 "testing"
1414 "time"
15+ "unicode/utf8"
1516
1617 "github.com/bnema/vev/internal/domain"
1718 "github.com/bnema/vev/internal/ports"
19+ "github.com/bnema/vev/internal/protocol"
1820 "github.com/bnema/vev/internal/protocol/catalogue"
1921 "github.com/stretchr/testify/require"
2022 )
@@ -202,7 +204,7 @@ func (s *testStore) LoadHosts() (ports.BrokerHosts, error) {
202204 return s.hosts, nil
203205 }
204206 authority := ports.BrokerHosts{Revision: 1}
205- for _, host := range s.loaded.Hosts {
207+ for _, host := range s.loaded.Daemons {
206208 authority.Hosts = append(authority.Hosts, ports.BrokerHostRecord{Registration: host.Registration, Pinned: true, Policy: poolPolicy()})
207209 }
208210 return authority, nil
@@ -368,24 +370,24 @@ type probeCall struct {
368370 }
369371
370372 type probeAnswer struct {
371- snapshot ports.RemoteHostSnapshot
373+ snapshot ports.BrokerDaemonObservation
372374 err error
373375 }
374376
375-func (p *testProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.RemoteHostSnapshot, error) {
377+func (p *testProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
376378 call := probeCall{registration: registration, result: make(chan probeAnswer, 1)}
377379 select {
378380 case p.calls <- call:
379381 case <-ctx.Done():
380382 p.noteCanceled()
381- return ports.RemoteHostSnapshot{}, ctx.Err()
383+ return ports.BrokerDaemonObservation{}, ctx.Err()
382384 }
383385 select {
384386 case answer := <-call.result:
385387 return answer.snapshot, answer.err
386388 case <-ctx.Done():
387389 p.noteCanceled()
388- return ports.RemoteHostSnapshot{}, ctx.Err()
390+ return ports.BrokerDaemonObservation{}, ctx.Err()
389391 }
390392 }
391393
@@ -420,7 +422,7 @@ func newChurnProbe() *churnProbe {
420422 }
421423 }
422424
423-func (p *churnProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.RemoteHostSnapshot, error) {
425+func (p *churnProbe) Probe(ctx context.Context, registration domain.RemoteRegistration) (ports.BrokerDaemonObservation, error) {
424426 p.mu.Lock()
425427 live := 0
426428 for other, endpoint := range p.contexts {
@@ -454,7 +456,7 @@ func (p *churnProbe) Probe(ctx context.Context, registration domain.RemoteRegist
454456 case <-ctx.Done():
455457 case <-p.release:
456458 }
457- return ports.RemoteHostSnapshot{}, ctx.Err()
459+ return ports.BrokerDaemonObservation{}, ctx.Err()
458460 }
459461
460462 // openRelease lets every still-blocked probe return, so a failing test never
@@ -499,6 +501,21 @@ func registration(t *testing.T, endpoint string, marker byte) domain.RemoteRegis
499501 return result
500502 }
501503
504+// hostRecord builds one pinned authority record with the shared test policy.
505+// The projection-only setHosts seam takes records now, so membership carries
506+// the same configured authority (registration plus policy) as production.
507+func hostRecord(reg domain.RemoteRegistration) ports.BrokerHostRecord {
508+ return ports.BrokerHostRecord{Registration: reg, Pinned: true, Policy: poolPolicy()}
509+}
510+
511+func hostRecords(registrations ...domain.RemoteRegistration) []ports.BrokerHostRecord {
512+ records := make([]ports.BrokerHostRecord, 0, len(registrations))
513+ for _, reg := range registrations {
514+ records = append(records, hostRecord(reg))
515+ }
516+ return records
517+}
518+
502519 // hostSession builds one catalogue-valid session. The registry applies the
503520 // same durable projection rules as the store before adopting an observation, so
504521 // a fixture session must carry a non-zero lifecycle identity and a non-nil tab
@@ -590,7 +607,7 @@ func waitSnapshot(t *testing.T, r *Registry, predicate func(ports.BrokerSnapshot
590607 }
591608 }
592609
593-func waitHost(t *testing.T, r *Registry, endpoint string, predicate func(ports.RemoteHostSnapshot) bool) ports.RemoteHostSnapshot {
610+func waitHost(t *testing.T, r *Registry, endpoint string, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation {
594611 t.Helper()
595612 snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool {
596613 host, ok := snapshot.Find(endpoint)
@@ -603,7 +620,7 @@ func waitHost(t *testing.T, r *Registry, endpoint string, predicate func(ports.R
603620 // requireHostStableWithin asserts a stale completion has no delayed effect:
604621 // the published host stays exactly as observed for a bounded window, and any
605622 // intervening publication fails immediately.
606-func requireHostStableWithin(t *testing.T, r *Registry, endpoint string, want ports.RemoteHostSnapshot, window time.Duration) {
623+func requireHostStableWithin(t *testing.T, r *Registry, endpoint string, want ports.BrokerDaemonObservation, window time.Duration) {
607624 t.Helper()
608625 sub := r.Subscribe()
609626 defer sub.Close()
@@ -682,14 +699,15 @@ func attemptFor(r *Registry, endpoint string) uint64 {
682699 func TestRegistryRestoresPersistedSnapshotUnderFreshEpoch(t *testing.T) {
683700 reg := registration(t, "example.test", 1)
684701 sessions := []catalogue.RemoteCatalogSession{{
685- Name: "work",
686- State: catalogue.RemoteCatalogSessionUp,
687- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},
702+ LifecycleID: domain.SessionLifecycleID{1},
703+ Name: "work",
704+ State: catalogue.RemoteCatalogSessionUp,
705+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},
688706 }}
689707 stored := ports.BrokerSnapshot{
690708 Epoch: 7,
691709 Revision: 9,
692- Hosts: []ports.RemoteHostSnapshot{{
710+ Daemons: []ports.BrokerDaemonObservation{{
693711 Endpoint: reg.Endpoint,
694712 Registration: reg,
695713 Availability: domain.RemoteAvailabilityReachable,
@@ -709,10 +727,10 @@ func TestRegistryRestoresPersistedSnapshotUnderFreshEpoch(t *testing.T) {
709727 // A restored broker process starts a fresh revision series: revisions
710728 // never compare across epochs.
711729 require.Equal(t, ports.BrokerRevision(1), snapshot.Revision)
712- require.Len(t, snapshot.Hosts, 1)
730+ require.Len(t, snapshot.Daemons, 1)
713731 require.Empty(t, snapshot.Removed)
714732
715- host := snapshot.Hosts[0]
733+ host := snapshot.Daemons[0]
716734 require.True(t, host.Registration.Equal(reg))
717735 require.Equal(t, domain.RemoteAvailabilityReachable, host.Availability)
718736 require.Equal(t, time.Unix(80, 0), host.NextDue)
@@ -753,7 +771,7 @@ func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) {
753771 require.Error(t, err)
754772
755773 // A durable record without an epoch is inconsistent, never silently empty.
756- _, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Hosts: []ports.RemoteHostSnapshot{{Endpoint: reg.Endpoint, Registration: reg}}}}, newTestProbe(1), clock, nil)
774+ _, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Daemons: []ports.BrokerDaemonObservation{{Endpoint: reg.Endpoint, Registration: reg}}}}, newTestProbe(1), clock, nil)
757775 require.Error(t, err)
758776 _, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Removed: []ports.BrokerHostTombstone{{Endpoint: reg.Endpoint, Registration: reg, RetiredRevision: 3}}}}, newTestProbe(1), clock, nil)
759777 require.Error(t, err)
@@ -763,7 +781,7 @@ func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) {
763781 empty, err := NewRegistry(1, &testStore{}, newTestProbe(1), clock, nil)
764782 require.NoError(t, err)
765783 require.Equal(t, ports.BrokerRevision(1), empty.Snapshot().Revision)
766- require.Empty(t, empty.Snapshot().Hosts)
784+ require.Empty(t, empty.Snapshot().Daemons)
767785 }
768786
769787 func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) {
@@ -777,7 +795,7 @@ func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) {
777795 currentEpoch := ports.BrokerSnapshot{
778796 Epoch: 7,
779797 Revision: 9,
780- Hosts: []ports.RemoteHostSnapshot{{
798+ Daemons: []ports.BrokerDaemonObservation{{
781799 Endpoint: reg.Endpoint,
782800 Registration: reg,
783801 Availability: domain.RemoteAvailabilityReachable,
@@ -799,26 +817,26 @@ func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) {
799817 // A different epoch is still adopted normally.
800818 registry, err := NewRegistry(8, &testStore{loaded: currentEpoch}, newTestProbe(1), clock, nil)
801819 require.NoError(t, err)
802- require.Len(t, registry.Snapshot().Hosts, 1)
820+ require.Len(t, registry.Snapshot().Daemons, 1)
803821 }
804822
805823 func TestRegistrySetHostsRejectsInvalidRegistrations(t *testing.T) {
806824 registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
807825
808826 reg := registration(t, "example.test", 1)
809- require.Error(t, registry.setHosts([]domain.RemoteRegistration{reg, reg}))
810- require.Error(t, registry.setHosts([]domain.RemoteRegistration{{Endpoint: reg.Endpoint}}))
811- require.Empty(t, registry.Snapshot().Hosts)
827+ require.Error(t, registry.setHosts(hostRecords(reg, reg)))
828+ require.Error(t, registry.setHosts(hostRecords(domain.RemoteRegistration{Endpoint: reg.Endpoint})))
829+ require.Empty(t, registry.Snapshot().Daemons)
812830 }
813831
814832 func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) {
815833 registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
816834 reg := registration(t, "example.test", 1)
817- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
835+ require.NoError(t, registry.setHosts(hostRecords(reg)))
818836
819- oversized := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts+1)
837+ oversized := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts+1)
820838 for index := 0; index <= ports.BrokerMaxHosts; index++ {
821- oversized = append(oversized, registration(t, fmt.Sprintf("host-%d.test", index), byte(index+1)))
839+ oversized = append(oversized, hostRecord(registration(t, fmt.Sprintf("host-%d.test", index), byte(index+1))))
822840 }
823841 before := registry.Snapshot()
824842 require.Error(t, registry.setHosts(oversized))
@@ -827,16 +845,16 @@ func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) {
827845
828846 // The boundary itself is accepted.
829847 require.NoError(t, registry.setHosts(oversized[:ports.BrokerMaxHosts]))
830- require.Len(t, registry.Snapshot().Hosts, ports.BrokerMaxHosts)
848+ require.Len(t, registry.Snapshot().Daemons, ports.BrokerMaxHosts)
831849 }
832850
833-func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) {
851+func TestRegistryUnchangedSetHostsIsNoOpAndReorderRepublishes(t *testing.T) {
834852 clock := newManualClock(time.Unix(100, 0))
835853 probe := newTestProbe(2)
836854 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
837855 first := registration(t, "example.test", 1)
838856 second := registration(t, "other.test", 2)
839- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second}))
857+ require.NoError(t, registry.setHosts(hostRecords(first, second)))
840858 startRegistry(t, registry)
841859
842860 // Every configured host is observed exactly once.
@@ -847,37 +865,128 @@ func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) {
847865 }
848866 require.Len(t, calls, 2)
849867 before := registry.Snapshot()
850- for _, host := range before.Hosts {
868+ for _, host := range before.Daemons {
851869 require.True(t, host.Checking)
852870 }
853871
854- // Identical membership, including in a different order, never republishes
855- // and never restarts an in-flight observation.
856- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{second, first}))
857- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second}))
872+ // Identical membership in the same order never republishes and never
873+ // restarts an in-flight observation.
874+ require.NoError(t, registry.setHosts(hostRecords(first, second)))
858875 require.Equal(t, before, registry.Snapshot())
859876 requireNoProbeWithin(t, probe, probeGrace)
860877
878+ // A reordering of identical records is a membership change: publication
879+ // order and the presentation rank stamped from it follow durable membership
880+ // authority, so the registry republishes both without cancelling the
881+ // in-flight observation.
882+ require.NoError(t, registry.setHosts(hostRecords(second, first)))
883+ reordered := registry.Snapshot()
884+ require.Greater(t, reordered.Revision, before.Revision)
885+ require.Equal(t, []string{"other.test", "example.test"}, []string{reordered.Daemons[0].Endpoint, reordered.Daemons[1].Endpoint})
886+ require.Equal(t, 0, reordered.Daemons[0].Rank)
887+ require.Equal(t, 1, reordered.Daemons[1].Rank)
888+ for _, host := range reordered.Daemons {
889+ require.True(t, host.Checking)
890+ }
891+ requireNoProbeWithin(t, probe, probeGrace)
892+
893+ // Restoring the configured order restores the published order and ranks.
894+ require.NoError(t, registry.setHosts(hostRecords(first, second)))
895+ require.Equal(t, before.Daemons, registry.Snapshot().Daemons)
896+ requireNoProbeWithin(t, probe, probeGrace)
897+
861898 // The in-flight observations still complete normally.
862899 for endpoint, call := range calls {
863- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
864- waitHost(t, registry, endpoint, func(host ports.RemoteHostSnapshot) bool {
900+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
901+ waitHost(t, registry, endpoint, func(host ports.BrokerDaemonObservation) bool {
865902 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
866903 })
867904 }
868905 }
869906
907+// TestRegistrySanitizesDerivedDisplayOrigin pins the boundary between routing
908+// authority and presentation: a target the routing validator accepts may carry
909+// bidi controls or line separators, and a hint the registry derives from it must
910+// never render them. The sanitized hint is published, satisfies the contract
911+// gate, and leaves the host observable.
912+func TestRegistrySanitizesDerivedDisplayOrigin(t *testing.T) {
913+ tests := []struct {
914+ name string
915+ endpoint string
916+ want string
917+ }{
918+ {name: "login prefix strips the bidi rune from the origin", endpoint: "user@ho\u202est.test", want: "host.test"},
919+ {name: "bare target drops the bidi rune", endpoint: "ho\u202est.test", want: "host.test"},
920+ {name: "right-to-left mark dropped", endpoint: "user@ho\u200fst.test", want: "host.test"},
921+ {name: "ordinary target unchanged", endpoint: "user@host.test", want: "host.test"},
922+ }
923+ for _, tc := range tests {
924+ t.Run(tc.name, func(t *testing.T) {
925+ clock := newManualClock(time.Unix(100, 0))
926+ probe := newTestProbe(1)
927+ registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
928+ require.NoError(t, registry.setHosts(hostRecords(registration(t, tc.endpoint, 1))))
929+
930+ host := registry.Snapshot().Daemons[0]
931+ require.Equal(t, tc.endpoint, host.Endpoint, "the routing target stays authoritative")
932+ require.Equal(t, tc.want, host.DisplayOrigin)
933+ require.NoError(t, host.Validate())
934+ require.NoError(t, registry.Snapshot().Validate())
935+
936+ // Sanitizing the hint never makes the host unobservable.
937+ startRegistry(t, registry)
938+ call := receiveCall(t, probe)
939+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
940+ waitHost(t, registry, tc.endpoint, func(host ports.BrokerDaemonObservation) bool {
941+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
942+ })
943+ })
944+ }
945+}
946+
947+// TestRegistryRefusesUnpublishableMembership pins the fail-closed membership
948+// boundary: a target whose derived hint holds only refused runes has no
949+// displayable origin, and membership that cannot be published is refused where
950+// authority is owned instead of freezing every later publication.
951+func TestRegistryRefusesUnpublishableMembership(t *testing.T) {
952+ registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
953+ err := registry.setHosts(hostRecords(registration(t, "user@\u202e", 1)))
954+ require.ErrorContains(t, err, "not publishable")
955+ require.Empty(t, registry.Snapshot().Daemons)
956+}
957+
958+// TestRegistryRefusesInvalidPublication pins the last-resort publication guard:
959+// a projection that reached the live set unpublished is never published, because
960+// a snapshot the wire refuses aborts every client connection on subscribe, and a
961+// refused publication never advances the revision series.
962+func TestRegistryRefusesInvalidPublication(t *testing.T) {
963+ registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
964+ require.NoError(t, registry.setHosts(hostRecords(registration(t, "example.test", 1))))
965+ before := registry.Snapshot()
966+
967+ // Inject behind the membership boundary, which now refuses such a record.
968+ registry.mu.Lock()
969+ broken := before.Daemons[0].Clone()
970+ broken.DisplayOrigin = ""
971+ registry.hosts[broken.Endpoint] = broken
972+ registry.publishLocked(false)
973+ registry.mu.Unlock()
974+
975+ require.Equal(t, before, registry.Snapshot())
976+}
977+
870978 func TestRegistrySnapshotIsImmutable(t *testing.T) {
871979 clock := newManualClock(time.Unix(100, 0))
872980 probe := newTestProbe(1)
873981 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
874982 reg := registration(t, "example.test", 1)
875- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
983+ require.NoError(t, registry.setHosts(hostRecords(reg)))
876984 startRegistry(t, registry)
877985
878986 call := receiveCall(t, probe)
879- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
880- Availability: domain.RemoteAvailabilityReachable,
987+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
988+ Availability: domain.RemoteAvailabilityReachable,
989+ InventoryKnown: true,
881990 Sessions: []catalogue.RemoteCatalogSession{{
882991 LifecycleID: domain.SessionLifecycleID{1},
883992 Name: "work",
@@ -885,7 +994,7 @@ func TestRegistrySnapshotIsImmutable(t *testing.T) {
885994 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},
886995 }},
887996 }}
888- observed := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
997+ observed := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
889998 return !host.Checking && len(host.Sessions) == 1
890999 })
8911000 require.Equal(t, "work", observed.Sessions[0].Name)
@@ -895,22 +1004,23 @@ func TestRegistrySnapshotIsImmutable(t *testing.T) {
8951004 observed.Sessions[0].Name = "mutated"
8961005 observed.Sessions[0].Tabs[0].Name = "mutated"
8971006 current := registry.Snapshot()
898- require.Equal(t, reg.Endpoint, current.Hosts[0].Endpoint)
899- require.Equal(t, "work", current.Hosts[0].Sessions[0].Name)
900- require.Equal(t, "shell", current.Hosts[0].Sessions[0].Tabs[0].Name)
1007+ require.Equal(t, reg.Endpoint, current.Daemons[0].Endpoint)
1008+ require.Equal(t, "work", current.Daemons[0].Sessions[0].Name)
1009+ require.Equal(t, "shell", current.Daemons[0].Sessions[0].Tabs[0].Name)
9011010
9021011 // A later publication never mutates a snapshot already handed out.
9031012 earlier := registry.Snapshot()
9041013 registry.RequestProbe(reg.Endpoint)
9051014 next := receiveCall(t, probe)
906- next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
907- Availability: domain.RemoteAvailabilityReachable,
908- Sessions: []catalogue.RemoteCatalogSession{hostSession("other")},
1015+ next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1016+ Availability: domain.RemoteAvailabilityReachable,
1017+ InventoryKnown: true,
1018+ Sessions: []catalogue.RemoteCatalogSession{hostSession("other")},
9091019 }}
910- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1020+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
9111021 return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "other"
9121022 })
913- require.Equal(t, "work", earlier.Hosts[0].Sessions[0].Name)
1023+ require.Equal(t, "work", earlier.Daemons[0].Sessions[0].Name)
9141024 }
9151025
9161026 func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) {
@@ -919,13 +1029,13 @@ func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) {
9191029 probe := newTestProbe(1)
9201030 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
9211031 reg := registration(t, "example.test", 1)
922- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1032+ require.NoError(t, registry.setHosts(hostRecords(reg)))
9231033 startRegistry(t, registry)
9241034
9251035 call := receiveCall(t, probe)
926- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
1036+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
9271037
928- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1038+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
9291039 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
9301040 })
9311041 require.Equal(t, start, host.LastAttempt)
@@ -948,12 +1058,12 @@ func TestRegistryDemandOverridesFreshSchedule(t *testing.T) {
9481058 probe := newTestProbe(1)
9491059 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
9501060 reg := registration(t, "example.test", 1)
951- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1061+ require.NoError(t, registry.setHosts(hostRecords(reg)))
9521062 startRegistry(t, registry)
9531063
9541064 call := receiveCall(t, probe)
955- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
956- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1065+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1066+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
9571067 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
9581068 })
9591069
@@ -970,7 +1080,7 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) {
9701080 probe := newTestProbe(2)
9711081 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
9721082 reg := registration(t, "example.test", 1)
973- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1083+ require.NoError(t, registry.setHosts(hostRecords(reg)))
9741084 startRegistry(t, registry)
9751085
9761086 call := receiveCall(t, probe)
@@ -982,8 +1092,8 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) {
9821092 t.Fatalf("concurrent demand started a duplicate probe for %q", duplicate.registration.Endpoint)
9831093 default:
9841094 }
985- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
986- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1095+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1096+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
9871097 return host.Availability == domain.RemoteAvailabilityReachable && host.LastSuccess.Equal(start)
9881098 })
9891099 require.Equal(t, start.Add(defaultFreshFor), host.NextDue)
@@ -991,8 +1101,8 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) {
9911101 // The single coalesced follow-up is admitted once the first completes.
9921102 followUp := receiveCall(t, probe)
9931103 require.True(t, followUp.registration.Equal(reg))
994- followUp.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
995- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1104+ followUp.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1105+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
9961106 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
9971107 })
9981108 }
@@ -1002,7 +1112,7 @@ func TestRegistryIgnoresDemandForUnknownEndpoint(t *testing.T) {
10021112 probe := newTestProbe(1)
10031113 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
10041114 reg := registration(t, "example.test", 1)
1005- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1115+ require.NoError(t, registry.setHosts(hostRecords(reg)))
10061116 registry.RequestProbe("unknown.test")
10071117 startRegistry(t, registry)
10081118
@@ -1019,11 +1129,11 @@ func TestRegistryPublishesReachabilityAndPersists(t *testing.T) {
10191129 store := &testStore{}
10201130 registry := newTestRegistry(t, 3, store, probe, clock)
10211131 reg := registration(t, "example.test", 1)
1022- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1132+ require.NoError(t, registry.setHosts(hostRecords(reg)))
10231133 startRegistry(t, registry)
10241134
10251135 call := receiveCall(t, probe)
1026- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
1136+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
10271137 Availability: domain.RemoteAvailabilityReachable,
10281138 InventoryKnown: true,
10291139 Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
@@ -1034,13 +1144,13 @@ func TestRegistryPublishesReachabilityAndPersists(t *testing.T) {
10341144 })
10351145
10361146 stored := waitStored(t, store, func(stored ports.BrokerSnapshot) bool {
1037- return stored.Revision >= snapshot.Revision && len(stored.Hosts) == 1
1147+ return stored.Revision >= snapshot.Revision && len(stored.Daemons) == 1
10381148 })
10391149 require.Equal(t, ports.BrokerEpoch(3), stored.Epoch)
1040- require.Len(t, stored.Hosts, 1)
1041- require.Equal(t, "work", stored.Hosts[0].Sessions[0].Name)
1042- require.Equal(t, reg.Endpoint, stored.Hosts[0].Endpoint)
1043- require.False(t, stored.Hosts[0].Checking)
1150+ require.Len(t, stored.Daemons, 1)
1151+ require.Equal(t, "work", stored.Daemons[0].Sessions[0].Name)
1152+ require.Equal(t, reg.Endpoint, stored.Daemons[0].Endpoint)
1153+ require.False(t, stored.Daemons[0].Checking)
10441154 }
10451155
10461156 func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
@@ -1054,31 +1164,31 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
10541164 }{
10551165 {
10561166 name: "unreachable without error",
1057- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityUnreachable}},
1167+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}},
10581168 wantAvailability: domain.RemoteAvailabilityUnreachable,
10591169 wantKind: domain.RemoteFailureTransport,
10601170 },
10611171 {
10621172 name: "unknown outcome is not reachability",
1063- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{}},
1173+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{}},
10641174 wantAvailability: domain.RemoteAvailabilityUnreachable,
10651175 wantKind: domain.RemoteFailureTransport,
10661176 },
10671177 {
10681178 name: "incompatible",
1069- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityIncompatible}},
1179+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityIncompatible}},
10701180 wantAvailability: domain.RemoteAvailabilityIncompatible,
10711181 wantKind: domain.RemoteFailureIncompatible,
10721182 },
10731183 {
10741184 name: "authentication",
1075- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityAuthFailed}},
1185+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityAuthFailed}},
10761186 wantAvailability: domain.RemoteAvailabilityAuthFailed,
10771187 wantKind: domain.RemoteFailureAuthentication,
10781188 },
10791189 {
10801190 name: "invalid response",
1081- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityInvalidResponse}},
1191+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityInvalidResponse}},
10821192 wantAvailability: domain.RemoteAvailabilityInvalidResponse,
10831193 wantKind: domain.RemoteFailureInvalidResponse,
10841194 },
@@ -1104,12 +1214,12 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
11041214 probe := newTestProbe(1)
11051215 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
11061216 reg := registration(t, "example.test", 1)
1107- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1217+ require.NoError(t, registry.setHosts(hostRecords(reg)))
11081218 startRegistry(t, registry)
11091219
11101220 call := receiveCall(t, probe)
11111221 call.result <- tt.answer
1112- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1222+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
11131223 return !host.Checking && host.ConsecutiveFailures == 1
11141224 })
11151225 require.Equal(t, tt.wantAvailability, host.Availability)
@@ -1135,18 +1245,19 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing.
11351245 probe := newTestProbe(1)
11361246 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
11371247 reg := registration(t, "example.test", 1)
1138- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1248+ require.NoError(t, registry.setHosts(hostRecords(reg)))
11391249 startRegistry(t, registry)
11401250 sub := registry.Subscribe()
11411251 defer sub.Close()
11421252
11431253 // A reachable observation establishes the last-known inventory.
11441254 call := receiveCall(t, probe)
1145- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
1146- Availability: domain.RemoteAvailabilityReachable,
1147- Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
1255+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1256+ Availability: domain.RemoteAvailabilityReachable,
1257+ InventoryKnown: true,
1258+ Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
11481259 }}
1149- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1260+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
11501261 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
11511262 })
11521263
@@ -1157,12 +1268,13 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing.
11571268 // never pass unnoticed.
11581269 clock.Advance(defaultFreshFor)
11591270 call = receiveCall(t, probe)
1160- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
1161- Availability: domain.RemoteAvailabilityReachable,
1162- Sessions: hostSessions(ports.BrokerMaxSessionsPerHost + 1),
1271+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1272+ Availability: domain.RemoteAvailabilityReachable,
1273+ InventoryKnown: true,
1274+ Sessions: hostSessions(ports.BrokerMaxSessionsPerHost + 1),
11631275 }}
11641276 deadline := time.After(time.Second)
1165- var host ports.RemoteHostSnapshot
1277+ var host ports.BrokerDaemonObservation
11661278 for {
11671279 snapshot := registry.Snapshot()
11681280 require.NoError(t, snapshot.Validate())
@@ -1192,24 +1304,25 @@ func TestRegistryAcceptsProbeProjectionAtSessionBound(t *testing.T) {
11921304 probe := newTestProbe(1)
11931305 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
11941306 reg := registration(t, "example.test", 1)
1195- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1307+ require.NoError(t, registry.setHosts(hostRecords(reg)))
11961308 startRegistry(t, registry)
11971309
11981310 // The bound itself is valid: exactly BrokerMaxSessionsPerHost sessions are
11991311 // accepted and published, and the resulting snapshot validates.
12001312 call := receiveCall(t, probe)
1201- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
1202- Availability: domain.RemoteAvailabilityReachable,
1203- Sessions: hostSessions(ports.BrokerMaxSessionsPerHost),
1313+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1314+ Availability: domain.RemoteAvailabilityReachable,
1315+ InventoryKnown: true,
1316+ Sessions: hostSessions(ports.BrokerMaxSessionsPerHost),
12041317 }}
1205- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1318+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
12061319 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
12071320 })
12081321 require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost)
12091322 require.Zero(t, host.ConsecutiveFailures)
12101323 snapshot := registry.Snapshot()
12111324 require.NoError(t, snapshot.Validate())
1212- require.Len(t, snapshot.Hosts[0].Sessions, ports.BrokerMaxSessionsPerHost)
1325+ require.Len(t, snapshot.Daemons[0].Sessions, ports.BrokerMaxSessionsPerHost)
12131326 }
12141327
12151328 // TestRegistryRejectsNonDurableObservation proves the registry and the durable
@@ -1222,15 +1335,16 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) {
12221335 probe := newTestProbe(1)
12231336 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
12241337 reg := registration(t, "example.test", 1)
1225- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1338+ require.NoError(t, registry.setHosts(hostRecords(reg)))
12261339 startRegistry(t, registry)
12271340
12281341 call := receiveCall(t, probe)
1229- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
1230- Availability: domain.RemoteAvailabilityReachable,
1231- Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
1342+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1343+ Availability: domain.RemoteAvailabilityReachable,
1344+ InventoryKnown: true,
1345+ Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
12321346 }}
1233- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1347+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
12341348 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
12351349 })
12361350
@@ -1238,11 +1352,12 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) {
12381352 // a zero lifecycle identity and an absent tab list) is a malformed response.
12391353 clock.Advance(defaultFreshFor)
12401354 call = receiveCall(t, probe)
1241- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
1242- Availability: domain.RemoteAvailabilityReachable,
1243- Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}},
1355+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1356+ Availability: domain.RemoteAvailabilityReachable,
1357+ InventoryKnown: true,
1358+ Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}},
12441359 }}
1245- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1360+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
12461361 return !host.Checking && host.ConsecutiveFailures == 1
12471362 })
12481363 require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability)
@@ -1252,7 +1367,7 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) {
12521367 require.Equal(t, start, host.LastSuccess)
12531368 snapshot := registry.Snapshot()
12541369 require.NoError(t, snapshot.Validate())
1255- require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Hosts[0]))
1370+ require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Daemons[0]))
12561371 }
12571372
12581373 // TestRegistryRevisionOverflowFailsClosed pins the revision series: zero is not
@@ -1266,7 +1381,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) {
12661381 registry, err := NewRegistry(1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)), logger)
12671382 require.NoError(t, err)
12681383 first := registration(t, "example.test", 1)
1269- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first}))
1384+ require.NoError(t, registry.setHosts(hostRecords(first)))
12701385 before := registry.Snapshot()
12711386 require.NoError(t, before.Validate())
12721387 require.Equal(t, ports.BrokerRevision(2), before.Revision)
@@ -1277,7 +1392,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) {
12771392
12781393 // Both mutation paths refuse the change before touching the store or the
12791394 // projection, so neither durable authority nor the publication series moves.
1280- require.ErrorIs(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "other.test", 2)}), ports.ErrBrokerRevisionExhausted)
1395+ require.ErrorIs(t, registry.setHosts(hostRecords(registration(t, "other.test", 2))), ports.ErrBrokerRevisionExhausted)
12811396 require.ErrorIs(t, registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: first, Pinned: true, Policy: poolPolicy()}}), ports.ErrBrokerRevisionExhausted)
12821397 require.Equal(t, authority, registry.authority)
12831398
@@ -1292,7 +1407,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) {
12921407 require.NoError(t, after.Validate())
12931408 require.Greater(t, after.Revision, ports.BrokerRevision(0))
12941409 require.Equal(t, ^ports.BrokerRevision(0), registry.revision)
1295- require.Equal(t, first, after.Hosts[0].Registration)
1410+ require.Equal(t, first, after.Daemons[0].Registration)
12961411 // The refusal is observable: an exhausted series logs instead of failing
12971412 // silently behind a frozen snapshot.
12981413 require.Contains(t, logs.String(), "revision series exhausted")
@@ -1304,16 +1419,17 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
13041419 probe := newTestProbe(1)
13051420 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
13061421 reg := registration(t, "example.test", 1)
1307- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1422+ require.NoError(t, registry.setHosts(hostRecords(reg)))
13081423 startRegistry(t, registry)
13091424
13101425 // A successful observation owns the success time and opens no episode.
13111426 call := receiveCall(t, probe)
1312- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
1313- Availability: domain.RemoteAvailabilityReachable,
1314- Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
1427+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1428+ Availability: domain.RemoteAvailabilityReachable,
1429+ InventoryKnown: true,
1430+ Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
13151431 }}
1316- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1432+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
13171433 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
13181434 })
13191435 require.Equal(t, start, host.LastSuccess)
@@ -1322,11 +1438,12 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
13221438 // A failure preserves the last success and opens exactly one episode.
13231439 clock.Advance(defaultFreshFor)
13241440 call = receiveCall(t, probe)
1325- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{
1326- Availability: domain.RemoteAvailabilityIncompatible,
1327- Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: catalogue.RemoteCatalogSessionUp}},
1441+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1442+ Availability: domain.RemoteAvailabilityIncompatible,
1443+ InventoryKnown: true,
1444+ Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: catalogue.RemoteCatalogSessionUp}},
13281445 }}
1329- host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1446+ host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
13301447 return !host.Checking && host.ConsecutiveFailures == 1
13311448 })
13321449 require.Equal(t, start, host.LastSuccess)
@@ -1340,7 +1457,7 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
13401457 clock.Advance(defaultRetryBase)
13411458 call = receiveCall(t, probe)
13421459 call.result <- probeAnswer{err: errors.New("offline")}
1343- host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1460+ host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
13441461 return !host.Checking && host.ConsecutiveFailures == 2
13451462 })
13461463 require.Equal(t, start, host.LastSuccess)
@@ -1350,8 +1467,8 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
13501467 clock.Advance(2 * defaultRetryBase)
13511468 recovered := clock.Now()
13521469 call = receiveCall(t, probe)
1353- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
1354- host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1470+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1471+ host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
13551472 return !host.Checking && host.ConsecutiveFailures == 0
13561473 })
13571474 require.Equal(t, recovered, host.LastSuccess)
@@ -1366,7 +1483,7 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
13661483 probe := newTestProbe(1)
13671484 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
13681485 reg := registration(t, "example.test", 1)
1369- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1486+ require.NoError(t, registry.setHosts(hostRecords(reg)))
13701487 startRegistry(t, registry)
13711488
13721489 probeErr := errors.New("offline")
@@ -1385,7 +1502,7 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
13851502 call := receiveCall(t, probe)
13861503 at := clock.Now()
13871504 call.result <- probeAnswer{err: probeErr}
1388- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1505+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
13891506 return !host.Checking && host.ConsecutiveFailures == uint(index+1)
13901507 })
13911508 require.Equal(t, domain.RemoteAvailabilityUnreachable, host.Availability)
@@ -1410,12 +1527,12 @@ func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) {
14101527 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
14111528 registry.jitter = func(base time.Duration, _ string, _ uint64) time.Duration { return base / 2 }
14121529 reg := registration(t, "example.test", 1)
1413- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1530+ require.NoError(t, registry.setHosts(hostRecords(reg)))
14141531 startRegistry(t, registry)
14151532
14161533 call := receiveCall(t, probe)
14171534 call.result <- probeAnswer{err: errors.New("offline")}
1418- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1535+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
14191536 return !host.Checking && host.Availability == domain.RemoteAvailabilityUnreachable
14201537 })
14211538 require.Equal(t, start.Add(defaultRetryBase/2), host.NextDue)
@@ -1423,8 +1540,8 @@ func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) {
14231540 clock.Advance(defaultRetryBase / 2)
14241541 next := receiveCall(t, probe)
14251542 successAt := clock.Now()
1426- next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
1427- host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1543+ next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1544+ host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
14281545 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
14291546 })
14301547 // The healthy refresh is jittered too: a fleet of hosts never converges
@@ -1457,9 +1574,10 @@ func TestRegistryJitterIsDeterministicAndBounded(t *testing.T) {
14571574
14581575 func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) {
14591576 replacementAnswer := func(name string) probeAnswer {
1460- return probeAnswer{snapshot: ports.RemoteHostSnapshot{
1461- Availability: domain.RemoteAvailabilityReachable,
1462- Sessions: []catalogue.RemoteCatalogSession{hostSession(name)},
1577+ return probeAnswer{snapshot: ports.BrokerDaemonObservation{
1578+ Availability: domain.RemoteAvailabilityReachable,
1579+ InventoryKnown: true,
1580+ Sessions: []catalogue.RemoteCatalogSession{hostSession(name)},
14631581 }}
14641582 }
14651583 assertFenced := func(t *testing.T, current, replacement domain.RemoteRegistration) {
@@ -1467,18 +1585,18 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) {
14671585 clock := newManualClock(time.Unix(100, 0))
14681586 probe := newTestProbe(2)
14691587 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1470- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current}))
1588+ require.NoError(t, registry.setHosts(hostRecords(current)))
14711589 startRegistry(t, registry)
14721590 currentCall := receiveCall(t, probe)
14731591 require.True(t, currentCall.registration.Equal(current))
14741592
1475- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{replacement}))
1593+ require.NoError(t, registry.setHosts(hostRecords(replacement)))
14761594 replacementCall := receiveCall(t, probe)
14771595 require.True(t, replacementCall.registration.Equal(replacement))
14781596
14791597 // The replacement completes and becomes authoritative...
14801598 replacementCall.result <- replacementAnswer("replacement")
1481- applied := waitHost(t, registry, replacement.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1599+ applied := waitHost(t, registry, replacement.Endpoint, func(host ports.BrokerDaemonObservation) bool {
14821600 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && len(host.Sessions) == 1
14831601 })
14841602 require.True(t, applied.Registration.Equal(replacement))
@@ -1495,7 +1613,7 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) {
14951613 next := receiveCall(t, probe)
14961614 require.True(t, next.registration.Equal(replacement))
14971615 next.result <- replacementAnswer("next")
1498- waitHost(t, registry, replacement.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1616+ waitHost(t, registry, replacement.Endpoint, func(host ports.BrokerDaemonObservation) bool {
14991617 return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "next"
15001618 })
15011619 }
@@ -1516,7 +1634,7 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.
15161634 probe := newTestProbe(2)
15171635 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
15181636 reg := registration(t, "example.test", 1)
1519- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1637+ require.NoError(t, registry.setHosts(hostRecords(reg)))
15201638
15211639 ctx, cancel := context.WithCancel(context.Background())
15221640 defer cancel()
@@ -1532,20 +1650,20 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.
15321650 // Removal retires a bounded tombstone and drops the in-flight token.
15331651 require.NoError(t, registry.setHosts(nil))
15341652 removed := registry.Snapshot()
1535- require.Empty(t, removed.Hosts)
1653+ require.Empty(t, removed.Daemons)
15361654 require.Len(t, removed.Removed, 1)
15371655 require.Equal(t, removed.Revision, removed.Removed[0].RetiredRevision)
15381656 require.True(t, removed.Removed[0].Fences(reg))
15391657 require.NoError(t, removed.Validate())
15401658
15411659 // The retired attempt can never revive a removed endpoint.
1542- registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
1543- require.Empty(t, registry.Snapshot().Hosts)
1660+ registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
1661+ require.Empty(t, registry.Snapshot().Daemons)
15441662 require.Len(t, registry.Snapshot().Removed, 1)
15451663
15461664 // Re-adding the identical registration supersedes the tombstone, so only
15471665 // the attempt token can fence the retired completion.
1548- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1666+ require.NoError(t, registry.setHosts(hostRecords(reg)))
15491667 require.Empty(t, registry.Snapshot().Removed)
15501668 dispatch()
15511669 freshCall := receiveCall(t, probe)
@@ -1553,13 +1671,13 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.
15531671 fresh := attemptFor(registry, reg.Endpoint)
15541672 require.Greater(t, fresh, retired)
15551673
1556- registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
1674+ registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
15571675 stale, ok := registry.Snapshot().Find(reg.Endpoint)
15581676 require.True(t, ok)
15591677 require.True(t, stale.Checking)
15601678 require.Equal(t, domain.RemoteAvailabilityUnknown, stale.Availability)
15611679
1562- registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: fresh, snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
1680+ registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: fresh, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
15631681 applied, ok := registry.Snapshot().Find(reg.Endpoint)
15641682 require.True(t, ok)
15651683 require.False(t, applied.Checking)
@@ -1571,9 +1689,9 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) {
15711689 replacement := registration(t, "example.test", 2)
15721690 tests := []struct {
15731691 name string
1574- next []domain.RemoteRegistration
1692+ next []ports.BrokerHostRecord
15751693 }{
1576- {name: "replacement", next: []domain.RemoteRegistration{replacement}},
1694+ {name: "replacement", next: []ports.BrokerHostRecord{hostRecord(replacement)}},
15771695 {name: "removal", next: nil},
15781696 }
15791697 for _, tt := range tests {
@@ -1581,7 +1699,7 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) {
15811699 clock := newManualClock(time.Unix(100, 0))
15821700 probe := newTestProbe(2)
15831701 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1584- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current}))
1702+ require.NoError(t, registry.setHosts(hostRecords(current)))
15851703 startRegistry(t, registry)
15861704
15871705 call := receiveCall(t, probe)
@@ -1609,7 +1727,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) {
16091727
16101728 first := registration(t, "example.test", 1)
16111729 second := registration(t, "example.test", 2)
1612- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first}))
1730+ require.NoError(t, registry.setHosts(hostRecords(first)))
16131731
16141732 ctx, cancel := context.WithCancel(context.Background())
16151733 defer cancel()
@@ -1635,7 +1753,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) {
16351753 if round%2 == 0 {
16361754 next = second
16371755 }
1638- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{next}))
1756+ require.NoError(t, registry.setHosts(hostRecords(next)))
16391757 select {
16401758 case <-probe.startedSig:
16411759 case <-time.After(time.Second):
@@ -1657,7 +1775,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) {
16571775 if (worker+round)%2 == 0 {
16581776 next = second
16591777 }
1660- if err := registry.setHosts([]domain.RemoteRegistration{next}); err != nil {
1778+ if err := registry.setHosts(hostRecords(next)); err != nil {
16611779 failures[worker] = err
16621780 return
16631781 }
@@ -1700,9 +1818,9 @@ func TestRegistryPublishesBoundedTombstones(t *testing.T) {
17001818 // Two full membership rounds with distinct endpoints: without pruning the
17011819 // retired set would double.
17021820 for round := 0; round < 2; round++ {
1703- registrations := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts)
1821+ registrations := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts)
17041822 for index := 0; index < ports.BrokerMaxHosts; index++ {
1705- registrations = append(registrations, registration(t, fmt.Sprintf("round-%d-host-%d.test", round, index), byte(round*ports.BrokerMaxHosts+index+1)))
1823+ registrations = append(registrations, hostRecord(registration(t, fmt.Sprintf("round-%d-host-%d.test", round, index), byte(round*ports.BrokerMaxHosts+index+1))))
17061824 }
17071825 require.NoError(t, registry.setHosts(registrations))
17081826 require.NoError(t, registry.setHosts(nil))
@@ -1730,7 +1848,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) {
17301848 defer registry.store.close()
17311849
17321850 reg := registration(t, "example.test", 1)
1733- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1851+ require.NoError(t, registry.setHosts(hostRecords(reg)))
17341852 // The writer is now blocked inside the first durable write.
17351853 first := <-store.entered
17361854 require.Equal(t, ports.BrokerRevision(2), first)
@@ -1743,7 +1861,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) {
17431861 defer close(published)
17441862 for generation := domain.RemoteGeneration(2); generation <= 6; generation++ {
17451863 reg.Generation = generation
1746- if err := registry.setHosts([]domain.RemoteRegistration{reg}); err != nil {
1864+ if err := registry.setHosts(hostRecords(reg)); err != nil {
17471865 setErr = err
17481866 return
17491867 }
@@ -1778,10 +1896,10 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) {
17781896 require.NoError(t, err)
17791897
17801898 reg := registration(t, "example.test", 1)
1781- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1899+ require.NoError(t, registry.setHosts(hostRecords(reg)))
17821900 startRegistry(t, registry)
17831901 call := receiveCall(t, probe)
1784- require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1902+ require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
17851903 return host.Checking
17861904 }).Checking)
17871905
@@ -1801,8 +1919,8 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) {
18011919 select {
18021920 case snapshot := <-observed:
18031921 require.NoError(t, snapshot.Validate())
1804- require.Len(t, snapshot.Hosts, 1)
1805- require.Equal(t, reg, snapshot.Hosts[0].Registration)
1922+ require.Len(t, snapshot.Daemons, 1)
1923+ require.Equal(t, reg, snapshot.Daemons[0].Registration)
18061924 case <-time.After(time.Second):
18071925 t.Fatal("Snapshot blocked behind the durable membership CAS")
18081926 }
@@ -1811,7 +1929,7 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) {
18111929 // parked: applying the result needs the registry lock, so the in-flight
18121930 // projection must stay exactly as published until membership commits.
18131931 registry.hint()
1814- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
1932+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
18151933 pending, ok := registry.Snapshot().Find(reg.Endpoint)
18161934 require.True(t, ok)
18171935 require.True(t, pending.Checking)
@@ -1834,7 +1952,7 @@ window:
18341952 // applies the observation and resumes scheduling the next one.
18351953 store.releaseCAS()
18361954 require.NoError(t, <-replaced)
1837- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
1955+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
18381956 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
18391957 })
18401958 require.Equal(t, clock.Now(), host.LastSuccess)
@@ -1853,19 +1971,19 @@ func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T)
18531971 defer registry.store.close()
18541972
18551973 reg := registration(t, "example.test", 1)
1856- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1974+ require.NoError(t, registry.setHosts(hostRecords(reg)))
18571975 waitForLog(t, logs, "durable snapshot write failed")
18581976 require.Contains(t, logs.String(), storeErr.Error())
18591977
18601978 // A failed write is not fatal and never blocks the next publication.
18611979 store.setStoreErr(nil)
18621980 reg.Generation++
1863- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1981+ require.NoError(t, registry.setHosts(hostRecords(reg)))
18641982 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
18651983 return snapshot.Revision == registry.Snapshot().Revision
18661984 })
1867- require.Len(t, stored.Hosts, 1)
1868- require.True(t, stored.Hosts[0].Registration.Equal(reg))
1985+ require.Len(t, stored.Daemons, 1)
1986+ require.True(t, stored.Daemons[0].Registration.Equal(reg))
18691987 }
18701988
18711989 func TestRegistryPersistedCopyClearsChecking(t *testing.T) {
@@ -1874,26 +1992,26 @@ func TestRegistryPersistedCopyClearsChecking(t *testing.T) {
18741992 store := &testStore{}
18751993 registry := newTestRegistry(t, 3, store, probe, clock)
18761994 reg := registration(t, "example.test", 1)
1877- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
1995+ require.NoError(t, registry.setHosts(hostRecords(reg)))
18781996 startRegistry(t, registry)
18791997
18801998 // The observation is in flight: the published projection carries Checking,
18811999 // the durable copy never does.
1882- require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
2000+ require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
18832001 return host.Checking
18842002 }).Checking)
18852003 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
1886- return len(snapshot.Hosts) == 1
2004+ return len(snapshot.Daemons) == 1
18872005 })
1888- require.False(t, stored.Hosts[0].Checking)
2006+ require.False(t, stored.Daemons[0].Checking)
18892007
18902008 // Completing the observation persists the settled projection.
18912009 call := receiveCall(t, probe)
1892- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
2010+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
18932011 settled := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
1894- return len(snapshot.Hosts) == 1 && snapshot.Hosts[0].Availability == domain.RemoteAvailabilityReachable
2012+ return len(snapshot.Daemons) == 1 && snapshot.Daemons[0].Availability == domain.RemoteAvailabilityReachable
18952013 })
1896- require.False(t, settled.Hosts[0].Checking)
2014+ require.False(t, settled.Daemons[0].Checking)
18972015 }
18982016
18992017 // TestRegistryNeverPersistsRetirementTombstones proves retirement tombstones
@@ -1907,16 +2025,16 @@ func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) {
19072025 defer registry.store.close()
19082026
19092027 reg := registration(t, "retired.test", 1)
1910- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
2028+ require.NoError(t, registry.setHosts(hostRecords(reg)))
19112029 require.NoError(t, registry.setHosts(nil))
19122030
19132031 // Removal publishes a bounded tombstone and drops the live host in memory.
1914- require.Empty(t, registry.Snapshot().Hosts)
2032+ require.Empty(t, registry.Snapshot().Daemons)
19152033 require.Len(t, registry.Snapshot().Removed, 1)
19162034
19172035 // The durable copy drops the tombstone before the store ever sees it.
19182036 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
1919- return len(snapshot.Hosts) == 0 && snapshot.Revision == registry.Snapshot().Revision
2037+ return len(snapshot.Daemons) == 0 && snapshot.Revision == registry.Snapshot().Revision
19202038 })
19212039 require.Empty(t, stored.Removed)
19222040
@@ -1924,7 +2042,7 @@ func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) {
19242042 // stale tombstone.
19252043 reopened, err := NewRegistry(2, &testStore{loaded: stored}, newTestProbe(1), clock, nil)
19262044 require.NoError(t, err)
1927- require.Empty(t, reopened.Snapshot().Hosts)
2045+ require.Empty(t, reopened.Snapshot().Daemons)
19282046 require.Empty(t, reopened.Snapshot().Removed)
19292047 }
19302048
@@ -1937,7 +2055,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) {
19372055 reg := registration(t, "example.test", 1)
19382056 for generation := domain.RemoteGeneration(1); generation <= 10; generation++ {
19392057 reg.Generation = generation
1940- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
2058+ require.NoError(t, registry.setHosts(hostRecords(reg)))
19412059 }
19422060 // A subscriber that never drains is bounded to one pending wake and
19432061 // never blocks publication.
@@ -1949,7 +2067,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) {
19492067 sub.Close()
19502068 sub.Close()
19512069 reg.Generation++
1952- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
2070+ require.NoError(t, registry.setHosts(hostRecords(reg)))
19532071 select {
19542072 case <-sub.Changed():
19552073 t.Fatal("closed subscription received a notification")
@@ -1972,7 +2090,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) {
19722090 t.Fatal("in-flight probe did not observe cancellation")
19732091 }
19742092 // Cleanup settles the projection: no host stays in flight.
1975- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
2093+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
19762094 return !host.Checking
19772095 })
19782096 require.Equal(t, domain.RemoteAvailabilityUnknown, host.Availability)
@@ -1986,14 +2104,14 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) {
19862104 registry, err := NewRegistry(1, store, probe, clock, logger)
19872105 require.NoError(t, err)
19882106 reg := registration(t, "example.test", 1)
1989- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))
2107+ require.NoError(t, registry.setHosts(hostRecords(reg)))
19902108
19912109 ctx, cancel := context.WithCancel(context.Background())
19922110 done := make(chan struct{})
19932111 go func() { defer close(done); registry.Run(ctx) }()
19942112
19952113 call := receiveCall(t, probe)
1996- require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
2114+ require.True(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
19972115 return host.Checking
19982116 }).Checking)
19992117
@@ -2014,22 +2132,22 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) {
20142132 t.Fatal("registry did not stop after cancellation")
20152133 }
20162134 // Cleanup publishes the settled projection: nothing stays in flight.
2017- require.False(t, waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {
2135+ require.False(t, waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
20182136 return !host.Checking
20192137 }).Checking)
20202138
20212139 // The retired completion can never be applied once the run is over.
2022- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
2140+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
20232141 settled := registry.Snapshot()
2024- require.Equal(t, domain.RemoteAvailabilityUnknown, settled.Hosts[0].Availability)
2025- require.Zero(t, settled.Hosts[0].ConsecutiveFailures)
2142+ require.Equal(t, domain.RemoteAvailabilityUnknown, settled.Daemons[0].Availability)
2143+ require.Zero(t, settled.Daemons[0].ConsecutiveFailures)
20262144
20272145 // Persistence is flushed before shutdown and no write outlives it.
20282146 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
20292147 return snapshot.Revision >= settled.Revision
20302148 })
20312149 require.Equal(t, settled.Revision, stored.Revision)
2032- require.False(t, stored.Hosts[0].Checking)
2150+ require.False(t, stored.Daemons[0].Checking)
20332151 writes := store.writeCount()
20342152 require.NoError(t, registry.setHosts(nil))
20352153 require.Equal(t, writes, store.writeCount())
@@ -2064,18 +2182,186 @@ func TestRegistryRevisionsIncreaseMonotonicallyWithinEpoch(t *testing.T) {
20642182
20652183 first := registration(t, "a.test", 1)
20662184 second := registration(t, "b.test", 2)
2067- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first}))
2185+ require.NoError(t, registry.setHosts(hostRecords(first)))
20682186 observe()
2069- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second}))
2187+ require.NoError(t, registry.setHosts(hostRecords(first, second)))
20702188 observe()
20712189
20722190 startRegistry(t, registry)
20732191 for range 2 {
20742192 call := receiveCall(t, probe)
2075- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}
2076- waitHost(t, registry, call.registration.Endpoint, func(host ports.RemoteHostSnapshot) bool {
2193+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
2194+ waitHost(t, registry, call.registration.Endpoint, func(host ports.BrokerDaemonObservation) bool {
20772195 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
20782196 })
20792197 observe()
20802198 }
20812199 }
2200+
2201+// TestRegistryProbeCannotOverrideConfiguredAuthority proves the ownership rule:
2202+// a probe supplies observed state only. Configured authority (local flag,
2203+// endpoint, registration, policy, display origin, rank) is stamped by the
2204+// registry from its own host records and a hostile probe result can never
2205+// override it.
2206+func TestRegistryProbeCannotOverrideConfiguredAuthority(t *testing.T) {
2207+ clock := newManualClock(time.Unix(100, 0))
2208+ probe := newTestProbe(1)
2209+ registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
2210+ reg := registration(t, "user@host:22", 1)
2211+ require.NoError(t, registry.setHosts(hostRecords(reg)))
2212+ startRegistry(t, registry)
2213+
2214+ call := receiveCall(t, probe)
2215+ require.True(t, call.registration.Equal(reg))
2216+
2217+ // Every authority field is invented by the probe, including a local flag,
2218+ // a foreign endpoint and registration, a different policy, and display/rank
2219+ // hints. None of it may reach the published projection.
2220+ hostilePolicy := poolPolicy()
2221+ hostilePolicy.Trust = "attacker"
2222+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
2223+ Local: true,
2224+ Endpoint: "attacker@evil",
2225+ DisplayOrigin: "evil",
2226+ Rank: 42,
2227+ Registration: registration(t, "attacker@evil", 9),
2228+ Policy: hostilePolicy,
2229+ Availability: domain.RemoteAvailabilityReachable,
2230+ }}
2231+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2232+ return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
2233+ })
2234+ require.False(t, host.Local)
2235+ require.Equal(t, reg.Endpoint, host.Endpoint)
2236+ require.Equal(t, domain.RemoteDisplayOrigin(reg.Endpoint), host.DisplayOrigin)
2237+ require.Zero(t, host.Rank)
2238+ require.True(t, host.Registration.Equal(reg))
2239+ require.Equal(t, poolPolicy(), host.Policy)
2240+ require.NotEqual(t, hostilePolicy, host.Policy)
2241+ require.Len(t, registry.Snapshot().Daemons, 1)
2242+}
2243+
2244+// TestRegistryPublishesObservedDaemonIdentity proves observed identity,
2245+// incarnation, protocol version, and capabilities flow from a probe result into
2246+// the published snapshot; configured authority is stamped alongside them.
2247+func TestRegistryPublishesObservedDaemonIdentity(t *testing.T) {
2248+ clock := newManualClock(time.Unix(100, 0))
2249+ probe := newTestProbe(1)
2250+ registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
2251+ reg := registration(t, "user@host:22", 1)
2252+ require.NoError(t, registry.setHosts(hostRecords(reg)))
2253+ startRegistry(t, registry)
2254+
2255+ call := receiveCall(t, probe)
2256+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
2257+ Identity: "authed-daemon",
2258+ Incarnation: ports.BrokerDaemonIncarnation{7, 11},
2259+ ProtocolVersion: protocol.Version,
2260+ Capabilities: protocol.CapabilityResume | protocol.CapabilityUDP,
2261+ Availability: domain.RemoteAvailabilityReachable,
2262+ }}
2263+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2264+ return !host.Checking && host.Identity != ""
2265+ })
2266+ require.Equal(t, ports.BrokerDaemonIdentity("authed-daemon"), host.Identity)
2267+ require.Equal(t, ports.BrokerDaemonIncarnation{7, 11}, host.Incarnation)
2268+ require.Equal(t, protocol.Version, host.ProtocolVersion)
2269+ require.Equal(t, protocol.CapabilityResume|protocol.CapabilityUDP, host.Capabilities)
2270+ require.Equal(t, poolPolicy(), host.Policy)
2271+ require.NoError(t, registry.Snapshot().Validate())
2272+}
2273+
2274+// TestRegistryRejectsObservedIdentityWithoutProtocolVersion pins the partial
2275+// identity rule: an observation that reports identity and incarnation without a
2276+// protocol version fails ports validation and is classified as an invalid
2277+// response, so it never publishes a half-observed daemon.
2278+func TestRegistryRejectsObservedIdentityWithoutProtocolVersion(t *testing.T) {
2279+ // The contract itself rejects partial identity.
2280+ require.Error(t, (ports.BrokerDaemonObservation{
2281+ DisplayOrigin: "host",
2282+ Policy: poolPolicy(),
2283+ Identity: "authed-daemon",
2284+ Incarnation: ports.BrokerDaemonIncarnation{7},
2285+ Availability: domain.RemoteAvailabilityReachable,
2286+ }).Validate())
2287+
2288+ clock := newManualClock(time.Unix(100, 0))
2289+ probe := newTestProbe(1)
2290+ registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
2291+ reg := registration(t, "user@host:22", 1)
2292+ require.NoError(t, registry.setHosts(hostRecords(reg)))
2293+ startRegistry(t, registry)
2294+
2295+ call := receiveCall(t, probe)
2296+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
2297+ Identity: "authed-daemon",
2298+ Incarnation: ports.BrokerDaemonIncarnation{7},
2299+ Availability: domain.RemoteAvailabilityReachable,
2300+ }}
2301+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2302+ return !host.Checking && host.ConsecutiveFailures == 1
2303+ })
2304+ require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability)
2305+ require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind)
2306+ require.Empty(t, host.Identity, "partial identity must never be published")
2307+ require.True(t, host.Incarnation.IsZero())
2308+}
2309+
2310+// TestRegistryPublishesRemoteDaemonsInRegistrationOrder pins the publication
2311+// invariant: remote daemons appear in durable membership registration order (no
2312+// local producer exists yet, so the snapshot carries remotes only).
2313+func TestRegistryPublishesRemoteDaemonsInRegistrationOrder(t *testing.T) {
2314+ registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
2315+ first := registration(t, "a.test", 1)
2316+ second := registration(t, "b.test", 2)
2317+ third := registration(t, "c.test", 3)
2318+ require.NoError(t, registry.setHosts(hostRecords(first, second)))
2319+ require.Equal(t, []string{"a.test", "b.test"}, daemonEndpoints(registry.Snapshot()))
2320+
2321+ // Membership changes publish in the new registration order, not sorted and
2322+ // not map order.
2323+ require.NoError(t, registry.setHosts(hostRecords(third, second, first)))
2324+ require.Equal(t, []string{"c.test", "b.test", "a.test"}, daemonEndpoints(registry.Snapshot()))
2325+}
2326+
2327+func daemonEndpoints(snapshot ports.BrokerSnapshot) []string {
2328+ endpoints := make([]string, 0, len(snapshot.Daemons))
2329+ for _, daemon := range snapshot.Daemons {
2330+ endpoints = append(endpoints, daemon.Endpoint)
2331+ }
2332+ return endpoints
2333+}
2334+
2335+// TestDurableProjectionDropsLocalObservationAndPolicy pins the durable format
2336+// decision: the persistable copy carries remote observations only (a local
2337+// daemon observation is process-local) and omits policy, because membership is
2338+// the single policy authority and the loader re-stamps it.
2339+func TestDurableProjectionDropsLocalObservationAndPolicy(t *testing.T) {
2340+ reg := registration(t, "user@host:22", 1)
2341+ snapshot := ports.BrokerSnapshot{Epoch: 1, Revision: 1, Daemons: []ports.BrokerDaemonObservation{
2342+ {Local: true, DisplayOrigin: "local", Policy: poolPolicy(), Availability: domain.RemoteAvailabilityReachable},
2343+ {Endpoint: reg.Endpoint, DisplayOrigin: domain.RemoteDisplayOrigin(reg.Endpoint), Registration: reg, Policy: poolPolicy(), Availability: domain.RemoteAvailabilityReachable},
2344+ }}
2345+ out := durable(snapshot)
2346+ require.Len(t, out.Daemons, 1)
2347+ require.False(t, out.Daemons[0].Local)
2348+ require.Equal(t, reg.Endpoint, out.Daemons[0].Endpoint)
2349+ require.Equal(t, ports.BrokerPolicy{}, out.Daemons[0].Policy)
2350+ require.Nil(t, out.Removed)
2351+ // The published snapshot the caller handed in is untouched.
2352+ require.Len(t, snapshot.Daemons, 2)
2353+ require.Equal(t, poolPolicy(), snapshot.Daemons[0].Policy)
2354+}
2355+
2356+// TestHostDisplayOriginIsBounded pins the presentation-hint bound: a derived
2357+// origin is clamped rune-safely at the ports limit so an over-long configured
2358+// endpoint still yields a valid publication instead of an unobservable host.
2359+func TestHostDisplayOriginIsBounded(t *testing.T) {
2360+ require.Equal(t, "host:22", hostDisplayOrigin("user@host:22"))
2361+ require.Equal(t, "plain.test", hostDisplayOrigin("plain.test"))
2362+
2363+ origin := hostDisplayOrigin("user@" + strings.Repeat("€", 100))
2364+ require.LessOrEqual(t, len(origin), ports.BrokerMaxDisplayOriginBytes)
2365+ require.True(t, utf8.ValidString(origin))
2366+ require.NotEmpty(t, origin)
2367+}
M internal/usecase/broker/service_test.go +7 -7
@@ -110,8 +110,8 @@ func TestAuthorityAdmissionContextNotRetained(t *testing.T) {
110110 // The admission context is discarded: the connection keeps serving after the
111111 // caller's setup deadline expires.
112112 cancel()
113- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)}))
114- require.Len(t, service.Snapshot().Hosts, 1)
113+ require.NoError(t, registry.setHosts(hostRecords(registration(t, "user@host:22", 1))))
114+ require.Len(t, service.Snapshot().Daemons, 1)
115115
116116 stream, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1))
117117 require.NoError(t, err)
@@ -251,14 +251,14 @@ func TestServiceSnapshotDelegates(t *testing.T) {
251251 require.NoError(t, err)
252252 defer sub.Close()
253253
254- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)}))
254+ require.NoError(t, registry.setHosts(hostRecords(registration(t, "user@host:22", 1))))
255255 select {
256256 case <-sub.Changed():
257257 case <-time.After(time.Second):
258258 t.Fatal("the connection subscription must observe a registry publication")
259259 }
260260 require.Equal(t, registry.Snapshot(), service.Snapshot())
261- require.Len(t, service.Snapshot().Hosts, 1)
261+ require.Len(t, service.Snapshot().Daemons, 1)
262262 }
263263
264264 func TestServiceOperationLeaseLifetime(t *testing.T) {
@@ -443,7 +443,7 @@ func TestServiceOfflineMembershipRefusal(t *testing.T) {
443443 require.False(t, removed)
444444 require.ErrorIs(t, err, ErrOfflineMembership)
445445
446- require.Empty(t, registry.Snapshot().Hosts, "a refused mutation must not touch authority")
446+ require.Empty(t, registry.Snapshot().Daemons, "a refused mutation must not touch authority")
447447 before := registry.Snapshot()
448448 service.RequestReconcile("new@host:22")
449449 require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op")
@@ -456,7 +456,7 @@ func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) {
456456 store.loaded = ports.BrokerSnapshot{
457457 Epoch: 9,
458458 Revision: 4,
459- Hosts: []ports.RemoteHostSnapshot{{
459+ Daemons: []ports.BrokerDaemonObservation{{
460460 Endpoint: endpoint,
461461 Registration: registration(t, endpoint, 1),
462462 Availability: domain.RemoteAvailabilityReachable,
@@ -482,7 +482,7 @@ func TestRegistryObservationDisabledIssuesNoProbesOrTimers(t *testing.T) {
482482 probe := newTestProbe(4)
483483 r, err := NewRegistryWithConfig(1, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true})
484484 require.NoError(t, err)
485- require.NoError(t, r.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)}))
485+ require.NoError(t, r.setHosts(hostRecords(registration(t, "user@host:22", 1))))
486486
487487 ctx, cancel := context.WithCancel(context.Background())
488488 done := make(chan struct{})

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