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.
193 `go tool buf generate`, and handle the new variant in `sessionwire`.193 `go tool buf generate`, and handle the new variant in `sessionwire`.
194 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.194 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
195 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.195 - 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.
197 197
198 ## Broker wire core (Plan 001 P3.1, not activated)198 ## Broker wire core (Plan 001 P3.1, not activated)
199 199
@@ -265,6 +265,30 @@ nonblocking drain on false precedes Reset, supporting buffered timer adapters.
265 Policy identities have independent field-labelled validation and a named bound.265 Policy identities have independent field-labelled validation and a named bound.
266 These are P2.2 port contracts, not a wire migration or production activation.266 These are P2.2 port contracts, not a wire migration or production activation.
267 267
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+
268 ## Broker local IPC (Plan 001 P3.3, not activated)292 ## Broker local IPC (Plan 001 P3.3, not activated)
269 293
270 `internal/adapters/brokeripc` carries the P3.1 broker conversation over one294 `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
486 // dispatched, because the local identity is already admitted either way.486 // dispatched, because the local identity is already admitted either way.
487 if _, err := c.sendContext(ctx, brokerwire.OpenStream{487 if _, err := c.sendContext(ctx, brokerwire.OpenStream{
488 Epoch: scoped.Epoch, Connection: scoped.Connection, Stream: scoped.Stream,488 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,
490 Endpoint: scoped.Endpoint, Registration: scoped.Registration,491 Endpoint: scoped.Endpoint, Registration: scoped.Registration,
491 Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy,492 Target: scoped.Target, Env: scoped.Env, Policy: scoped.Policy,
492 }); err != nil {493 }); err != nil {
M internal/adapters/brokeripc/emit.go +24 -20
@@ -11,12 +11,13 @@ import (
11 // Snapshot publication (P3.3).11 // Snapshot publication (P3.3).
12 //12 //
13 // A broker publication travels as the exact multipart transfer the P3.113 // A broker publication travels as the exact multipart transfer the P3.1
14-// assembler accepts: Begin(index 0, element counts), then every host part in14+// assembler accepts: Begin(index 0, element counts), then every daemon part in
15-// host-index order immediately followed by that host's session parts in15+// 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. Host16+// session-index order, then the tombstones, then End at the final index. The
17-// parts carry no inline sessions; sessions travel as their own parts. Both17+// local daemon, when present, is daemon index 0 and its sessions carry
18-// sides agree on one deterministic layout, so the receiving assembler commits18+// Local=true. Daemon parts carry no inline sessions; sessions travel as their
19-// only a transfer whose indexes, counts, and order are exact.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.
20 //21 //
21 // The emitter refuses a transfer the peer's assembler would refuse: more parts22 // The emitter refuses a transfer the peer's assembler would refuse: more parts
22 // than MaxSnapshotParts, or a total encoded payload above the staged ceiling.23 // than MaxSnapshotParts, or a total encoded payload above the staged ceiling.
@@ -33,20 +34,21 @@ func snapshotParts(snapshot ports.BrokerSnapshot, epoch ports.BrokerEpoch, conne
33 if snapshot.Revision == 0 {34 if snapshot.Revision == 0 {
34 return nil, errors.New("brokeripc: snapshot publication has no revision")35 return nil, errors.New("brokeripc: snapshot publication has no revision")
35 }36 }
36- if len(snapshot.Hosts) > ports.BrokerMaxHosts || len(snapshot.Removed) > ports.BrokerMaxTombstones {37+ if len(snapshot.Daemons) > ports.BrokerMaxDaemonsPerSnapshot || len(snapshot.Removed) > ports.BrokerMaxTombstones {
37- return nil, errors.New("brokeripc: snapshot publication exceeds host bound")38+ return nil, errors.New("brokeripc: snapshot publication exceeds daemon bound")
38 }39 }
39 sessions := 040 sessions := 0
40- for _, host := range snapshot.Hosts {41+ for _, daemon := range snapshot.Daemons {
41- if len(host.Sessions) > ports.BrokerMaxSessionsPerHost {42+ if len(daemon.Sessions) > ports.BrokerMaxSessionsPerHost {
42 return nil, errors.New("brokeripc: snapshot publication exceeds session bound")43 return nil, errors.New("brokeripc: snapshot publication exceeds session bound")
43 }44 }
44- sessions += len(host.Sessions)45+ sessions += len(daemon.Sessions)
45 }46 }
46- total := 1 + len(snapshot.Hosts) + sessions + len(snapshot.Removed) + 147+ total := 1 + len(snapshot.Daemons) + sessions + len(snapshot.Removed) + 1
47 if total > brokerwire.MaxSnapshotParts {48 if total > brokerwire.MaxSnapshotParts {
48 return nil, errors.New("brokeripc: snapshot publication exceeds part bound")49 return nil, errors.New("brokeripc: snapshot publication exceeds part bound")
49 }50 }
51+ localPresent := len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local
50 52
51 parts := make([]brokerwire.SnapshotPart, 0, total)53 parts := make([]brokerwire.SnapshotPart, 0, total)
52 base := func(part brokerwire.SnapshotPartPayload) brokerwire.SnapshotPart {54 base := func(part brokerwire.SnapshotPartPayload) brokerwire.SnapshotPart {
@@ -56,23 +58,25 @@ func snapshotParts(snapshot ports.BrokerSnapshot, epoch ports.BrokerEpoch, conne
56 }58 }
57 }59 }
58 parts = append(parts, base(brokerwire.SnapshotBegin{60 parts = append(parts, base(brokerwire.SnapshotBegin{
59- HostCount: uint32(len(snapshot.Hosts)),61+ HostCount: uint32(len(snapshot.Daemons)),
60 SessionCount: uint32(sessions),62 SessionCount: uint32(sessions),
61 TombstoneCount: uint32(len(snapshot.Removed)),63 TombstoneCount: uint32(len(snapshot.Removed)),
64+ LocalPresent: localPresent,
62 }))65 }))
63- for hostIndex, host := range snapshot.Hosts {66+ for daemonIndex, daemon := range snapshot.Daemons {
64- projection := host.Clone()67+ projection := daemon.Clone()
65 sessionCount := len(projection.Sessions)68 sessionCount := len(projection.Sessions)
66 projection.Sessions = nil69 projection.Sessions = nil
67- parts = append(parts, base(brokerwire.SnapshotHostPart{70+ parts = append(parts, base(brokerwire.SnapshotDaemonPart{
68- HostIndex: uint32(hostIndex),71+ HostIndex: uint32(daemonIndex),
69- Host: projection,72+ Daemon: projection,
70 SessionCount: uint32(sessionCount),73 SessionCount: uint32(sessionCount),
71 }))74 }))
72- for sessionIndex, session := range host.Sessions {75+ for sessionIndex, session := range daemon.Sessions {
73 parts = append(parts, base(brokerwire.SnapshotSessionPart{76 parts = append(parts, base(brokerwire.SnapshotSessionPart{
74- HostIndex: uint32(hostIndex),77+ HostIndex: uint32(daemonIndex),
75 SessionIndex: uint32(sessionIndex),78 SessionIndex: uint32(sessionIndex),
79+ Local: daemon.Local,
76 Session: cloneCatalogSession(session),80 Session: cloneCatalogSession(session),
77 }))81 }))
78 }82 }
M internal/adapters/brokeripc/helpers_test.go +23 -6
@@ -45,13 +45,14 @@ func testPolicy() ports.BrokerPolicy {
45 }45 }
46 }46 }
47 47
48-// testHostAt builds one catalogue-valid host projection for index i.48+// testDaemonAt builds one catalogue-valid remote daemon projection for index i.
49-func testHostAt(i int) ports.RemoteHostSnapshot {49+func testDaemonAt(i int) ports.BrokerDaemonObservation {
50 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)50 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)
51- return ports.RemoteHostSnapshot{51+ return ports.BrokerDaemonObservation{
52 Endpoint: endpoint,52 Endpoint: endpoint,
53 DisplayOrigin: endpoint,53 DisplayOrigin: endpoint,
54 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)},54 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(),
55 Availability: domain.RemoteAvailabilityReachable,56 Availability: domain.RemoteAvailabilityReachable,
56 LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(),57 LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(),
57 InventoryKnown: true,58 InventoryKnown: true,
@@ -71,9 +72,25 @@ func testSessionAt(host, index int) catalogue.RemoteCatalogSession {
71 72
72 // testSnapshot builds one valid publication for an epoch and revision.73 // testSnapshot builds one valid publication for an epoch and revision.
73 func testSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot {74 func testSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision) ports.BrokerSnapshot {
74- host := testHostAt(0)75+ daemon := testDaemonAt(0)
75- host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}76+ daemon.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
76- return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Hosts: []ports.RemoteHostSnapshot{host}}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}}
77 }94 }
78 95
79 // snapshotHub is a multi-subscriber publication hub standing in for the broker96 // 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 (
24 // session-less hosts.24 // session-less hosts.
25 func TestSnapshotPartsLayoutRoundTrips(t *testing.T) {25 func TestSnapshotPartsLayoutRoundTrips(t *testing.T) {
26 snapshot := testSnapshot(9, 12)26 snapshot := testSnapshot(9, 12)
27- snapshot.Hosts = append(snapshot.Hosts, testHostAt(1))27+ snapshot.Daemons = append(snapshot.Daemons, testDaemonAt(1))
28 snapshot.Removed = []ports.BrokerHostTombstone{{28 snapshot.Removed = []ports.BrokerHostTombstone{{
29 Endpoint: "retired@old:22",29 Endpoint: "retired@old:22",
30- Registration: testHostAt(0).Registration,30+ Registration: testDaemonAt(0).Registration,
31 RetiredRevision: 3,31 RetiredRevision: 3,
32 }}32 }}
33 snapshot.Removed[0].Registration.Endpoint = "retired@old:22"33 snapshot.Removed[0].Registration.Endpoint = "retired@old:22"
@@ -61,9 +61,9 @@ func TestSnapshotPartsLayoutRoundTrips(t *testing.T) {
61 }61 }
62 require.True(t, found, "the transfer must commit")62 require.True(t, found, "the transfer must commit")
63 require.Equal(t, snapshot.Revision, committed.Revision)63 require.Equal(t, snapshot.Revision, committed.Revision)
64- require.Len(t, committed.Hosts, 2)64+ require.Len(t, committed.Daemons, 2)
65- require.Len(t, committed.Hosts[0].Sessions, 2)65+ require.Len(t, committed.Daemons[0].Sessions, 2)
66- for _, host := range committed.Hosts {66+ for _, host := range committed.Daemons {
67 for _, session := range host.Sessions {67 for _, session := range host.Sessions {
68 require.Equal(t, []catalogue.RemoteCatalogTab{}, session.Tabs)68 require.Equal(t, []catalogue.RemoteCatalogTab{}, session.Tabs)
69 }69 }
@@ -72,13 +72,54 @@ func TestSnapshotPartsLayoutRoundTrips(t *testing.T) {
72 require.Equal(t, "retired@old:22", committed.Removed[0].Endpoint)72 require.Equal(t, "retired@old:22", committed.Removed[0].Endpoint)
73 }73 }
74 74
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+
75 // TestSnapshotPartsRefusesOverBoundInventory proves the emitter fails closed on116 // TestSnapshotPartsRefusesOverBoundInventory proves the emitter fails closed on
76 // an inventory the peer's assembler would refuse, instead of silently sending a117 // an inventory the peer's assembler would refuse, instead of silently sending a
77 // transfer that can never commit.118 // transfer that can never commit.
78 func TestSnapshotPartsRefusesOverBoundInventory(t *testing.T) {119 func TestSnapshotPartsRefusesOverBoundInventory(t *testing.T) {
79 snapshot := testSnapshot(1, 1)120 snapshot := testSnapshot(1, 1)
80 over := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost+1)121 over := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost+1)
81- snapshot.Hosts[0].Sessions = over122+ snapshot.Daemons[0].Sessions = over
82 _, err := snapshotParts(snapshot, 1, ports.BrokerConnectionID{1}, 1)123 _, err := snapshotParts(snapshot, 1, ports.BrokerConnectionID{1}, 1)
83 require.Error(t, err)124 require.Error(t, err)
84 125
M internal/adapters/brokeripc/session_test.go +35 -8
@@ -132,6 +132,33 @@ func TestSessionCloseStreamFillsConnectionScope(t *testing.T) {
132 require.ErrorIs(t, session.CloseStream(ports.BrokerConnectionID{0x99}, 2), ports.BrokerAdmissionStale)132 require.ErrorIs(t, session.CloseStream(ports.BrokerConnectionID{0x99}, 2), ports.BrokerAdmissionStale)
133 }133 }
134 134
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+
135 // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly162 // TestRegistrationAssignsScopeAtAccept proves the client adapter adopts exactly
136 // the scope the listener assigned at accept.163 // the scope the listener assigned at accept.
137 func TestRegistrationAssignsScopeAtAccept(t *testing.T) {164 func TestRegistrationAssignsScopeAtAccept(t *testing.T) {
@@ -158,10 +185,10 @@ func TestSubscribePublishesSnapshot(t *testing.T) {
158 185
159 got := client.Snapshot()186 got := client.Snapshot()
160 require.Equal(t, e.epoch, got.Epoch)187 require.Equal(t, e.epoch, got.Epoch)
161- require.Len(t, got.Hosts, 1)188+ require.Len(t, got.Daemons, 1)
162- require.Equal(t, snapshot.Hosts[0].Endpoint, got.Hosts[0].Endpoint)189+ require.Equal(t, snapshot.Daemons[0].Endpoint, got.Daemons[0].Endpoint)
163- require.Len(t, got.Hosts[0].Sessions, 2)190+ require.Len(t, got.Daemons[0].Sessions, 2)
164- require.Equal(t, snapshot.Hosts[0].Sessions[0].Name, got.Hosts[0].Sessions[0].Name)191+ require.Equal(t, snapshot.Daemons[0].Sessions[0].Name, got.Daemons[0].Sessions[0].Name)
165 192
166 select {193 select {
167 case <-sub.Changed():194 case <-sub.Changed():
@@ -619,11 +646,11 @@ func TestRequestReconcileIsBoundedHint(t *testing.T) {
619 snapshot := e.publish(e.epoch, 4)646 snapshot := e.publish(e.epoch, 4)
620 require.Eventually(t, func() bool { return client.Snapshot().Revision == 4 }, 5*time.Second, 10*time.Millisecond)647 require.Eventually(t, func() bool { return client.Snapshot().Revision == 4 }, 5*time.Second, 10*time.Millisecond)
621 648
622- client.RequestReconcile(snapshot.Hosts[0].Endpoint)649+ client.RequestReconcile(snapshot.Daemons[0].Endpoint)
623 require.Eventually(t, func() bool {650 require.Eventually(t, func() bool {
624 core.mu.Lock()651 core.mu.Lock()
625 defer core.mu.Unlock()652 defer core.mu.Unlock()
626- return len(core.reconcile) == 1 && core.reconcile[0] == snapshot.Hosts[0].Endpoint653+ return len(core.reconcile) == 1 && core.reconcile[0] == snapshot.Daemons[0].Endpoint
627 }, 5*time.Second, 10*time.Millisecond)654 }, 5*time.Second, 10*time.Millisecond)
628 }655 }
629 656
@@ -689,12 +716,12 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) {
689 func TestDomainFixturesAreValid(t *testing.T) {716 func TestDomainFixturesAreValid(t *testing.T) {
690 snapshot := testSnapshot(1, 1)717 snapshot := testSnapshot(1, 1)
691 require.NoError(t, snapshot.Validate())718 require.NoError(t, snapshot.Validate())
692- for _, host := range snapshot.Hosts {719+ for _, host := range snapshot.Daemons {
693 require.NoError(t, ports.ValidateDurableHostProjection(host))720 require.NoError(t, ports.ValidateDurableHostProjection(host))
694 }721 }
695 require.NoError(t, testPolicy().Validate())722 require.NoError(t, testPolicy().Validate())
696 catalogueSession := testSessionAt(0, 0)723 catalogueSession := testSessionAt(0, 0)
697 require.False(t, strings.HasPrefix(catalogueSession.Name, " "))724 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)
699 require.True(t, errors.Is(errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale), ports.BrokerAdmissionStale))726 require.True(t, errors.Is(errors.Join(ErrScopeMismatch, ports.BrokerAdmissionStale), ports.BrokerAdmissionStale))
700 }727 }
M internal/adapters/brokeripc/stream.go +2 -1
@@ -203,7 +203,8 @@ func (s *serverSession) startStream(m brokerwire.OpenStream) {
203 return203 return
204 }204 }
205 request := ports.BrokerOpenStreamRequest{205 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,
207 Connection: m.Connection, Stream: m.Stream,208 Connection: m.Connection, Stream: m.Stream,
208 Endpoint: m.Endpoint, Registration: m.Registration,209 Endpoint: m.Endpoint, Registration: m.Registration,
209 Target: m.Target, Env: m.Env, Policy: m.Policy,210 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
82 are never persisted. Retirement tombstones are process-local fencing state and82 are never persisted. Retirement tombstones are process-local fencing state and
83 are never durable: the registry's persistable copy and the store both clear83 are never durable: the registry's persistable copy and the store both clear
84 `Snapshot.Removed` before writing, so reopening sees membership without stale84 `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.
86 91
87 Production cutover, lifecycle supervision, and IPC composition remain later92 Production cutover, lifecycle supervision, and IPC composition remain later
88 phases. Close ordering must drain registry snapshot writers before releasing the93 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) {
131 // Unbound v2/v3 cache data is validated and backed up, never promoted131 // Unbound v2/v3 cache data is validated and backed up, never promoted
132 // to authority. v4 can seed only its exact surviving incarnation.132 // to authority. v4 can seed only its exact surviving incarnation.
133 if e.Host == h.Registration.Endpoint && e.Incarnation == h.Registration.Incarnation {133 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+ })
135 }147 }
136 }148 }
137 }149 }
138 }150 }
139- if len(r.State.Snapshot.Hosts) > 0 {151+ if len(r.State.Snapshot.Daemons) > 0 {
140 var epoch [8]byte152 var epoch [8]byte
141 if _, err = rand.Read(epoch[:]); err != nil {153 if _, err = rand.Read(epoch[:]); err != nil {
142 return r, err154 return r, err
M internal/adapters/brokerstore/legacy_test.go +2 -2
@@ -60,10 +60,10 @@ func TestDefensiveCopiesAndConcurrentAccess(t *testing.T) {
60 h.Hosts[0].Policy.Trust = "changed"60 h.Hosts[0].Policy.Trust = "changed"
61 snap, err := s.Load()61 snap, err := s.Load()
62 require.NoError(t, err)62 require.NoError(t, err)
63- snap.Hosts[0].Sessions[0].Tabs[0].Name = "changed"63+ snap.Daemons[0].Sessions[0].Tabs[0].Name = "changed"
64 again, err := s.Load()64 again, err := s.Load()
65 require.NoError(t, err)65 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)
67 hosts, err := s.LoadHosts()67 hosts, err := s.LoadHosts()
68 require.NoError(t, err)68 require.NoError(t, err)
69 require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust)69 require.Equal(t, "known-hosts", hosts.Hosts[0].Policy.Trust)
M internal/adapters/brokerstore/store.go +90 -20
@@ -17,6 +17,7 @@ import (
17 "sync"17 "sync"
18 "syscall"18 "syscall"
19 19
20+ "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"21 "github.com/bnema/vev/internal/ports"
21 "github.com/bnema/vev/pkg/safedir"22 "github.com/bnema/vev/pkg/safedir"
22 )23 )
@@ -286,14 +287,18 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error {
286 }287 }
287 // Removed tombstones and in-flight checking/failure detail are process-local288 // Removed tombstones and in-flight checking/failure detail are process-local
288 // fencing and observation state; the durable snapshot never carries them, so289 // 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.
290 snapshot = snapshot.Clone()294 snapshot = snapshot.Clone()
291 snapshot.Removed = nil295 snapshot.Removed = nil
292- for i := range snapshot.Hosts {296+ for i := range snapshot.Daemons {
293- snapshot.Hosts[i].Checking = false297+ snapshot.Daemons[i].Checking = false
294- snapshot.Hosts[i].LastFailure.Err = nil298+ snapshot.Daemons[i].LastFailure.Err = nil
299+ snapshot.Daemons[i].Policy = ports.BrokerPolicy{}
295 }300 }
296- if err := snapshot.Validate(); err != nil {301+ if err := validateDurableSnapshot(snapshot); err != nil {
297 return err302 return err
298 }303 }
299 if s.writeEpoch != 0 && s.writeEpoch != snapshot.Epoch {304 if s.writeEpoch != 0 && s.writeEpoch != snapshot.Epoch {
@@ -304,10 +309,10 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error {
304 return ErrStale309 return ErrStale
305 }310 }
306 // Reject rather than partially adopt a queued publication from retired hosts.311 // Reject rather than partially adopt a queued publication from retired hosts.
307- for _, h := range snapshot.Hosts {312+ for _, daemon := range snapshot.Daemons {
308 found := false313 found := false
309 for _, a := range s.state.Hosts.Hosts {314 for _, a := range s.state.Hosts.Hosts {
310- if h.Registration.Equal(a.Registration) {315+ if daemon.Registration.Equal(a.Registration) {
311 found = true316 found = true
312 }317 }
313 }318 }
@@ -323,14 +328,15 @@ func (s *Store) Store(snapshot ports.BrokerSnapshot) error {
323 s.writeEpoch = snapshot.Epoch328 s.writeEpoch = snapshot.Epoch
324 return nil329 return nil
325 }330 }
331+
326 func filter(snapshot ports.BrokerSnapshot, hosts ports.BrokerHosts) ports.BrokerSnapshot {332 func filter(snapshot ports.BrokerSnapshot, hosts ports.BrokerHosts) ports.BrokerSnapshot {
327 out := snapshot.Clone()333 out := snapshot.Clone()
328- out.Hosts = nil334+ out.Daemons = nil
329 out.Removed = nil335 out.Removed = nil
330- for _, h := range snapshot.Hosts {336+ for _, daemon := range snapshot.Daemons {
331 for _, a := range hosts.Hosts {337 for _, a := range hosts.Hosts {
332- if h.Registration.Equal(a.Registration) {338+ if daemon.Registration.Equal(a.Registration) {
333- out.Hosts = append(out.Hosts, h.Clone())339+ out.Daemons = append(out.Daemons, daemon.Clone())
334 }340 }
335 }341 }
336 }342 }
@@ -360,32 +366,96 @@ func validate(st state) error {
360 return invalid("%v", err)366 return invalid("%v", err)
361 }367 }
362 if st.Snapshot.Epoch == 0 {368 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 {
364 return invalid("invalid empty snapshot")370 return invalid("invalid empty snapshot")
365 }371 }
366 return nil372 return nil
367 }373 }
368- if err := st.Snapshot.Validate(); err != nil {374+ if err := validateDurableSnapshot(st.Snapshot); err != nil {
369 return invalid("%v", err)375 return invalid("%v", err)
370 }376 }
371- for _, h := range st.Snapshot.Hosts {377+ for _, daemon := range st.Snapshot.Daemons {
372 found := false378 found := false
373 for _, a := range st.Hosts.Hosts {379 for _, a := range st.Hosts.Hosts {
374- if h.Registration.Equal(a.Registration) {380+ if daemon.Registration.Equal(a.Registration) {
375 found = true381 found = true
376 }382 }
377 }383 }
378- if !found || h.Checking || h.LastFailure.Err != nil {384+ if !found || daemon.Checking || daemon.LastFailure.Err != nil {
379 return invalid("non-authoritative snapshot")385 return invalid("non-authoritative snapshot")
380 }386 }
381- // The store enforces the same durable projection rules the registry387+ }
382- // checks before publishing, so an accepted publication always persists.388+ return nil
383- if err := ports.ValidateDurableHostProjection(h); err != nil {389+}
384- return invalid("%v", err)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")
385 }432 }
386 }433 }
387 return nil434 return nil
388 }435 }
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+}
389 func (s *Store) commit(next state) error {459 func (s *Store) commit(next state) error {
390 if err := validate(next); err != nil {460 if err := validate(next); err != nil {
391 return err461 return err
M internal/adapters/brokerstore/store_test.go +41 -7
@@ -17,9 +17,26 @@ import (
17 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
18 )18 )
19 19
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+
20 func options(t *testing.T) Options {37 func options(t *testing.T) Options {
21 t.Helper()38 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()}}
23 }40 }
24 func TestPriorFixtures(t *testing.T) {41 func TestPriorFixtures(t *testing.T) {
25 for h := 1; h <= 3; h++ {42 for h := 1; h <= 3; h++ {
@@ -46,11 +63,11 @@ func TestPriorFixtures(t *testing.T) {
46 snap, err := s.Load()63 snap, err := s.Load()
47 require.NoError(t, err)64 require.NoError(t, err)
48 if h == 3 && c == 4 {65 if h == 3 && c == 4 {
49- require.Len(t, snap.Hosts, 1)66+ require.Len(t, snap.Daemons, 1)
50- require.Len(t, snap.Hosts[0].Sessions, 1)67+ require.Len(t, snap.Daemons[0].Sessions, 1)
51- require.Equal(t, "t_work", snap.Hosts[0].Sessions[0].Tabs[0].ID)68+ require.Equal(t, "t_work", snap.Daemons[0].Sessions[0].Tabs[0].ID)
52 } else {69 } else {
53- require.Empty(t, snap.Hosts)70+ require.Empty(t, snap.Daemons)
54 }71 }
55 require.NoError(t, s.Close())72 require.NoError(t, s.Close())
56 o.LegacyHosts = "missing"73 o.LegacyHosts = "missing"
@@ -453,7 +470,7 @@ func TestAuthorityGenerationPolicyAndRemoveReAdd(t *testing.T) {
453 470
454 // A queued publication from the retired incarnation is refused even though471 // A queued publication from the retired incarnation is refused even though
455 // its revision is newer.472 // 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)
457 474
458 // Re-adding the endpoint needs a fresh incarnation; the retired475 // Re-adding the endpoint needs a fresh incarnation; the retired
459 // observation never comes back for it.476 // observation never comes back for it.
@@ -620,9 +637,26 @@ func TestFencingAndCommitFaults(t *testing.T) {
620 defer s.Close()637 defer s.Close()
621 got, err := s.Load()638 got, err := s.Load()
622 require.NoError(t, err)639 require.NoError(t, err)
623- require.Empty(t, got.Hosts)640+ require.Empty(t, got.Daemons)
624 gotH, err := s.LoadHosts()641 gotH, err := s.LoadHosts()
625 require.NoError(t, err)642 require.NoError(t, err)
626 require.Equal(t, h.Hosts, gotH.Hosts)643 require.Equal(t, h.Hosts, gotH.Hosts)
627 })644 })
628 }645 }
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) {
419 419
420 func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {420 func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
421 request := ports.BrokerOpenStreamRequest{421 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,
423 Connection: m.Connection, Stream: m.Stream,424 Connection: m.Connection, Stream: m.Stream,
424 Endpoint: m.Endpoint, Registration: m.Registration,425 Endpoint: m.Endpoint, Registration: m.Registration,
425 Target: m.Target, Env: m.Env, Policy: m.Policy,426 Target: m.Target, Env: m.Env, Policy: m.Policy,
@@ -450,22 +451,64 @@ func openStreamToWire(m OpenStream) (*wire.OpenStream, error) {
450 default:451 default:
451 return nil, ErrInvalidMessage452 return nil, ErrInvalidMessage
452 }453 }
454+ admission, err := admissionToWire(m.Admission)
455+ if err != nil {
456+ return nil, err
457+ }
453 out := &wire.OpenStream{458 out := &wire.OpenStream{
454 Ref: refToWire(m.Epoch, m.Connection, m.Stream),459 Ref: refToWire(m.Epoch, m.Connection, m.Stream),
455 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,460 Purpose: purpose, Local: m.Local, Endpoint: m.Endpoint,
456 Env: append([]string(nil), m.Env...),461 Env: append([]string(nil), m.Env...),
457 Policy: policyToWire(m.Policy),462 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,
458 }467 }
459 if !m.Local {468 if !m.Local {
460 out.Registration = registrationToWire(m.Registration)469 out.Registration = registrationToWire(m.Registration)
461 }470 }
462- if m.Purpose == ports.BrokerStreamAttachment {471+ // Only exact attach/resume carries a session target; a creation
463- target := m.Target472+ // admission carries a zero target that must never be encoded as a
464- out.Target = exactTargetToWire(target)473+ // present message.
474+ if m.Purpose == ports.BrokerStreamAttachment && m.Admission == ports.BrokerAdmissionExact {
475+ out.Target = exactTargetToWire(m.Target)
465 }476 }
466 return out, nil477 return out, nil
467 }478 }
468 479
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+
469 func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {512 func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
470 var out OpenStream513 var out OpenStream
471 if message == nil {514 if message == nil {
@@ -486,6 +529,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
486 default:529 default:
487 return OpenStream{}, ErrInvalidMessage530 return OpenStream{}, ErrInvalidMessage
488 }531 }
532+ admission, err := admissionFromWire(message.GetAdmission())
533+ if err != nil {
534+ return OpenStream{}, ErrInvalidMessage
535+ }
489 local := message.GetLocal()536 local := message.GetLocal()
490 var registration domain.RemoteRegistration537 var registration domain.RemoteRegistration
491 if !local {538 if !local {
@@ -496,8 +543,10 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
496 } else if message.GetRegistration() != nil {543 } else if message.GetRegistration() != nil {
497 return OpenStream{}, ErrInvalidMessage544 return OpenStream{}, ErrInvalidMessage
498 }545 }
546+ // Only exact attach/resume carries a session target. A present target on
547+ // any other admission or purpose is malformed.
499 var target protocol.ExactSessionTarget548 var target protocol.ExactSessionTarget
500- if purpose == ports.BrokerStreamAttachment {549+ if purpose == ports.BrokerStreamAttachment && admission == ports.BrokerAdmissionExact {
501 target, err = exactTargetFromWire(message.GetTarget())550 target, err = exactTargetFromWire(message.GetTarget())
502 if err != nil {551 if err != nil {
503 return OpenStream{}, ErrInvalidMessage552 return OpenStream{}, ErrInvalidMessage
@@ -519,11 +568,14 @@ func openStreamFromWire(message *wire.OpenStream) (OpenStream, error) {
519 endpoint := message.GetEndpoint()568 endpoint := message.GetEndpoint()
520 candidate := OpenStream{569 candidate := OpenStream{
521 Epoch: epoch, Connection: connection, Stream: stream,570 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,
523 Registration: registration, Target: target, Env: env, Policy: policy,574 Registration: registration, Target: target, Env: env, Policy: policy,
524 }575 }
525 request := ports.BrokerOpenStreamRequest{576 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,
527 Connection: candidate.Connection, Stream: candidate.Stream,579 Connection: candidate.Connection, Stream: candidate.Stream,
528 Endpoint: candidate.Endpoint, Registration: candidate.Registration,580 Endpoint: candidate.Endpoint, Registration: candidate.Registration,
529 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,581 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,
@@ -780,26 +832,30 @@ func snapshotPartToWire(m SnapshotPart) (*wire.SnapshotPart, error) {
780 }832 }
781 switch part := m.Part.(type) {833 switch part := m.Part.(type) {
782 case SnapshotBegin:834 case SnapshotBegin:
783- if part.HostCount > ports.BrokerMaxHosts ||835+ if part.HostCount > ports.BrokerMaxDaemonsPerSnapshot ||
784- part.SessionCount > ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost ||836+ part.SessionCount > ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost ||
785 part.TombstoneCount > ports.BrokerMaxTombstones {837 part.TombstoneCount > ports.BrokerMaxTombstones {
786 return nil, ErrTooLarge838 return nil, ErrTooLarge
787 }839 }
840+ if part.LocalPresent && part.HostCount == 0 {
841+ return nil, ErrInvalidMessage
842+ }
788 out.Part = &wire.SnapshotPart_Begin{Begin: &wire.SnapshotBegin{843 out.Part = &wire.SnapshotPart_Begin{Begin: &wire.SnapshotBegin{
789 HostCount: part.HostCount, SessionCount: part.SessionCount, TombstoneCount: part.TombstoneCount,844 HostCount: part.HostCount, SessionCount: part.SessionCount, TombstoneCount: part.TombstoneCount,
845+ LocalPresent: part.LocalPresent,
790 }}846 }}
791 case *SnapshotBegin:847 case *SnapshotBegin:
792 if part == nil {848 if part == nil {
793 return nil, ErrInvalidMessage849 return nil, ErrInvalidMessage
794 }850 }
795 return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part})851 return snapshotPartToWire(SnapshotPart{Epoch: m.Epoch, Connection: m.Connection, Generation: m.Generation, Revision: m.Revision, Index: m.Index, Part: *part})
796- case SnapshotHostPart:852+ case SnapshotDaemonPart:
797- converted, err := snapshotHostToWire(part)853+ converted, err := snapshotDaemonToWire(part)
798 if err != nil {854 if err != nil {
799 return nil, err855 return nil, err
800 }856 }
801- out.Part = &wire.SnapshotPart_Host{Host: converted}857+ out.Part = &wire.SnapshotPart_Daemon{Daemon: converted}
802- case *SnapshotHostPart:858+ case *SnapshotDaemonPart:
803 if part == nil {859 if part == nil {
804 return nil, ErrInvalidMessage860 return nil, ErrInvalidMessage
805 }861 }
@@ -867,34 +923,38 @@ func snapshotPartFromWire(message *wire.SnapshotPart) (SnapshotPart, error) {
867 if payload.Begin == nil {923 if payload.Begin == nil {
868 return SnapshotPart{}, ErrInvalidMessage924 return SnapshotPart{}, ErrInvalidMessage
869 }925 }
870- if payload.Begin.GetHostCount() > ports.BrokerMaxHosts ||926+ if payload.Begin.GetHostCount() > ports.BrokerMaxDaemonsPerSnapshot ||
871- payload.Begin.GetSessionCount() > ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost ||927+ payload.Begin.GetSessionCount() > ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost ||
872 payload.Begin.GetTombstoneCount() > ports.BrokerMaxTombstones {928 payload.Begin.GetTombstoneCount() > ports.BrokerMaxTombstones {
873 return SnapshotPart{}, ErrTooLarge929 return SnapshotPart{}, ErrTooLarge
874 }930 }
931+ if payload.Begin.GetLocalPresent() && payload.Begin.GetHostCount() == 0 {
932+ return SnapshotPart{}, ErrInvalidMessage
933+ }
875 part.Part = SnapshotBegin{934 part.Part = SnapshotBegin{
876 HostCount: payload.Begin.GetHostCount(),935 HostCount: payload.Begin.GetHostCount(),
877 SessionCount: payload.Begin.GetSessionCount(),936 SessionCount: payload.Begin.GetSessionCount(),
878 TombstoneCount: payload.Begin.GetTombstoneCount(),937 TombstoneCount: payload.Begin.GetTombstoneCount(),
938+ LocalPresent: payload.Begin.GetLocalPresent(),
879 }939 }
880- case *wire.SnapshotPart_Host:940+ case *wire.SnapshotPart_Daemon:
881- if payload.Host == nil {941+ if payload.Daemon == nil {
882 return SnapshotPart{}, ErrInvalidMessage942 return SnapshotPart{}, ErrInvalidMessage
883 }943 }
884- host, index, sessionCount, err := snapshotHostFromWire(payload.Host)944+ daemon, index, sessionCount, err := snapshotDaemonFromWire(payload.Daemon)
885 if err != nil {945 if err != nil {
886 return SnapshotPart{}, err946 return SnapshotPart{}, err
887 }947 }
888- part.Part = SnapshotHostPart{HostIndex: index, Host: host, SessionCount: sessionCount}948+ part.Part = SnapshotDaemonPart{HostIndex: index, Daemon: daemon, SessionCount: sessionCount}
889 case *wire.SnapshotPart_Session:949 case *wire.SnapshotPart_Session:
890 if payload.Session == nil {950 if payload.Session == nil {
891 return SnapshotPart{}, ErrInvalidMessage951 return SnapshotPart{}, ErrInvalidMessage
892 }952 }
893- session, hostIndex, sessionIndex, err := snapshotSessionFromWire(payload.Session)953+ session, hostIndex, sessionIndex, local, err := snapshotSessionFromWire(payload.Session)
894 if err != nil {954 if err != nil {
895 return SnapshotPart{}, err955 return SnapshotPart{}, err
896 }956 }
897- part.Part = SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: session}957+ part.Part = SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Local: local, Session: session}
898 case *wire.SnapshotPart_Tombstone:958 case *wire.SnapshotPart_Tombstone:
899 if payload.Tombstone == nil {959 if payload.Tombstone == nil {
900 return SnapshotPart{}, ErrInvalidMessage960 return SnapshotPart{}, ErrInvalidMessage
@@ -922,118 +982,167 @@ func snapshotPartFromWire(message *wire.SnapshotPart) (SnapshotPart, error) {
922 return part, nil982 return part, nil
923 }983 }
924 984
925-func snapshotHostToWire(part SnapshotHostPart) (*wire.SnapshotHost, error) {985+// snapshotDaemonToWire converts one daemon part losslessly. It refuses a
926- if part.HostIndex >= ports.BrokerMaxHosts {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 {
927 return nil, ErrTooLarge992 return nil, ErrTooLarge
928 }993 }
929- host := part.Host994+ daemon := part.Daemon
930- if err := validateSnapshotHostFields(host); err != nil {995+ if err := validateSnapshotDaemonFields(daemon); err != nil {
931 return nil, err996 return nil, err
932 }997 }
933- if err := host.Registration.Validate(); err != nil {998+ if err := daemon.Validate(); err != nil {
934- return nil, ErrInvalidMessage
935- }
936- if host.Endpoint != host.Registration.Endpoint {
937 return nil, ErrInvalidMessage999 return nil, ErrInvalidMessage
938 }1000 }
939- if err := ports.ValidateDurableHostProjection(host); err != nil {1001+ if !daemon.Local {
940- return nil, ErrInvalidMessage1002+ if err := ports.ValidateDurableHostProjection(daemon); err != nil {
1003+ return nil, ErrInvalidMessage
1004+ }
941 }1005 }
942- if len(host.Sessions) != 0 {1006+ if len(daemon.Sessions) != 0 {
943 return nil, ErrInvalidMessage1007 return nil, ErrInvalidMessage
944 }1008 }
945 if part.SessionCount > ports.BrokerMaxSessionsPerHost {1009 if part.SessionCount > ports.BrokerMaxSessionsPerHost {
946 return nil, ErrTooLarge1010 return nil, ErrTooLarge
947 }1011 }
948- lastAttemptSeconds, lastAttemptNanos := brokerTimeToWire(host.LastAttempt)1012+ lastAttemptSeconds, lastAttemptNanos := brokerTimeToWire(daemon.LastAttempt)
949- lastSuccessSeconds, lastSuccessNanos := brokerTimeToWire(host.LastSuccess)1013+ lastSuccessSeconds, lastSuccessNanos := brokerTimeToWire(daemon.LastSuccess)
950- nextDueSeconds, nextDueNanos := brokerTimeToWire(host.NextDue)1014+ nextDueSeconds, nextDueNanos := brokerTimeToWire(daemon.NextDue)
951- return &wire.SnapshotHost{1015+ var registration *wire.RemoteRegistration
952- HostIndex: part.HostIndex, Endpoint: host.Endpoint, DisplayOrigin: host.DisplayOrigin,1016+ if !daemon.Local {
953- Rank: uint32(host.Rank), Registration: registrationToWire(host.Registration),1017+ registration = registrationToWire(daemon.Registration)
954- Availability: uint32(host.Availability), Checking: host.Checking,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,
955 LastAttempt: &wire.BrokerTimestamp{Seconds: lastAttemptSeconds, Nanos: lastAttemptNanos},1031 LastAttempt: &wire.BrokerTimestamp{Seconds: lastAttemptSeconds, Nanos: lastAttemptNanos},
956 LastSuccess: &wire.BrokerTimestamp{Seconds: lastSuccessSeconds, Nanos: lastSuccessNanos},1032 LastSuccess: &wire.BrokerTimestamp{Seconds: lastSuccessSeconds, Nanos: lastSuccessNanos},
957 NextDue: &wire.BrokerTimestamp{Seconds: nextDueSeconds, Nanos: nextDueNanos},1033 NextDue: &wire.BrokerTimestamp{Seconds: nextDueSeconds, Nanos: nextDueNanos},
958- ConsecutiveFailures: uint64(host.ConsecutiveFailures), FailureEpisode: host.FailureEpisode,1034+ ConsecutiveFailures: uint64(daemon.ConsecutiveFailures), FailureEpisode: daemon.FailureEpisode,
959- FailureKind: uint32(host.LastFailure.Kind),1035+ FailureKind: uint32(daemon.LastFailure.Kind),
960- InventoryKnown: host.InventoryKnown, SessionCount: part.SessionCount,1036+ InventoryKnown: daemon.InventoryKnown, SessionCount: part.SessionCount,
961 }, nil1037 }, nil
962 }1038 }
963 1039
964-func snapshotHostFromWire(message *wire.SnapshotHost) (ports.RemoteHostSnapshot, uint32, uint32, error) {1040+func snapshotDaemonFromWire(message *wire.SnapshotDaemon) (ports.BrokerDaemonObservation, uint32, uint32, error) {
965- var host ports.RemoteHostSnapshot1041+ var daemon ports.BrokerDaemonObservation
966 if message == nil {1042 if message == nil {
967- return host, 0, 0, ErrInvalidMessage1043+ return daemon, 0, 0, ErrInvalidMessage
968 }1044 }
969- if message.GetHostIndex() >= ports.BrokerMaxHosts {1045+ if message.GetHostIndex() >= ports.BrokerMaxDaemonsPerSnapshot {
970- return ports.RemoteHostSnapshot{}, 0, 0, ErrTooLarge1046+ return daemon, 0, 0, ErrTooLarge
971 }1047 }
1048+ local := message.GetLocal()
972 endpoint := message.GetEndpoint()1049 endpoint := message.GetEndpoint()
973- if err := validateBrokerEndpoint(endpoint); err != nil {
974- return ports.RemoteHostSnapshot{}, 0, 0, err
975- }
976 origin := message.GetDisplayOrigin()1050 origin := message.GetDisplayOrigin()
977 if err := validateBrokerOrigin(origin); err != nil {1051 if err := validateBrokerOrigin(origin); err != nil {
978- return ports.RemoteHostSnapshot{}, 0, 0, err1052+ return daemon, 0, 0, err
979 }1053 }
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())
981 if err != nil {1073 if err != nil {
982- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage1074+ return daemon, 0, 0, ErrInvalidMessage
983 }1075 }
984- if endpoint != registration.Endpoint {1076+ raw := message.GetDaemonIncarnation()
985- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage1077+ 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
986 }1086 }
987 availability, err := brokerEnum8[domain.RemoteAvailability](message.GetAvailability())1087 availability, err := brokerEnum8[domain.RemoteAvailability](message.GetAvailability())
988 if err != nil {1088 if err != nil {
989- return ports.RemoteHostSnapshot{}, 0, 0, err1089+ return daemon, 0, 0, ErrInvalidMessage
990 }1090 }
991 if availability < domain.RemoteAvailabilityUnknown || availability > domain.RemoteAvailabilityInvalidResponse {1091 if availability < domain.RemoteAvailabilityUnknown || availability > domain.RemoteAvailabilityInvalidResponse {
992- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage1092+ return daemon, 0, 0, ErrInvalidMessage
993 }1093 }
994 failureKind, err := brokerEnum8[domain.RemoteFailureKind](message.GetFailureKind())1094 failureKind, err := brokerEnum8[domain.RemoteFailureKind](message.GetFailureKind())
995 if err != nil {1095 if err != nil {
996- return ports.RemoteHostSnapshot{}, 0, 0, err1096+ return daemon, 0, 0, ErrInvalidMessage
997 }1097 }
998 if failureKind > domain.RemoteFailureInvalidResponse {1098 if failureKind > domain.RemoteFailureInvalidResponse {
999- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage1099+ return daemon, 0, 0, ErrInvalidMessage
1000 }1100 }
1001 lastAttempt, err := brokerTimeFromWire(message.GetLastAttempt().GetSeconds(), message.GetLastAttempt().GetNanos())1101 lastAttempt, err := brokerTimeFromWire(message.GetLastAttempt().GetSeconds(), message.GetLastAttempt().GetNanos())
1002 if err != nil {1102 if err != nil {
1003- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage1103+ return daemon, 0, 0, ErrInvalidMessage
1004 }1104 }
1005 lastSuccess, err := brokerTimeFromWire(message.GetLastSuccess().GetSeconds(), message.GetLastSuccess().GetNanos())1105 lastSuccess, err := brokerTimeFromWire(message.GetLastSuccess().GetSeconds(), message.GetLastSuccess().GetNanos())
1006 if err != nil {1106 if err != nil {
1007- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage1107+ return daemon, 0, 0, ErrInvalidMessage
1008 }1108 }
1009 nextDue, err := brokerTimeFromWire(message.GetNextDue().GetSeconds(), message.GetNextDue().GetNanos())1109 nextDue, err := brokerTimeFromWire(message.GetNextDue().GetSeconds(), message.GetNextDue().GetNanos())
1010 if err != nil {1110 if err != nil {
1011- return ports.RemoteHostSnapshot{}, 0, 0, ErrInvalidMessage1111+ return daemon, 0, 0, ErrInvalidMessage
1012 }1112 }
1013 if message.GetConsecutiveFailures() > math.MaxUint32 {1113 if message.GetConsecutiveFailures() > math.MaxUint32 {
1014- return ports.RemoteHostSnapshot{}, 0, 0, errConvertRange1114+ return daemon, 0, 0, errConvertRange
1015 }1115 }
1016- sessionCount := message.GetSessionCount()1116+ if message.GetSessionCount() > ports.BrokerMaxSessionsPerHost {
1017- if sessionCount > ports.BrokerMaxSessionsPerHost {1117+ return daemon, 0, 0, ErrTooLarge
1018- return ports.RemoteHostSnapshot{}, 0, 0, ErrTooLarge
1019 }1118 }
1020- host = ports.RemoteHostSnapshot{1119+ daemon = ports.BrokerDaemonObservation{
1021- Endpoint: endpoint, DisplayOrigin: origin, Rank: int(message.GetRank()),1120+ Local: local, Endpoint: endpoint, DisplayOrigin: origin, Rank: int(message.GetRank()),
1022- Registration: registration, Availability: availability, Checking: message.GetChecking(),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(),
1023 LastAttempt: lastAttempt, LastSuccess: lastSuccess, NextDue: nextDue,1125 LastAttempt: lastAttempt, LastSuccess: lastSuccess, NextDue: nextDue,
1024 ConsecutiveFailures: uint(message.GetConsecutiveFailures()), FailureEpisode: message.GetFailureEpisode(),1126 ConsecutiveFailures: uint(message.GetConsecutiveFailures()), FailureEpisode: message.GetFailureEpisode(),
1025 LastFailure: domain.RemoteFailure{Kind: failureKind},1127 LastFailure: domain.RemoteFailure{Kind: failureKind},
1026 InventoryKnown: message.GetInventoryKnown(),1128 InventoryKnown: message.GetInventoryKnown(),
1027 Sessions: []catalogue.RemoteCatalogSession{},1129 Sessions: []catalogue.RemoteCatalogSession{},
1028 }1130 }
1029- if err := validateSnapshotHostFields(host); err != nil {1131+ if err := validateSnapshotDaemonFields(daemon); err != nil {
1030- return ports.RemoteHostSnapshot{}, 0, 0, err1132+ return daemon, 0, 0, err
1031 }1133 }
1032- return host, message.GetHostIndex(), message.GetSessionCount(), nil1134+ if err := daemon.Validate(); err != nil {
1135+ return daemon, 0, 0, ErrInvalidMessage
1136+ }
1137+ return daemon, message.GetHostIndex(), message.GetSessionCount(), nil
1033 }1138 }
1034 1139
1035 func snapshotSessionToWire(part SnapshotSessionPart) (*wire.SnapshotSession, error) {1140 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 {
1037 return nil, ErrTooLarge1146 return nil, ErrTooLarge
1038 }1147 }
1039 if part.SessionIndex >= ports.BrokerMaxSessionsPerHost {1148 if part.SessionIndex >= ports.BrokerMaxSessionsPerHost {
@@ -1044,26 +1153,32 @@ func snapshotSessionToWire(part SnapshotSessionPart) (*wire.SnapshotSession, err
1044 }1153 }
1045 return &wire.SnapshotSession{1154 return &wire.SnapshotSession{
1046 HostIndex: part.HostIndex, SessionIndex: part.SessionIndex,1155 HostIndex: part.HostIndex, SessionIndex: part.SessionIndex,
1156+ Local: part.Local,
1047 Session: catalogSessionToWire(part.Session),1157 Session: catalogSessionToWire(part.Session),
1048 }, nil1158 }, nil
1049 }1159 }
1050 1160
1051-func snapshotSessionFromWire(message *wire.SnapshotSession) (catalogue.RemoteCatalogSession, uint32, uint32, error) {1161+func snapshotSessionFromWire(message *wire.SnapshotSession) (catalogue.RemoteCatalogSession, uint32, uint32, bool, error) {
1052 var session catalogue.RemoteCatalogSession1162 var session catalogue.RemoteCatalogSession
1053 if message == nil {1163 if message == nil {
1054- return session, 0, 0, ErrInvalidMessage1164+ return session, 0, 0, false, ErrInvalidMessage
1055 }1165 }
1056- if message.GetHostIndex() >= ports.BrokerMaxHosts {1166+ local := message.GetLocal()
1057- return catalogue.RemoteCatalogSession{}, 0, 0, ErrTooLarge1167+ 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
1058 }1173 }
1059 if message.GetSessionIndex() >= ports.BrokerMaxSessionsPerHost {1174 if message.GetSessionIndex() >= ports.BrokerMaxSessionsPerHost {
1060- return catalogue.RemoteCatalogSession{}, 0, 0, ErrTooLarge1175+ return session, 0, 0, false, ErrTooLarge
1061 }1176 }
1062 converted, err := catalogSessionFromWire(message.GetSession())1177 converted, err := catalogSessionFromWire(message.GetSession())
1063 if err != nil {1178 if err != nil {
1064- return catalogue.RemoteCatalogSession{}, 0, 0, err1179+ return session, 0, 0, false, err
1065 }1180 }
1066- return converted, message.GetHostIndex(), message.GetSessionIndex(), nil1181+ return converted, message.GetHostIndex(), message.GetSessionIndex(), local, nil
1067 }1182 }
1068 1183
1069 func operationResultToWire(m OperationResult) (*wire.OperationResult, error) {1184 func operationResultToWire(m OperationResult) (*wire.OperationResult, error) {
M internal/adapters/brokerwire/codec_test.go +205 -47
@@ -74,19 +74,39 @@ func testCatalogSession() catalogue.RemoteCatalogSession {
74 }74 }
75 }75 }
76 76
77-func testHostSnapshot() ports.RemoteHostSnapshot {77+// testDaemonObservation builds one observed, catalogue-valid remote daemon
78- return ports.RemoteHostSnapshot{78+// projection. Sessions are empty: they travel as separate parts.
79- Endpoint: "dev@host:22",79+func testDaemonObservation() ports.BrokerDaemonObservation {
80- DisplayOrigin: "dev@host",80+ return ports.BrokerDaemonObservation{
81- Rank: 2,81+ Endpoint: "dev@host:22",
82- Registration: testRegistration(),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(),
83 Availability: domain.RemoteAvailabilityReachable,109 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},
90 InventoryKnown: true,110 InventoryKnown: true,
91 Sessions: []catalogue.RemoteCatalogSession{},111 Sessions: []catalogue.RemoteCatalogSession{},
92 }112 }
@@ -334,7 +354,7 @@ func TestBrokerClientVariants(t *testing.T) {
334 {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 105},354 {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 105},
335 {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 106},355 {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 106},
336 {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107},356 {"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},
338 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},358 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},
339 {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},359 {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},
340 }360 }
@@ -353,6 +373,141 @@ func TestBrokerClientVariants(t *testing.T) {
353 }373 }
354 }374 }
355 375
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+
356 // TestBrokerServerVariants inventories every server variant tag.511 // TestBrokerServerVariants inventories every server variant tag.
357 func TestBrokerServerVariants(t *testing.T) {512 func TestBrokerServerVariants(t *testing.T) {
358 connection := testConnectionID(0x11)513 connection := testConnectionID(0x11)
@@ -363,11 +518,13 @@ func TestBrokerServerVariants(t *testing.T) {
363 tag protowire.Number518 tag protowire.Number
364 }{519 }{
365 {"registered", Registered{Epoch: 7, Connection: connection}, 201},520 {"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},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},
367- {"snapshot_host", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotHostPart{HostIndex: 0, Host: testHostSnapshot(), SessionCount: 1}}, 202},522+ {"snapshot_daemon", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: 0, Daemon: testLocalDaemonObservation(), SessionCount: 0}}, 202},
368- {"snapshot_session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}}, 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},
369- {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 3, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202},524+ {"snapshot_session", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 3, Part: SnapshotSessionPart{HostIndex: 1, SessionIndex: 0, Session: testCatalogSession()}}, 202},
370- {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotEnd{}}, 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},
371 {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true}, 203},528 {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true}, 203},
372 {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204},529 {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204},
373 {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205},530 {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205},
@@ -445,15 +602,16 @@ func TestBrokerTabIndexBound(t *testing.T) {
445 require.ErrorIs(t, err, errConvertRange)602 require.ErrorIs(t, err, errConvertRange)
446 }603 }
447 604
448-// mutateHostIndex re-encodes one host snapshot part envelope with its host605+// mutateDaemonIndex re-encodes one daemon snapshot part envelope with its
449-// index overwritten, so decode can be fed a value encode would never emit.606+// daemon index overwritten, so decode can be fed a value encode would never
450-func mutateHostIndex(t *testing.T, part SnapshotPart, hostIndex uint32) []byte {607+// emit.
608+func mutateDaemonIndex(t *testing.T, part SnapshotPart, hostIndex uint32) []byte {
451 t.Helper()609 t.Helper()
452 raw := mustEncodeServer(t, part)610 raw := mustEncodeServer(t, part)
453 envelope := &wire.BrokerServerEnvelope{}611 envelope := &wire.BrokerServerEnvelope{}
454 require.NoError(t, wire.ScanEnvelope(envelope, raw))612 require.NoError(t, wire.ScanEnvelope(envelope, raw))
455 require.NoError(t, proto.Unmarshal(raw, envelope))613 require.NoError(t, proto.Unmarshal(raw, envelope))
456- envelope.GetSnapshotPart().GetHost().HostIndex = hostIndex614+ envelope.GetSnapshotPart().GetDaemon().HostIndex = hostIndex
457 mutated, err := proto.Marshal(envelope)615 mutated, err := proto.Marshal(envelope)
458 require.NoError(t, err)616 require.NoError(t, err)
459 return mutated617 return mutated
@@ -476,57 +634,57 @@ func mutateSessionIndexes(t *testing.T, part SnapshotPart, hostIndex, sessionInd
476 634
477 // TestBrokerSnapshotIndexBounds proves snapshot part indexes are strict635 // TestBrokerSnapshotIndexBounds proves snapshot part indexes are strict
478 // ranges: max-1 is accepted on both encode and decode, and max is refused as a636 // 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 and637+// range refusal on both paths. Daemon indexes are bounded by
480-// session indexes by BrokerMaxSessionsPerHost.638+// BrokerMaxDaemonsPerSnapshot and session indexes by BrokerMaxSessionsPerHost.
481 func TestBrokerSnapshotIndexBounds(t *testing.T) {639 func TestBrokerSnapshotIndexBounds(t *testing.T) {
482 connection := testConnectionID(0x11)640 connection := testConnectionID(0x11)
483- hostPart := func(hostIndex uint32) SnapshotPart {641+ daemonPart := func(hostIndex uint32) SnapshotPart {
484- return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotHostPart{HostIndex: hostIndex, Host: testHostSnapshot()}}642+ return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 1, Part: SnapshotDaemonPart{HostIndex: hostIndex, Daemon: testDaemonObservation()}}
485 }643 }
486 sessionPart := func(hostIndex, sessionIndex uint32) SnapshotPart {644 sessionPart := func(hostIndex, sessionIndex uint32) SnapshotPart {
487 return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: testCatalogSession()}}645 return SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: hostIndex, SessionIndex: sessionIndex, Session: testCatalogSession()}}
488 }646 }
489 647
490- t.Run("host part at max-1 accepted", func(t *testing.T) {648+ t.Run("daemon part at max-1 accepted", func(t *testing.T) {
491- message := hostPart(ports.BrokerMaxHosts - 1)649+ message := daemonPart(ports.BrokerMaxDaemonsPerSnapshot - 1)
492 raw := mustEncodeServer(t, message)650 raw := mustEncodeServer(t, message)
493 decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)651 decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
494 require.NoError(t, err)652 require.NoError(t, err)
495 require.Equal(t, message, decoded)653 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)
497 require.NoError(t, err)655 require.NoError(t, err)
498 require.Equal(t, message, decoded)656 require.Equal(t, message, decoded)
499 })657 })
500 658
501- t.Run("host part at max refused", func(t *testing.T) {659+ t.Run("daemon part at max refused", func(t *testing.T) {
502- _, err := EncodeServer(hostPart(ports.BrokerMaxHosts), testEnvelopeCeiling, testChunkCeiling)660+ _, err := EncodeServer(daemonPart(ports.BrokerMaxDaemonsPerSnapshot), testEnvelopeCeiling, testChunkCeiling)
503 require.ErrorIs(t, err, ErrTooLarge)661 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)
505 require.ErrorIs(t, err, ErrTooLarge)663 require.ErrorIs(t, err, ErrTooLarge)
506 })664 })
507 665
508- t.Run("host part at uint32 maximum refused", func(t *testing.T) {666+ t.Run("daemon part at uint32 maximum refused", func(t *testing.T) {
509- _, err := EncodeServer(hostPart(^uint32(0)), testEnvelopeCeiling, testChunkCeiling)667+ _, err := EncodeServer(daemonPart(^uint32(0)), testEnvelopeCeiling, testChunkCeiling)
510 require.ErrorIs(t, err, ErrTooLarge)668 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)
512 require.ErrorIs(t, err, ErrTooLarge)670 require.ErrorIs(t, err, ErrTooLarge)
513 })671 })
514 672
515 t.Run("session part at max-1 accepted", func(t *testing.T) {673 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)
517 raw := mustEncodeServer(t, message)675 raw := mustEncodeServer(t, message)
518 decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)676 decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
519 require.NoError(t, err)677 require.NoError(t, err)
520 require.Equal(t, message, decoded)678 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)
522 require.NoError(t, err)680 require.NoError(t, err)
523 require.Equal(t, message, decoded)681 require.Equal(t, message, decoded)
524 })682 })
525 683
526 t.Run("session part host index at max refused", func(t *testing.T) {684 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)
528 require.ErrorIs(t, err, ErrTooLarge)686 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)
530 require.ErrorIs(t, err, ErrTooLarge)688 require.ErrorIs(t, err, ErrTooLarge)
531 })689 })
532 690
@@ -591,10 +749,10 @@ func TestBrokerBounds(t *testing.T) {
591 for i := range many {749 for i := range many {
592 many[i] = "A=B"750 many[i] = "A=B"
593 }751 }
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)
595 require.ErrorIs(t, err, ErrTooLarge)753 require.ErrorIs(t, err, ErrTooLarge)
596 oversize := []string{"K=" + strings.Repeat("x", ports.BrokerMaxEnvEntryBytes)}754 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)
598 require.ErrorIs(t, err, ErrTooLarge)756 require.ErrorIs(t, err, ErrTooLarge)
599 })757 })
600 t.Run("error text", func(t *testing.T) {758 t.Run("error text", func(t *testing.T) {
@@ -604,7 +762,7 @@ func TestBrokerBounds(t *testing.T) {
604 require.ErrorIs(t, err, ErrTooLarge)762 require.ErrorIs(t, err, ErrTooLarge)
605 })763 })
606 t.Run("snapshot counts", func(t *testing.T) {764 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)
608 require.ErrorIs(t, err, ErrTooLarge)766 require.ErrorIs(t, err, ErrTooLarge)
609 })767 })
610 t.Run("control env carries no attachment state", func(t *testing.T) {768 t.Run("control env carries no attachment state", func(t *testing.T) {
@@ -693,7 +851,7 @@ func TestBrokerValidationNegatives(t *testing.T) {
693 require.ErrorIs(t, err, ErrInvalidMessage)851 require.ErrorIs(t, err, ErrInvalidMessage)
694 })852 })
695 t.Run("taxonomy switches", func(t *testing.T) {853 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()})
697 envelope := &wire.BrokerClientEnvelope{}855 envelope := &wire.BrokerClientEnvelope{}
698 require.NoError(t, wire.ScanEnvelope(envelope, raw))856 require.NoError(t, wire.ScanEnvelope(envelope, raw))
699 require.NoError(t, proto.Unmarshal(raw, envelope))857 require.NoError(t, proto.Unmarshal(raw, envelope))
@@ -725,11 +883,11 @@ func TestBrokerValidationNegatives(t *testing.T) {
725 require.ErrorIs(t, err, ErrInvalidMessage)883 require.ErrorIs(t, err, ErrInvalidMessage)
726 })884 })
727 t.Run("env without equals refused", func(t *testing.T) {885 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)
729 require.ErrorIs(t, err, ErrInvalidMessage)887 require.ErrorIs(t, err, ErrInvalidMessage)
730 })888 })
731 t.Run("invalid policy refused", func(t *testing.T) {889 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)
733 require.ErrorIs(t, err, ErrInvalidMessage)891 require.ErrorIs(t, err, ErrInvalidMessage)
734 })892 })
735 t.Run("snapshot revision zero refused", func(t *testing.T) {893 t.Run("snapshot revision zero refused", func(t *testing.T) {
@@ -741,7 +899,7 @@ func TestBrokerValidationNegatives(t *testing.T) {
741 require.ErrorIs(t, err, ErrInvalidMessage)899 require.ErrorIs(t, err, ErrInvalidMessage)
742 })900 })
743 t.Run("local open with registration refused", func(t *testing.T) {901 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)
745 require.ErrorIs(t, err, ErrInvalidMessage)903 require.ErrorIs(t, err, ErrInvalidMessage)
746 })904 })
747 t.Run("bad shutdown reason refused", func(t *testing.T) {905 t.Run("bad shutdown reason refused", func(t *testing.T) {
@@ -845,7 +1003,7 @@ func fuzzClientSeeds(t testing.TB) [][]byte {
845 for _, message := range []ClientMessage{1003 for _, message := range []ClientMessage{
846 Register{},1004 Register{},
847 Subscribe{Epoch: 7, Connection: testConnectionID(0x11), Generation: 1},1005 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()},
849 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},1007 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},
850 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},1008 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},
851 } {1009 } {
M internal/adapters/brokerwire/convert.go +16 -9
@@ -435,24 +435,31 @@ func validateCatalogSession(session catalogue.RemoteCatalogSession) error {
435 return nil435 return nil
436 }436 }
437 437
438-// validateSnapshotHostBounds enforces the per-host snapshot contract:438+// validateSnapshotDaemonFields enforces the per-daemon snapshot contract:
439 // endpoint grammar, presentation origin, rank fit, closed availability and439 // endpoint grammar, presentation origin, rank fit, closed availability and
440-// failure taxonomies, and time values that survive the wire exactly.440+// failure taxonomies, and a syntactically present policy. The full port
441-func validateSnapshotHostFields(host ports.RemoteHostSnapshot) error {441+// projection rules (identity/incarnation pairing, observed version,
442- if err := validateBrokerEndpoint(host.Endpoint); err != nil {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 {
443 return err450 return err
444 }451 }
445- if err := validateBrokerOrigin(host.DisplayOrigin); err != nil {452+ if err := validateBrokerOrigin(daemon.DisplayOrigin); err != nil {
446 return err453 return err
447 }454 }
448- if host.Rank < 0 || host.Rank > math.MaxUint32 {455+ if daemon.Rank < 0 || daemon.Rank > math.MaxUint32 {
449 return errConvertRange456 return errConvertRange
450 }457 }
451- if host.Availability < domain.RemoteAvailabilityUnknown ||458+ if daemon.Availability < domain.RemoteAvailabilityUnknown ||
452- host.Availability > domain.RemoteAvailabilityInvalidResponse {459+ daemon.Availability > domain.RemoteAvailabilityInvalidResponse {
453 return ErrInvalidMessage460 return ErrInvalidMessage
454 }461 }
455- if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse {462+ if daemon.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
456 return ErrInvalidMessage463 return ErrInvalidMessage
457 }464 }
458 return nil465 return nil
M internal/adapters/brokerwire/messages.go +17 -8
@@ -99,12 +99,16 @@ func (Reconcile) brokerClientMessage() {}
99 99
100 // OpenStream asks the broker to open one independently cancellable logical100 // OpenStream asks the broker to open one independently cancellable logical
101 // stream to the owning daemon. Env is the per-request session environment101 // 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.
103 type OpenStream struct {105 type OpenStream struct {
104 Epoch ports.BrokerEpoch106 Epoch ports.BrokerEpoch
105 Connection ports.BrokerConnectionID107 Connection ports.BrokerConnectionID
106 Stream ports.BrokerStreamID108 Stream ports.BrokerStreamID
107 Purpose ports.BrokerStreamPurpose109 Purpose ports.BrokerStreamPurpose
110+ Admission ports.BrokerStreamAdmission
111+ Name string
108 Local bool112 Local bool
109 Endpoint string113 Endpoint string
110 Registration domain.RemoteRegistration114 Registration domain.RemoteRegistration
@@ -163,29 +167,34 @@ func (SnapshotPart) brokerServerMessage() {}
163 type SnapshotPartPayload interface{ snapshotPartPayload() }167 type SnapshotPartPayload interface{ snapshotPartPayload() }
164 168
165 // SnapshotBegin opens a snapshot publication with its element counts.169 // 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.
166 type SnapshotBegin struct {172 type SnapshotBegin struct {
167 HostCount uint32173 HostCount uint32
168 SessionCount uint32174 SessionCount uint32
169 TombstoneCount uint32175 TombstoneCount uint32
176+ LocalPresent bool
170 }177 }
171 178
172 func (SnapshotBegin) snapshotPartPayload() {}179 func (SnapshotBegin) snapshotPartPayload() {}
173 180
174-// SnapshotHostPart is one host projection. Sessions travel as separate181+// SnapshotDaemonPart is one daemon projection. Sessions travel as separate
175-// SnapshotSessionPart parts bound by host index; SessionCount advertises182+// SnapshotSessionPart parts bound by daemon index; SessionCount advertises
176-// how many follow. The embedded Host must carry no inline sessions.183+// how many follow. The embedded Daemon must carry no inline sessions.
177-type SnapshotHostPart struct {184+type SnapshotDaemonPart struct {
178 HostIndex uint32185 HostIndex uint32
179- Host ports.RemoteHostSnapshot186+ Daemon ports.BrokerDaemonObservation
180 SessionCount uint32187 SessionCount uint32
181 }188 }
182 189
183-func (SnapshotHostPart) snapshotPartPayload() {}190+func (SnapshotDaemonPart) snapshotPartPayload() {}
184 191
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.
186 type SnapshotSessionPart struct {194 type SnapshotSessionPart struct {
187 HostIndex uint32195 HostIndex uint32
188 SessionIndex uint32196 SessionIndex uint32
197+ Local bool
189 Session catalogue.RemoteCatalogSession198 Session catalogue.RemoteCatalogSession
190 }199 }
191 200
M internal/adapters/brokerwire/snapshot.go +132 -108
@@ -9,36 +9,40 @@
9 //9 //
10 // Transfer layout (indexes are exact positions):10 // Transfer layout (indexes are exact positions):
11 //11 //
12-// Begin(0) Host/Session... Tombstone... End(N-1)12+// Begin(0) Daemon/Session... Tombstone... End(N-1)
13 //13 //
14-// Begin carries the total host, session, and tombstone counts. Host parts14+// Begin carries the total daemon, session, and tombstone counts plus whether
15-// arrive in host_index order; each Host advertises how many Session parts15+// the local daemon is present. Daemon parts arrive in host_index order, with
16-// follow it immediately, with per-host session_index order. Tombstones16+// the local daemon (when present) first at index 0 and remotes following in
17-// arrive in tombstone_index order after all hosts and sessions. End closes17+// registration order; each Daemon advertises how many Session parts follow it
18-// the transfer. Total parts are 1 + hosts + sessions + tombstones + 1,18+// immediately, with per-daemon session_index order. A session of the local
19-// bounded by MaxSnapshotParts (64 hosts, 64*256 sessions, 64 tombstones).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).
20 //23 //
21 // Bounds: at most MaxSnapshotParts parts and MaxSnapshotStagedBytes staged24 // Bounds: at most MaxSnapshotParts parts and MaxSnapshotStagedBytes staged
22 // estimate per transfer. The staged estimate is a deterministic function of25 // estimate per transfer. The staged estimate is a deterministic function of
23-// endpoint, origin, session, tab, and tombstone text lengths plus fixed26+// endpoint, origin, policy, identity, session, tab, and tombstone text lengths
24-// per-part overhead; it bounds hostile memory without measuring wire bytes.27+// plus fixed per-part overhead; it bounds hostile memory without measuring
28+// wire bytes.
25 //29 //
26 // Publication is atomic: the committed snapshot is replaced only when End30 // Publication is atomic: the committed snapshot is replaced only when End
27 // arrives with exact indexes and the assembled BrokerSnapshot passes31 // arrives with exact indexes and the assembled BrokerSnapshot passes
28-// ports.BrokerSnapshot.Validate plus per-host32+// ports.BrokerSnapshot.Validate plus, for every remote daemon,
29-// ports.ValidateDurableHostProjection. Any malformed index, count, order,33+// ports.ValidateDurableHostProjection. Any malformed index, count, order, or
30-// or validation failure aborts staging and retains the old snapshot. A new34+// validation failure aborts staging and retains the old snapshot. A new Begin
31-// Begin for the same generation with a newer revision coalesces by35+// for the same generation with a newer revision coalesces by replacing
32-// replacing staging; an older revision is rejected and retains both the36+// staging; an older revision is rejected and retains both the committed
33-// committed snapshot and any staging in flight. Equal revisions are allowed37+// snapshot and any staging in flight. Equal revisions are allowed so a resync
34-// so a resync may republish the committed revision.38+// may republish the committed revision.
35 //39 //
36 // Generation fencing: the assembler belongs to exactly one nonzero40 // Generation fencing: the assembler belongs to exactly one nonzero
37 // subscription generation, adopted from the first Begin or pinned by41 // subscription generation, adopted from the first Begin or pinned by
38-// WithGeneration. Parts with a stale generation are ignored without42+// WithGeneration. Parts with a stale generation are ignored without touching
39-// touching staging; parts with a future generation are rejected, whether or43+// staging; parts with a future generation are rejected, whether or not a
40-// not a transfer is in flight. Scope fencing is exact: every part must carry44+// transfer is in flight. Scope fencing is exact: every part must carry the
41-// the assembler's epoch and connection.45+// assembler's epoch and connection.
42 //46 //
43 // There is no I/O, socket, pump, or P3.2 transport here. The owning47 // There is no I/O, socket, pump, or P3.2 transport here. The owning
44 // Connection discards staging on generation advance, unsubscribe, or close.48 // Connection discards staging on generation advance, unsubscribe, or close.
@@ -53,9 +57,9 @@ import (
53 )57 )
54 58
55 const (59 const (
56- // MaxSnapshotParts bounds one multipart publication: 1 Begin + 6460+ // MaxSnapshotParts bounds one multipart publication: 1 Begin + 65 daemons
57- // hosts + 64*256 sessions + 64 tombstones + 1 End.61+ // + 65*256 sessions + 64 tombstones + 1 End.
58- MaxSnapshotParts = 1 + ports.BrokerMaxHosts + ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost + ports.BrokerMaxTombstones + 162+ MaxSnapshotParts = 1 + ports.BrokerMaxDaemonsPerSnapshot + ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost + ports.BrokerMaxTombstones + 1
59 // MaxSnapshotStagedBytes bounds the deterministic staged estimate of63 // MaxSnapshotStagedBytes bounds the deterministic staged estimate of
60 // one transfer (80 MiB).64 // one transfer (80 MiB).
61 MaxSnapshotStagedBytes = uint64(80 << 20)65 MaxSnapshotStagedBytes = uint64(80 << 20)
@@ -121,18 +125,38 @@ type snapshotStaging struct {
121 hostCount uint32125 hostCount uint32
122 sessionTotal uint32126 sessionTotal uint32
123 tombstoneCount uint32127 tombstoneCount uint32
128+ localPresent bool
124 totalParts uint32129 totalParts uint32
125 nextIndex uint32130 nextIndex uint32
126- hosts []ports.RemoteHostSnapshot131+ daemons []ports.BrokerDaemonObservation
127- hostExpected []uint32132+ daemonExpected []uint32
128- hostSeen []uint32133+ daemonSeen []uint32
129- hostsSeen uint32134+ daemonsSeen uint32
130 sessionsSeen uint32135 sessionsSeen uint32
131 tombstones []ports.BrokerHostTombstone136 tombstones []ports.BrokerHostTombstone
132 tombstonesSeen uint32137 tombstonesSeen uint32
133 stagedBytes uint64138 stagedBytes uint64
134 }139 }
135 140
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+
136 // NewSnapshotAssembler binds an assembler to one exact scope assigned by160 // NewSnapshotAssembler binds an assembler to one exact scope assigned by
137 // Registered. Every part must carry that scope.161 // Registered. Every part must carry that scope.
138 func NewSnapshotAssembler(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, opts ...SnapshotOption) *SnapshotAssembler {162 func NewSnapshotAssembler(epoch ports.BrokerEpoch, connection ports.BrokerConnectionID, opts ...SnapshotOption) *SnapshotAssembler {
@@ -242,23 +266,7 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot,
242 if a.hasCommitted && part.Revision < a.committed.Revision {266 if a.hasCommitted && part.Revision < a.committed.Revision {
243 return false, ports.BrokerSnapshot{}, ErrSnapshotStale267 return false, ports.BrokerSnapshot{}, ErrSnapshotStale
244 }268 }
245- total := 1 + uint64(begin.HostCount) + uint64(begin.SessionCount) + uint64(begin.TombstoneCount) + 1269+ staging := newSnapshotStaging(part.Generation, part.Revision, begin)
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- }
262 staged := estimatePartBytes(part)270 staged := estimatePartBytes(part)
263 if staged > a.maxStagedBytes {271 if staged > a.maxStagedBytes {
264 return false, ports.BrokerSnapshot{}, ErrTooLarge272 return false, ports.BrokerSnapshot{}, ErrTooLarge
@@ -284,11 +292,6 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot,
284 a.staging = nil292 a.staging = nil
285 return false, ports.BrokerSnapshot{}, err293 return false, ports.BrokerSnapshot{}, err
286 }294 }
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- }
292 switch {295 switch {
293 case part.Revision == staging.revision:296 case part.Revision == staging.revision:
294 // Restart of the same transfer: discard progress.297 // Restart of the same transfer: discard progress.
@@ -299,19 +302,7 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot,
299 default:302 default:
300 return false, ports.BrokerSnapshot{}, ErrSnapshotStale303 return false, ports.BrokerSnapshot{}, ErrSnapshotStale
301 }304 }
302- fresh := &snapshotStaging{305+ fresh := newSnapshotStaging(part.Generation, part.Revision, begin)
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- }
315 staged := estimatePartBytes(part)306 staged := estimatePartBytes(part)
316 if staged > a.maxStagedBytes {307 if staged > a.maxStagedBytes {
317 a.staging = nil308 a.staging = nil
@@ -342,9 +333,9 @@ func (a *SnapshotAssembler) Add(part SnapshotPart) (bool, ports.BrokerSnapshot,
342 return false, ports.BrokerSnapshot{}, ErrTooLarge333 return false, ports.BrokerSnapshot{}, ErrTooLarge
343 }334 }
344 switch payload := part.Part.(type) {335 switch payload := part.Part.(type) {
345- case SnapshotHostPart, *SnapshotHostPart:336+ case SnapshotDaemonPart, *SnapshotDaemonPart:
346- hostPart := normalizeHostPart(payload)337+ daemonPart := normalizeDaemonPart(payload)
347- if err := a.stageHost(staging, hostPart); err != nil {338+ if err := a.stageDaemon(staging, daemonPart); err != nil {
348 a.staging = nil339 a.staging = nil
349 return false, ports.BrokerSnapshot{}, err340 return false, ports.BrokerSnapshot{}, err
350 }341 }
@@ -394,17 +385,17 @@ func normalizeBegin(payload SnapshotPartPayload) (SnapshotBegin, bool) {
394 }385 }
395 }386 }
396 387
397-func normalizeHostPart(payload SnapshotPartPayload) SnapshotHostPart {388+func normalizeDaemonPart(payload SnapshotPartPayload) SnapshotDaemonPart {
398 switch p := payload.(type) {389 switch p := payload.(type) {
399- case SnapshotHostPart:390+ case SnapshotDaemonPart:
400 return p391 return p
401- case *SnapshotHostPart:392+ case *SnapshotDaemonPart:
402 if p == nil {393 if p == nil {
403- return SnapshotHostPart{HostIndex: ^uint32(0)}394+ return SnapshotDaemonPart{HostIndex: ^uint32(0)}
404 }395 }
405 return *p396 return *p
406 default:397 default:
407- return SnapshotHostPart{HostIndex: ^uint32(0)}398+ return SnapshotDaemonPart{HostIndex: ^uint32(0)}
408 }399 }
409 }400 }
410 401
@@ -437,19 +428,24 @@ func normalizeTombstonePart(payload SnapshotPartPayload) SnapshotTombstonePart {
437 }428 }
438 429
439 func checkBeginCounts(begin SnapshotBegin) error {430 func checkBeginCounts(begin SnapshotBegin) error {
440- if begin.HostCount > uint32(ports.BrokerMaxHosts) ||431+ if begin.HostCount > uint32(ports.BrokerMaxDaemonsPerSnapshot) ||
441- begin.SessionCount > uint32(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost) ||432+ begin.SessionCount > uint32(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost) ||
442 begin.TombstoneCount > uint32(ports.BrokerMaxTombstones) {433 begin.TombstoneCount > uint32(ports.BrokerMaxTombstones) {
443 return ErrTooLarge434 return ErrTooLarge
444 }435 }
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+ }
445 return nil441 return nil
446 }442 }
447 443
448-func (a *SnapshotAssembler) stageHost(staging *snapshotStaging, part SnapshotHostPart) error {444+func (a *SnapshotAssembler) stageDaemon(staging *snapshotStaging, part SnapshotDaemonPart) error {
449- if staging.hostsSeen >= staging.hostCount {445+ if staging.daemonsSeen >= staging.hostCount {
450 return ErrSnapshotInvalid446 return ErrSnapshotInvalid
451 }447 }
452- if part.HostIndex != staging.hostsSeen {448+ if part.HostIndex != staging.daemonsSeen {
453 return ErrSnapshotInvalid449 return ErrSnapshotInvalid
454 }450 }
455 if part.HostIndex >= staging.hostCount {451 if part.HostIndex >= staging.hostCount {
@@ -458,46 +454,60 @@ func (a *SnapshotAssembler) stageHost(staging *snapshotStaging, part SnapshotHos
458 if part.SessionCount > uint32(ports.BrokerMaxSessionsPerHost) {454 if part.SessionCount > uint32(ports.BrokerMaxSessionsPerHost) {
459 return ErrTooLarge455 return ErrTooLarge
460 }456 }
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 {
462 return ErrSnapshotInvalid467 return ErrSnapshotInvalid
463 }468 }
464 var seen uint64469 var seen uint64
465- for _, n := range staging.hostExpected[:staging.hostsSeen] {470+ for _, n := range staging.daemonExpected[:staging.daemonsSeen] {
466 seen += uint64(n)471 seen += uint64(n)
467 }472 }
468 if seen+uint64(part.SessionCount) > uint64(staging.sessionTotal) {473 if seen+uint64(part.SessionCount) > uint64(staging.sessionTotal) {
469 return ErrSnapshotInvalid474 return ErrSnapshotInvalid
470 }475 }
471- // Remaining hosts must still be able to fill the advertised total:476+ // Remaining daemons must still be able to fill the advertised total:
472- // even if every remaining host carried the per-host maximum, the477+ // even if every remaining daemon carried the per-daemon maximum, the
473 // total must stay reachable. This is checked exactly at End; here we478 // total must stay reachable. This is checked exactly at End; here we
474 // only refuse an already-exceeded sum.479 // only refuse an already-exceeded sum.
475- staging.hosts[part.HostIndex] = part.Host.Clone()480+ staging.daemons[part.HostIndex] = part.Daemon.Clone()
476- staging.hostExpected[part.HostIndex] = part.SessionCount481+ staging.daemonExpected[part.HostIndex] = part.SessionCount
477- staging.hostSeen[part.HostIndex] = 0482+ staging.daemonSeen[part.HostIndex] = 0
478- staging.hostsSeen++483+ staging.daemonsSeen++
479 return nil484 return nil
480 }485 }
481 486
482 func (a *SnapshotAssembler) stageSession(staging *snapshotStaging, part SnapshotSessionPart) error {487 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 {
484 return ErrSnapshotInvalid489 return ErrSnapshotInvalid
485 }490 }
486- current := staging.hostsSeen - 1491+ current := staging.daemonsSeen - 1
487 if part.HostIndex != current {492 if part.HostIndex != current {
488 return ErrSnapshotInvalid493 return ErrSnapshotInvalid
489 }494 }
490 if current >= staging.hostCount {495 if current >= staging.hostCount {
491 return ErrSnapshotInvalid496 return ErrSnapshotInvalid
492 }497 }
493- expected := staging.hostExpected[current]498+ expected := staging.daemonExpected[current]
494- if staging.hostSeen[current] >= expected {499+ if staging.daemonSeen[current] >= expected {
495 return ErrSnapshotInvalid500 return ErrSnapshotInvalid
496 }501 }
497- if part.SessionIndex != staging.hostSeen[current] {502+ if part.SessionIndex != staging.daemonSeen[current] {
498 return ErrSnapshotInvalid503 return ErrSnapshotInvalid
499 }504 }
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) {
501 return ErrTooLarge511 return ErrTooLarge
502 }512 }
503 if staging.sessionsSeen >= staging.sessionTotal {513 if staging.sessionsSeen >= staging.sessionTotal {
@@ -510,16 +520,16 @@ func (a *SnapshotAssembler) stageSession(staging *snapshotStaging, part Snapshot
510 tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs))520 tabs := make([]catalogue.RemoteCatalogTab, len(session.Tabs))
511 copy(tabs, session.Tabs)521 copy(tabs, session.Tabs)
512 session.Tabs = tabs522 session.Tabs = tabs
513- host := staging.hosts[current]523+ daemon := staging.daemons[current]
514- host.Sessions = append(host.Sessions, session)524+ daemon.Sessions = append(daemon.Sessions, session)
515- staging.hosts[current] = host525+ staging.daemons[current] = daemon
516- staging.hostSeen[current]++526+ staging.daemonSeen[current]++
517 staging.sessionsSeen++527 staging.sessionsSeen++
518 return nil528 return nil
519 }529 }
520 530
521 func (a *SnapshotAssembler) stageTombstone(staging *snapshotStaging, part SnapshotTombstonePart) error {531 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 {
523 return ErrSnapshotInvalid533 return ErrSnapshotInvalid
524 }534 }
525 if staging.tombstonesSeen >= staging.tombstoneCount {535 if staging.tombstonesSeen >= staging.tombstoneCount {
@@ -541,33 +551,37 @@ func (a *SnapshotAssembler) stageTombstone(staging *snapshotStaging, part Snapsh
541 }551 }
542 552
543 func (a *SnapshotAssembler) commitStaging(staging *snapshotStaging) (ports.BrokerSnapshot, error) {553 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 {
545 return ports.BrokerSnapshot{}, ErrSnapshotInvalid555 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
546 }556 }
547 if staging.nextIndex != staging.totalParts-1 {557 if staging.nextIndex != staging.totalParts-1 {
548 return ports.BrokerSnapshot{}, ErrSnapshotInvalid558 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
549 }559 }
550 var sum uint64560 var sum uint64
551- for _, n := range staging.hostExpected {561+ for _, n := range staging.daemonExpected {
552 sum += uint64(n)562 sum += uint64(n)
553 }563 }
554 if sum != uint64(staging.sessionTotal) {564 if sum != uint64(staging.sessionTotal) {
555 return ports.BrokerSnapshot{}, ErrSnapshotInvalid565 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
556 }566 }
557- hosts := make([]ports.RemoteHostSnapshot, len(staging.hosts))567+ daemons := make([]ports.BrokerDaemonObservation, len(staging.daemons))
558- for i, host := range staging.hosts {568+ for i, daemon := range staging.daemons {
559- hosts[i] = host.Clone()569+ daemons[i] = daemon.Clone()
560- if hosts[i].Sessions == nil {570+ if daemons[i].Sessions == nil {
561- hosts[i].Sessions = []catalogue.RemoteCatalogSession{}571+ daemons[i].Sessions = []catalogue.RemoteCatalogSession{}
562 }572 }
563 }573 }
564 removed := append([]ports.BrokerHostTombstone(nil), staging.tombstones...)574 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}
566 if err := snapshot.Validate(); err != nil {576 if err := snapshot.Validate(); err != nil {
567 return ports.BrokerSnapshot{}, ErrSnapshotInvalid577 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
568 }578 }
569- for _, host := range hosts {579+ for _, daemon := range daemons {
570- if err := ports.ValidateDurableHostProjection(host); err != nil {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 {
571 return ports.BrokerSnapshot{}, ErrSnapshotInvalid585 return ports.BrokerSnapshot{}, ErrSnapshotInvalid
572 }586 }
573 }587 }
@@ -579,13 +593,13 @@ func estimatePartBytes(part SnapshotPart) uint64 {
579 switch payload := part.Part.(type) {593 switch payload := part.Part.(type) {
580 case SnapshotBegin, *SnapshotBegin:594 case SnapshotBegin, *SnapshotBegin:
581 return overhead595 return overhead
582- case SnapshotHostPart:596+ case SnapshotDaemonPart:
583- return uint64(len(payload.Host.Endpoint)+len(payload.Host.DisplayOrigin)+len(payload.Host.Registration.Endpoint)+64) + overhead597+ return estimateDaemonBytes(payload.Daemon) + overhead
584- case *SnapshotHostPart:598+ case *SnapshotDaemonPart:
585 if payload == nil {599 if payload == nil {
586 return overhead600 return overhead
587 }601 }
588- return uint64(len(payload.Host.Endpoint)+len(payload.Host.DisplayOrigin)+len(payload.Host.Registration.Endpoint)+64) + overhead602+ return estimateDaemonBytes(payload.Daemon) + overhead
589 case SnapshotSessionPart:603 case SnapshotSessionPart:
590 return estimateCatalogSessionBytes(payload.Session) + overhead604 return estimateCatalogSessionBytes(payload.Session) + overhead
591 case *SnapshotSessionPart:605 case *SnapshotSessionPart:
@@ -607,6 +621,16 @@ func estimatePartBytes(part SnapshotPart) uint64 {
607 }621 }
608 }622 }
609 623
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+
610 // estimateCatalogSessionBytes bounds one session's staged memory. It charges634 // estimateCatalogSessionBytes bounds one session's staged memory. It charges
611 // every tab at least estimateTabBytes (covering the tab struct, its string635 // every tab at least estimateTabBytes (covering the tab struct, its string
612 // headers, and its slice slot) plus the variable-length text, and adds one636 // 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
25 return NewSnapshotAssembler(epoch, connection, opts...)25 return NewSnapshotAssembler(epoch, connection, opts...)
26 }26 }
27 27
28-// testHostAt builds one catalogue-valid host projection for host index i.28+// testDaemonAt builds one catalogue-valid remote daemon projection for daemon
29-func testHostAt(i int) ports.RemoteHostSnapshot {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 {
30 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)32 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)
31- return ports.RemoteHostSnapshot{33+ return ports.BrokerDaemonObservation{
32 Endpoint: endpoint,34 Endpoint: endpoint,
33 DisplayOrigin: endpoint,35 DisplayOrigin: endpoint,
34 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)},36 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(),
35 Availability: domain.RemoteAvailabilityReachable,38 Availability: domain.RemoteAvailabilityReachable,
36 LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(),39 LastSuccess: time.Unix(1700000000+int64(i), 0).UTC(),
37 InventoryKnown: true,40 InventoryKnown: true,
@@ -39,6 +42,18 @@ func testHostAt(i int) ports.RemoteHostSnapshot {
39 }42 }
40 }43 }
41 44
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+
42 // testSessionAt builds one catalogue-valid session for a host and index.57 // testSessionAt builds one catalogue-valid session for a host and index.
43 func testSessionAt(host, index int) catalogue.RemoteCatalogSession {58 func testSessionAt(host, index int) catalogue.RemoteCatalogSession {
44 return catalogue.RemoteCatalogSession{59 return catalogue.RemoteCatalogSession{
@@ -59,29 +74,39 @@ func testTombstoneAt(i int) ports.BrokerHostTombstone {
59 }74 }
60 }75 }
61 76
62-func sampleSnapshotHosts() []ports.RemoteHostSnapshot {77+func sampleSnapshotDaemons() []ports.BrokerDaemonObservation {
63- host0 := testHostAt(0)78+ daemon0 := testDaemonAt(0)
64- host0.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}79+ daemon0.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0), testSessionAt(0, 1)}
65- host1 := testHostAt(1)80+ daemon1 := testDaemonAt(1)
66- host1.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)}81+ daemon1.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(1, 0)}
67- return []ports.RemoteHostSnapshot{host0, host1}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}
68 }93 }
69 94
70 func sampleSnapshotTombstones() []ports.BrokerHostTombstone {95 func sampleSnapshotTombstones() []ports.BrokerHostTombstone {
71 return []ports.BrokerHostTombstone{testTombstoneAt(0)}96 return []ports.BrokerHostTombstone{testTombstoneAt(0)}
72 }97 }
73 98
74-func snapshotSessionTotal(hosts []ports.RemoteHostSnapshot) int {99+func snapshotSessionTotal(daemons []ports.BrokerDaemonObservation) int {
75 total := 0100 total := 0
76- for _, host := range hosts {101+ for _, daemon := range daemons {
77- total += len(host.Sessions)102+ total += len(daemon.Sessions)
78 }103 }
79 return total104 return total
80 }105 }
81 106
82-// snapshotPartsFor lays out one exact transfer: Begin(0), hosts with their107+// snapshotPartsFor lays out one exact transfer: Begin(0), daemons with their
83 // sessions, tombstones, End. Indexes are exact sequential positions.108 // 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 {
85 epoch, connection := testSnapshotScope()110 epoch, connection := testSnapshotScope()
86 next := uint32(0)111 next := uint32(0)
87 part := func(payload SnapshotPartPayload) SnapshotPart {112 part := func(payload SnapshotPartPayload) SnapshotPart {
@@ -89,19 +114,21 @@ func snapshotPartsFor(gen uint64, rev ports.BrokerRevision, hosts []ports.Remote
89 next++114 next++
90 return out115 return out
91 }116 }
117+ localPresent := len(daemons) > 0 && daemons[0].Local
92 parts := []SnapshotPart{part(SnapshotBegin{118 parts := []SnapshotPart{part(SnapshotBegin{
93- HostCount: uint32(len(hosts)),119+ HostCount: uint32(len(daemons)),
94- SessionCount: uint32(snapshotSessionTotal(hosts)),120+ SessionCount: uint32(snapshotSessionTotal(daemons)),
95 TombstoneCount: uint32(len(tombstones)),121 TombstoneCount: uint32(len(tombstones)),
122+ LocalPresent: localPresent,
96 })}123 })}
97- for hostIndex, host := range hosts {124+ for daemonIndex, daemon := range daemons {
98- // Host parts carry no inline sessions: the advertised count is125+ // Daemon parts carry no inline sessions: the advertised count is
99 // followed by that many session parts.126 // followed by that many session parts.
100- projection := host.Clone()127+ projection := daemon.Clone()
101 projection.Sessions = []catalogue.RemoteCatalogSession{}128 projection.Sessions = []catalogue.RemoteCatalogSession{}
102- parts = append(parts, part(SnapshotHostPart{HostIndex: uint32(hostIndex), Host: projection, SessionCount: uint32(len(host.Sessions))}))129+ parts = append(parts, part(SnapshotDaemonPart{HostIndex: uint32(daemonIndex), Daemon: projection, SessionCount: uint32(len(daemon.Sessions))}))
103- for sessionIndex, session := range host.Sessions {130+ for sessionIndex, session := range daemon.Sessions {
104- parts = append(parts, part(SnapshotSessionPart{HostIndex: uint32(hostIndex), SessionIndex: uint32(sessionIndex), Session: session}))131+ parts = append(parts, part(SnapshotSessionPart{HostIndex: uint32(daemonIndex), SessionIndex: uint32(sessionIndex), Local: daemon.Local, Session: session}))
105 }132 }
106 }133 }
107 for tombstoneIndex, tombstone := range tombstones {134 for tombstoneIndex, tombstone := range tombstones {
@@ -136,15 +163,15 @@ func runSnapshotParts(a *SnapshotAssembler, parts []SnapshotPart) error {
136 }163 }
137 164
138 func TestSnapshotAssemblerBoundsConstants(t *testing.T) {165 func TestSnapshotAssemblerBoundsConstants(t *testing.T) {
139- require.Equal(t, 16514, MaxSnapshotParts)166+ require.Equal(t, 16771, MaxSnapshotParts)
140- require.Equal(t, 1+64+64*256+64+1, MaxSnapshotParts)167+ require.Equal(t, 1+ports.BrokerMaxDaemonsPerSnapshot+ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost+ports.BrokerMaxTombstones+1, MaxSnapshotParts)
141 require.Equal(t, uint64(80<<20), MaxSnapshotStagedBytes)168 require.Equal(t, uint64(80<<20), MaxSnapshotStagedBytes)
142 }169 }
143 170
144 // TestSnapshotAssemblerExactTransfer proves exact order, count, and index171 // TestSnapshotAssemblerExactTransfer proves exact order, count, and index
145 // acceptance, and that committed order matches the staged order.172 // acceptance, and that committed order matches the staged order.
146 func TestSnapshotAssemblerExactTransfer(t *testing.T) {173 func TestSnapshotAssemblerExactTransfer(t *testing.T) {
147- hosts := sampleSnapshotHosts()174+ hosts := sampleSnapshotDaemons()
148 tombstones := sampleSnapshotTombstones()175 tombstones := sampleSnapshotTombstones()
149 parts := snapshotPartsFor(5, 3, hosts, tombstones)176 parts := snapshotPartsFor(5, 3, hosts, tombstones)
150 require.Len(t, parts, 1+2+3+1+1)177 require.Len(t, parts, 1+2+3+1+1)
@@ -169,13 +196,13 @@ func TestSnapshotAssemblerExactTransfer(t *testing.T) {
169 epoch, connection := testSnapshotScope()196 epoch, connection := testSnapshotScope()
170 require.Equal(t, epoch, committed.Epoch)197 require.Equal(t, epoch, committed.Epoch)
171 require.Equal(t, ports.BrokerRevision(3), committed.Revision)198 require.Equal(t, ports.BrokerRevision(3), committed.Revision)
172- require.Len(t, committed.Hosts, 2)199+ require.Len(t, committed.Daemons, 2)
173- require.Equal(t, "user0@host0:22", committed.Hosts[0].Endpoint)200+ require.Equal(t, "user0@host0:22", committed.Daemons[0].Endpoint)
174- require.Equal(t, "user1@host1:22", committed.Hosts[1].Endpoint)201+ require.Equal(t, "user1@host1:22", committed.Daemons[1].Endpoint)
175- require.Equal(t, 2, len(committed.Hosts[0].Sessions))202+ require.Equal(t, 2, len(committed.Daemons[0].Sessions))
176- require.Equal(t, "s00-000", committed.Hosts[0].Sessions[0].Name)203+ require.Equal(t, "s00-000", committed.Daemons[0].Sessions[0].Name)
177- require.Equal(t, "s00-001", committed.Hosts[0].Sessions[1].Name)204+ require.Equal(t, "s00-001", committed.Daemons[0].Sessions[1].Name)
178- require.Equal(t, "s01-000", committed.Hosts[1].Sessions[0].Name)205+ require.Equal(t, "s01-000", committed.Daemons[1].Sessions[0].Name)
179 require.Len(t, committed.Removed, 1)206 require.Len(t, committed.Removed, 1)
180 require.Equal(t, "retired0@old0:22", committed.Removed[0].Endpoint)207 require.Equal(t, "retired0@old0:22", committed.Removed[0].Endpoint)
181 _ = connection208 _ = connection
@@ -192,16 +219,95 @@ func TestSnapshotAssemblerEmptyTransfer(t *testing.T) {
192 completed, snapshot, err := a.Add(parts[1])219 completed, snapshot, err := a.Add(parts[1])
193 require.NoError(t, err)220 require.NoError(t, err)
194 require.True(t, completed)221 require.True(t, completed)
195- require.Empty(t, snapshot.Hosts)222+ require.Empty(t, snapshot.Daemons)
196 require.Empty(t, snapshot.Removed)223 require.Empty(t, snapshot.Removed)
197 }224 }
198 225
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+
199 // TestSnapshotAssemblerTransferRejections proves malformed indexes, counts,305 // TestSnapshotAssemblerTransferRejections proves malformed indexes, counts,
200 // order, payloads, and assembled validation failures abort staging and306 // order, payloads, and assembled validation failures abort staging and
201 // retain the committed snapshot.307 // retain the committed snapshot.
202 func TestSnapshotAssemblerTransferRejections(t *testing.T) {308 func TestSnapshotAssemblerTransferRejections(t *testing.T) {
203 base := func() []SnapshotPart {309 base := func() []SnapshotPart {
204- return snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones())310+ return snapshotPartsFor(5, 3, sampleSnapshotDaemons(), sampleSnapshotTombstones())
205 }311 }
206 mutate := func(parts []SnapshotPart, index int, apply func(*SnapshotPart)) []SnapshotPart {312 mutate := func(parts []SnapshotPart, index int, apply func(*SnapshotPart)) []SnapshotPart {
207 out := cloneSnapshotParts(parts)313 out := cloneSnapshotParts(parts)
@@ -217,10 +323,10 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) {
217 {"non-begin first", base()[1:], ErrSnapshotInvalid},323 {"non-begin first", base()[1:], ErrSnapshotInvalid},
218 {"missing session part", append(cloneSnapshotParts(base()[:3]), base()[4:]...), ErrSnapshotInvalid},324 {"missing session part", append(cloneSnapshotParts(base()[:3]), base()[4:]...), ErrSnapshotInvalid},
219 {"duplicate host index", mutate(base(), 4, func(p *SnapshotPart) {325 {"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}
221 }), ErrSnapshotInvalid},327 }), ErrSnapshotInvalid},
222 {"host index skips ahead", mutate(base(), 4, func(p *SnapshotPart) {328 {"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}
224 }), ErrSnapshotInvalid},330 }), ErrSnapshotInvalid},
225 {"session host index mismatch", mutate(base(), 3, func(p *SnapshotPart) {331 {"session host index mismatch", mutate(base(), 3, func(p *SnapshotPart) {
226 p.Part = SnapshotSessionPart{HostIndex: 1, SessionIndex: 1, Session: testSessionAt(0, 1)}332 p.Part = SnapshotSessionPart{HostIndex: 1, SessionIndex: 1, Session: testSessionAt(0, 1)}
@@ -234,15 +340,15 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) {
234 p.Part = SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: session}340 p.Part = SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: session}
235 }), ErrSnapshotInvalid},341 }), ErrSnapshotInvalid},
236 {"host carries inline sessions", mutate(base(), 1, func(p *SnapshotPart) {342 {"host carries inline sessions", mutate(base(), 1, func(p *SnapshotPart) {
237- host := testHostAt(0)343+ host := testDaemonAt(0)
238 host.Sessions = []catalogue.RemoteCatalogSession{testSessionAt(0, 0)}344 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}
240 }), ErrSnapshotInvalid},346 }), ErrSnapshotInvalid},
241 {"host session count over maximum", mutate(base(), 1, func(p *SnapshotPart) {347 {"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}
243 }), ErrTooLarge},349 }), ErrTooLarge},
244 {"host session count exceeds advertised total", mutate(base(), 1, func(p *SnapshotPart) {350 {"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}
246 }), ErrSnapshotInvalid},352 }), ErrSnapshotInvalid},
247 {"tombstone before all sessions", func() []SnapshotPart {353 {"tombstone before all sessions", func() []SnapshotPart {
248 parts := base()354 parts := base()
@@ -265,37 +371,37 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) {
265 p.Part = SnapshotBegin{HostCount: 2, SessionCount: 4, TombstoneCount: 1}371 p.Part = SnapshotBegin{HostCount: 2, SessionCount: 4, TombstoneCount: 1}
266 }), ErrSnapshotInvalid},372 }), ErrSnapshotInvalid},
267 {"begin host count over maximum", mutate(base(), 0, func(p *SnapshotPart) {373 {"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}
269 }), ErrTooLarge},375 }), ErrTooLarge},
270 {"begin session count over maximum", mutate(base(), 0, func(p *SnapshotPart) {376 {"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}
272 }), ErrTooLarge},378 }), ErrTooLarge},
273 {"begin tombstone count over maximum", mutate(base(), 0, func(p *SnapshotPart) {379 {"begin tombstone count over maximum", mutate(base(), 0, func(p *SnapshotPart) {
274 p.Part = SnapshotBegin{HostCount: 1, TombstoneCount: uint32(ports.BrokerMaxTombstones) + 1}380 p.Part = SnapshotBegin{HostCount: 1, TombstoneCount: uint32(ports.BrokerMaxTombstones) + 1}
275 }), ErrTooLarge},381 }), ErrTooLarge},
276 {"commit duplicate host endpoint", func() []SnapshotPart {382 {"commit duplicate host endpoint", func() []SnapshotPart {
277- hosts := sampleSnapshotHosts()383+ hosts := sampleSnapshotDaemons()
278 hosts[1].Endpoint = hosts[0].Endpoint384 hosts[1].Endpoint = hosts[0].Endpoint
279 hosts[1].Registration.Endpoint = hosts[0].Endpoint385 hosts[1].Registration.Endpoint = hosts[0].Endpoint
280 return snapshotPartsFor(5, 3, hosts, nil)386 return snapshotPartsFor(5, 3, hosts, nil)
281 }(), ErrSnapshotInvalid},387 }(), ErrSnapshotInvalid},
282 {"commit host endpoint registration mismatch", func() []SnapshotPart {388 {"commit host endpoint registration mismatch", func() []SnapshotPart {
283- hosts := sampleSnapshotHosts()389+ hosts := sampleSnapshotDaemons()
284 hosts[0].Endpoint = "other@elsewhere:22"390 hosts[0].Endpoint = "other@elsewhere:22"
285 return snapshotPartsFor(5, 3, hosts, nil)391 return snapshotPartsFor(5, 3, hosts, nil)
286 }(), ErrSnapshotInvalid},392 }(), ErrSnapshotInvalid},
287 {"commit invalid projection", func() []SnapshotPart {393 {"commit invalid projection", func() []SnapshotPart {
288- hosts := sampleSnapshotHosts()394+ hosts := sampleSnapshotDaemons()
289 hosts[0].Availability = 0395 hosts[0].Availability = 0
290 return snapshotPartsFor(5, 3, hosts, nil)396 return snapshotPartsFor(5, 3, hosts, nil)
291 }(), ErrSnapshotInvalid},397 }(), ErrSnapshotInvalid},
292 {"commit duplicate session name", func() []SnapshotPart {398 {"commit duplicate session name", func() []SnapshotPart {
293- hosts := sampleSnapshotHosts()399+ hosts := sampleSnapshotDaemons()
294 hosts[0].Sessions[1].Name = hosts[0].Sessions[0].Name400 hosts[0].Sessions[1].Name = hosts[0].Sessions[0].Name
295 return snapshotPartsFor(5, 3, hosts, nil)401 return snapshotPartsFor(5, 3, hosts, nil)
296 }(), ErrSnapshotInvalid},402 }(), ErrSnapshotInvalid},
297 {"commit tombstone for live host", func() []SnapshotPart {403 {"commit tombstone for live host", func() []SnapshotPart {
298- hosts := sampleSnapshotHosts()404+ hosts := sampleSnapshotDaemons()
299 tombstone := testTombstoneAt(0)405 tombstone := testTombstoneAt(0)
300 tombstone.Endpoint = hosts[0].Endpoint406 tombstone.Endpoint = hosts[0].Endpoint
301 tombstone.Registration.Endpoint = hosts[0].Endpoint407 tombstone.Registration.Endpoint = hosts[0].Endpoint
@@ -319,7 +425,7 @@ func TestSnapshotAssemblerTransferRejections(t *testing.T) {
319 // survives.425 // survives.
320 func TestSnapshotAssemblerLatePartAfterCommit(t *testing.T) {426 func TestSnapshotAssemblerLatePartAfterCommit(t *testing.T) {
321 a := testSnapshotAssembler(t)427 a := testSnapshotAssembler(t)
322- parts := snapshotPartsFor(5, 3, sampleSnapshotHosts(), sampleSnapshotTombstones())428+ parts := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), sampleSnapshotTombstones())
323 require.NoError(t, runSnapshotParts(a, parts))429 require.NoError(t, runSnapshotParts(a, parts))
324 late := parts[len(parts)-1]430 late := parts[len(parts)-1]
325 late.Index = uint32(len(parts))431 late.Index = uint32(len(parts))
@@ -331,13 +437,13 @@ func TestSnapshotAssemblerLatePartAfterCommit(t *testing.T) {
331 require.Equal(t, ports.BrokerRevision(3), committed.Revision)437 require.Equal(t, ports.BrokerRevision(3), committed.Revision)
332 }438 }
333 439
334-// TestSnapshotAssemblerBounds proves the 16,514-part and 80 MiB staged440+// TestSnapshotAssemblerBounds proves the 16,771-part and 80 MiB staged
335 // ceilings: maximum counts commit, one over is refused.441 // ceilings: maximum counts commit, one over is refused.
336 func TestSnapshotAssemblerBounds(t *testing.T) {442 func TestSnapshotAssemblerBounds(t *testing.T) {
337 t.Run("maximum counts commit", func(t *testing.T) {443 t.Run("maximum counts commit", func(t *testing.T) {
338- hosts := make([]ports.RemoteHostSnapshot, ports.BrokerMaxHosts)444+ hosts := make([]ports.BrokerDaemonObservation, ports.BrokerMaxDaemonsPerSnapshot)
339 for i := range hosts {445 for i := range hosts {
340- host := testHostAt(i)446+ host := testDaemonAt(i)
341 sessions := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost)447 sessions := make([]catalogue.RemoteCatalogSession, ports.BrokerMaxSessionsPerHost)
342 for j := range sessions {448 for j := range sessions {
343 sessions[j] = testSessionAt(i, j)449 sessions[j] = testSessionAt(i, j)
@@ -363,9 +469,9 @@ func TestSnapshotAssemblerBounds(t *testing.T) {
363 }469 }
364 }470 }
365 require.True(t, completed)471 require.True(t, completed)
366- require.Len(t, snapshot.Hosts, ports.BrokerMaxHosts)472+ require.Len(t, snapshot.Daemons, ports.BrokerMaxDaemonsPerSnapshot)
367 require.Len(t, snapshot.Removed, ports.BrokerMaxTombstones)473 require.Len(t, snapshot.Removed, ports.BrokerMaxTombstones)
368- for _, host := range snapshot.Hosts {474+ for _, host := range snapshot.Daemons {
369 require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost)475 require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost)
370 }476 }
371 require.Equal(t, ports.BrokerRevision(12), snapshot.Revision)477 require.Equal(t, ports.BrokerRevision(12), snapshot.Revision)
@@ -375,7 +481,7 @@ func TestSnapshotAssemblerBounds(t *testing.T) {
375 a := testSnapshotAssembler(t)481 a := testSnapshotAssembler(t)
376 _, _, err := a.Add(SnapshotPart{482 _, _, err := a.Add(SnapshotPart{
377 Epoch: 7, Connection: testConnectionID(0x21), Generation: 1, Revision: 1, Index: 0,483 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},
379 })485 })
380 require.ErrorIs(t, err, ErrTooLarge)486 require.ErrorIs(t, err, ErrTooLarge)
381 require.False(t, a.StagingActive())487 require.False(t, a.StagingActive())
@@ -383,7 +489,7 @@ func TestSnapshotAssemblerBounds(t *testing.T) {
383 489
384 t.Run("staged byte ceiling aborts and retains", func(t *testing.T) {490 t.Run("staged byte ceiling aborts and retains", func(t *testing.T) {
385 a := testSnapshotAssembler(t, WithMaxStagedBytes(200))491 a := testSnapshotAssembler(t, WithMaxStagedBytes(200))
386- parts := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)492+ parts := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)
387 // Begin (32) + host part stay within the cap; the first session part493 // Begin (32) + host part stay within the cap; the first session part
388 // crosses it.494 // crosses it.
389 require.NoError(t, runSnapshotParts(a, parts[:2]))495 require.NoError(t, runSnapshotParts(a, parts[:2]))
@@ -441,12 +547,12 @@ func TestSnapshotHostileMaxTabsBound(t *testing.T) {
441 session := testSessionAt(0, 0)547 session := testSessionAt(0, 0)
442 session.Tabs = maxTabs548 session.Tabs = maxTabs
443 perSession := estimateCatalogSessionBytes(session)549 perSession := estimateCatalogSessionBytes(session)
444- worst := perSession * uint64(ports.BrokerMaxHosts*ports.BrokerMaxSessionsPerHost)550+ worst := perSession * uint64(ports.BrokerMaxDaemonsPerSnapshot*ports.BrokerMaxSessionsPerHost)
445 require.Greater(t, worst, MaxSnapshotStagedBytes, "hostile max tabs must cross the 80 MiB ceiling")551 require.Greater(t, worst, MaxSnapshotStagedBytes, "hostile max tabs must cross the 80 MiB ceiling")
446 552
447- host := testHostAt(0)553+ host := testDaemonAt(0)
448 host.Sessions = []catalogue.RemoteCatalogSession{session}554 host.Sessions = []catalogue.RemoteCatalogSession{session}
449- parts := snapshotPartsFor(9, 1, []ports.RemoteHostSnapshot{host}, nil)555+ parts := snapshotPartsFor(9, 1, []ports.BrokerDaemonObservation{host}, nil)
450 556
451 tight := testSnapshotAssembler(t, WithMaxStagedBytes(perSession))557 tight := testSnapshotAssembler(t, WithMaxStagedBytes(perSession))
452 require.NoError(t, runSnapshotParts(tight, parts[:2]))558 require.NoError(t, runSnapshotParts(tight, parts[:2]))
@@ -459,22 +565,22 @@ func TestSnapshotHostileMaxTabsBound(t *testing.T) {
459 require.NoError(t, runSnapshotParts(generous, parts))565 require.NoError(t, runSnapshotParts(generous, parts))
460 committed, ok := generous.Snapshot()566 committed, ok := generous.Snapshot()
461 require.True(t, ok)567 require.True(t, ok)
462- require.Len(t, committed.Hosts, 1)568+ require.Len(t, committed.Daemons, 1)
463- require.Len(t, committed.Hosts[0].Sessions, 1)569+ require.Len(t, committed.Daemons[0].Sessions, 1)
464- require.Len(t, committed.Hosts[0].Sessions[0].Tabs, catalogue.RemoteCatalogMaxTabsPerSess)570+ require.Len(t, committed.Daemons[0].Sessions[0].Tabs, catalogue.RemoteCatalogMaxTabsPerSess)
465 }571 }
466 572
467 // TestSnapshotAssemblerAtomicCommit proves publication happens only at End573 // TestSnapshotAssemblerAtomicCommit proves publication happens only at End
468 // and that the returned snapshot is a defensive clone.574 // and that the returned snapshot is a defensive clone.
469 func TestSnapshotAssemblerAtomicCommit(t *testing.T) {575 func TestSnapshotAssemblerAtomicCommit(t *testing.T) {
470 a := testSnapshotAssembler(t)576 a := testSnapshotAssembler(t)
471- first := snapshotPartsFor(5, 1, sampleSnapshotHosts(), sampleSnapshotTombstones())577+ first := snapshotPartsFor(5, 1, sampleSnapshotDaemons(), sampleSnapshotTombstones())
472 require.NoError(t, runSnapshotParts(a, first))578 require.NoError(t, runSnapshotParts(a, first))
473 committed, ok := a.Snapshot()579 committed, ok := a.Snapshot()
474 require.True(t, ok)580 require.True(t, ok)
475 require.Equal(t, ports.BrokerRevision(1), committed.Revision)581 require.Equal(t, ports.BrokerRevision(1), committed.Revision)
476 582
477- second := snapshotPartsFor(5, 2, sampleSnapshotHosts(), nil)583+ second := snapshotPartsFor(5, 2, sampleSnapshotDaemons(), nil)
478 for _, part := range second[:len(second)-1] {584 for _, part := range second[:len(second)-1] {
479 completed, _, err := a.Add(part)585 completed, _, err := a.Add(part)
480 require.NoError(t, err)586 require.NoError(t, err)
@@ -494,18 +600,18 @@ func TestSnapshotAssemblerAtomicCommit(t *testing.T) {
494 600
495 // The committed snapshot is immutable to the caller: mutating a returned601 // The committed snapshot is immutable to the caller: mutating a returned
496 // value never reaches the assembler.602 // value never reaches the assembler.
497- published.Hosts[0].Endpoint = "mutated@nowhere:22"603+ published.Daemons[0].Endpoint = "mutated@nowhere:22"
498- published.Hosts[0].Sessions[0].Tabs = []catalogue.RemoteCatalogTab{{ID: "injected", Index: 0, Name: "injected"}}604+ published.Daemons[0].Sessions[0].Tabs = []catalogue.RemoteCatalogTab{{ID: "injected", Index: 0, Name: "injected"}}
499- published.Hosts[0].Sessions[0].Name = "mutated"605+ published.Daemons[0].Sessions[0].Name = "mutated"
500 again, ok := a.Snapshot()606 again, ok := a.Snapshot()
501 require.True(t, ok)607 require.True(t, ok)
502- require.Equal(t, "user0@host0:22", again.Hosts[0].Endpoint)608+ require.Equal(t, "user0@host0:22", again.Daemons[0].Endpoint)
503- require.Equal(t, "s00-000", again.Hosts[0].Sessions[0].Name)609+ require.Equal(t, "s00-000", again.Daemons[0].Sessions[0].Name)
504- require.Empty(t, again.Hosts[0].Sessions[0].Tabs)610+ require.Empty(t, again.Daemons[0].Sessions[0].Tabs)
505 611
506 // An aborted transfer retains the committed snapshot.612 // An aborted transfer retains the committed snapshot.
507- require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[:2]))613+ require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[:2]))
508- _, _, err = a.Add(snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[4])614+ _, _, err = a.Add(snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[4])
509 require.ErrorIs(t, err, ErrSnapshotInvalid)615 require.ErrorIs(t, err, ErrSnapshotInvalid)
510 again, ok = a.Snapshot()616 again, ok = a.Snapshot()
511 require.True(t, ok)617 require.True(t, ok)
@@ -517,13 +623,13 @@ func TestSnapshotAssemblerAtomicCommit(t *testing.T) {
517 // transfer starts from index 0.623 // transfer starts from index 0.
518 func TestSnapshotAssemblerInterruptedRetention(t *testing.T) {624 func TestSnapshotAssemblerInterruptedRetention(t *testing.T) {
519 a := testSnapshotAssembler(t)625 a := testSnapshotAssembler(t)
520- parts := snapshotPartsFor(5, 1, sampleSnapshotHosts(), sampleSnapshotTombstones())626+ parts := snapshotPartsFor(5, 1, sampleSnapshotDaemons(), sampleSnapshotTombstones())
521 require.NoError(t, runSnapshotParts(a, parts))627 require.NoError(t, runSnapshotParts(a, parts))
522 committed, ok := a.Snapshot()628 committed, ok := a.Snapshot()
523 require.True(t, ok)629 require.True(t, ok)
524 require.Equal(t, ports.BrokerRevision(1), committed.Revision)630 require.Equal(t, ports.BrokerRevision(1), committed.Revision)
525 631
526- restart := snapshotPartsFor(5, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())632+ restart := snapshotPartsFor(5, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones())
527 require.NoError(t, runSnapshotParts(a, restart[:3]))633 require.NoError(t, runSnapshotParts(a, restart[:3]))
528 require.True(t, a.StagingActive())634 require.True(t, a.StagingActive())
529 a.DiscardStaging()635 a.DiscardStaging()
@@ -555,7 +661,7 @@ func TestSnapshotAssemblerInterruptedRetention(t *testing.T) {
555 // staging.661 // staging.
556 func TestSnapshotAssemblerRevisionFencing(t *testing.T) {662 func TestSnapshotAssemblerRevisionFencing(t *testing.T) {
557 a := testSnapshotAssembler(t)663 a := testSnapshotAssembler(t)
558- hosts := sampleSnapshotHosts()664+ hosts := sampleSnapshotDaemons()
559 require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 5, hosts, nil)))665 require.NoError(t, runSnapshotParts(a, snapshotPartsFor(5, 5, hosts, nil)))
560 666
561 t.Run("older revision refused", func(t *testing.T) {667 t.Run("older revision refused", func(t *testing.T) {
@@ -607,7 +713,7 @@ func TestSnapshotAssemblerRevisionFencing(t *testing.T) {
607 // replaces staging: equal revisions restart progress, newer revisions713 // replaces staging: equal revisions restart progress, newer revisions
608 // coalesce.714 // coalesce.
609 func TestSnapshotAssemblerCoalescing(t *testing.T) {715 func TestSnapshotAssemblerCoalescing(t *testing.T) {
610- hosts := sampleSnapshotHosts()716+ hosts := sampleSnapshotDaemons()
611 base := snapshotPartsFor(5, 3, hosts, nil)717 base := snapshotPartsFor(5, 3, hosts, nil)
612 718
613 a := testSnapshotAssembler(t)719 a := testSnapshotAssembler(t)
@@ -644,7 +750,7 @@ func TestSnapshotAssemblerCoalescing(t *testing.T) {
644 // one generation, ignores stale parts, rejects future parts, and refuses a750 // one generation, ignores stale parts, rejects future parts, and refuses a
645 // zero generation.751 // zero generation.
646 func TestSnapshotAssemblerGenerationFencing(t *testing.T) {752 func TestSnapshotAssemblerGenerationFencing(t *testing.T) {
647- hosts := sampleSnapshotHosts()753+ hosts := sampleSnapshotDaemons()
648 base := snapshotPartsFor(5, 3, hosts, nil)754 base := snapshotPartsFor(5, 3, hosts, nil)
649 755
650 t.Run("zero generation refused", func(t *testing.T) {756 t.Run("zero generation refused", func(t *testing.T) {
@@ -703,7 +809,7 @@ func TestSnapshotAssemblerGenerationFencing(t *testing.T) {
703 // TestSnapshotAssemblerScopeFencing proves every part must carry the exact809 // TestSnapshotAssemblerScopeFencing proves every part must carry the exact
704 // assigned epoch and connection, whether or not a transfer is in flight.810 // assigned epoch and connection, whether or not a transfer is in flight.
705 func TestSnapshotAssemblerScopeFencing(t *testing.T) {811 func TestSnapshotAssemblerScopeFencing(t *testing.T) {
706- base := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)812+ base := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)
707 cases := []struct {813 cases := []struct {
708 name string814 name string
709 apply func(*SnapshotPart)815 apply func(*SnapshotPart)
@@ -737,7 +843,7 @@ func TestSnapshotAssemblerScopeFencing(t *testing.T) {
737 // TestSnapshotAssemblerZeroRevision proves a zero revision is refused.843 // TestSnapshotAssemblerZeroRevision proves a zero revision is refused.
738 func TestSnapshotAssemblerZeroRevision(t *testing.T) {844 func TestSnapshotAssemblerZeroRevision(t *testing.T) {
739 a := testSnapshotAssembler(t)845 a := testSnapshotAssembler(t)
740- part := snapshotPartsFor(5, 3, sampleSnapshotHosts(), nil)[0]846+ part := snapshotPartsFor(5, 3, sampleSnapshotDaemons(), nil)[0]
741 part.Revision = 0847 part.Revision = 0
742 _, _, err := a.Add(part)848 _, _, err := a.Add(part)
743 require.ErrorIs(t, err, ErrSnapshotInvalid)849 require.ErrorIs(t, err, ErrSnapshotInvalid)
@@ -769,13 +875,13 @@ func TestSnapshotAssemblerNilAndEmptySafety(t *testing.T) {
769 // part from the first one.875 // part from the first one.
770 func TestSnapshotAssemblerPinnedGeneration(t *testing.T) {876 func TestSnapshotAssemblerPinnedGeneration(t *testing.T) {
771 a := testSnapshotAssembler(t, WithGeneration(5))877 a := testSnapshotAssembler(t, WithGeneration(5))
772- stale := snapshotPartsFor(4, 1, sampleSnapshotHosts(), nil)[0]878+ stale := snapshotPartsFor(4, 1, sampleSnapshotDaemons(), nil)[0]
773 _, _, err := a.Add(stale)879 _, _, err := a.Add(stale)
774 require.ErrorIs(t, err, ErrStaleGeneration)880 require.ErrorIs(t, err, ErrStaleGeneration)
775- future := snapshotPartsFor(6, 1, sampleSnapshotHosts(), nil)[0]881+ future := snapshotPartsFor(6, 1, sampleSnapshotDaemons(), nil)[0]
776 _, _, err = a.Add(future)882 _, _, err = a.Add(future)
777 require.ErrorIs(t, err, ErrFutureGeneration)883 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)))
779 committed, ok := a.Snapshot()885 committed, ok := a.Snapshot()
780 require.True(t, ok)886 require.True(t, ok)
781 require.Equal(t, ports.BrokerRevision(1), committed.Revision)887 require.Equal(t, ports.BrokerRevision(1), committed.Revision)
@@ -786,7 +892,7 @@ func TestSnapshotAssemblerPinnedGeneration(t *testing.T) {
786 // keeps publishing it until a newer transfer commits.892 // keeps publishing it until a newer transfer commits.
787 func TestSnapshotAssemblerAdoptCommitted(t *testing.T) {893 func TestSnapshotAssemblerAdoptCommitted(t *testing.T) {
788 source := testSnapshotAssembler(t)894 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())))
790 committed, ok := source.Snapshot()896 committed, ok := source.Snapshot()
791 require.True(t, ok)897 require.True(t, ok)
792 898
@@ -801,24 +907,24 @@ func TestSnapshotAssemblerAdoptCommitted(t *testing.T) {
801 require.Len(t, adopted.Removed, 1)907 require.Len(t, adopted.Removed, 1)
802 908
803 // An older revision and a foreign epoch are ignored.909 // An older revision and a foreign epoch are ignored.
804- target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch, Revision: 2, Hosts: committed.Hosts})910+ target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch, Revision: 2, Daemons: committed.Daemons})
805- target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch + 1, Revision: 9, Hosts: committed.Hosts})911+ target.adoptCommitted(ports.BrokerSnapshot{Epoch: committed.Epoch + 1, Revision: 9, Daemons: committed.Daemons})
806 adopted, ok = target.Snapshot()912 adopted, ok = target.Snapshot()
807 require.True(t, ok)913 require.True(t, ok)
808 require.Equal(t, ports.BrokerRevision(3), adopted.Revision)914 require.Equal(t, ports.BrokerRevision(3), adopted.Revision)
809 915
810 // A newer transfer commits over the adopted snapshot.916 // 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)))
812 adopted, ok = target.Snapshot()918 adopted, ok = target.Snapshot()
813 require.True(t, ok)919 require.True(t, ok)
814 require.Equal(t, ports.BrokerRevision(4), adopted.Revision)920 require.Equal(t, ports.BrokerRevision(4), adopted.Revision)
815 require.Empty(t, adopted.Removed)921 require.Empty(t, adopted.Removed)
816 922
817 // The adopted value is independent of the source.923 // The adopted value is independent of the source.
818- adopted.Hosts[0].Endpoint = "mutated@nowhere:22"924+ adopted.Daemons[0].Endpoint = "mutated@nowhere:22"
819 again, ok := source.Snapshot()925 again, ok := source.Snapshot()
820 require.True(t, ok)926 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)
822 }928 }
823 929
824 // TestSnapshotAssemblerConcurrentReaders is a deterministic race test: one930 // TestSnapshotAssemblerConcurrentReaders is a deterministic race test: one
@@ -849,7 +955,7 @@ func TestSnapshotAssemblerConcurrentReaders(t *testing.T) {
849 }()955 }()
850 }956 }
851 for revision := 1; revision <= revisions; revision++ {957 for revision := 1; revision <= revisions; revision++ {
852- parts := snapshotPartsFor(5, ports.BrokerRevision(revision), sampleSnapshotHosts(), nil)958+ parts := snapshotPartsFor(5, ports.BrokerRevision(revision), sampleSnapshotDaemons(), nil)
853 for _, part := range parts {959 for _, part := range parts {
854 _, _, err := a.Add(part)960 _, _, err := a.Add(part)
855 require.NoError(t, err)961 require.NoError(t, err)
@@ -886,7 +992,7 @@ func fuzzSnapshotAssemblerSeeds(tb testing.TB) [][]byte {
886 require.NoError(tb, err)992 require.NoError(tb, err)
887 return raw993 return raw
888 }994 }
889- parts := snapshotPartsFor(4, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())995+ parts := snapshotPartsFor(4, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones())
890 frames := make([][]byte, 0, len(parts))996 frames := make([][]byte, 0, len(parts))
891 for _, part := range parts {997 for _, part := range parts {
892 frames = append(frames, encode(part))998 frames = append(frames, encode(part))
@@ -946,11 +1052,15 @@ func FuzzSnapshotAssembler(f *testing.F) {
946 }1052 }
947 1053
948 // requireValidBrokerSnapshot asserts one published snapshot is exactly what the1054 // 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).
950 func requireValidBrokerSnapshot(t *testing.T, snapshot ports.BrokerSnapshot) {1057 func requireValidBrokerSnapshot(t *testing.T, snapshot ports.BrokerSnapshot) {
951 t.Helper()1058 t.Helper()
952 require.NoError(t, snapshot.Validate())1059 require.NoError(t, snapshot.Validate())
953- for _, host := range snapshot.Hosts {1060+ for _, daemon := range snapshot.Daemons {
954- require.NoError(t, ports.ValidateDurableHostProjection(host))1061+ if daemon.Local {
1062+ continue
1063+ }
1064+ require.NoError(t, ports.ValidateDurableHostProjection(daemon))
955 }1065 }
956 }1066 }
M internal/adapters/brokerwire/state_test.go +11 -11
@@ -123,7 +123,7 @@ func TestConnStateString(t *testing.T) {
123 // Register -> Closing -> Closed.123 // Register -> Closing -> Closed.
124 func TestConnectionLifecycle(t *testing.T) {124 func TestConnectionLifecycle(t *testing.T) {
125 op := operationIDAt(1)125 op := operationIDAt(1)
126- part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]126+ part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0]
127 cases := []struct {127 cases := []struct {
128 name string128 name string
129 setup func(*testing.T) *Connection129 setup func(*testing.T) *Connection
@@ -285,7 +285,7 @@ func stageConnectionPart(t *testing.T, conn *Connection, part SnapshotPart) {
285 func TestConnectionSubscriptionStagingRetention(t *testing.T) {285 func TestConnectionSubscriptionStagingRetention(t *testing.T) {
286 conn := readyTestConnection(t)286 conn := readyTestConnection(t)
287 require.NoError(t, conn.Subscribe(1))287 require.NoError(t, conn.Subscribe(1))
288- for _, part := range snapshotPartsFor(1, 4, sampleSnapshotHosts(), sampleSnapshotTombstones()) {288+ for _, part := range snapshotPartsFor(1, 4, sampleSnapshotDaemons(), sampleSnapshotTombstones()) {
289 stageConnectionPart(t, conn, part)289 stageConnectionPart(t, conn, part)
290 }290 }
291 committed, ok := conn.Snapshot()291 committed, ok := conn.Snapshot()
@@ -293,7 +293,7 @@ func TestConnectionSubscriptionStagingRetention(t *testing.T) {
293 require.Equal(t, ports.BrokerRevision(4), committed.Revision)293 require.Equal(t, ports.BrokerRevision(4), committed.Revision)
294 294
295 // In-flight staging of the active generation is discarded on advance.295 // 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)
297 stageConnectionPart(t, conn, inFlight[0])297 stageConnectionPart(t, conn, inFlight[0])
298 require.True(t, conn.StagingActive())298 require.True(t, conn.StagingActive())
299 require.NoError(t, conn.Subscribe(2))299 require.NoError(t, conn.Subscribe(2))
@@ -312,7 +312,7 @@ func TestConnectionSubscriptionStagingRetention(t *testing.T) {
312 future.Generation = 3312 future.Generation = 3
313 _, _, err = conn.AddSnapshotPart(future)313 _, _, err = conn.AddSnapshotPart(future)
314 require.ErrorIs(t, err, ErrFutureGeneration)314 require.ErrorIs(t, err, ErrFutureGeneration)
315- next := snapshotPartsFor(2, 5, sampleSnapshotHosts(), nil)315+ next := snapshotPartsFor(2, 5, sampleSnapshotDaemons(), nil)
316 _, _, err = conn.AddSnapshotPart(next[1])316 _, _, err = conn.AddSnapshotPart(next[1])
317 require.ErrorIs(t, err, ErrSnapshotInvalid)317 require.ErrorIs(t, err, ErrSnapshotInvalid)
318 require.False(t, conn.StagingActive())318 require.False(t, conn.StagingActive())
@@ -352,7 +352,7 @@ func TestConnectionSnapshotParts(t *testing.T) {
352 t.Run("future generation rejected", func(t *testing.T) {352 t.Run("future generation rejected", func(t *testing.T) {
353 conn := readyTestConnection(t)353 conn := readyTestConnection(t)
354 require.NoError(t, conn.Subscribe(2))354 require.NoError(t, conn.Subscribe(2))
355- part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]355+ part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0]
356 part.Generation = 3356 part.Generation = 3
357 _, _, err := conn.AddSnapshotPart(part)357 _, _, err := conn.AddSnapshotPart(part)
358 require.ErrorIs(t, err, ErrFutureGeneration)358 require.ErrorIs(t, err, ErrFutureGeneration)
@@ -364,11 +364,11 @@ func TestConnectionSnapshotParts(t *testing.T) {
364 t.Run("foreign scope rejected", func(t *testing.T) {364 t.Run("foreign scope rejected", func(t *testing.T) {
365 conn := readyTestConnection(t)365 conn := readyTestConnection(t)
366 require.NoError(t, conn.Subscribe(2))366 require.NoError(t, conn.Subscribe(2))
367- part := snapshotPartsFor(2, 1, sampleSnapshotHosts(), nil)[0]367+ part := snapshotPartsFor(2, 1, sampleSnapshotDaemons(), nil)[0]
368 part.Epoch = 8368 part.Epoch = 8
369 _, _, err := conn.AddSnapshotPart(part)369 _, _, err := conn.AddSnapshotPart(part)
370 require.ErrorIs(t, err, ErrScopeMismatch)370 require.ErrorIs(t, err, ErrScopeMismatch)
371- part = snapshotPartsFor(2, 1, sampleSnapshotHosts(), nil)[0]371+ part = snapshotPartsFor(2, 1, sampleSnapshotDaemons(), nil)[0]
372 part.Connection = testConnectionID(0x77)372 part.Connection = testConnectionID(0x77)
373 _, _, err = conn.AddSnapshotPart(part)373 _, _, err = conn.AddSnapshotPart(part)
374 require.ErrorIs(t, err, ErrScopeMismatch)374 require.ErrorIs(t, err, ErrScopeMismatch)
@@ -377,7 +377,7 @@ func TestConnectionSnapshotParts(t *testing.T) {
377 t.Run("complete transfer through the connection", func(t *testing.T) {377 t.Run("complete transfer through the connection", func(t *testing.T) {
378 conn := readyTestConnection(t)378 conn := readyTestConnection(t)
379 require.NoError(t, conn.Subscribe(9))379 require.NoError(t, conn.Subscribe(9))
380- parts := snapshotPartsFor(9, 2, sampleSnapshotHosts(), sampleSnapshotTombstones())380+ parts := snapshotPartsFor(9, 2, sampleSnapshotDaemons(), sampleSnapshotTombstones())
381 var published bool381 var published bool
382 for _, part := range parts {382 for _, part := range parts {
383 completed, _, err := conn.AddSnapshotPart(part)383 completed, _, err := conn.AddSnapshotPart(part)
@@ -410,7 +410,7 @@ func TestConnectionExactScopeRegistration(t *testing.T) {
410 _, err = connectionStreams(conn).Close(other, 1)410 _, err = connectionStreams(conn).Close(other, 1)
411 require.ErrorIs(t, err, ErrScopeMismatch)411 require.ErrorIs(t, err, ErrScopeMismatch)
412 412
413- part := snapshotPartsFor(1, 1, sampleSnapshotHosts(), nil)[0]413+ part := snapshotPartsFor(1, 1, sampleSnapshotDaemons(), nil)[0]
414 part.Connection = other.Connection414 part.Connection = other.Connection
415 part.Epoch = other.Epoch415 part.Epoch = other.Epoch
416 require.NoError(t, conn.Subscribe(1))416 require.NoError(t, conn.Subscribe(1))
@@ -914,7 +914,7 @@ func TestStreamTrackerConcurrentFrames(t *testing.T) {
914 func TestConnectionConcurrentWork(t *testing.T) {914 func TestConnectionConcurrentWork(t *testing.T) {
915 conn := readyTestConnection(t)915 conn := readyTestConnection(t)
916 require.NoError(t, conn.Subscribe(1))916 require.NoError(t, conn.Subscribe(1))
917- hosts := sampleSnapshotHosts()917+ hosts := sampleSnapshotDaemons()
918 const revisions = 20918 const revisions = 20
919 const operations = 32919 const operations = 32
920 for i := 0; i < operations; i++ {920 for i := 0; i < operations; i++ {
@@ -1069,7 +1069,7 @@ func TestConnectionCloseRaceNoPostCloseSnapshotCommit(t *testing.T) {
1069 for i := 0; i < attempts; i++ {1069 for i := 0; i < attempts; i++ {
1070 conn := readyTestConnection(t)1070 conn := readyTestConnection(t)
1071 require.NoError(t, conn.Subscribe(2))1071 require.NoError(t, conn.Subscribe(2))
1072- parts := snapshotPartsFor(2, revision, sampleSnapshotHosts(), nil)1072+ parts := snapshotPartsFor(2, revision, sampleSnapshotDaemons(), nil)
1073 end := parts[len(parts)-1]1073 end := parts[len(parts)-1]
1074 for _, part := range parts[:len(parts)-1] {1074 for _, part := range parts[:len(parts)-1] {
1075 stageConnectionPart(t, conn, part)1075 stageConnectionPart(t, conn, part)
M internal/adapters/daemonmux/codec.go +24 -6
@@ -270,7 +270,7 @@ func openToWire(m Open) (*wire.MuxOpen, error) {
270 return nil, ErrInvalidMessage270 return nil, ErrInvalidMessage
271 }271 }
272 request := ports.BrokerOpenStreamRequest{272 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,
274 Connection: m.Ref.Connection, Stream: m.Ref.Client,274 Connection: m.Ref.Connection, Stream: m.Ref.Client,
275 Endpoint: m.Endpoint, Registration: m.Registration,275 Endpoint: m.Endpoint, Registration: m.Registration,
276 Target: m.Target, Env: m.Env, Policy: m.Policy,276 Target: m.Target, Env: m.Env, Policy: m.Policy,
@@ -304,7 +304,11 @@ func openToWire(m Open) (*wire.MuxOpen, error) {
304 out.Registration = registrationToWire(m.Registration)304 out.Registration = registrationToWire(m.Registration)
305 }305 }
306 if m.Purpose == ports.BrokerStreamAttachment {306 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+ }
308 }312 }
309 return out, nil313 return out, nil
310 }314 }
@@ -322,6 +326,16 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
322 if err != nil {326 if err != nil {
323 return Open{}, err327 return Open{}, err
324 }328 }
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+ }
325 local := message.GetLocal()339 local := message.GetLocal()
326 var registration domain.RemoteRegistration340 var registration domain.RemoteRegistration
327 if !local {341 if !local {
@@ -334,8 +348,12 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
334 }348 }
335 var target protocol.ExactSessionTarget349 var target protocol.ExactSessionTarget
336 if purpose == ports.BrokerStreamAttachment {350 if purpose == ports.BrokerStreamAttachment {
337- target, err = exactTargetFromWire(message.GetTarget())351+ if admission == ports.BrokerAdmissionExact {
338- if err != nil {352+ target, err = exactTargetFromWire(message.GetTarget())
353+ if err != nil {
354+ return Open{}, ErrInvalidMessage
355+ }
356+ } else if message.GetTarget() != nil {
339 return Open{}, ErrInvalidMessage357 return Open{}, ErrInvalidMessage
340 }358 }
341 } else if message.GetTarget() != nil {359 } else if message.GetTarget() != nil {
@@ -354,11 +372,11 @@ func openFromWire(message *wire.MuxOpen) (Open, error) {
354 }372 }
355 endpoint := message.GetEndpoint()373 endpoint := message.GetEndpoint()
356 candidate := Open{374 candidate := Open{
357- Ref: ref, Purpose: purpose, Local: local, Endpoint: endpoint,375+ Ref: ref, Purpose: purpose, Admission: admission, Name: name, Local: local, Endpoint: endpoint,
358 Registration: registration, Target: target, Env: env, Policy: policy,376 Registration: registration, Target: target, Env: env, Policy: policy,
359 }377 }
360 request := ports.BrokerOpenStreamRequest{378 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,
362 Connection: candidate.Ref.Connection, Stream: candidate.Ref.Client,380 Connection: candidate.Ref.Connection, Stream: candidate.Ref.Client,
363 Endpoint: candidate.Endpoint, Registration: candidate.Registration,381 Endpoint: candidate.Endpoint, Registration: candidate.Registration,
364 Target: candidate.Target, Env: candidate.Env, Policy: candidate.Policy,382 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 {
94 94
95 func testOpen() Open {95 func testOpen() Open {
96 return Open{96 return Open{
97- Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Local: false,97+ Ref: testRef(5), Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Local: false,
98 Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(),98 Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(),
99 Env: []string{"TERM=xterm-256color"}, Policy: testPolicy(),99 Env: []string{"TERM=xterm-256color"}, Policy: testPolicy(),
100 }100 }
@@ -662,3 +662,121 @@ func FuzzDecodeServer(f *testing.F) {
662 _, _ = DecodeServer(payload, testEnvelopeCeiling, testChunkCeiling)662 _, _ = DecodeServer(payload, testEnvelopeCeiling, testChunkCeiling)
663 })663 })
664 }664 }
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 {
80 type Open struct {80 type Open struct {
81 Ref StreamRef81 Ref StreamRef
82 Purpose ports.BrokerStreamPurpose82 Purpose ports.BrokerStreamPurpose
83+ Admission ports.BrokerStreamAdmission
84+ Name string
83 Local bool85 Local bool
84 Endpoint string86 Endpoint string
85 Registration domain.RemoteRegistration87 Registration domain.RemoteRegistration
M internal/app/broker_offline_status.go +9 -1
@@ -627,13 +627,21 @@ func probeBrokerStatus(ctx context.Context, socketPath string) (brokerStatusRepo
627 }627 }
628 628
629 // readyBrokerStatus projects one committed snapshot into the ready report.629 // 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.
630 func readyBrokerStatus(socketPath string, snapshot ports.BrokerSnapshot) brokerStatusReport {632 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+ }
631 return brokerStatusReport{639 return brokerStatusReport{
632 Status: "ready",640 Status: "ready",
633 Endpoint: socketPath,641 Endpoint: socketPath,
634 Epoch: snapshot.Epoch,642 Epoch: snapshot.Epoch,
635 Revision: snapshot.Revision,643 Revision: snapshot.Revision,
636- HostCount: len(snapshot.Hosts),644+ HostCount: hosts,
637 }645 }
638 }646 }
639 647
M internal/ports/broker.go +167 -57
@@ -233,16 +233,18 @@ func (t BrokerHostTombstone) Fences(candidate domain.RemoteRegistration) bool {
233 t.Registration.Generation >= candidate.Generation233 t.Registration.Generation >= candidate.Generation
234 }234 }
235 235
236-// BrokerSnapshot is an immutable, fully defensive broker publication. Hosts236+// BrokerSnapshot is an immutable, fully defensive broker publication.
237-// reuse the ordered RemoteHostSnapshot projection; Removed carries the237+// Daemons carries the broker-native daemon observations (Plan 001 P5.2a):
238-// bounded tombstone set that fences stale authority. Nested slices are238+// the local daemon entry first when present, then remote hosts in
239-// never mutated after publication. Removed is process-local fencing state and239+// registration order. Removed carries the bounded tombstone set that fences
240-// is never durable: a BrokerSnapshotStore persists and reloads hosts without240+// stale authority. Nested slices are never mutated after publication.
241-// tombstones.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.
242 type BrokerSnapshot struct {244 type BrokerSnapshot struct {
243 Epoch BrokerEpoch245 Epoch BrokerEpoch
244 Revision BrokerRevision246 Revision BrokerRevision
245- Hosts []RemoteHostSnapshot247+ Daemons []BrokerDaemonObservation
246 Removed []BrokerHostTombstone248 Removed []BrokerHostTombstone
247 }249 }
248 250
@@ -253,30 +255,35 @@ func (s BrokerSnapshot) Validate() error {
253 if s.Revision == 0 {255 if s.Revision == 0 {
254 return errors.New("ports: broker snapshot has no revision")256 return errors.New("ports: broker snapshot has no revision")
255 }257 }
256- if len(s.Hosts) > BrokerMaxHosts {258+ if len(s.Daemons) > BrokerMaxDaemonsPerSnapshot {
257- return errors.New("ports: broker snapshot has too many hosts")259+ return errors.New("ports: broker snapshot has too many daemons")
258 }260 }
259- if len(s.Removed) > BrokerMaxTombstones {261+ local := false
260- return errors.New("ports: broker snapshot has too many tombstones")262+ seen := make(map[string]struct{}, len(s.Daemons))
261- }263+ for i, daemon := range s.Daemons {
262- seen := make(map[string]struct{}, len(s.Hosts))264+ if err := daemon.Validate(); err != nil {
263- for _, host := range s.Hosts {265+ return fmt.Errorf("ports: broker snapshot daemon %d: %w", i, err)
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)
269 }266 }
270- if host.Endpoint != host.Registration.Endpoint {267+ if daemon.Local {
271- return errors.New("ports: broker snapshot host endpoint does not match registration")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
272 }279 }
273- if len(host.Sessions) > BrokerMaxSessionsPerHost {280+ if _, dup := seen[daemon.Endpoint]; dup {
274- return errors.New("ports: broker snapshot host has too many sessions")281+ return fmt.Errorf("ports: broker snapshot has duplicate host %q", daemon.Endpoint)
275 }282 }
276- if _, dup := seen[host.Endpoint]; dup {283+ seen[daemon.Endpoint] = struct{}{}
277- return fmt.Errorf("ports: broker snapshot has duplicate host %q", host.Endpoint)284+ }
278- }285+ if len(s.Removed) > BrokerMaxTombstones {
279- seen[host.Endpoint] = struct{}{}286+ return errors.New("ports: broker snapshot has too many tombstones")
280 }287 }
281 for _, tombstone := range s.Removed {288 for _, tombstone := range s.Removed {
282 if err := tombstone.Validate(); err != nil {289 if err := tombstone.Validate(); err != nil {
@@ -289,26 +296,27 @@ func (s BrokerSnapshot) Validate() error {
289 return nil296 return nil
290 }297 }
291 298
292-// Clone returns a defensive copy with independent host, session, tab, and299+// Clone returns a defensive copy with independent daemon, session, and
293 // tombstone slices.300 // tombstone slices.
294 func (s BrokerSnapshot) Clone() BrokerSnapshot {301 func (s BrokerSnapshot) Clone() BrokerSnapshot {
295 out := s302 out := s
296- out.Hosts = make([]RemoteHostSnapshot, len(s.Hosts))303+ out.Daemons = make([]BrokerDaemonObservation, len(s.Daemons))
297- for i, host := range s.Hosts {304+ for i, daemon := range s.Daemons {
298- out.Hosts[i] = host.Clone()305+ out.Daemons[i] = daemon.Clone()
299 }306 }
300 out.Removed = append([]BrokerHostTombstone(nil), s.Removed...)307 out.Removed = append([]BrokerHostTombstone(nil), s.Removed...)
301 return out308 return out
302 }309 }
303 310
304-// Find returns the snapshot for an exact endpoint identity.311+// Find returns the observation for an exact remote endpoint identity. The
305-func (s BrokerSnapshot) Find(endpoint string) (RemoteHostSnapshot, bool) {312+// local daemon has no endpoint and is located by its Local flag.
306- for _, host := range s.Hosts {313+func (s BrokerSnapshot) Find(endpoint string) (BrokerDaemonObservation, bool) {
307- if host.Endpoint == endpoint {314+ for _, daemon := range s.Daemons {
308- return host, true315+ if !daemon.Local && daemon.Endpoint == endpoint {
316+ return daemon, true
309 }317 }
310 }318 }
311- return RemoteHostSnapshot{}, false319+ return BrokerDaemonObservation{}, false
312 }320 }
313 321
314 // Supersedes reports whether s is newer than other. Any epoch change322 // Supersedes reports whether s is newer than other. Any epoch change
@@ -342,14 +350,58 @@ func BrokerCompletionIsStale(currentRegistration domain.RemoteRegistration, curr
342 return !currentRegistration.Equal(completionRegistration)350 return !currentRegistration.Equal(completionRegistration)
343 }351 }
344 352
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+
345 // BrokerOpenStreamRequest asks the broker to open one independently393 // BrokerOpenStreamRequest asks the broker to open one independently
346 // cancellable logical stream to the owning daemon. The daemon revalidates394 // cancellable logical stream to the owning daemon. The daemon revalidates
347-// the exact session identity before attachment. Env is the per-request395+// the exact session identity before attachment. Admission selects the
348-// session environment; it is never inherited from the broker process396+// attachment admission variant; Name is the validated session name for
349-// environment.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.
350 type BrokerOpenStreamRequest struct {400 type BrokerOpenStreamRequest struct {
351 Epoch BrokerEpoch401 Epoch BrokerEpoch
352 Purpose BrokerStreamPurpose402 Purpose BrokerStreamPurpose
403+ Admission BrokerStreamAdmission
404+ Name string
353 Local bool405 Local bool
354 Connection BrokerConnectionID406 Connection BrokerConnectionID
355 Stream BrokerStreamID407 Stream BrokerStreamID
@@ -384,11 +436,34 @@ func (r BrokerOpenStreamRequest) Validate() error {
384 }436 }
385 switch r.Purpose {437 switch r.Purpose {
386 case BrokerStreamAttachment:438 case BrokerStreamAttachment:
387- if err := r.Target.Validate(); err != nil {439+ if err := r.Admission.Validate(); err != nil {
388 return err440 return err
389 }441 }
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+ }
390 case BrokerStreamControl, BrokerStreamObservation:465 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 {
392 return errors.New("ports: control/observation carries attachment state")467 return errors.New("ports: control/observation carries attachment state")
393 }468 }
394 default:469 default:
@@ -644,21 +719,27 @@ type BrokerSnapshotStore interface {
644 }719 }
645 720
646 // BrokerHostProbe observes one exact host registration without creating an721 // BrokerHostProbe observes one exact host registration without creating an
647-// attachment. The returned projection must describe the same registration and722+// attachment. The probe reports only observed state in a
648-// must satisfy the durable projection rules (see723+// BrokerDaemonObservation: the observed daemon identity, incarnation,
649-// ValidateDurableHostProjection): the registry re-checks exactly those rules,724+// protocol version, capabilities, availability, failure, freshness, and
650-// so an over-bound or catalogue-invalid session inventory is classified as an725+// session inventory. Configured authority (endpoint, registration, policy,
651-// invalid response and never published. Implementations should therefore726+// display origin, rank) is stamped by the registry from its own records and
652-// validate the projection with catalogue.ValidateRemoteCatalog against the727+// never trusted from the probe; unknown identity, version, or inventory is
653-// RemoteCatalogMax* bounds before returning it rather than relying on that728+// zero and is never invented. The returned observation must satisfy the
654-// rejection. Probe must return promptly once ctx is cancelled: the registry729+// durable projection rules (see ValidateDurableHostProjection): the registry
655-// cancels the attempt context when a registration is replaced or removed, and730+// re-checks exactly those rules, so an over-bound or catalogue-invalid session
656-// when the run settles. A probe that731+// inventory is classified as an invalid response and never published.
657-// ignores cancellation delays that retirement and can overlap a replacement732+// Implementations should therefore validate the observation with
658-// attempt for the same endpoint. Stale completions that arrive anyway are733+// catalogue.ValidateRemoteCatalog against the RemoteCatalogMax* bounds
659-// fenced by epoch and registration identity before publication.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.
660 type BrokerHostProbe interface {741 type BrokerHostProbe interface {
661- Probe(ctx context.Context, registration domain.RemoteRegistration) (RemoteHostSnapshot, error)742+ Probe(ctx context.Context, registration domain.RemoteRegistration) (BrokerDaemonObservation, error)
662 }743 }
663 744
664 // BrokerPhysicalConnection is one pooled physical transport to an745 // BrokerPhysicalConnection is one pooled physical transport to an
@@ -773,9 +854,38 @@ func validateBrokerDisplayText(value string, maxBytes int, label string) error {
773 return fmt.Errorf("ports: broker %s is not valid UTF-8", label)854 return fmt.Errorf("ports: broker %s is not valid UTF-8", label)
774 }855 }
775 for _, r := range value {856 for _, r := range value {
776- if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || r == '\u2028' || r == '\u2029' {857+ if !brokerDisplayRuneAllowed(r) {
777 return fmt.Errorf("ports: broker %s contains disallowed characters", label)858 return fmt.Errorf("ports: broker %s contains disallowed characters", label)
778 }859 }
779 }860 }
780 return nil861 return nil
781 }862 }
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 {
94 // one seam. This guard keeps that composition compile-checked.94 // one seam. This guard keeps that composition compile-checked.
95 var _ BrokerSnapshotStore = (BrokerHostStore)(nil)95 var _ BrokerSnapshotStore = (BrokerHostStore)(nil)
96 96
97-// ValidateDurableHostProjection reports whether one host projection satisfies97+// ValidateDurableHostProjection reports whether one daemon observation
98-// the semantic rules of durable broker state: a published availability and98+// satisfies the rules the offline store and wire accept: a local observation
99-// failure kind inside their closed ranges, and a session inventory the99+// is never durable, availability and failure kinds stay in their closed
100-// catalogue accepts for the host's exact incarnation and success time. The100+// ranges, and a present session inventory is a valid catalogue cache entry
101-// offline store and the registry both apply this rule, so a projection the101+// against the exact registration incarnation. The offline store and the
102-// registry publishes is never rejected (and therefore silently never102+// registry both apply this rule, so a projection the registry publishes is
103-// persisted) by the store. Transient state (Checking and the live failure103+// never rejected (and therefore silently never persisted) by the store.
104-// cause) is deliberately not part of the durable rule.104+// Transient state (Checking and the live failure cause) is deliberately not
105-func ValidateDurableHostProjection(host RemoteHostSnapshot) error {105+// part of the durable rule.
106- if host.Availability < domain.RemoteAvailabilityUnknown || host.Availability > domain.RemoteAvailabilityInvalidResponse {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 {
107 return errors.New("ports: broker host availability is out of range")111 return errors.New("ports: broker host availability is out of range")
108 }112 }
109- if host.LastFailure.Kind > domain.RemoteFailureInvalidResponse {113+ if obs.LastFailure.Kind > domain.RemoteFailureInvalidResponse {
110 return errors.New("ports: broker host failure kind is out of range")114 return errors.New("ports: broker host failure kind is out of range")
111 }115 }
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 {
113 return nil125 return nil
114 }126 }
115 return catalogue.ValidateRemoteCatalogCacheEntries([]catalogue.RemoteCatalogCacheEntry{{127 return catalogue.ValidateRemoteCatalogCacheEntries([]catalogue.RemoteCatalogCacheEntry{{
116- Host: host.Endpoint,128+ Host: obs.Endpoint,
117- Incarnation: host.Registration.Incarnation,129+ Incarnation: obs.Registration.Incarnation,
118- FetchedAt: host.LastSuccess,130+ FetchedAt: obs.LastSuccess,
119- Sessions: host.Sessions,131+ Sessions: obs.Sessions,
120 }})132 }})
121 }133 }
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 (
5 "strings"5 "strings"
6 "testing"6 "testing"
7 "time"7 "time"
8+ "unicode/utf8"
8 9
9 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"11 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/protocol/catalogue"12 "github.com/bnema/vev/internal/protocol/catalogue"
13+ "github.com/stretchr/testify/require"
12 )14 )
13 15
14 func testBrokerRegistration(endpoint string, incarnation byte, generation domain.RemoteGeneration) domain.RemoteRegistration {16 func testBrokerRegistration(endpoint string, incarnation byte, generation domain.RemoteGeneration) domain.RemoteRegistration {
@@ -37,11 +39,21 @@ func testBrokerPolicy() BrokerPolicy {
37 }39 }
38 }40 }
39 41
40-func testBrokerHost(endpoint string) RemoteHostSnapshot {42+func testBrokerObservation(endpoint string) BrokerDaemonObservation {
41- return RemoteHostSnapshot{43+ return BrokerDaemonObservation{
42 Endpoint: endpoint,44 Endpoint: endpoint,
43 DisplayOrigin: endpoint,45 DisplayOrigin: endpoint,
44 Registration: testBrokerRegistration(endpoint, 1, 1),46 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(),
45 Availability: domain.RemoteAvailabilityReachable,57 Availability: domain.RemoteAvailabilityReachable,
46 }58 }
47 }59 }
@@ -50,7 +62,7 @@ func validBrokerSnapshot() BrokerSnapshot {
50 return BrokerSnapshot{62 return BrokerSnapshot{
51 Epoch: 3,63 Epoch: 3,
52 Revision: 9,64 Revision: 9,
53- Hosts: []RemoteHostSnapshot{testBrokerHost("user@arch")},65+ Daemons: []BrokerDaemonObservation{testBrokerObservation("user@arch")},
54 }66 }
55 }67 }
56 68
@@ -76,36 +88,45 @@ func TestValidateDurableHostProjection(t *testing.T) {
76 }88 }
77 tests := []struct {89 tests := []struct {
78 name string90 name string
79- mutate func(*RemoteHostSnapshot)91+ mutate func(*BrokerDaemonObservation)
80 wantErr bool92 wantErr bool
81 }{93 }{
82- {name: "empty inventory", mutate: func(*RemoteHostSnapshot) {}},94+ {name: "empty inventory", mutate: func(*BrokerDaemonObservation) {}},
83- {name: "valid inventory", mutate: func(h *RemoteHostSnapshot) { h.Sessions = []catalogue.RemoteCatalogSession{session} }},95+ {name: "valid inventory", mutate: func(o *BrokerDaemonObservation) {
84- {name: "zero availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = 0 }, wantErr: true},96+ o.InventoryKnown = true
85- {name: "unknown availability", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityUnknown }},97+ o.Sessions = []catalogue.RemoteCatalogSession{session}
86- {name: "availability past the closed range", mutate: func(h *RemoteHostSnapshot) { h.Availability = domain.RemoteAvailabilityInvalidResponse + 1 }, wantErr: true},98+ }},
87- {name: "failure kind past the closed range", mutate: func(h *RemoteHostSnapshot) { h.LastFailure.Kind = domain.RemoteFailureInvalidResponse + 1 }, wantErr: true},99+ {name: "local observation", mutate: func(o *BrokerDaemonObservation) {
88- {name: "missing lifecycle identity", mutate: func(h *RemoteHostSnapshot) {100+ *o = testLocalBrokerObservation()
89- h.Sessions = []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{}}}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{}}}
90 }, wantErr: true},109 }, wantErr: true},
91- {name: "absent tab list", mutate: func(h *RemoteHostSnapshot) {110+ {name: "absent tab list", mutate: func(o *BrokerDaemonObservation) {
92 broken := session111 broken := session
93 broken.Tabs = nil112 broken.Tabs = nil
94- h.Sessions = []catalogue.RemoteCatalogSession{broken}113+ o.InventoryKnown = true
114+ o.Sessions = []catalogue.RemoteCatalogSession{broken}
95 }, wantErr: true},115 }, wantErr: true},
96- {name: "sessions past the catalogue bound", mutate: func(h *RemoteHostSnapshot) {116+ {name: "sessions past the catalogue bound", mutate: func(o *BrokerDaemonObservation) {
97- h.Sessions = make([]catalogue.RemoteCatalogSession, catalogue.RemoteCatalogMaxSessions+1)117+ o.InventoryKnown = true
98- for i := range h.Sessions {118+ o.Sessions = make([]catalogue.RemoteCatalogSession, catalogue.RemoteCatalogMaxSessions+1)
99- h.Sessions[i] = session119+ for i := range o.Sessions {
120+ o.Sessions[i] = session
100 }121 }
101 }, wantErr: true},122 }, wantErr: true},
102 }123 }
103 for _, tc := range tests {124 for _, tc := range tests {
104 t.Run(tc.name, func(t *testing.T) {125 t.Run(tc.name, func(t *testing.T) {
105- host := testBrokerHost("user@arch")126+ obs := testBrokerObservation("user@arch")
106- host.LastSuccess = time.Unix(50, 0)127+ obs.LastSuccess = time.Unix(50, 0)
107- tc.mutate(&host)128+ tc.mutate(&obs)
108- if gotErr := ValidateDurableHostProjection(host); (gotErr != nil) != tc.wantErr {129+ if gotErr := ValidateDurableHostProjection(obs); (gotErr != nil) != tc.wantErr {
109 t.Fatalf("ValidateDurableHostProjection() = %v, wantErr %t", gotErr, tc.wantErr)130 t.Fatalf("ValidateDurableHostProjection() = %v, wantErr %t", gotErr, tc.wantErr)
110 }131 }
111 })132 })
@@ -121,21 +142,27 @@ func TestBrokerSnapshotValidateBounds(t *testing.T) {
121 {name: "valid", mutate: func(*BrokerSnapshot) {}, wantErr: false},142 {name: "valid", mutate: func(*BrokerSnapshot) {}, wantErr: false},
122 {name: "zero epoch", mutate: func(s *BrokerSnapshot) { s.Epoch = 0 }, wantErr: true},143 {name: "zero epoch", mutate: func(s *BrokerSnapshot) { s.Epoch = 0 }, wantErr: true},
123 {name: "zero revision", mutate: func(s *BrokerSnapshot) { s.Revision = 0 }, wantErr: true},144 {name: "zero revision", mutate: func(s *BrokerSnapshot) { s.Revision = 0 }, wantErr: true},
124- {name: "too many hosts", mutate: func(s *BrokerSnapshot) {145+ {name: "too many daemons", mutate: func(s *BrokerSnapshot) {
125- s.Hosts = make([]RemoteHostSnapshot, BrokerMaxHosts+1)146+ s.Daemons = make([]BrokerDaemonObservation, BrokerMaxDaemonsPerSnapshot+1)
126- for i := range s.Hosts {147+ for i := range s.Daemons {
127- s.Hosts[i] = testBrokerHost("user@arch")148+ s.Daemons[i] = testBrokerObservation("user@arch")
128- s.Hosts[i].Registration = testBrokerRegistration("user@arch", 1, 1)149+ s.Daemons[i].Registration = testBrokerRegistration("user@arch", 1, 1)
129 }150 }
130 }, wantErr: true},151 }, 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},
131 {name: "duplicate host", mutate: func(s *BrokerSnapshot) {158 {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"))
133 }, wantErr: true},160 }, wantErr: true},
134 {name: "endpoint registration mismatch", mutate: func(s *BrokerSnapshot) {161 {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)
136 }, wantErr: true},163 }, wantErr: true},
137 {name: "zero registration incarnation", mutate: func(s *BrokerSnapshot) {164 {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}
139 }, wantErr: true},166 }, wantErr: true},
140 {name: "live host as tombstone", mutate: func(s *BrokerSnapshot) {167 {name: "live host as tombstone", mutate: func(s *BrokerSnapshot) {
141 s.Removed = []BrokerHostTombstone{{168 s.Removed = []BrokerHostTombstone{{
@@ -228,7 +255,9 @@ func TestBrokerSnapshotEpochRevisionOrdering(t *testing.T) {
228 255
229 func TestBrokerSnapshotCloneAndFind(t *testing.T) {256 func TestBrokerSnapshotCloneAndFind(t *testing.T) {
230 original := validBrokerSnapshot()257 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{{
232 LifecycleID: testBrokerLifecycle(),261 LifecycleID: testBrokerLifecycle(),
233 Name: "work",262 Name: "work",
234 State: catalogue.RemoteCatalogSessionUp,263 State: catalogue.RemoteCatalogSessionUp,
@@ -241,9 +270,18 @@ func TestBrokerSnapshotCloneAndFind(t *testing.T) {
241 if _, ok := cloned.Find("user@mule"); ok {270 if _, ok := cloned.Find("user@mule"); ok {
242 t.Fatal("Find() must miss unknown endpoints")271 t.Fatal("Find() must miss unknown endpoints")
243 }272 }
244- cloned.Hosts[0].Sessions[0].Name = "mutated"273+ local := 0
245- cloned.Hosts[0].Sessions[0].Tabs[0].ID = "mutated"274+ for _, daemon := range cloned.Daemons {
246- if original.Hosts[0].Sessions[0].Name != "work" || original.Hosts[0].Sessions[0].Tabs[0].ID != "tab-1" {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" {
247 t.Fatal("Clone() shares memory with the original")285 t.Fatal("Clone() shares memory with the original")
248 }286 }
249 }287 }
@@ -352,6 +390,7 @@ func TestBrokerOpenStreamRequestValidate(t *testing.T) {
352 valid := func() BrokerOpenStreamRequest {390 valid := func() BrokerOpenStreamRequest {
353 return BrokerOpenStreamRequest{391 return BrokerOpenStreamRequest{
354 Epoch: 1, Purpose: BrokerStreamAttachment,392 Epoch: 1, Purpose: BrokerStreamAttachment,
393+ Admission: BrokerAdmissionExact,
355 Connection: BrokerConnectionID{1},394 Connection: BrokerConnectionID{1},
356 Stream: 2,395 Stream: 2,
357 Endpoint: "user@arch",396 Endpoint: "user@arch",
@@ -379,6 +418,26 @@ func TestBrokerOpenStreamRequestValidate(t *testing.T) {
379 {name: "bad session name", mutate: func(r *BrokerOpenStreamRequest) {418 {name: "bad session name", mutate: func(r *BrokerOpenStreamRequest) {
380 r.Target = protocol.ExactSessionTarget{LifecycleID: testBrokerLifecycle(), SessionName: "bad name!"}419 r.Target = protocol.ExactSessionTarget{LifecycleID: testBrokerLifecycle(), SessionName: "bad name!"}
381 }},420 }},
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+ }},
382 {name: "too many env entries", mutate: func(r *BrokerOpenStreamRequest) {441 {name: "too many env entries", mutate: func(r *BrokerOpenStreamRequest) {
383 r.Env = make([]string, BrokerMaxEnvEntries+1)442 r.Env = make([]string, BrokerMaxEnvEntries+1)
384 }},443 }},
@@ -546,24 +605,107 @@ func TestBrokerInterfacesArePorts(t *testing.T) {
546 }605 }
547 }606 }
548 607
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+
549 func TestBrokerStreamPurposeShapes(t *testing.T) {684 func TestBrokerStreamPurposeShapes(t *testing.T) {
550 for _, tc := range []struct {685 for _, tc := range []struct {
551- name string686+ name string
552- purpose BrokerStreamPurpose687+ purpose BrokerStreamPurpose
553- target bool688+ admission BrokerStreamAdmission
554- env bool689+ createName string
555- valid bool690+ target bool
691+ env bool
692+ valid bool
556 }{693 }{
557- {"attachment", BrokerStreamAttachment, true, true, true},694+ {"attachment exact", BrokerStreamAttachment, BrokerAdmissionExact, "", true, true, true},
558- {"attachment missing target", BrokerStreamAttachment, false, false, false},695+ {"attachment missing target", BrokerStreamAttachment, BrokerAdmissionExact, "", false, false, false},
559- {"control", BrokerStreamControl, false, false, true},696+ {"attachment missing admission", BrokerStreamAttachment, 0, "", true, false, false},
560- {"observation", BrokerStreamObservation, false, false, true},697+ {"attachment named creation", BrokerStreamAttachment, BrokerAdmissionCreateNamed, "work", false, true, true},
561- {"control target", BrokerStreamControl, true, false, false},698+ {"attachment ephemeral creation", BrokerStreamAttachment, BrokerAdmissionCreateEphemeral, "", false, true, true},
562- {"observation env", BrokerStreamObservation, false, true, false},699+ {"control", BrokerStreamControl, 0, "", false, false, true},
563- {"unknown", 0, false, false, false},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},
564 } {706 } {
565 t.Run(tc.name, func(t *testing.T) {707 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()}
567 if tc.target {709 if tc.target {
568 r.Target = testBrokerTarget()710 r.Target = testBrokerTarget()
569 }711 }
M internal/ports/mocks/broker_mock.go +8 -8
@@ -871,22 +871,22 @@ func (_m *MockBrokerHostProbe) EXPECT() *MockBrokerHostProbe_Expecter {
871 }871 }
872 872
873 // Probe provides a mock function for the type MockBrokerHostProbe873 // 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) {
875 ret := _mock.Called(ctx, registration)875 ret := _mock.Called(ctx, registration)
876 876
877 if len(ret) == 0 {877 if len(ret) == 0 {
878 panic("no return value specified for Probe")878 panic("no return value specified for Probe")
879 }879 }
880 880
881- var r0 ports.RemoteHostSnapshot881+ var r0 ports.BrokerDaemonObservation
882 var r1 error882 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 {
884 return returnFunc(ctx, registration)884 return returnFunc(ctx, registration)
885 }885 }
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 {
887 r0 = returnFunc(ctx, registration)887 r0 = returnFunc(ctx, registration)
888 } else {888 } else {
889- r0 = ret.Get(0).(ports.RemoteHostSnapshot)889+ r0 = ret.Get(0).(ports.BrokerDaemonObservation)
890 }890 }
891 if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration) error); ok {891 if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration) error); ok {
892 r1 = returnFunc(ctx, registration)892 r1 = returnFunc(ctx, registration)
@@ -926,12 +926,12 @@ func (_c *MockBrokerHostProbe_Probe_Call) Run(run func(ctx context.Context, regi
926 return _c926 return _c
927 }927 }
928 928
929-func (_c *MockBrokerHostProbe_Probe_Call) Return(remoteHostSnapshot ports.RemoteHostSnapshot, err error) *MockBrokerHostProbe_Probe_Call {929+func (_c *MockBrokerHostProbe_Probe_Call) Return(brokerDaemonObservation ports.BrokerDaemonObservation, err error) *MockBrokerHostProbe_Probe_Call {
930- _c.Call.Return(remoteHostSnapshot, err)930+ _c.Call.Return(brokerDaemonObservation, err)
931 return _c931 return _c
932 }932 }
933 933
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 {
935 _c.Call.Return(run)935 _c.Call.Return(run)
936 return _c936 return _c
937 }937 }
M internal/protocol/session.go +1 -1
@@ -16,7 +16,7 @@ import (
16 // daemonmux physical preamble conversation (multiplex.proto), whose16 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire17 // negotiated ceilings and accepted daemon binding ride the same wire
18 // version as the session and broker conversations.18 // version as the session and broker conversations.
19-const Version uint16 = 5419+const Version uint16 = 55
20 20
21 // HandshakeTimeout bounds every transport handshake from connect through the21 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local22 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(54), Version)13+ require.Equal(t, uint16(55), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
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 {
178 "registered": {Payload: &BrokerServerEnvelope_Registered{Registered: &Registered{Scope: brokerScope()}}},178 "registered": {Payload: &BrokerServerEnvelope_Registered{Registered: &Registered{Scope: brokerScope()}}},
179 "snapshot_part": {Payload: &BrokerServerEnvelope_SnapshotPart{SnapshotPart: &SnapshotPart{179 "snapshot_part": {Payload: &BrokerServerEnvelope_SnapshotPart{SnapshotPart: &SnapshotPart{
180 Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9,180 Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9,
181- Part: &SnapshotPart_Host{Host: &SnapshotHost{181+ Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{
182 HostIndex: 1, Endpoint: "dev@host:22", DisplayOrigin: "dev@host",182 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,
184 Checking: true,187 Checking: true,
185 LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1},188 LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1},
186 LastSuccess: &BrokerTimestamp{Seconds: 1700000001, Nanos: 2},189 LastSuccess: &BrokerTimestamp{Seconds: 1700000001, Nanos: 2},
@@ -249,11 +252,11 @@ func TestBrokerEnvelopeRoundTrip(t *testing.T) {
249 })252 })
250 }253 }
251 254
252- // The snapshot nested unions round-trip their non-host parts too, so255+ // The snapshot nested unions round-trip their non-daemon parts too, so
253 // every part tag is exercised.256 // every part tag is exercised.
254 for name, part := range map[string]*SnapshotPart{257 for name, part := range map[string]*SnapshotPart{
255- "begin": {Part: &SnapshotPart_Begin{Begin: &SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 3}}},258+ "begin": {Part: &SnapshotPart_Begin{Begin: &SnapshotBegin{HostCount: 1, SessionCount: 2, TombstoneCount: 3, LocalPresent: true}}},
256- "session": {Part: &SnapshotPart_Session{Session: &SnapshotSession{HostIndex: 1, SessionIndex: 2, Session: brokerCatalogSession()}}},259+ "session": {Part: &SnapshotPart_Session{Session: &SnapshotSession{HostIndex: 1, SessionIndex: 2, Session: brokerCatalogSession(), Local: true}}},
257 "tombstone": {Part: &SnapshotPart_Tombstone{Tombstone: &SnapshotTombstone{TombstoneIndex: 3, Registration: brokerRegistration(), RetiredRevision: 4}}},260 "tombstone": {Part: &SnapshotPart_Tombstone{Tombstone: &SnapshotTombstone{TombstoneIndex: 3, Registration: brokerRegistration(), RetiredRevision: 4}}},
258 "end": {Part: &SnapshotPart_End{End: &SnapshotEnd{}}},261 "end": {Part: &SnapshotPart_End{End: &SnapshotEnd{}}},
259 } {262 } {
@@ -318,9 +321,9 @@ func TestBrokerEnvelopeDuplicateAlternatives(t *testing.T) {
318 }321 }
319 322
320 // The nested snapshot part union is exclusive as well.323 // 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"}}})
322 end := mustMarshalBroker(t, &SnapshotPart{Part: &SnapshotPart_End{End: &SnapshotEnd{}}})325 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) {
324 t.Fatalf("duplicate snapshot part = %v, want ErrScanDuplicate", err)327 t.Fatalf("duplicate snapshot part = %v, want ErrScanDuplicate", err)
325 }328 }
326 }329 }
@@ -353,12 +356,12 @@ func TestBrokerEnvelopeRejectsUnknownAndWrongTypes(t *testing.T) {
353 }356 }
354 357
355 // Unknown field inside a nested snapshot part message.358 // Unknown field inside a nested snapshot part message.
356- part := protowire.AppendTag(nil, 11, protowire.BytesType) // SnapshotPart.host359+ part := protowire.AppendTag(nil, 15, protowire.BytesType) // SnapshotPart.daemon
357- host := protowire.AppendTag(nil, 2, protowire.BytesType) // SnapshotHost.endpoint360+ daemon := protowire.AppendTag(nil, 3, protowire.BytesType) // SnapshotDaemon.endpoint
358- host = protowire.AppendBytes(host, []byte("dev@host:22"))361+ daemon = protowire.AppendBytes(daemon, []byte("dev@host:22"))
359- host = protowire.AppendTag(host, 99, protowire.VarintType)362+ daemon = protowire.AppendTag(daemon, 99, protowire.VarintType)
360- host = protowire.AppendVarint(host, 1)363+ daemon = protowire.AppendVarint(daemon, 1)
361- part = protowire.AppendBytes(part, host)364+ part = protowire.AppendBytes(part, daemon)
362 raw = protowire.AppendTag(nil, 202, protowire.BytesType) // BrokerServerEnvelope.snapshot_part365 raw = protowire.AppendTag(nil, 202, protowire.BytesType) // BrokerServerEnvelope.snapshot_part
363 raw = protowire.AppendBytes(raw, part)366 raw = protowire.AppendBytes(raw, part)
364 if err := ScanEnvelope(&BrokerServerEnvelope{}, raw); !errors.Is(err, ErrScanUnknown) {367 if err := ScanEnvelope(&BrokerServerEnvelope{}, raw); !errors.Is(err, ErrScanUnknown) {
M internal/protocol/wire/envelope_inventory_test.go +2 -2
@@ -15,8 +15,8 @@ import (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 54 {18+ if protocol.Version != 55 {
19- t.Fatalf("protocol.Version = %d, want 54", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 55", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
M internal/protocol/wire/multiplex.pb.go +31 -9 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +56 -21
@@ -150,6 +150,11 @@ message Reconcile {
150 // OpenStream asks the broker to open one independently cancellable logical150 // OpenStream asks the broker to open one independently cancellable logical
151 // stream to the owning daemon. env is the per-request session environment151 // stream to the owning daemon. env is the per-request session environment
152 // and is never inherited from the broker process environment.152 // 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.
153 message OpenStream {158 message OpenStream {
154 BrokerStreamRef ref = 1;159 BrokerStreamRef ref = 1;
155 // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation.160 // Closed taxonomy: 1 = attachment, 2 = control, 3 = observation.
@@ -157,9 +162,16 @@ message OpenStream {
157 bool local = 3;162 bool local = 3;
158 string endpoint = 4;163 string endpoint = 4;
159 RemoteRegistration registration = 5;164 RemoteRegistration registration = 5;
165+ // Present only for exact attach/resume; the zero-value message is never
166+ // sent for a creation admission.
160 ExactTarget target = 6;167 ExactTarget target = 6;
161 repeated string env = 7;168 repeated string env = 7;
162 BrokerWirePolicy policy = 8;169 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;
163 }175 }
164 176
165 // ClientStreamData carries one opaque client-to-daemon stream frame.177 // ClientStreamData carries one opaque client-to-daemon stream frame.
@@ -188,7 +200,7 @@ message SnapshotPart {
188 uint32 index = 4;200 uint32 index = 4;
189 oneof part {201 oneof part {
190 SnapshotBegin begin = 10;202 SnapshotBegin begin = 10;
191- SnapshotHost host = 11;203+ SnapshotDaemon daemon = 15;
192 SnapshotSession session = 12;204 SnapshotSession session = 12;
193 SnapshotTombstone tombstone = 13;205 SnapshotTombstone tombstone = 13;
194 SnapshotEnd end = 14;206 SnapshotEnd end = 14;
@@ -196,40 +208,63 @@ message SnapshotPart {
196 }208 }
197 209
198 // SnapshotBegin opens a snapshot publication with its element counts.210 // 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.
199 message SnapshotBegin {214 message SnapshotBegin {
200 uint32 host_count = 1;215 uint32 host_count = 1;
201 uint32 session_count = 2;216 uint32 session_count = 2;
202 uint32 tombstone_count = 3;217 uint32 tombstone_count = 3;
218+ bool local_present = 4;
203 }219 }
204 220
205-// SnapshotHost is one host projection (ports.RemoteHostSnapshot). The221+// SnapshotDaemon is one daemon projection (ports.BrokerDaemonObservation),
206-// endpoint and display origin stay separate: the latter is222+// local or remote. Configured authority (endpoint, registration, policy,
207-// presentation-only. Sessions travel as separate SnapshotSession parts.223+// display origin, rank) stays separate from observed state (identity,
208-message SnapshotHost {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 {
209 uint32 host_index = 1;229 uint32 host_index = 1;
210- string endpoint = 2;230+ bool local = 2;
211- string display_origin = 3;231+ string endpoint = 3;
212- uint32 rank = 4;232+ string display_origin = 4;
213- RemoteRegistration registration = 5;233+ uint32 rank = 5;
214- // Closed availability taxonomy; 0 is the zero value.234+ RemoteRegistration registration = 6;
215- uint32 availability = 6;235+ BrokerWirePolicy policy = 7;
216- bool checking = 7;236+ // Authenticated daemon identity; empty until observed.
217- BrokerTimestamp last_attempt = 8;237+ string daemon_identity = 8;
218- BrokerTimestamp last_success = 9;238+ // Daemon process incarnation; exactly 16 bytes when an identity is
219- BrokerTimestamp next_due = 10;239+ // present and absent otherwise.
220- uint64 consecutive_failures = 11;240+ bytes daemon_incarnation = 9;
221- uint64 failure_episode = 12;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;
222 // Closed cause taxonomy (domain.RemoteFailureKind); 0 = none.254 // Closed cause taxonomy (domain.RemoteFailureKind); 0 = none.
223- uint32 failure_kind = 13;255+ uint32 failure_kind = 19;
224- bool inventory_known = 14;256+ bool inventory_known = 20;
225- uint32 session_count = 15;257+ uint32 session_count = 21;
226 }258 }
227 259
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.
229 message SnapshotSession {263 message SnapshotSession {
230 uint32 host_index = 1;264 uint32 host_index = 1;
231 uint32 session_index = 2;265 uint32 session_index = 2;
232 BrokerCatalogSession session = 3;266 BrokerCatalogSession session = 3;
267+ bool local = 4;
233 }268 }
234 269
235 // SnapshotTombstone fences one retired host registration so a stale probe270 // 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 {
51 ExactTarget target = 6;51 ExactTarget target = 6;
52 repeated string env = 7;52 repeated string env = 7;
53 BrokerWirePolicy policy = 8;53 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;
54 }59 }
55 60
56 // MuxOpened confirms one logical stream is established.61 // MuxOpened confirms one logical stream is established.
M internal/usecase/broker/registry.go +260 -51
@@ -8,10 +8,12 @@ import (
8 "hash/fnv"8 "hash/fnv"
9 "log/slog"9 "log/slog"
10 "reflect"10 "reflect"
11+ "slices"
11 "sort"12 "sort"
12 "sync"13 "sync"
13 "sync/atomic"14 "sync/atomic"
14 "time"15 "time"
16+ "unicode/utf8"
15 17
16 "github.com/bnema/vev/internal/domain"18 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"19 "github.com/bnema/vev/internal/ports"
@@ -79,7 +81,15 @@ type Registry struct {
79 // completion is fenced even when the endpoint is removed and re-added with81 // completion is fenced even when the endpoint is removed and re-added with
80 // the identical registration.82 // the identical registration.
81 attempts uint6483 attempts uint64
82- hosts map[string]ports.RemoteHostSnapshot84+ // 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
83 // inflight records the one admitted attempt per endpoint. The record93 // inflight records the one admitted attempt per endpoint. The record
84 // carries the attempt's derived context cancel so a retired attempt is94 // carries the attempt's derived context cancel so a retired attempt is
85 // released the moment its registration is replaced, removed, or the run95 // released the moment its registration is replaced, removed, or the run
@@ -128,7 +138,7 @@ func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore,
128 r := &Registry{138 r := &Registry{
129 epoch: epoch, probe: probe, clock: clock, log: log,139 epoch: epoch, probe: probe, clock: clock, log: log,
130 observationDisabled: cfg.ObservationDisabled,140 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),
132 pending: make(map[string]bool), tombstones: make(map[string]ports.BrokerHostTombstone),142 pending: make(map[string]bool), tombstones: make(map[string]ports.BrokerHostTombstone),
133 subs: make(map[*subscription]struct{}), wake: make(chan struct{}, 1),143 subs: make(map[*subscription]struct{}), wake: make(chan struct{}, 1),
134 freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit,144 freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit,
@@ -183,19 +193,87 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error {
183 if err := hosts.Validate(); err != nil {193 if err := hosts.Validate(); err != nil {
184 return err194 return err
185 }195 }
186- next := make(map[string]ports.RemoteHostSnapshot, len(hosts.Hosts))196+ next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts))
187- for _, record := range hosts.Hosts {197+ order := make([]string, 0, len(hosts.Hosts))
198+ for rank, record := range hosts.Hosts {
188 registration := record.Registration199 registration := record.Registration
189 restored, present := r.hosts[registration.Endpoint]200 restored, present := r.hosts[registration.Endpoint]
190 if !present || !restored.Registration.Equal(registration) {201 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}
192 }203 }
193- next[registration.Endpoint] = restored204+ 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)
194 }213 }
195 r.hosts = next214 r.hosts = next
215+ r.order = order
196 return nil216 return nil
197 }217 }
198 218
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+
199 // restore adopts a validated durable snapshot. A zero-epoch snapshot is only277 // restore adopts a validated durable snapshot. A zero-epoch snapshot is only
200 // the empty placeholder a store returns before its first write: any record278 // the empty placeholder a store returns before its first write: any record
201 // without an epoch is rejected instead of silently dropped. A persisted279 // without an epoch is rejected instead of silently dropped. A persisted
@@ -207,7 +285,7 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error {
207 // fencing state and never survive a restart.285 // fencing state and never survive a restart.
208 func (r *Registry) restore(snapshot ports.BrokerSnapshot) error {286 func (r *Registry) restore(snapshot ports.BrokerSnapshot) error {
209 if snapshot.Epoch == 0 {287 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 {
211 return errors.New("broker: persisted snapshot has no epoch")289 return errors.New("broker: persisted snapshot has no epoch")
212 }290 }
213 return nil291 return nil
@@ -215,17 +293,78 @@ func (r *Registry) restore(snapshot ports.BrokerSnapshot) error {
215 if snapshot.Epoch == r.epoch {293 if snapshot.Epoch == r.epoch {
216 return errors.New("broker: persisted snapshot epoch matches the new registry epoch")294 return errors.New("broker: persisted snapshot epoch matches the new registry epoch")
217 }295 }
218- if err := snapshot.Validate(); err != nil {296+ if err := validateRestoredSnapshot(snapshot); err != nil {
219 return err297 return err
220 }298 }
221- for _, host := range snapshot.Hosts {299+ for _, daemon := range snapshot.Daemons {
222- host = host.Clone()300+ daemon = daemon.Clone()
223- host.Checking = false301+ daemon.Checking = false
224- r.hosts[host.Endpoint] = host302+ r.hosts[daemon.Endpoint] = daemon
225 }303 }
226 return nil304 return nil
227 }305 }
228 306
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+
229 // Snapshot returns a defensive immutable copy without I/O.368 // Snapshot returns a defensive immutable copy without I/O.
230 func (r *Registry) Snapshot() ports.BrokerSnapshot { return r.current.Load().Clone() }369 func (r *Registry) Snapshot() ports.BrokerSnapshot { return r.current.Load().Clone() }
231 370
@@ -262,10 +401,12 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
262 if err := ports.ValidateBrokerHostRecords(records); err != nil {401 if err := ports.ValidateBrokerHostRecords(records); err != nil {
263 return err402 return err
264 }403 }
265- next := make(map[string]ports.RemoteHostSnapshot, len(records))404+ next := make(map[string]ports.BrokerDaemonObservation, len(records))
266- for _, record := range records {405+ order := make([]string, 0, len(records))
406+ for rank, record := range records {
267 reg := record.Registration407 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)
269 }410 }
270 r.mu.Lock()411 r.mu.Lock()
271 defer r.mu.Unlock()412 defer r.mu.Unlock()
@@ -279,7 +420,7 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
279 return fmt.Errorf("broker: replace hosts: %w", err)420 return fmt.Errorf("broker: replace hosts: %w", err)
280 }421 }
281 r.authority = ports.BrokerHosts{Revision: r.authority.Revision + 1, Hosts: records}422 r.authority = ports.BrokerHosts{Revision: r.authority.Revision + 1, Hosts: records}
282- r.setHostsLocked(next)423+ r.setHostsLocked(next, order)
283 return nil424 return nil
284 }425 }
285 426
@@ -291,19 +432,33 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
291 // Removed registrations retire bounded tombstones so a stale completion can432 // Removed registrations retire bounded tombstones so a stale completion can
292 // never revive them; replaced or re-added endpoints are fenced by the433 // never revive them; replaced or re-added endpoints are fenced by the
293 // registry attempt token instead.434 // registry attempt token instead.
294-func (r *Registry) setHosts(registrations []domain.RemoteRegistration) error {435+func (r *Registry) setHosts(records []ports.BrokerHostRecord) error {
295- if len(registrations) > ports.BrokerMaxHosts {436+ if len(records) > ports.BrokerMaxHosts {
296- return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(registrations), ports.BrokerMaxHosts)437+ return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts)
297- }438+ }
298- next := make(map[string]ports.RemoteHostSnapshot, len(registrations))439+ next := make(map[string]ports.BrokerDaemonObservation, len(records))
299- for _, registration := range registrations {440+ order := make([]string, 0, len(records))
300- if err := registration.Validate(); err != nil {441+ for rank, record := range records {
442+ if err := record.Registration.Validate(); err != nil {
301 return err443 return err
302 }444 }
445+ if err := record.Policy.Validate(); err != nil {
446+ return err
447+ }
448+ registration := record.Registration
303 if _, duplicate := next[registration.Endpoint]; duplicate {449 if _, duplicate := next[registration.Endpoint]; duplicate {
304 return errors.New("broker: duplicate host registration")450 return errors.New("broker: duplicate host registration")
305 }451 }
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)
307 }462 }
308 463
309 r.mu.Lock()464 r.mu.Lock()
@@ -311,21 +466,27 @@ func (r *Registry) setHosts(registrations []domain.RemoteRegistration) error {
311 if r.revisionExhaustedLocked() {466 if r.revisionExhaustedLocked() {
312 return fmt.Errorf("broker: set hosts: %w", ports.ErrBrokerRevisionExhausted)467 return fmt.Errorf("broker: set hosts: %w", ports.ErrBrokerRevisionExhausted)
313 }468 }
314- r.setHostsLocked(next)469+ r.setHostsLocked(next, order)
315 return nil470 return nil
316 }471 }
317 472
318 // setHostsLocked applies only a projection; the public path commits authority473 // setHostsLocked applies only a projection; the public path commits authority
319 // first. The caller holds mu through both steps, fencing probe completions.474 // first. The caller holds mu through both steps, fencing probe completions.
320-func (r *Registry) setHostsLocked(next map[string]ports.RemoteHostSnapshot) {475+// order is the registration order the projection was built from; it becomes
321- if registrationsUnchanged(r.hosts, next) {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) {
322 return479 return
323 }480 }
324 for endpoint, current := range r.hosts {481 for endpoint, current := range r.hosts {
325 candidate, present := next[endpoint]482 candidate, present := next[endpoint]
326- if present && current.Registration.Equal(candidate.Registration) {483+ if present && current.Registration.Equal(candidate.Registration) && current.Policy == candidate.Policy {
327- // Unchanged registration: keep the live observation as is.484+ // Unchanged authority: keep the live observation as is. Rank is the
328- next[endpoint] = current485+ // 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
329 continue490 continue
330 }491 }
331 // A retired registration owns no observation: cancelling its attempt492 // A retired registration owns no observation: cancelling its attempt
@@ -351,6 +512,7 @@ func (r *Registry) setHostsLocked(next map[string]ports.RemoteHostSnapshot) {
351 delete(r.tombstones, endpoint)512 delete(r.tombstones, endpoint)
352 }513 }
353 r.hosts = next514 r.hosts = next
515+ r.order = order
354 r.publishLocked(true)516 r.publishLocked(true)
355 r.hint()517 r.hint()
356 }518 }
@@ -480,7 +642,7 @@ type probeResult struct {
480 endpoint string642 endpoint string
481 registration domain.RemoteRegistration643 registration domain.RemoteRegistration
482 attempt uint64644 attempt uint64
483- snapshot ports.RemoteHostSnapshot645+ snapshot ports.BrokerDaemonObservation
484 err error646 err error
485 at time.Time647 at time.Time
486 }648 }
@@ -547,9 +709,10 @@ func (r *Registry) apply(result probeResult) {
547 709
548 kind, availability, failed := observationOutcome(result)710 kind, availability, failed := observationOutcome(result)
549 if !failed {711 if !failed {
550- observed := result.snapshot.Clone()712+ // Configured authority comes only from the registry's own projection:
551- observed.Endpoint = result.endpoint713+ // a probe result supplies observed state, never endpoint, registration,
552- observed.Registration = result.registration714+ // policy, display origin, or rank.
715+ observed := stampObservationAuthority(result.snapshot.Clone(), current)
553 observed.Checking = false716 observed.Checking = false
554 observed.LastAttempt = result.at717 observed.LastAttempt = result.at
555 observed.LastSuccess = result.at718 observed.LastSuccess = result.at
@@ -557,10 +720,16 @@ func (r *Registry) apply(result probeResult) {
557 observed.ConsecutiveFailures = 0720 observed.ConsecutiveFailures = 0
558 observed.LastFailure = domain.RemoteFailure{}721 observed.LastFailure = domain.RemoteFailure{}
559 observed.FailureEpisode = current.FailureEpisode722 observed.FailureEpisode = current.FailureEpisode
560- // A projection the durable store would reject is an invalid response,723+ // The published observation must be fully valid once authority is
561- // not an observation: publishing it would leave the registry valid but724+ // stamped: a probe that invents partial identity (identity without a
562- // permanently unpersisted. The store applies the same rule.725+ // protocol version) or an inventory without a known catalogue is an
563- if err := ports.ValidateDurableHostProjection(observed); err != nil {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 {
564 r.log.Debug("broker: observation is not persistable", "endpoint", result.endpoint, "err", err)733 r.log.Debug("broker: observation is not persistable", "endpoint", result.endpoint, "err", err)
565 kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true734 kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
566 } else {735 } else {
@@ -687,16 +856,23 @@ func (r *Registry) nextDelay(now time.Time) time.Duration {
687 return 0856 return 0
688 }857 }
689 858
690-// registrationsUnchanged reports exact membership equality: the same859+// registrationsUnchanged reports exact membership equality: the same endpoints
691-// endpoints bound to the same registration identities. Projection fields such860+// in the same registration order, bound to the same registration identities and
692-// as availability, checking, or inventory never participate.861+// policy. Publication order and presentation rank are functions of durable
693-func registrationsUnchanged(current, next map[string]ports.RemoteHostSnapshot) bool {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 {
694 if len(current) != len(next) {867 if len(current) != len(next) {
695 return false868 return false
696 }869 }
870+ if !slices.Equal(currentOrder, nextOrder) {
871+ return false
872+ }
697 for endpoint, candidate := range next {873 for endpoint, candidate := range next {
698 existing, ok := current[endpoint]874 existing, ok := current[endpoint]
699- if !ok || !existing.Registration.Equal(candidate.Registration) {875+ if !ok || !existing.Registration.Equal(candidate.Registration) || existing.Policy != candidate.Policy {
700 return false876 return false
701 }877 }
702 }878 }
@@ -782,13 +958,28 @@ func (r *Registry) publishLocked(persist bool) {
782 if r.revisionExhaustedLocked() {958 if r.revisionExhaustedLocked() {
783 return959 return
784 }960 }
785- r.revision++961+ // Remote daemons are published in registration order. The local daemon,
786- hosts := make([]ports.RemoteHostSnapshot, 0, len(r.hosts))962+ // when a producer exists (Plan 001 P5.3a), is prepended ahead of these;
787- for _, host := range r.hosts {963+ // the registry produces no local observation yet and never invents one.
788- hosts = append(hosts, host.Clone())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())
789 }971 }
790- sort.Slice(hosts, func(i, j int) bool { return hosts[i].Endpoint < hosts[j].Endpoint })972+ snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision + 1, Daemons: daemons, Removed: r.tombstonesLocked()}
791- snapshot := ports.BrokerSnapshot{Epoch: r.epoch, Revision: r.revision, Hosts: hosts, 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
792 r.current.Store(&snapshot)983 r.current.Store(&snapshot)
793 if persist {984 if persist {
794 // enqueue only stages an immutable publication: rendering the durable985 // enqueue only stages an immutable publication: rendering the durable
@@ -807,12 +998,30 @@ func (r *Registry) publishLocked(persist bool) {
807 // they retire registrations only for this process lifetime and a restart never998 // they retire registrations only for this process lifetime and a restart never
808 // adopts them, so persisting them would leak stale authority into durable999 // adopts them, so persisting them would leak stale authority into durable
809 // state. Removed is therefore cleared before persistence.1000 // 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.
810 func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {1011 func durable(snapshot ports.BrokerSnapshot) ports.BrokerSnapshot {
811 out := snapshot.Clone()1012 out := snapshot.Clone()
812 out.Removed = nil1013 out.Removed = nil
813- for i := range out.Hosts {1014+ daemons := out.Daemons[:0]
814- out.Hosts[i].Checking = false1015+ 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)
815 }1023 }
1024+ out.Daemons = daemons
816 return out1025 return out
817 }1026 }
818 1027
M internal/usecase/broker/registry_durable_test.go +68 -6
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
12 portsmocks "github.com/bnema/vev/internal/ports/mocks"12 portsmocks "github.com/bnema/vev/internal/ports/mocks"
13+ "github.com/bnema/vev/internal/protocol"
13 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
14 )15 )
15 16
@@ -38,22 +39,22 @@ func TestRegistryDurableReplaceHostsRestart(t *testing.T) {
38 require.NoError(t, err)39 require.NoError(t, err)
39 defer r.settle()40 defer r.settle()
40 require.Equal(t, previous, r.authority.Hosts)41 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))
42 if len(previous) > 0 {43 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")
44 }45 }
45 require.NoError(t, r.ReplaceHosts(stage.records))46 require.NoError(t, r.ReplaceHosts(stage.records))
46 authority, err := store.LoadHosts()47 authority, err := store.LoadHosts()
47 require.NoError(t, err)48 require.NoError(t, err)
48 require.Equal(t, r.authority, authority)49 require.Equal(t, r.authority, authority)
49 require.Equal(t, stage.records, authority.Hosts)50 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))
51 if len(stage.records) > 0 {52 if len(stage.records) > 0 {
52 reg := stage.records[0].Registration53 reg := stage.records[0].Registration
53 ctx, cancel := context.WithCancel(context.Background())54 ctx, cancel := context.WithCancel(context.Background())
54 defer cancel()55 defer cancel()
55 r.inflight[reg.Endpoint] = &probeAttempt{token: 1, cancel: cancel}56 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}})
57 require.ErrorIs(t, ctx.Err(), context.Canceled)58 require.ErrorIs(t, ctx.Err(), context.Canceled)
58 }59 }
59 r.settle() // flush before inspecting persisted projection60 r.settle() // flush before inspecting persisted projection
@@ -121,7 +122,7 @@ func TestRegistryReplaceHostsAfterShutdownIsTyped(t *testing.T) {
121 store := newTestStore()122 store := newTestStore()
122 r := newTestRegistry(t, 1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)))123 r := newTestRegistry(t, 1, store, newTestProbe(1), newManualClock(time.Unix(100, 0)))
123 record := ports.BrokerHostRecord{Registration: registration(t, "host", 1), Pinned: true, Policy: poolPolicy()}124 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)))
125 r.settle()126 r.settle()
126 127
127 before := r.Snapshot()128 before := r.Snapshot()
@@ -150,7 +151,7 @@ func TestRegistryDurableChurnFlushesLatestMembership(t *testing.T) {
150 record.Registration.Generation++151 record.Registration.Generation++
151 record.Policy.Trust = "replacement-trust"152 record.Policy.Trust = "replacement-trust"
152 require.NoError(t, r.ReplaceHosts([]ports.BrokerHostRecord{record}))153 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)
154 if i < 20 {155 if i < 20 {
155 require.NoError(t, r.ReplaceHosts(nil))156 require.NoError(t, r.ReplaceHosts(nil))
156 }157 }
@@ -163,3 +164,64 @@ func TestRegistryDurableChurnFlushesLatestMembership(t *testing.T) {
163 require.NoError(t, err)164 require.NoError(t, err)
164 require.Equal(t, r.authority, authority)165 require.Equal(t, r.authority, authority)
165 }166 }
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) {
17 old := registration(t, "host", 1)17 old := registration(t, "host", 1)
18 current := old18 current := old
19 store := portsmocks.NewMockBrokerHostStore(t)19 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}}}
21 hosts := ports.BrokerHosts{Revision: 4}21 hosts := ports.BrokerHosts{Revision: 4}
22 if mode == "replaced" {22 if mode == "replaced" {
23 current.Generation++23 current.Generation++
@@ -36,12 +36,12 @@ func TestRegistryRestoresAuthoritativeMembership(t *testing.T) {
36 require.Equal(t, ports.BrokerEpoch(2), got.Epoch)36 require.Equal(t, ports.BrokerEpoch(2), got.Epoch)
37 require.Equal(t, ports.BrokerRevision(1), got.Revision)37 require.Equal(t, ports.BrokerRevision(1), got.Revision)
38 if mode == "removed" {38 if mode == "removed" {
39- require.Empty(t, got.Hosts)39+ require.Empty(t, got.Daemons)
40 return40 return
41 }41 }
42- require.Len(t, got.Hosts, 1)42+ require.Len(t, got.Daemons, 1)
43- require.Equal(t, current, got.Hosts[0].Registration)43+ require.Equal(t, current, got.Daemons[0].Registration)
44- require.Equal(t, mode == "matching", got.Hosts[0].InventoryKnown)44+ require.Equal(t, mode == "matching", got.Daemons[0].InventoryKnown)
45 })45 })
46 }46 }
47 }47 }
@@ -98,7 +98,7 @@ func TestRegistryRestoreIsReadOnlyAndPublicationLocal(t *testing.T) {
98 clock := newManualClock(time.Unix(100, 0))98 clock := newManualClock(time.Unix(100, 0))
99 reg := registration(t, "host", 1)99 reg := registration(t, "host", 1)
100 store := portsmocks.NewMockBrokerHostStore(t)100 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{{
102 Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true,102 Endpoint: reg.Endpoint, Registration: reg, Availability: domain.RemoteAvailabilityReachable, InventoryKnown: true,
103 }}}, nil).Once()103 }}}, nil).Once()
104 store.EXPECT().LoadHosts().Return(ports.BrokerHosts{Revision: 99, Hosts: []ports.BrokerHostRecord{{Registration: reg, Pinned: true, Policy: poolPolicy()}}}, nil).Once()104 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) {
110 require.Equal(t, ports.BrokerEpoch(6), snapshot.Epoch)110 require.Equal(t, ports.BrokerEpoch(6), snapshot.Epoch)
111 // Restoring reuses the matching observation but never republishes it: a111 // Restoring reuses the matching observation but never republishes it: a
112 // fresh process adopts authority and rewrites only when it observes.112 // fresh process adopts authority and rewrites only when it observes.
113- require.Len(t, snapshot.Hosts, 1)113+ require.Len(t, snapshot.Daemons, 1)
114- require.True(t, snapshot.Hosts[0].InventoryKnown)114+ require.True(t, snapshot.Daemons[0].InventoryKnown)
115- require.Equal(t, domain.RemoteAvailabilityReachable, snapshot.Hosts[0].Availability)115+ require.Equal(t, domain.RemoteAvailabilityReachable, snapshot.Daemons[0].Availability)
116 }116 }
M internal/usecase/broker/registry_test.go +464 -178
@@ -12,9 +12,11 @@ import (
12 "sync"12 "sync"
13 "testing"13 "testing"
14 "time"14 "time"
15+ "unicode/utf8"
15 16
16 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
19+ "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/catalogue"20 "github.com/bnema/vev/internal/protocol/catalogue"
19 "github.com/stretchr/testify/require"21 "github.com/stretchr/testify/require"
20 )22 )
@@ -202,7 +204,7 @@ func (s *testStore) LoadHosts() (ports.BrokerHosts, error) {
202 return s.hosts, nil204 return s.hosts, nil
203 }205 }
204 authority := ports.BrokerHosts{Revision: 1}206 authority := ports.BrokerHosts{Revision: 1}
205- for _, host := range s.loaded.Hosts {207+ for _, host := range s.loaded.Daemons {
206 authority.Hosts = append(authority.Hosts, ports.BrokerHostRecord{Registration: host.Registration, Pinned: true, Policy: poolPolicy()})208 authority.Hosts = append(authority.Hosts, ports.BrokerHostRecord{Registration: host.Registration, Pinned: true, Policy: poolPolicy()})
207 }209 }
208 return authority, nil210 return authority, nil
@@ -368,24 +370,24 @@ type probeCall struct {
368 }370 }
369 371
370 type probeAnswer struct {372 type probeAnswer struct {
371- snapshot ports.RemoteHostSnapshot373+ snapshot ports.BrokerDaemonObservation
372 err error374 err error
373 }375 }
374 376
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) {
376 call := probeCall{registration: registration, result: make(chan probeAnswer, 1)}378 call := probeCall{registration: registration, result: make(chan probeAnswer, 1)}
377 select {379 select {
378 case p.calls <- call:380 case p.calls <- call:
379 case <-ctx.Done():381 case <-ctx.Done():
380 p.noteCanceled()382 p.noteCanceled()
381- return ports.RemoteHostSnapshot{}, ctx.Err()383+ return ports.BrokerDaemonObservation{}, ctx.Err()
382 }384 }
383 select {385 select {
384 case answer := <-call.result:386 case answer := <-call.result:
385 return answer.snapshot, answer.err387 return answer.snapshot, answer.err
386 case <-ctx.Done():388 case <-ctx.Done():
387 p.noteCanceled()389 p.noteCanceled()
388- return ports.RemoteHostSnapshot{}, ctx.Err()390+ return ports.BrokerDaemonObservation{}, ctx.Err()
389 }391 }
390 }392 }
391 393
@@ -420,7 +422,7 @@ func newChurnProbe() *churnProbe {
420 }422 }
421 }423 }
422 424
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) {
424 p.mu.Lock()426 p.mu.Lock()
425 live := 0427 live := 0
426 for other, endpoint := range p.contexts {428 for other, endpoint := range p.contexts {
@@ -454,7 +456,7 @@ func (p *churnProbe) Probe(ctx context.Context, registration domain.RemoteRegist
454 case <-ctx.Done():456 case <-ctx.Done():
455 case <-p.release:457 case <-p.release:
456 }458 }
457- return ports.RemoteHostSnapshot{}, ctx.Err()459+ return ports.BrokerDaemonObservation{}, ctx.Err()
458 }460 }
459 461
460 // openRelease lets every still-blocked probe return, so a failing test never462 // 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
499 return result501 return result
500 }502 }
501 503
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+
502 // hostSession builds one catalogue-valid session. The registry applies the519 // hostSession builds one catalogue-valid session. The registry applies the
503 // same durable projection rules as the store before adopting an observation, so520 // same durable projection rules as the store before adopting an observation, so
504 // a fixture session must carry a non-zero lifecycle identity and a non-nil tab521 // 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
590 }607 }
591 }608 }
592 609
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 {
594 t.Helper()611 t.Helper()
595 snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool {612 snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool {
596 host, ok := snapshot.Find(endpoint)613 host, ok := snapshot.Find(endpoint)
@@ -603,7 +620,7 @@ func waitHost(t *testing.T, r *Registry, endpoint string, predicate func(ports.R
603 // requireHostStableWithin asserts a stale completion has no delayed effect:620 // requireHostStableWithin asserts a stale completion has no delayed effect:
604 // the published host stays exactly as observed for a bounded window, and any621 // the published host stays exactly as observed for a bounded window, and any
605 // intervening publication fails immediately.622 // 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) {
607 t.Helper()624 t.Helper()
608 sub := r.Subscribe()625 sub := r.Subscribe()
609 defer sub.Close()626 defer sub.Close()
@@ -682,14 +699,15 @@ func attemptFor(r *Registry, endpoint string) uint64 {
682 func TestRegistryRestoresPersistedSnapshotUnderFreshEpoch(t *testing.T) {699 func TestRegistryRestoresPersistedSnapshotUnderFreshEpoch(t *testing.T) {
683 reg := registration(t, "example.test", 1)700 reg := registration(t, "example.test", 1)
684 sessions := []catalogue.RemoteCatalogSession{{701 sessions := []catalogue.RemoteCatalogSession{{
685- Name: "work",702+ LifecycleID: domain.SessionLifecycleID{1},
686- State: catalogue.RemoteCatalogSessionUp,703+ Name: "work",
687- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},704+ State: catalogue.RemoteCatalogSessionUp,
705+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},
688 }}706 }}
689 stored := ports.BrokerSnapshot{707 stored := ports.BrokerSnapshot{
690 Epoch: 7,708 Epoch: 7,
691 Revision: 9,709 Revision: 9,
692- Hosts: []ports.RemoteHostSnapshot{{710+ Daemons: []ports.BrokerDaemonObservation{{
693 Endpoint: reg.Endpoint,711 Endpoint: reg.Endpoint,
694 Registration: reg,712 Registration: reg,
695 Availability: domain.RemoteAvailabilityReachable,713 Availability: domain.RemoteAvailabilityReachable,
@@ -709,10 +727,10 @@ func TestRegistryRestoresPersistedSnapshotUnderFreshEpoch(t *testing.T) {
709 // A restored broker process starts a fresh revision series: revisions727 // A restored broker process starts a fresh revision series: revisions
710 // never compare across epochs.728 // never compare across epochs.
711 require.Equal(t, ports.BrokerRevision(1), snapshot.Revision)729 require.Equal(t, ports.BrokerRevision(1), snapshot.Revision)
712- require.Len(t, snapshot.Hosts, 1)730+ require.Len(t, snapshot.Daemons, 1)
713 require.Empty(t, snapshot.Removed)731 require.Empty(t, snapshot.Removed)
714 732
715- host := snapshot.Hosts[0]733+ host := snapshot.Daemons[0]
716 require.True(t, host.Registration.Equal(reg))734 require.True(t, host.Registration.Equal(reg))
717 require.Equal(t, domain.RemoteAvailabilityReachable, host.Availability)735 require.Equal(t, domain.RemoteAvailabilityReachable, host.Availability)
718 require.Equal(t, time.Unix(80, 0), host.NextDue)736 require.Equal(t, time.Unix(80, 0), host.NextDue)
@@ -753,7 +771,7 @@ func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) {
753 require.Error(t, err)771 require.Error(t, err)
754 772
755 // A durable record without an epoch is inconsistent, never silently empty.773 // 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)
757 require.Error(t, err)775 require.Error(t, err)
758 _, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Removed: []ports.BrokerHostTombstone{{Endpoint: reg.Endpoint, Registration: reg, RetiredRevision: 3}}}}, newTestProbe(1), clock, nil)776 _, err = NewRegistry(1, &testStore{loaded: ports.BrokerSnapshot{Epoch: 0, Removed: []ports.BrokerHostTombstone{{Endpoint: reg.Endpoint, Registration: reg, RetiredRevision: 3}}}}, newTestProbe(1), clock, nil)
759 require.Error(t, err)777 require.Error(t, err)
@@ -763,7 +781,7 @@ func TestRegistryRestoreRejectsInvalidInputs(t *testing.T) {
763 empty, err := NewRegistry(1, &testStore{}, newTestProbe(1), clock, nil)781 empty, err := NewRegistry(1, &testStore{}, newTestProbe(1), clock, nil)
764 require.NoError(t, err)782 require.NoError(t, err)
765 require.Equal(t, ports.BrokerRevision(1), empty.Snapshot().Revision)783 require.Equal(t, ports.BrokerRevision(1), empty.Snapshot().Revision)
766- require.Empty(t, empty.Snapshot().Hosts)784+ require.Empty(t, empty.Snapshot().Daemons)
767 }785 }
768 786
769 func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) {787 func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) {
@@ -777,7 +795,7 @@ func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) {
777 currentEpoch := ports.BrokerSnapshot{795 currentEpoch := ports.BrokerSnapshot{
778 Epoch: 7,796 Epoch: 7,
779 Revision: 9,797 Revision: 9,
780- Hosts: []ports.RemoteHostSnapshot{{798+ Daemons: []ports.BrokerDaemonObservation{{
781 Endpoint: reg.Endpoint,799 Endpoint: reg.Endpoint,
782 Registration: reg,800 Registration: reg,
783 Availability: domain.RemoteAvailabilityReachable,801 Availability: domain.RemoteAvailabilityReachable,
@@ -799,26 +817,26 @@ func TestRegistryRestoreRejectsPersistedSnapshotFromItsOwnEpoch(t *testing.T) {
799 // A different epoch is still adopted normally.817 // A different epoch is still adopted normally.
800 registry, err := NewRegistry(8, &testStore{loaded: currentEpoch}, newTestProbe(1), clock, nil)818 registry, err := NewRegistry(8, &testStore{loaded: currentEpoch}, newTestProbe(1), clock, nil)
801 require.NoError(t, err)819 require.NoError(t, err)
802- require.Len(t, registry.Snapshot().Hosts, 1)820+ require.Len(t, registry.Snapshot().Daemons, 1)
803 }821 }
804 822
805 func TestRegistrySetHostsRejectsInvalidRegistrations(t *testing.T) {823 func TestRegistrySetHostsRejectsInvalidRegistrations(t *testing.T) {
806 registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))824 registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
807 825
808 reg := registration(t, "example.test", 1)826 reg := registration(t, "example.test", 1)
809- require.Error(t, registry.setHosts([]domain.RemoteRegistration{reg, reg}))827+ require.Error(t, registry.setHosts(hostRecords(reg, reg)))
810- require.Error(t, registry.setHosts([]domain.RemoteRegistration{{Endpoint: reg.Endpoint}}))828+ require.Error(t, registry.setHosts(hostRecords(domain.RemoteRegistration{Endpoint: reg.Endpoint})))
811- require.Empty(t, registry.Snapshot().Hosts)829+ require.Empty(t, registry.Snapshot().Daemons)
812 }830 }
813 831
814 func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) {832 func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) {
815 registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))833 registry := newTestRegistry(t, 1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)))
816 reg := registration(t, "example.test", 1)834 reg := registration(t, "example.test", 1)
817- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))835+ require.NoError(t, registry.setHosts(hostRecords(reg)))
818 836
819- oversized := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts+1)837+ oversized := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts+1)
820 for index := 0; index <= ports.BrokerMaxHosts; index++ {838 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))))
822 }840 }
823 before := registry.Snapshot()841 before := registry.Snapshot()
824 require.Error(t, registry.setHosts(oversized))842 require.Error(t, registry.setHosts(oversized))
@@ -827,16 +845,16 @@ func TestRegistrySetHostsRejectsHostLimitWithoutMutation(t *testing.T) {
827 845
828 // The boundary itself is accepted.846 // The boundary itself is accepted.
829 require.NoError(t, registry.setHosts(oversized[:ports.BrokerMaxHosts]))847 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)
831 }849 }
832 850
833-func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) {851+func TestRegistryUnchangedSetHostsIsNoOpAndReorderRepublishes(t *testing.T) {
834 clock := newManualClock(time.Unix(100, 0))852 clock := newManualClock(time.Unix(100, 0))
835 probe := newTestProbe(2)853 probe := newTestProbe(2)
836 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)854 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
837 first := registration(t, "example.test", 1)855 first := registration(t, "example.test", 1)
838 second := registration(t, "other.test", 2)856 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)))
840 startRegistry(t, registry)858 startRegistry(t, registry)
841 859
842 // Every configured host is observed exactly once.860 // Every configured host is observed exactly once.
@@ -847,37 +865,128 @@ func TestRegistryUnchangedSetHostsIsNoOp(t *testing.T) {
847 }865 }
848 require.Len(t, calls, 2)866 require.Len(t, calls, 2)
849 before := registry.Snapshot()867 before := registry.Snapshot()
850- for _, host := range before.Hosts {868+ for _, host := range before.Daemons {
851 require.True(t, host.Checking)869 require.True(t, host.Checking)
852 }870 }
853 871
854- // Identical membership, including in a different order, never republishes872+ // Identical membership in the same order never republishes and never
855- // and never restarts an in-flight observation.873+ // restarts an in-flight observation.
856- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{second, first}))874+ require.NoError(t, registry.setHosts(hostRecords(first, second)))
857- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second}))
858 require.Equal(t, before, registry.Snapshot())875 require.Equal(t, before, registry.Snapshot())
859 requireNoProbeWithin(t, probe, probeGrace)876 requireNoProbeWithin(t, probe, probeGrace)
860 877
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+
861 // The in-flight observations still complete normally.898 // The in-flight observations still complete normally.
862 for endpoint, call := range calls {899 for endpoint, call := range calls {
863- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}900+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
864- waitHost(t, registry, endpoint, func(host ports.RemoteHostSnapshot) bool {901+ waitHost(t, registry, endpoint, func(host ports.BrokerDaemonObservation) bool {
865 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable902 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
866 })903 })
867 }904 }
868 }905 }
869 906
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+
870 func TestRegistrySnapshotIsImmutable(t *testing.T) {978 func TestRegistrySnapshotIsImmutable(t *testing.T) {
871 clock := newManualClock(time.Unix(100, 0))979 clock := newManualClock(time.Unix(100, 0))
872 probe := newTestProbe(1)980 probe := newTestProbe(1)
873 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)981 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
874 reg := registration(t, "example.test", 1)982 reg := registration(t, "example.test", 1)
875- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))983+ require.NoError(t, registry.setHosts(hostRecords(reg)))
876 startRegistry(t, registry)984 startRegistry(t, registry)
877 985
878 call := receiveCall(t, probe)986 call := receiveCall(t, probe)
879- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{987+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
880- Availability: domain.RemoteAvailabilityReachable,988+ Availability: domain.RemoteAvailabilityReachable,
989+ InventoryKnown: true,
881 Sessions: []catalogue.RemoteCatalogSession{{990 Sessions: []catalogue.RemoteCatalogSession{{
882 LifecycleID: domain.SessionLifecycleID{1},991 LifecycleID: domain.SessionLifecycleID{1},
883 Name: "work",992 Name: "work",
@@ -885,7 +994,7 @@ func TestRegistrySnapshotIsImmutable(t *testing.T) {
885 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},994 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Name: "shell"}},
886 }},995 }},
887 }}996 }}
888- observed := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {997+ observed := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
889 return !host.Checking && len(host.Sessions) == 1998 return !host.Checking && len(host.Sessions) == 1
890 })999 })
891 require.Equal(t, "work", observed.Sessions[0].Name)1000 require.Equal(t, "work", observed.Sessions[0].Name)
@@ -895,22 +1004,23 @@ func TestRegistrySnapshotIsImmutable(t *testing.T) {
895 observed.Sessions[0].Name = "mutated"1004 observed.Sessions[0].Name = "mutated"
896 observed.Sessions[0].Tabs[0].Name = "mutated"1005 observed.Sessions[0].Tabs[0].Name = "mutated"
897 current := registry.Snapshot()1006 current := registry.Snapshot()
898- require.Equal(t, reg.Endpoint, current.Hosts[0].Endpoint)1007+ require.Equal(t, reg.Endpoint, current.Daemons[0].Endpoint)
899- require.Equal(t, "work", current.Hosts[0].Sessions[0].Name)1008+ require.Equal(t, "work", current.Daemons[0].Sessions[0].Name)
900- require.Equal(t, "shell", current.Hosts[0].Sessions[0].Tabs[0].Name)1009+ require.Equal(t, "shell", current.Daemons[0].Sessions[0].Tabs[0].Name)
901 1010
902 // A later publication never mutates a snapshot already handed out.1011 // A later publication never mutates a snapshot already handed out.
903 earlier := registry.Snapshot()1012 earlier := registry.Snapshot()
904 registry.RequestProbe(reg.Endpoint)1013 registry.RequestProbe(reg.Endpoint)
905 next := receiveCall(t, probe)1014 next := receiveCall(t, probe)
906- next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1015+ next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
907- Availability: domain.RemoteAvailabilityReachable,1016+ Availability: domain.RemoteAvailabilityReachable,
908- Sessions: []catalogue.RemoteCatalogSession{hostSession("other")},1017+ InventoryKnown: true,
1018+ Sessions: []catalogue.RemoteCatalogSession{hostSession("other")},
909 }}1019 }}
910- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1020+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
911 return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "other"1021 return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "other"
912 })1022 })
913- require.Equal(t, "work", earlier.Hosts[0].Sessions[0].Name)1023+ require.Equal(t, "work", earlier.Daemons[0].Sessions[0].Name)
914 }1024 }
915 1025
916 func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) {1026 func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) {
@@ -919,13 +1029,13 @@ func TestRegistrySchedulesFreshObservationAfterSuccess(t *testing.T) {
919 probe := newTestProbe(1)1029 probe := newTestProbe(1)
920 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1030 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
921 reg := registration(t, "example.test", 1)1031 reg := registration(t, "example.test", 1)
922- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1032+ require.NoError(t, registry.setHosts(hostRecords(reg)))
923 startRegistry(t, registry)1033 startRegistry(t, registry)
924 1034
925 call := receiveCall(t, probe)1035 call := receiveCall(t, probe)
926- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}1036+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
927 1037
928- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1038+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
929 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1039 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
930 })1040 })
931 require.Equal(t, start, host.LastAttempt)1041 require.Equal(t, start, host.LastAttempt)
@@ -948,12 +1058,12 @@ func TestRegistryDemandOverridesFreshSchedule(t *testing.T) {
948 probe := newTestProbe(1)1058 probe := newTestProbe(1)
949 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1059 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
950 reg := registration(t, "example.test", 1)1060 reg := registration(t, "example.test", 1)
951- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1061+ require.NoError(t, registry.setHosts(hostRecords(reg)))
952 startRegistry(t, registry)1062 startRegistry(t, registry)
953 1063
954 call := receiveCall(t, probe)1064 call := receiveCall(t, probe)
955- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}1065+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
956- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1066+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
957 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1067 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
958 })1068 })
959 1069
@@ -970,7 +1080,7 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) {
970 probe := newTestProbe(2)1080 probe := newTestProbe(2)
971 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1081 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
972 reg := registration(t, "example.test", 1)1082 reg := registration(t, "example.test", 1)
973- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1083+ require.NoError(t, registry.setHosts(hostRecords(reg)))
974 startRegistry(t, registry)1084 startRegistry(t, registry)
975 1085
976 call := receiveCall(t, probe)1086 call := receiveCall(t, probe)
@@ -982,8 +1092,8 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) {
982 t.Fatalf("concurrent demand started a duplicate probe for %q", duplicate.registration.Endpoint)1092 t.Fatalf("concurrent demand started a duplicate probe for %q", duplicate.registration.Endpoint)
983 default:1093 default:
984 }1094 }
985- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}1095+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
986- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1096+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
987 return host.Availability == domain.RemoteAvailabilityReachable && host.LastSuccess.Equal(start)1097 return host.Availability == domain.RemoteAvailabilityReachable && host.LastSuccess.Equal(start)
988 })1098 })
989 require.Equal(t, start.Add(defaultFreshFor), host.NextDue)1099 require.Equal(t, start.Add(defaultFreshFor), host.NextDue)
@@ -991,8 +1101,8 @@ func TestRegistryCoalescesConcurrentDemand(t *testing.T) {
991 // The single coalesced follow-up is admitted once the first completes.1101 // The single coalesced follow-up is admitted once the first completes.
992 followUp := receiveCall(t, probe)1102 followUp := receiveCall(t, probe)
993 require.True(t, followUp.registration.Equal(reg))1103 require.True(t, followUp.registration.Equal(reg))
994- followUp.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}1104+ followUp.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
995- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1105+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
996 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1106 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
997 })1107 })
998 }1108 }
@@ -1002,7 +1112,7 @@ func TestRegistryIgnoresDemandForUnknownEndpoint(t *testing.T) {
1002 probe := newTestProbe(1)1112 probe := newTestProbe(1)
1003 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1113 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1004 reg := registration(t, "example.test", 1)1114 reg := registration(t, "example.test", 1)
1005- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1115+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1006 registry.RequestProbe("unknown.test")1116 registry.RequestProbe("unknown.test")
1007 startRegistry(t, registry)1117 startRegistry(t, registry)
1008 1118
@@ -1019,11 +1129,11 @@ func TestRegistryPublishesReachabilityAndPersists(t *testing.T) {
1019 store := &testStore{}1129 store := &testStore{}
1020 registry := newTestRegistry(t, 3, store, probe, clock)1130 registry := newTestRegistry(t, 3, store, probe, clock)
1021 reg := registration(t, "example.test", 1)1131 reg := registration(t, "example.test", 1)
1022- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1132+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1023 startRegistry(t, registry)1133 startRegistry(t, registry)
1024 1134
1025 call := receiveCall(t, probe)1135 call := receiveCall(t, probe)
1026- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1136+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1027 Availability: domain.RemoteAvailabilityReachable,1137 Availability: domain.RemoteAvailabilityReachable,
1028 InventoryKnown: true,1138 InventoryKnown: true,
1029 Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},1139 Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
@@ -1034,13 +1144,13 @@ func TestRegistryPublishesReachabilityAndPersists(t *testing.T) {
1034 })1144 })
1035 1145
1036 stored := waitStored(t, store, func(stored ports.BrokerSnapshot) bool {1146 stored := waitStored(t, store, func(stored ports.BrokerSnapshot) bool {
1037- return stored.Revision >= snapshot.Revision && len(stored.Hosts) == 11147+ return stored.Revision >= snapshot.Revision && len(stored.Daemons) == 1
1038 })1148 })
1039 require.Equal(t, ports.BrokerEpoch(3), stored.Epoch)1149 require.Equal(t, ports.BrokerEpoch(3), stored.Epoch)
1040- require.Len(t, stored.Hosts, 1)1150+ require.Len(t, stored.Daemons, 1)
1041- require.Equal(t, "work", stored.Hosts[0].Sessions[0].Name)1151+ require.Equal(t, "work", stored.Daemons[0].Sessions[0].Name)
1042- require.Equal(t, reg.Endpoint, stored.Hosts[0].Endpoint)1152+ require.Equal(t, reg.Endpoint, stored.Daemons[0].Endpoint)
1043- require.False(t, stored.Hosts[0].Checking)1153+ require.False(t, stored.Daemons[0].Checking)
1044 }1154 }
1045 1155
1046 func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {1156 func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
@@ -1054,31 +1164,31 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
1054 }{1164 }{
1055 {1165 {
1056 name: "unreachable without error",1166 name: "unreachable without error",
1057- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityUnreachable}},1167+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}},
1058 wantAvailability: domain.RemoteAvailabilityUnreachable,1168 wantAvailability: domain.RemoteAvailabilityUnreachable,
1059 wantKind: domain.RemoteFailureTransport,1169 wantKind: domain.RemoteFailureTransport,
1060 },1170 },
1061 {1171 {
1062 name: "unknown outcome is not reachability",1172 name: "unknown outcome is not reachability",
1063- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{}},1173+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{}},
1064 wantAvailability: domain.RemoteAvailabilityUnreachable,1174 wantAvailability: domain.RemoteAvailabilityUnreachable,
1065 wantKind: domain.RemoteFailureTransport,1175 wantKind: domain.RemoteFailureTransport,
1066 },1176 },
1067 {1177 {
1068 name: "incompatible",1178 name: "incompatible",
1069- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityIncompatible}},1179+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityIncompatible}},
1070 wantAvailability: domain.RemoteAvailabilityIncompatible,1180 wantAvailability: domain.RemoteAvailabilityIncompatible,
1071 wantKind: domain.RemoteFailureIncompatible,1181 wantKind: domain.RemoteFailureIncompatible,
1072 },1182 },
1073 {1183 {
1074 name: "authentication",1184 name: "authentication",
1075- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityAuthFailed}},1185+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityAuthFailed}},
1076 wantAvailability: domain.RemoteAvailabilityAuthFailed,1186 wantAvailability: domain.RemoteAvailabilityAuthFailed,
1077 wantKind: domain.RemoteFailureAuthentication,1187 wantKind: domain.RemoteFailureAuthentication,
1078 },1188 },
1079 {1189 {
1080 name: "invalid response",1190 name: "invalid response",
1081- answer: probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityInvalidResponse}},1191+ answer: probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityInvalidResponse}},
1082 wantAvailability: domain.RemoteAvailabilityInvalidResponse,1192 wantAvailability: domain.RemoteAvailabilityInvalidResponse,
1083 wantKind: domain.RemoteFailureInvalidResponse,1193 wantKind: domain.RemoteFailureInvalidResponse,
1084 },1194 },
@@ -1104,12 +1214,12 @@ func TestRegistryNonReachableOutcomesUseTypedFailureBackoff(t *testing.T) {
1104 probe := newTestProbe(1)1214 probe := newTestProbe(1)
1105 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1215 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1106 reg := registration(t, "example.test", 1)1216 reg := registration(t, "example.test", 1)
1107- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1217+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1108 startRegistry(t, registry)1218 startRegistry(t, registry)
1109 1219
1110 call := receiveCall(t, probe)1220 call := receiveCall(t, probe)
1111 call.result <- tt.answer1221 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 {
1113 return !host.Checking && host.ConsecutiveFailures == 11223 return !host.Checking && host.ConsecutiveFailures == 1
1114 })1224 })
1115 require.Equal(t, tt.wantAvailability, host.Availability)1225 require.Equal(t, tt.wantAvailability, host.Availability)
@@ -1135,18 +1245,19 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing.
1135 probe := newTestProbe(1)1245 probe := newTestProbe(1)
1136 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1246 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1137 reg := registration(t, "example.test", 1)1247 reg := registration(t, "example.test", 1)
1138- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1248+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1139 startRegistry(t, registry)1249 startRegistry(t, registry)
1140 sub := registry.Subscribe()1250 sub := registry.Subscribe()
1141 defer sub.Close()1251 defer sub.Close()
1142 1252
1143 // A reachable observation establishes the last-known inventory.1253 // A reachable observation establishes the last-known inventory.
1144 call := receiveCall(t, probe)1254 call := receiveCall(t, probe)
1145- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1255+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1146- Availability: domain.RemoteAvailabilityReachable,1256+ Availability: domain.RemoteAvailabilityReachable,
1147- Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},1257+ InventoryKnown: true,
1258+ Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
1148 }}1259 }}
1149- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1260+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1150 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1261 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1151 })1262 })
1152 1263
@@ -1157,12 +1268,13 @@ func TestRegistryClassifiesOversizedProbeProjectionAsInvalidResponse(t *testing.
1157 // never pass unnoticed.1268 // never pass unnoticed.
1158 clock.Advance(defaultFreshFor)1269 clock.Advance(defaultFreshFor)
1159 call = receiveCall(t, probe)1270 call = receiveCall(t, probe)
1160- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1271+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1161- Availability: domain.RemoteAvailabilityReachable,1272+ Availability: domain.RemoteAvailabilityReachable,
1162- Sessions: hostSessions(ports.BrokerMaxSessionsPerHost + 1),1273+ InventoryKnown: true,
1274+ Sessions: hostSessions(ports.BrokerMaxSessionsPerHost + 1),
1163 }}1275 }}
1164 deadline := time.After(time.Second)1276 deadline := time.After(time.Second)
1165- var host ports.RemoteHostSnapshot1277+ var host ports.BrokerDaemonObservation
1166 for {1278 for {
1167 snapshot := registry.Snapshot()1279 snapshot := registry.Snapshot()
1168 require.NoError(t, snapshot.Validate())1280 require.NoError(t, snapshot.Validate())
@@ -1192,24 +1304,25 @@ func TestRegistryAcceptsProbeProjectionAtSessionBound(t *testing.T) {
1192 probe := newTestProbe(1)1304 probe := newTestProbe(1)
1193 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1305 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1194 reg := registration(t, "example.test", 1)1306 reg := registration(t, "example.test", 1)
1195- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1307+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1196 startRegistry(t, registry)1308 startRegistry(t, registry)
1197 1309
1198 // The bound itself is valid: exactly BrokerMaxSessionsPerHost sessions are1310 // The bound itself is valid: exactly BrokerMaxSessionsPerHost sessions are
1199 // accepted and published, and the resulting snapshot validates.1311 // accepted and published, and the resulting snapshot validates.
1200 call := receiveCall(t, probe)1312 call := receiveCall(t, probe)
1201- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1313+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1202- Availability: domain.RemoteAvailabilityReachable,1314+ Availability: domain.RemoteAvailabilityReachable,
1203- Sessions: hostSessions(ports.BrokerMaxSessionsPerHost),1315+ InventoryKnown: true,
1316+ Sessions: hostSessions(ports.BrokerMaxSessionsPerHost),
1204 }}1317 }}
1205- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1318+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1206 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1319 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1207 })1320 })
1208 require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost)1321 require.Len(t, host.Sessions, ports.BrokerMaxSessionsPerHost)
1209 require.Zero(t, host.ConsecutiveFailures)1322 require.Zero(t, host.ConsecutiveFailures)
1210 snapshot := registry.Snapshot()1323 snapshot := registry.Snapshot()
1211 require.NoError(t, snapshot.Validate())1324 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)
1213 }1326 }
1214 1327
1215 // TestRegistryRejectsNonDurableObservation proves the registry and the durable1328 // TestRegistryRejectsNonDurableObservation proves the registry and the durable
@@ -1222,15 +1335,16 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) {
1222 probe := newTestProbe(1)1335 probe := newTestProbe(1)
1223 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1336 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1224 reg := registration(t, "example.test", 1)1337 reg := registration(t, "example.test", 1)
1225- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1338+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1226 startRegistry(t, registry)1339 startRegistry(t, registry)
1227 1340
1228 call := receiveCall(t, probe)1341 call := receiveCall(t, probe)
1229- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1342+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1230- Availability: domain.RemoteAvailabilityReachable,1343+ Availability: domain.RemoteAvailabilityReachable,
1231- Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},1344+ InventoryKnown: true,
1345+ Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
1232 }}1346 }}
1233- waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1347+ waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1234 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1348 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1235 })1349 })
1236 1350
@@ -1238,11 +1352,12 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) {
1238 // a zero lifecycle identity and an absent tab list) is a malformed response.1352 // a zero lifecycle identity and an absent tab list) is a malformed response.
1239 clock.Advance(defaultFreshFor)1353 clock.Advance(defaultFreshFor)
1240 call = receiveCall(t, probe)1354 call = receiveCall(t, probe)
1241- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1355+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1242- Availability: domain.RemoteAvailabilityReachable,1356+ Availability: domain.RemoteAvailabilityReachable,
1243- Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}},1357+ InventoryKnown: true,
1358+ Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}},
1244 }}1359 }}
1245- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1360+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1246 return !host.Checking && host.ConsecutiveFailures == 11361 return !host.Checking && host.ConsecutiveFailures == 1
1247 })1362 })
1248 require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability)1363 require.Equal(t, domain.RemoteAvailabilityInvalidResponse, host.Availability)
@@ -1252,7 +1367,7 @@ func TestRegistryRejectsNonDurableObservation(t *testing.T) {
1252 require.Equal(t, start, host.LastSuccess)1367 require.Equal(t, start, host.LastSuccess)
1253 snapshot := registry.Snapshot()1368 snapshot := registry.Snapshot()
1254 require.NoError(t, snapshot.Validate())1369 require.NoError(t, snapshot.Validate())
1255- require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Hosts[0]))1370+ require.NoError(t, ports.ValidateDurableHostProjection(snapshot.Daemons[0]))
1256 }1371 }
1257 1372
1258 // TestRegistryRevisionOverflowFailsClosed pins the revision series: zero is not1373 // TestRegistryRevisionOverflowFailsClosed pins the revision series: zero is not
@@ -1266,7 +1381,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) {
1266 registry, err := NewRegistry(1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)), logger)1381 registry, err := NewRegistry(1, newTestStore(), newTestProbe(1), newManualClock(time.Unix(100, 0)), logger)
1267 require.NoError(t, err)1382 require.NoError(t, err)
1268 first := registration(t, "example.test", 1)1383 first := registration(t, "example.test", 1)
1269- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first}))1384+ require.NoError(t, registry.setHosts(hostRecords(first)))
1270 before := registry.Snapshot()1385 before := registry.Snapshot()
1271 require.NoError(t, before.Validate())1386 require.NoError(t, before.Validate())
1272 require.Equal(t, ports.BrokerRevision(2), before.Revision)1387 require.Equal(t, ports.BrokerRevision(2), before.Revision)
@@ -1277,7 +1392,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) {
1277 1392
1278 // Both mutation paths refuse the change before touching the store or the1393 // Both mutation paths refuse the change before touching the store or the
1279 // projection, so neither durable authority nor the publication series moves.1394 // 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)
1281 require.ErrorIs(t, registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: first, Pinned: true, Policy: poolPolicy()}}), ports.ErrBrokerRevisionExhausted)1396 require.ErrorIs(t, registry.ReplaceHosts([]ports.BrokerHostRecord{{Registration: first, Pinned: true, Policy: poolPolicy()}}), ports.ErrBrokerRevisionExhausted)
1282 require.Equal(t, authority, registry.authority)1397 require.Equal(t, authority, registry.authority)
1283 1398
@@ -1292,7 +1407,7 @@ func TestRegistryRevisionOverflowFailsClosed(t *testing.T) {
1292 require.NoError(t, after.Validate())1407 require.NoError(t, after.Validate())
1293 require.Greater(t, after.Revision, ports.BrokerRevision(0))1408 require.Greater(t, after.Revision, ports.BrokerRevision(0))
1294 require.Equal(t, ^ports.BrokerRevision(0), registry.revision)1409 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)
1296 // The refusal is observable: an exhausted series logs instead of failing1411 // The refusal is observable: an exhausted series logs instead of failing
1297 // silently behind a frozen snapshot.1412 // silently behind a frozen snapshot.
1298 require.Contains(t, logs.String(), "revision series exhausted")1413 require.Contains(t, logs.String(), "revision series exhausted")
@@ -1304,16 +1419,17 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
1304 probe := newTestProbe(1)1419 probe := newTestProbe(1)
1305 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1420 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1306 reg := registration(t, "example.test", 1)1421 reg := registration(t, "example.test", 1)
1307- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1422+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1308 startRegistry(t, registry)1423 startRegistry(t, registry)
1309 1424
1310 // A successful observation owns the success time and opens no episode.1425 // A successful observation owns the success time and opens no episode.
1311 call := receiveCall(t, probe)1426 call := receiveCall(t, probe)
1312- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1427+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1313- Availability: domain.RemoteAvailabilityReachable,1428+ Availability: domain.RemoteAvailabilityReachable,
1314- Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},1429+ InventoryKnown: true,
1430+ Sessions: []catalogue.RemoteCatalogSession{hostSession("work")},
1315 }}1431 }}
1316- host := waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1432+ host := waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1317 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1433 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1318 })1434 })
1319 require.Equal(t, start, host.LastSuccess)1435 require.Equal(t, start, host.LastSuccess)
@@ -1322,11 +1438,12 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
1322 // A failure preserves the last success and opens exactly one episode.1438 // A failure preserves the last success and opens exactly one episode.
1323 clock.Advance(defaultFreshFor)1439 clock.Advance(defaultFreshFor)
1324 call = receiveCall(t, probe)1440 call = receiveCall(t, probe)
1325- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{1441+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{
1326- Availability: domain.RemoteAvailabilityIncompatible,1442+ Availability: domain.RemoteAvailabilityIncompatible,
1327- Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: catalogue.RemoteCatalogSessionUp}},1443+ InventoryKnown: true,
1444+ Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: catalogue.RemoteCatalogSessionUp}},
1328 }}1445 }}
1329- host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1446+ host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1330 return !host.Checking && host.ConsecutiveFailures == 11447 return !host.Checking && host.ConsecutiveFailures == 1
1331 })1448 })
1332 require.Equal(t, start, host.LastSuccess)1449 require.Equal(t, start, host.LastSuccess)
@@ -1340,7 +1457,7 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
1340 clock.Advance(defaultRetryBase)1457 clock.Advance(defaultRetryBase)
1341 call = receiveCall(t, probe)1458 call = receiveCall(t, probe)
1342 call.result <- probeAnswer{err: errors.New("offline")}1459 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 {
1344 return !host.Checking && host.ConsecutiveFailures == 21461 return !host.Checking && host.ConsecutiveFailures == 2
1345 })1462 })
1346 require.Equal(t, start, host.LastSuccess)1463 require.Equal(t, start, host.LastSuccess)
@@ -1350,8 +1467,8 @@ func TestRegistryPreservesLastSuccessAndOwnsFailureEpisode(t *testing.T) {
1350 clock.Advance(2 * defaultRetryBase)1467 clock.Advance(2 * defaultRetryBase)
1351 recovered := clock.Now()1468 recovered := clock.Now()
1352 call = receiveCall(t, probe)1469 call = receiveCall(t, probe)
1353- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}1470+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1354- host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1471+ host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1355 return !host.Checking && host.ConsecutiveFailures == 01472 return !host.Checking && host.ConsecutiveFailures == 0
1356 })1473 })
1357 require.Equal(t, recovered, host.LastSuccess)1474 require.Equal(t, recovered, host.LastSuccess)
@@ -1366,7 +1483,7 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
1366 probe := newTestProbe(1)1483 probe := newTestProbe(1)
1367 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1484 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1368 reg := registration(t, "example.test", 1)1485 reg := registration(t, "example.test", 1)
1369- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1486+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1370 startRegistry(t, registry)1487 startRegistry(t, registry)
1371 1488
1372 probeErr := errors.New("offline")1489 probeErr := errors.New("offline")
@@ -1385,7 +1502,7 @@ func TestRegistryBackoffIsExponentialAndCapped(t *testing.T) {
1385 call := receiveCall(t, probe)1502 call := receiveCall(t, probe)
1386 at := clock.Now()1503 at := clock.Now()
1387 call.result <- probeAnswer{err: probeErr}1504 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 {
1389 return !host.Checking && host.ConsecutiveFailures == uint(index+1)1506 return !host.Checking && host.ConsecutiveFailures == uint(index+1)
1390 })1507 })
1391 require.Equal(t, domain.RemoteAvailabilityUnreachable, host.Availability)1508 require.Equal(t, domain.RemoteAvailabilityUnreachable, host.Availability)
@@ -1410,12 +1527,12 @@ func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) {
1410 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1527 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1411 registry.jitter = func(base time.Duration, _ string, _ uint64) time.Duration { return base / 2 }1528 registry.jitter = func(base time.Duration, _ string, _ uint64) time.Duration { return base / 2 }
1412 reg := registration(t, "example.test", 1)1529 reg := registration(t, "example.test", 1)
1413- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1530+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1414 startRegistry(t, registry)1531 startRegistry(t, registry)
1415 1532
1416 call := receiveCall(t, probe)1533 call := receiveCall(t, probe)
1417 call.result <- probeAnswer{err: errors.New("offline")}1534 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 {
1419 return !host.Checking && host.Availability == domain.RemoteAvailabilityUnreachable1536 return !host.Checking && host.Availability == domain.RemoteAvailabilityUnreachable
1420 })1537 })
1421 require.Equal(t, start.Add(defaultRetryBase/2), host.NextDue)1538 require.Equal(t, start.Add(defaultRetryBase/2), host.NextDue)
@@ -1423,8 +1540,8 @@ func TestRegistryJitterAppliesToRetryAndHealthyRefresh(t *testing.T) {
1423 clock.Advance(defaultRetryBase / 2)1540 clock.Advance(defaultRetryBase / 2)
1424 next := receiveCall(t, probe)1541 next := receiveCall(t, probe)
1425 successAt := clock.Now()1542 successAt := clock.Now()
1426- next.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}1543+ next.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1427- host = waitHost(t, registry, reg.Endpoint, func(host ports.RemoteHostSnapshot) bool {1544+ host = waitHost(t, registry, reg.Endpoint, func(host ports.BrokerDaemonObservation) bool {
1428 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1545 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1429 })1546 })
1430 // The healthy refresh is jittered too: a fleet of hosts never converges1547 // The healthy refresh is jittered too: a fleet of hosts never converges
@@ -1457,9 +1574,10 @@ func TestRegistryJitterIsDeterministicAndBounded(t *testing.T) {
1457 1574
1458 func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) {1575 func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) {
1459 replacementAnswer := func(name string) probeAnswer {1576 replacementAnswer := func(name string) probeAnswer {
1460- return probeAnswer{snapshot: ports.RemoteHostSnapshot{1577+ return probeAnswer{snapshot: ports.BrokerDaemonObservation{
1461- Availability: domain.RemoteAvailabilityReachable,1578+ Availability: domain.RemoteAvailabilityReachable,
1462- Sessions: []catalogue.RemoteCatalogSession{hostSession(name)},1579+ InventoryKnown: true,
1580+ Sessions: []catalogue.RemoteCatalogSession{hostSession(name)},
1463 }}1581 }}
1464 }1582 }
1465 assertFenced := func(t *testing.T, current, replacement domain.RemoteRegistration) {1583 assertFenced := func(t *testing.T, current, replacement domain.RemoteRegistration) {
@@ -1467,18 +1585,18 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) {
1467 clock := newManualClock(time.Unix(100, 0))1585 clock := newManualClock(time.Unix(100, 0))
1468 probe := newTestProbe(2)1586 probe := newTestProbe(2)
1469 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1587 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1470- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current}))1588+ require.NoError(t, registry.setHosts(hostRecords(current)))
1471 startRegistry(t, registry)1589 startRegistry(t, registry)
1472 currentCall := receiveCall(t, probe)1590 currentCall := receiveCall(t, probe)
1473 require.True(t, currentCall.registration.Equal(current))1591 require.True(t, currentCall.registration.Equal(current))
1474 1592
1475- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{replacement}))1593+ require.NoError(t, registry.setHosts(hostRecords(replacement)))
1476 replacementCall := receiveCall(t, probe)1594 replacementCall := receiveCall(t, probe)
1477 require.True(t, replacementCall.registration.Equal(replacement))1595 require.True(t, replacementCall.registration.Equal(replacement))
1478 1596
1479 // The replacement completes and becomes authoritative...1597 // The replacement completes and becomes authoritative...
1480 replacementCall.result <- replacementAnswer("replacement")1598 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 {
1482 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && len(host.Sessions) == 11600 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable && len(host.Sessions) == 1
1483 })1601 })
1484 require.True(t, applied.Registration.Equal(replacement))1602 require.True(t, applied.Registration.Equal(replacement))
@@ -1495,7 +1613,7 @@ func TestRegistryFencesStaleCompletionAcrossRegistrationChanges(t *testing.T) {
1495 next := receiveCall(t, probe)1613 next := receiveCall(t, probe)
1496 require.True(t, next.registration.Equal(replacement))1614 require.True(t, next.registration.Equal(replacement))
1497 next.result <- replacementAnswer("next")1615 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 {
1499 return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "next"1617 return !host.Checking && len(host.Sessions) == 1 && host.Sessions[0].Name == "next"
1500 })1618 })
1501 }1619 }
@@ -1516,7 +1634,7 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.
1516 probe := newTestProbe(2)1634 probe := newTestProbe(2)
1517 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1635 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1518 reg := registration(t, "example.test", 1)1636 reg := registration(t, "example.test", 1)
1519- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1637+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1520 1638
1521 ctx, cancel := context.WithCancel(context.Background())1639 ctx, cancel := context.WithCancel(context.Background())
1522 defer cancel()1640 defer cancel()
@@ -1532,20 +1650,20 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.
1532 // Removal retires a bounded tombstone and drops the in-flight token.1650 // Removal retires a bounded tombstone and drops the in-flight token.
1533 require.NoError(t, registry.setHosts(nil))1651 require.NoError(t, registry.setHosts(nil))
1534 removed := registry.Snapshot()1652 removed := registry.Snapshot()
1535- require.Empty(t, removed.Hosts)1653+ require.Empty(t, removed.Daemons)
1536 require.Len(t, removed.Removed, 1)1654 require.Len(t, removed.Removed, 1)
1537 require.Equal(t, removed.Revision, removed.Removed[0].RetiredRevision)1655 require.Equal(t, removed.Revision, removed.Removed[0].RetiredRevision)
1538 require.True(t, removed.Removed[0].Fences(reg))1656 require.True(t, removed.Removed[0].Fences(reg))
1539 require.NoError(t, removed.Validate())1657 require.NoError(t, removed.Validate())
1540 1658
1541 // The retired attempt can never revive a removed endpoint.1659 // 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()})1660+ registry.apply(probeResult{endpoint: reg.Endpoint, registration: reg, attempt: retired, snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}, at: clock.Now()})
1543- require.Empty(t, registry.Snapshot().Hosts)1661+ require.Empty(t, registry.Snapshot().Daemons)
1544 require.Len(t, registry.Snapshot().Removed, 1)1662 require.Len(t, registry.Snapshot().Removed, 1)
1545 1663
1546 // Re-adding the identical registration supersedes the tombstone, so only1664 // Re-adding the identical registration supersedes the tombstone, so only
1547 // the attempt token can fence the retired completion.1665 // 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)))
1549 require.Empty(t, registry.Snapshot().Removed)1667 require.Empty(t, registry.Snapshot().Removed)
1550 dispatch()1668 dispatch()
1551 freshCall := receiveCall(t, probe)1669 freshCall := receiveCall(t, probe)
@@ -1553,13 +1671,13 @@ func TestRegistryFencesRetiredAttemptsAcrossRemovalAndIdenticalReAdd(t *testing.
1553 fresh := attemptFor(registry, reg.Endpoint)1671 fresh := attemptFor(registry, reg.Endpoint)
1554 require.Greater(t, fresh, retired)1672 require.Greater(t, fresh, retired)
1555 1673
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()})
1557 stale, ok := registry.Snapshot().Find(reg.Endpoint)1675 stale, ok := registry.Snapshot().Find(reg.Endpoint)
1558 require.True(t, ok)1676 require.True(t, ok)
1559 require.True(t, stale.Checking)1677 require.True(t, stale.Checking)
1560 require.Equal(t, domain.RemoteAvailabilityUnknown, stale.Availability)1678 require.Equal(t, domain.RemoteAvailabilityUnknown, stale.Availability)
1561 1679
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()})
1563 applied, ok := registry.Snapshot().Find(reg.Endpoint)1681 applied, ok := registry.Snapshot().Find(reg.Endpoint)
1564 require.True(t, ok)1682 require.True(t, ok)
1565 require.False(t, applied.Checking)1683 require.False(t, applied.Checking)
@@ -1571,9 +1689,9 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) {
1571 replacement := registration(t, "example.test", 2)1689 replacement := registration(t, "example.test", 2)
1572 tests := []struct {1690 tests := []struct {
1573 name string1691 name string
1574- next []domain.RemoteRegistration1692+ next []ports.BrokerHostRecord
1575 }{1693 }{
1576- {name: "replacement", next: []domain.RemoteRegistration{replacement}},1694+ {name: "replacement", next: []ports.BrokerHostRecord{hostRecord(replacement)}},
1577 {name: "removal", next: nil},1695 {name: "removal", next: nil},
1578 }1696 }
1579 for _, tt := range tests {1697 for _, tt := range tests {
@@ -1581,7 +1699,7 @@ func TestRegistryRetiresInflightAttemptsOnReplacementAndRemoval(t *testing.T) {
1581 clock := newManualClock(time.Unix(100, 0))1699 clock := newManualClock(time.Unix(100, 0))
1582 probe := newTestProbe(2)1700 probe := newTestProbe(2)
1583 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)1701 registry := newTestRegistry(t, 1, newTestStore(), probe, clock)
1584- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{current}))1702+ require.NoError(t, registry.setHosts(hostRecords(current)))
1585 startRegistry(t, registry)1703 startRegistry(t, registry)
1586 1704
1587 call := receiveCall(t, probe)1705 call := receiveCall(t, probe)
@@ -1609,7 +1727,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) {
1609 1727
1610 first := registration(t, "example.test", 1)1728 first := registration(t, "example.test", 1)
1611 second := registration(t, "example.test", 2)1729 second := registration(t, "example.test", 2)
1612- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first}))1730+ require.NoError(t, registry.setHosts(hostRecords(first)))
1613 1731
1614 ctx, cancel := context.WithCancel(context.Background())1732 ctx, cancel := context.WithCancel(context.Background())
1615 defer cancel()1733 defer cancel()
@@ -1635,7 +1753,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) {
1635 if round%2 == 0 {1753 if round%2 == 0 {
1636 next = second1754 next = second
1637 }1755 }
1638- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{next}))1756+ require.NoError(t, registry.setHosts(hostRecords(next)))
1639 select {1757 select {
1640 case <-probe.startedSig:1758 case <-probe.startedSig:
1641 case <-time.After(time.Second):1759 case <-time.After(time.Second):
@@ -1657,7 +1775,7 @@ func TestRegistryChurnKeepsOneActiveProbePerEndpoint(t *testing.T) {
1657 if (worker+round)%2 == 0 {1775 if (worker+round)%2 == 0 {
1658 next = second1776 next = second
1659 }1777 }
1660- if err := registry.setHosts([]domain.RemoteRegistration{next}); err != nil {1778+ if err := registry.setHosts(hostRecords(next)); err != nil {
1661 failures[worker] = err1779 failures[worker] = err
1662 return1780 return
1663 }1781 }
@@ -1700,9 +1818,9 @@ func TestRegistryPublishesBoundedTombstones(t *testing.T) {
1700 // Two full membership rounds with distinct endpoints: without pruning the1818 // Two full membership rounds with distinct endpoints: without pruning the
1701 // retired set would double.1819 // retired set would double.
1702 for round := 0; round < 2; round++ {1820 for round := 0; round < 2; round++ {
1703- registrations := make([]domain.RemoteRegistration, 0, ports.BrokerMaxHosts)1821+ registrations := make([]ports.BrokerHostRecord, 0, ports.BrokerMaxHosts)
1704 for index := 0; index < ports.BrokerMaxHosts; index++ {1822 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))))
1706 }1824 }
1707 require.NoError(t, registry.setHosts(registrations))1825 require.NoError(t, registry.setHosts(registrations))
1708 require.NoError(t, registry.setHosts(nil))1826 require.NoError(t, registry.setHosts(nil))
@@ -1730,7 +1848,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) {
1730 defer registry.store.close()1848 defer registry.store.close()
1731 1849
1732 reg := registration(t, "example.test", 1)1850 reg := registration(t, "example.test", 1)
1733- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1851+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1734 // The writer is now blocked inside the first durable write.1852 // The writer is now blocked inside the first durable write.
1735 first := <-store.entered1853 first := <-store.entered
1736 require.Equal(t, ports.BrokerRevision(2), first)1854 require.Equal(t, ports.BrokerRevision(2), first)
@@ -1743,7 +1861,7 @@ func TestRegistryPersistenceRunsOffLockAndCoalescesWrites(t *testing.T) {
1743 defer close(published)1861 defer close(published)
1744 for generation := domain.RemoteGeneration(2); generation <= 6; generation++ {1862 for generation := domain.RemoteGeneration(2); generation <= 6; generation++ {
1745 reg.Generation = generation1863 reg.Generation = generation
1746- if err := registry.setHosts([]domain.RemoteRegistration{reg}); err != nil {1864+ if err := registry.setHosts(hostRecords(reg)); err != nil {
1747 setErr = err1865 setErr = err
1748 return1866 return
1749 }1867 }
@@ -1778,10 +1896,10 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) {
1778 require.NoError(t, err)1896 require.NoError(t, err)
1779 1897
1780 reg := registration(t, "example.test", 1)1898 reg := registration(t, "example.test", 1)
1781- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1899+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1782 startRegistry(t, registry)1900 startRegistry(t, registry)
1783 call := receiveCall(t, probe)1901 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 {
1785 return host.Checking1903 return host.Checking
1786 }).Checking)1904 }).Checking)
1787 1905
@@ -1801,8 +1919,8 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) {
1801 select {1919 select {
1802 case snapshot := <-observed:1920 case snapshot := <-observed:
1803 require.NoError(t, snapshot.Validate())1921 require.NoError(t, snapshot.Validate())
1804- require.Len(t, snapshot.Hosts, 1)1922+ require.Len(t, snapshot.Daemons, 1)
1805- require.Equal(t, reg, snapshot.Hosts[0].Registration)1923+ require.Equal(t, reg, snapshot.Daemons[0].Registration)
1806 case <-time.After(time.Second):1924 case <-time.After(time.Second):
1807 t.Fatal("Snapshot blocked behind the durable membership CAS")1925 t.Fatal("Snapshot blocked behind the durable membership CAS")
1808 }1926 }
@@ -1811,7 +1929,7 @@ func TestRegistryReplaceHostsBlockedCASKeepsSnapshotLockFree(t *testing.T) {
1811 // parked: applying the result needs the registry lock, so the in-flight1929 // parked: applying the result needs the registry lock, so the in-flight
1812 // projection must stay exactly as published until membership commits.1930 // projection must stay exactly as published until membership commits.
1813 registry.hint()1931 registry.hint()
1814- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}1932+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1815 pending, ok := registry.Snapshot().Find(reg.Endpoint)1933 pending, ok := registry.Snapshot().Find(reg.Endpoint)
1816 require.True(t, ok)1934 require.True(t, ok)
1817 require.True(t, pending.Checking)1935 require.True(t, pending.Checking)
@@ -1834,7 +1952,7 @@ window:
1834 // applies the observation and resumes scheduling the next one.1952 // applies the observation and resumes scheduling the next one.
1835 store.releaseCAS()1953 store.releaseCAS()
1836 require.NoError(t, <-replaced)1954 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 {
1838 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable1956 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
1839 })1957 })
1840 require.Equal(t, clock.Now(), host.LastSuccess)1958 require.Equal(t, clock.Now(), host.LastSuccess)
@@ -1853,19 +1971,19 @@ func TestRegistryStoreErrorsAreObservableAndDoNotStallPublication(t *testing.T)
1853 defer registry.store.close()1971 defer registry.store.close()
1854 1972
1855 reg := registration(t, "example.test", 1)1973 reg := registration(t, "example.test", 1)
1856- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1974+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1857 waitForLog(t, logs, "durable snapshot write failed")1975 waitForLog(t, logs, "durable snapshot write failed")
1858 require.Contains(t, logs.String(), storeErr.Error())1976 require.Contains(t, logs.String(), storeErr.Error())
1859 1977
1860 // A failed write is not fatal and never blocks the next publication.1978 // A failed write is not fatal and never blocks the next publication.
1861 store.setStoreErr(nil)1979 store.setStoreErr(nil)
1862 reg.Generation++1980 reg.Generation++
1863- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1981+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1864 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {1982 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
1865 return snapshot.Revision == registry.Snapshot().Revision1983 return snapshot.Revision == registry.Snapshot().Revision
1866 })1984 })
1867- require.Len(t, stored.Hosts, 1)1985+ require.Len(t, stored.Daemons, 1)
1868- require.True(t, stored.Hosts[0].Registration.Equal(reg))1986+ require.True(t, stored.Daemons[0].Registration.Equal(reg))
1869 }1987 }
1870 1988
1871 func TestRegistryPersistedCopyClearsChecking(t *testing.T) {1989 func TestRegistryPersistedCopyClearsChecking(t *testing.T) {
@@ -1874,26 +1992,26 @@ func TestRegistryPersistedCopyClearsChecking(t *testing.T) {
1874 store := &testStore{}1992 store := &testStore{}
1875 registry := newTestRegistry(t, 3, store, probe, clock)1993 registry := newTestRegistry(t, 3, store, probe, clock)
1876 reg := registration(t, "example.test", 1)1994 reg := registration(t, "example.test", 1)
1877- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))1995+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1878 startRegistry(t, registry)1996 startRegistry(t, registry)
1879 1997
1880 // The observation is in flight: the published projection carries Checking,1998 // The observation is in flight: the published projection carries Checking,
1881 // the durable copy never does.1999 // 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 {
1883 return host.Checking2001 return host.Checking
1884 }).Checking)2002 }).Checking)
1885 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {2003 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
1886- return len(snapshot.Hosts) == 12004+ return len(snapshot.Daemons) == 1
1887 })2005 })
1888- require.False(t, stored.Hosts[0].Checking)2006+ require.False(t, stored.Daemons[0].Checking)
1889 2007
1890 // Completing the observation persists the settled projection.2008 // Completing the observation persists the settled projection.
1891 call := receiveCall(t, probe)2009 call := receiveCall(t, probe)
1892- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}2010+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
1893 settled := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {2011 settled := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
1894- return len(snapshot.Hosts) == 1 && snapshot.Hosts[0].Availability == domain.RemoteAvailabilityReachable2012+ return len(snapshot.Daemons) == 1 && snapshot.Daemons[0].Availability == domain.RemoteAvailabilityReachable
1895 })2013 })
1896- require.False(t, settled.Hosts[0].Checking)2014+ require.False(t, settled.Daemons[0].Checking)
1897 }2015 }
1898 2016
1899 // TestRegistryNeverPersistsRetirementTombstones proves retirement tombstones2017 // TestRegistryNeverPersistsRetirementTombstones proves retirement tombstones
@@ -1907,16 +2025,16 @@ func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) {
1907 defer registry.store.close()2025 defer registry.store.close()
1908 2026
1909 reg := registration(t, "retired.test", 1)2027 reg := registration(t, "retired.test", 1)
1910- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))2028+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1911 require.NoError(t, registry.setHosts(nil))2029 require.NoError(t, registry.setHosts(nil))
1912 2030
1913 // Removal publishes a bounded tombstone and drops the live host in memory.2031 // 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)
1915 require.Len(t, registry.Snapshot().Removed, 1)2033 require.Len(t, registry.Snapshot().Removed, 1)
1916 2034
1917 // The durable copy drops the tombstone before the store ever sees it.2035 // The durable copy drops the tombstone before the store ever sees it.
1918 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {2036 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
1919- return len(snapshot.Hosts) == 0 && snapshot.Revision == registry.Snapshot().Revision2037+ return len(snapshot.Daemons) == 0 && snapshot.Revision == registry.Snapshot().Revision
1920 })2038 })
1921 require.Empty(t, stored.Removed)2039 require.Empty(t, stored.Removed)
1922 2040
@@ -1924,7 +2042,7 @@ func TestRegistryNeverPersistsRetirementTombstones(t *testing.T) {
1924 // stale tombstone.2042 // stale tombstone.
1925 reopened, err := NewRegistry(2, &testStore{loaded: stored}, newTestProbe(1), clock, nil)2043 reopened, err := NewRegistry(2, &testStore{loaded: stored}, newTestProbe(1), clock, nil)
1926 require.NoError(t, err)2044 require.NoError(t, err)
1927- require.Empty(t, reopened.Snapshot().Hosts)2045+ require.Empty(t, reopened.Snapshot().Daemons)
1928 require.Empty(t, reopened.Snapshot().Removed)2046 require.Empty(t, reopened.Snapshot().Removed)
1929 }2047 }
1930 2048
@@ -1937,7 +2055,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) {
1937 reg := registration(t, "example.test", 1)2055 reg := registration(t, "example.test", 1)
1938 for generation := domain.RemoteGeneration(1); generation <= 10; generation++ {2056 for generation := domain.RemoteGeneration(1); generation <= 10; generation++ {
1939 reg.Generation = generation2057 reg.Generation = generation
1940- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))2058+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1941 }2059 }
1942 // A subscriber that never drains is bounded to one pending wake and2060 // A subscriber that never drains is bounded to one pending wake and
1943 // never blocks publication.2061 // never blocks publication.
@@ -1949,7 +2067,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) {
1949 sub.Close()2067 sub.Close()
1950 sub.Close()2068 sub.Close()
1951 reg.Generation++2069 reg.Generation++
1952- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))2070+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1953 select {2071 select {
1954 case <-sub.Changed():2072 case <-sub.Changed():
1955 t.Fatal("closed subscription received a notification")2073 t.Fatal("closed subscription received a notification")
@@ -1972,7 +2090,7 @@ func TestRegistryBoundsSlowSubscribersAndStopsOnCancellation(t *testing.T) {
1972 t.Fatal("in-flight probe did not observe cancellation")2090 t.Fatal("in-flight probe did not observe cancellation")
1973 }2091 }
1974 // Cleanup settles the projection: no host stays in flight.2092 // 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 {
1976 return !host.Checking2094 return !host.Checking
1977 })2095 })
1978 require.Equal(t, domain.RemoteAvailabilityUnknown, host.Availability)2096 require.Equal(t, domain.RemoteAvailabilityUnknown, host.Availability)
@@ -1986,14 +2104,14 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) {
1986 registry, err := NewRegistry(1, store, probe, clock, logger)2104 registry, err := NewRegistry(1, store, probe, clock, logger)
1987 require.NoError(t, err)2105 require.NoError(t, err)
1988 reg := registration(t, "example.test", 1)2106 reg := registration(t, "example.test", 1)
1989- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{reg}))2107+ require.NoError(t, registry.setHosts(hostRecords(reg)))
1990 2108
1991 ctx, cancel := context.WithCancel(context.Background())2109 ctx, cancel := context.WithCancel(context.Background())
1992 done := make(chan struct{})2110 done := make(chan struct{})
1993 go func() { defer close(done); registry.Run(ctx) }()2111 go func() { defer close(done); registry.Run(ctx) }()
1994 2112
1995 call := receiveCall(t, probe)2113 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 {
1997 return host.Checking2115 return host.Checking
1998 }).Checking)2116 }).Checking)
1999 2117
@@ -2014,22 +2132,22 @@ func TestRegistryRunIsSingleShotAndCleansUpInFlightState(t *testing.T) {
2014 t.Fatal("registry did not stop after cancellation")2132 t.Fatal("registry did not stop after cancellation")
2015 }2133 }
2016 // Cleanup publishes the settled projection: nothing stays in flight.2134 // 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 {
2018 return !host.Checking2136 return !host.Checking
2019 }).Checking)2137 }).Checking)
2020 2138
2021 // The retired completion can never be applied once the run is over.2139 // 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}}
2023 settled := registry.Snapshot()2141 settled := registry.Snapshot()
2024- require.Equal(t, domain.RemoteAvailabilityUnknown, settled.Hosts[0].Availability)2142+ require.Equal(t, domain.RemoteAvailabilityUnknown, settled.Daemons[0].Availability)
2025- require.Zero(t, settled.Hosts[0].ConsecutiveFailures)2143+ require.Zero(t, settled.Daemons[0].ConsecutiveFailures)
2026 2144
2027 // Persistence is flushed before shutdown and no write outlives it.2145 // Persistence is flushed before shutdown and no write outlives it.
2028 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {2146 stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
2029 return snapshot.Revision >= settled.Revision2147 return snapshot.Revision >= settled.Revision
2030 })2148 })
2031 require.Equal(t, settled.Revision, stored.Revision)2149 require.Equal(t, settled.Revision, stored.Revision)
2032- require.False(t, stored.Hosts[0].Checking)2150+ require.False(t, stored.Daemons[0].Checking)
2033 writes := store.writeCount()2151 writes := store.writeCount()
2034 require.NoError(t, registry.setHosts(nil))2152 require.NoError(t, registry.setHosts(nil))
2035 require.Equal(t, writes, store.writeCount())2153 require.Equal(t, writes, store.writeCount())
@@ -2064,18 +2182,186 @@ func TestRegistryRevisionsIncreaseMonotonicallyWithinEpoch(t *testing.T) {
2064 2182
2065 first := registration(t, "a.test", 1)2183 first := registration(t, "a.test", 1)
2066 second := registration(t, "b.test", 2)2184 second := registration(t, "b.test", 2)
2067- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first}))2185+ require.NoError(t, registry.setHosts(hostRecords(first)))
2068 observe()2186 observe()
2069- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{first, second}))2187+ require.NoError(t, registry.setHosts(hostRecords(first, second)))
2070 observe()2188 observe()
2071 2189
2072 startRegistry(t, registry)2190 startRegistry(t, registry)
2073 for range 2 {2191 for range 2 {
2074 call := receiveCall(t, probe)2192 call := receiveCall(t, probe)
2075- call.result <- probeAnswer{snapshot: ports.RemoteHostSnapshot{Availability: domain.RemoteAvailabilityReachable}}2193+ call.result <- probeAnswer{snapshot: ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityReachable}}
2076- waitHost(t, registry, call.registration.Endpoint, func(host ports.RemoteHostSnapshot) bool {2194+ waitHost(t, registry, call.registration.Endpoint, func(host ports.BrokerDaemonObservation) bool {
2077 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable2195 return !host.Checking && host.Availability == domain.RemoteAvailabilityReachable
2078 })2196 })
2079 observe()2197 observe()
2080 }2198 }
2081 }2199 }
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) {
110 // The admission context is discarded: the connection keeps serving after the110 // The admission context is discarded: the connection keeps serving after the
111 // caller's setup deadline expires.111 // caller's setup deadline expires.
112 cancel()112 cancel()
113- require.NoError(t, registry.setHosts([]domain.RemoteRegistration{registration(t, "user@host:22", 1)}))113+ require.NoError(t, registry.setHosts(hostRecords(registration(t, "user@host:22", 1))))
114- require.Len(t, service.Snapshot().Hosts, 1)114+ require.Len(t, service.Snapshot().Daemons, 1)
115 115
116 stream, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1))116 stream, err := service.OpenStream(context.Background(), poolRequest(service.ConnectionID(), 1))
117 require.NoError(t, err)117 require.NoError(t, err)
@@ -251,14 +251,14 @@ func TestServiceSnapshotDelegates(t *testing.T) {
251 require.NoError(t, err)251 require.NoError(t, err)
252 defer sub.Close()252 defer sub.Close()
253 253
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))))
255 select {255 select {
256 case <-sub.Changed():256 case <-sub.Changed():
257 case <-time.After(time.Second):257 case <-time.After(time.Second):
258 t.Fatal("the connection subscription must observe a registry publication")258 t.Fatal("the connection subscription must observe a registry publication")
259 }259 }
260 require.Equal(t, registry.Snapshot(), service.Snapshot())260 require.Equal(t, registry.Snapshot(), service.Snapshot())
261- require.Len(t, service.Snapshot().Hosts, 1)261+ require.Len(t, service.Snapshot().Daemons, 1)
262 }262 }
263 263
264 func TestServiceOperationLeaseLifetime(t *testing.T) {264 func TestServiceOperationLeaseLifetime(t *testing.T) {
@@ -443,7 +443,7 @@ func TestServiceOfflineMembershipRefusal(t *testing.T) {
443 require.False(t, removed)443 require.False(t, removed)
444 require.ErrorIs(t, err, ErrOfflineMembership)444 require.ErrorIs(t, err, ErrOfflineMembership)
445 445
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")
447 before := registry.Snapshot()447 before := registry.Snapshot()
448 service.RequestReconcile("new@host:22")448 service.RequestReconcile("new@host:22")
449 require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op")449 require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op")
@@ -456,7 +456,7 @@ func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) {
456 store.loaded = ports.BrokerSnapshot{456 store.loaded = ports.BrokerSnapshot{
457 Epoch: 9,457 Epoch: 9,
458 Revision: 4,458 Revision: 4,
459- Hosts: []ports.RemoteHostSnapshot{{459+ Daemons: []ports.BrokerDaemonObservation{{
460 Endpoint: endpoint,460 Endpoint: endpoint,
461 Registration: registration(t, endpoint, 1),461 Registration: registration(t, endpoint, 1),
462 Availability: domain.RemoteAvailabilityReachable,462 Availability: domain.RemoteAvailabilityReachable,
@@ -482,7 +482,7 @@ func TestRegistryObservationDisabledIssuesNoProbesOrTimers(t *testing.T) {
482 probe := newTestProbe(4)482 probe := newTestProbe(4)
483 r, err := NewRegistryWithConfig(1, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true})483 r, err := NewRegistryWithConfig(1, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true})
484 require.NoError(t, err)484 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))))
486 486
487 ctx, cancel := context.WithCancel(context.Background())487 ctx, cancel := context.WithCancel(context.Background())
488 done := make(chan struct{})488 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