detached 68c0c3b

feat(broker): expose mutable membership over IPC

commit
68c0c3b2077fd87c974d3446da89976deac16043
author
brice · · 2026-09-20 04:31 UTC
parent
c9fb7d8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +2948 -507
M docs/architecture.md +3 -3
@@ -23,7 +23,7 @@ vev uses a hexagonal core with typed session messages at the client and daemon b
2323 decode-failure classification.
2424 - `internal/adapters/brokerwire`: translates between typed broker messages
2525 and the separate broker Protobuf conversation (`schema/broker.proto`;
26- client tags 101-110, server tags 201-209, disjoint from every session
26+ client tags 101-111, server tags 201-209, disjoint from every session
2727 tag, each direction its own closed `oneof`). It owns the stateless
2828 broker codec over `wire.ScanEnvelope`, the broker preamble (roles 3/4,
2929 exact `protocol.Version`, 1 MiB..16 MiB / 1..64 KiB ceilings, 4 KiB
@@ -197,12 +197,12 @@ Use cases exchange `protocol.ClientMessage` and `protocol.ServerMessage` values.
197197 `go tool buf generate`, and handle the new variant in `sessionwire`.
198198 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
199199 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.
200-- 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.
200+- Bump `internal/protocol.Version` for negotiated wire layout changes (currently `56`). The preamble epoch (`wire.ProtocolEpoch`, QUIC ALPN `vev/1`) bumps only for an intentional clean break.
201201
202202 ## Broker wire core (Plan 001 P3.1, not activated)
203203
204204 `internal/adapters/brokerwire` owns the broker's separate Protobuf
205-conversation end to end: client tags 101-110 and server tags 201-209 are
205+conversation end to end: client tags 101-111 and server tags 201-209 are
206206 disjoint from every session tag, and each direction is its own closed
207207 directional `oneof` union. A broker connection opens with exactly one
208208 broker preamble (roles 3/4) that carries the same magic, epoch, exact
M docs/protocol.md +1 -1
@@ -97,7 +97,7 @@ connection and correlated by `RequestID`.
9797 ## Broker negotiation
9898
9999 Broker connections are a separate conversation from the session protocol.
100-`internal/adapters/brokerwire` owns it: client tags 101-110 and server tags
100+`internal/adapters/brokerwire` owns it: client tags 101-111 and server tags
101101 201-209 are disjoint from every session tag, and each direction is its own
102102 closed directional `oneof` union. A broker connection opens with exactly one
103103 broker preamble (`PreambleRequest` client → server, `PreambleResponse` server
M internal/adapters/brokeripc/bounded_setup_test.go +2 -6
@@ -176,9 +176,7 @@ func TestClientConnectionOutlivesSetupContext(t *testing.T) {
176176 time.Sleep(400 * time.Millisecond)
177177 require.ErrorIs(t, setupCtx.Err(), context.DeadlineExceeded, "the setup context must have expired")
178178
179- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
180- defer cancel()
181- require.NoError(t, client.AddHost(ctx, "after@setup:22"), "a registered connection must outlive its setup context")
179+ requireConnectionDelegates(t, client)
182180 }
183181
184182 // TestServerRegistrationBudgetSettlesSilentPeer proves a same-user peer cannot
@@ -279,9 +277,7 @@ func TestServerRegistrationBudgetLeavesRegisteredSessionHealthy(t *testing.T) {
279277
280278 // Outlive the accept-time registration budget.
281279 time.Sleep(400 * time.Millisecond)
282- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
283- defer cancel()
284- require.NoError(t, client.AddHost(ctx, "after@budget:22"))
280+ requireConnectionDelegates(t, client)
285281 select {
286282 case <-core.done:
287283 t.Fatal("a session that registered in time must not be settled by the registration budget")
M internal/adapters/brokeripc/client.go +61 -28
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/vev/internal/adapters/brokerwire"
1010 "github.com/bnema/vev/internal/adapters/ipc"
1111 "github.com/bnema/vev/internal/adapters/streamframe"
12+ "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
1314 "github.com/bnema/vev/internal/protocol/wire"
1415 )
@@ -121,6 +122,7 @@ func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier)
121122 ctx: ctx,
122123 cancel: cancelSession,
123124 pending: make(map[ports.BrokerOperationID]chan operationResult),
125+ kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind),
124126 streams: make(map[ports.BrokerStreamID]*clientStream),
125127 done: make(chan struct{}),
126128 }
@@ -192,9 +194,10 @@ func exchangeRegister(transport wire.BoundedTransport, ceilings brokerwire.Ceili
192194
193195 // operationResult is one reply to a mutating operation.
194196 type operationResult struct {
195- outcome ports.BrokerMutationOutcome
196- removed bool
197- detail brokerwire.ErrorDetail
197+ outcome ports.BrokerMutationOutcome
198+ removed bool
199+ registration domain.RemoteRegistration
200+ detail brokerwire.ErrorDetail
198201 }
199202
200203 // client implements ports.BrokerService over one broker IPC connection.
@@ -217,6 +220,7 @@ type client struct {
217220 nextStream ports.BrokerStreamID
218221 snapshot ports.BrokerSnapshot
219222 pending map[ports.BrokerOperationID]chan operationResult
223+ kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind
220224 streams map[ports.BrokerStreamID]*clientStream
221225 closeOnce sync.Once
222226 done chan struct{}
@@ -267,10 +271,21 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error {
267271 if !c.scopeMatches(m.Epoch, m.Connection) {
268272 return errors.Join(ErrScopeMismatch, ErrProtocol)
269273 }
270- // The operation's waiter records the tracker completion exactly once;
271- // a result with no waiter (an abandoned or replayed operation) is
272- // dropped here.
273- c.completeOperation(m.Operation, operationResult{outcome: m.Outcome, removed: m.Removed, detail: m.Error})
274+ c.mu.Lock()
275+ _, ok := c.pending[m.Operation]
276+ kind := c.kinds[m.Operation]
277+ c.mu.Unlock()
278+ // An abandoned or replayed completion has no authority over current
279+ // state and is dropped. A completion for a live operation must satisfy
280+ // that request kind's payload invariant.
281+ if !ok {
282+ return nil
283+ }
284+ if err := m.ValidateForMutation(kind); err != nil {
285+ c.completeOperation(m.Operation, operationResult{outcome: ports.BrokerOutcomeUnknown, detail: errorDetail(err)})
286+ return errors.Join(ErrMalformedFrame, err)
287+ }
288+ c.completeOperation(m.Operation, operationResult{outcome: m.Outcome, removed: m.Removed, registration: m.Registration, detail: m.Error})
274289 return nil
275290 case brokerwire.StreamOpened:
276291 if !c.scopeMatches(m.Epoch, m.Connection) {
@@ -361,17 +376,26 @@ func (c *client) publish(snapshot ports.BrokerSnapshot) {
361376 }
362377 }
363378
379+// releaseOperation removes all client-side state for an operation.
380+func (c *client) releaseOperation(operation ports.BrokerOperationID) {
381+ c.mu.Lock()
382+ delete(c.pending, operation)
383+ delete(c.kinds, operation)
384+ c.mu.Unlock()
385+}
386+
364387 // completeOperation hands one result to the operation's waiter, if any.
365388 func (c *client) completeOperation(operation ports.BrokerOperationID, result operationResult) {
366389 c.mu.Lock()
367- ch := c.pending[operation]
390+ reply, ok := c.pending[operation]
368391 delete(c.pending, operation)
392+ delete(c.kinds, operation)
369393 c.mu.Unlock()
370- if ch == nil {
394+ if !ok {
371395 return
372396 }
373397 select {
374- case ch <- result:
398+ case reply <- result:
375399 default:
376400 }
377401 }
@@ -533,30 +557,37 @@ func (c *client) CloseStream(connection ports.BrokerConnectionID, stream ports.B
533557 return nil
534558 }
535559
536-// AddHost requests one host registration. A lost reply is reported as
537-// outcome-unknown, never as an invitation to replay blindly.
538-func (c *client) AddHost(ctx context.Context, target string) error {
539- result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
540- return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target}
560+// AddHost sends one exact endpoint and policy mutation.
561+func (c *client) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
562+ result, err := c.mutate(ctx, brokerwire.MutationKindAddHost, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
563+ return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: endpoint, Policy: policy}
541564 })
542565 if err != nil {
543- return err
566+ return domain.RemoteRegistration{}, err
544567 }
545- return mutationFailure(result)
568+ return result.registration, mutationFailure(result)
546569 }
547570
548-// RemoveHost requests one host removal and reports whether the host was present.
549-func (c *client) RemoveHost(ctx context.Context, target string) (bool, error) {
550- result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
551- return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target}
571+// RemoveHost sends one exact-registration mutation.
572+func (c *client) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
573+ result, err := c.mutate(ctx, brokerwire.MutationKindRemoveHost, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
574+ return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Registration: expected}
552575 })
553576 if err != nil {
554577 return false, err
555578 }
556- if err := mutationFailure(result); err != nil {
557- return false, err
579+ return result.removed, mutationFailure(result)
580+}
581+
582+// UpdateHostPolicy sends one exact-registration replacement policy mutation.
583+func (c *client) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
584+ result, err := c.mutate(ctx, brokerwire.MutationKindUpdateHostPolicy, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
585+ return brokerwire.UpdateHostPolicy{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Registration: expected, Policy: policy}
586+ })
587+ if err != nil {
588+ return domain.RemoteRegistration{}, err
558589 }
559- return result.removed, nil
590+ return result.registration, mutationFailure(result)
560591 }
561592
562593 // RequestReconcile asks the broker to re-observe one endpoint. The port carries
@@ -631,7 +662,7 @@ func (c *client) scopeRequest(request ports.BrokerOpenStreamRequest) (ports.Brok
631662 // error and records a failed outcome; once the wire attempt was launched, every
632663 // failure is reported as outcome-unknown, so a caller refreshes authoritative
633664 // state instead of replaying a non-idempotent mutation blindly.
634-func (c *client) mutate(ctx context.Context, build func(ports.BrokerOperationID) brokerwire.ClientMessage) (operationResult, error) {
665+func (c *client) mutate(ctx context.Context, kind brokerwire.RegisterMutationKind, build func(ports.BrokerOperationID) brokerwire.ClientMessage) (operationResult, error) {
635666 operation, err := newOperationID()
636667 if err != nil {
637668 return operationResult{}, err
@@ -652,14 +683,16 @@ func (c *client) mutate(ctx context.Context, build func(ports.BrokerOperationID)
652683 return operationResult{}, admissionError(err)
653684 }
654685 c.pending[operation] = reply
686+ if c.kinds == nil {
687+ c.kinds = make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind)
688+ }
689+ c.kinds[operation] = kind
655690 c.mu.Unlock()
656691 // No result observed is exactly the outcome-unknown case; a request that
657692 // was never launched is recorded as failed instead.
658693 outcome := ports.BrokerOutcomeUnknown
659694 defer func() {
660- c.mu.Lock()
661- delete(c.pending, operation)
662- c.mu.Unlock()
695+ c.releaseOperation(operation)
663696 _ = c.conn.CompleteOperation(operation, outcome)
664697 }()
665698 attempted, err := c.sendContext(ctx, build(operation))
M internal/adapters/brokeripc/client_mutation_test.go +443 -61
@@ -11,6 +11,7 @@ import (
1111 "github.com/stretchr/testify/require"
1212
1313 "github.com/bnema/vev/internal/adapters/brokerwire"
14+ "github.com/bnema/vev/internal/domain"
1415 "github.com/bnema/vev/internal/ports"
1516 "github.com/bnema/vev/internal/protocol/wire"
1617 )
@@ -122,6 +123,7 @@ func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) {
122123 ctx: ctx,
123124 cancel: cancel,
124125 pending: make(map[ports.BrokerOperationID]chan operationResult),
126+ kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind),
125127 streams: make(map[ports.BrokerStreamID]*clientStream),
126128 done: make(chan struct{}),
127129 }
@@ -129,38 +131,104 @@ func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) {
129131 return c, transport
130132 }
131133
132-// mutationOutcome is one AddHost or RemoveHost result.
134+// mutationOutcome is one membership mutation result: the removal bool, the
135+// authoritative registration (additions and policy updates), and the typed
136+// error a caller observes.
133137 type mutationOutcome struct {
134- removed bool
135- err error
138+ removed bool
139+ registration domain.RemoteRegistration
140+ err error
136141 }
137142
138-// mutationCall adapts one mutating operation to the shared table shape.
143+// mutationCall adapts one public ports.BrokerService membership method to the
144+// shared table shape. Every entry drives the real public API, so the wire
145+// request the carriage captures is exactly the one production sends.
139146 type mutationCall struct {
140147 name string
141148 endpoint string
142149 wantRemoved bool
143- call func(ctx context.Context, c *client, endpoint string) (bool, error)
150+ kind brokerwire.RegisterMutationKind
151+ call func(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error)
152+ // success builds the payload of one legal wire result for this request
153+ // kind; the test fills in the connection scope and operation identity.
154+ success func(operation ports.BrokerOperationID) brokerwire.OperationResult
144155 }
145156
146157 var mutationCalls = []mutationCall{
147158 {
148159 name: "AddHost",
149160 endpoint: "new@host:22",
150- call: func(ctx context.Context, c *client, endpoint string) (bool, error) {
151- return false, c.AddHost(ctx, endpoint)
161+ kind: brokerwire.MutationKindAddHost,
162+ call: func(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) {
163+ registration, err := c.AddHost(ctx, "new@host:22", testPolicy())
164+ return false, registration, err
165+ },
166+ success: func(operation ports.BrokerOperationID) brokerwire.OperationResult {
167+ return brokerwire.OperationResult{
168+ Operation: operation, Outcome: ports.BrokerOutcomeOK,
169+ Registration: mutationRegistration("new@host:22"),
170+ }
152171 },
153172 },
154173 {
155174 name: "RemoveHost",
156175 endpoint: "known",
157176 wantRemoved: true,
158- call: func(ctx context.Context, c *client, endpoint string) (bool, error) {
159- return c.RemoveHost(ctx, endpoint)
177+ kind: brokerwire.MutationKindRemoveHost,
178+ call: func(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) {
179+ removed, err := c.RemoveHost(ctx, mutationRegistration("known"))
180+ return removed, domain.RemoteRegistration{}, err
181+ },
182+ success: func(operation ports.BrokerOperationID) brokerwire.OperationResult {
183+ return brokerwire.OperationResult{Operation: operation, Outcome: ports.BrokerOutcomeOK, Removed: true}
184+ },
185+ },
186+ {
187+ name: "UpdateHostPolicy",
188+ endpoint: "known",
189+ kind: brokerwire.MutationKindUpdateHostPolicy,
190+ call: func(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) {
191+ registration, err := c.UpdateHostPolicy(ctx, mutationRegistration("known"), testPolicy())
192+ return false, registration, err
193+ },
194+ success: func(operation ports.BrokerOperationID) brokerwire.OperationResult {
195+ expected := mutationRegistration("known")
196+ expected.Generation++
197+ return brokerwire.OperationResult{Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: expected}
160198 },
161199 },
162200 }
163201
202+// mutationRegistration builds one valid exact registration for a mutation
203+// fixture.
204+func mutationRegistration(endpoint string) domain.RemoteRegistration {
205+ registration, err := domain.NewRemoteRegistration(endpoint, [16]byte{0x11})
206+ if err != nil {
207+ panic(err)
208+ }
209+ return registration
210+}
211+
212+// run drives one table entry through the public API.
213+func (call mutationCall) run(ctx context.Context, c *client) (bool, domain.RemoteRegistration, error) {
214+ return call.call(ctx, c)
215+}
216+
217+// deliverSuccess plays one legal success result for a captured request.
218+func (call mutationCall) deliverSuccess(t *testing.T, c *client, operation ports.BrokerOperationID) {
219+ t.Helper()
220+ result := call.success(operation)
221+ result.Epoch = c.scope.Epoch
222+ result.Connection = c.scope.Connection
223+ require.NoError(t, c.dispatch(result))
224+}
225+
226+// deliverResult plays one explicit broker reply for a captured request.
227+func deliverResult(t *testing.T, c *client, result brokerwire.OperationResult) {
228+ t.Helper()
229+ require.NoError(t, c.dispatch(result))
230+}
231+
164232 // awaitResult waits for one mutation call to return.
165233 func awaitResult(t *testing.T, results <-chan mutationOutcome) mutationOutcome {
166234 t.Helper()
@@ -195,26 +263,60 @@ func operationOf(t *testing.T, message brokerwire.ClientMessage) ports.BrokerOpe
195263 return m.Operation
196264 case brokerwire.RemoveHost:
197265 return m.Operation
266+ case brokerwire.UpdateHostPolicy:
267+ return m.Operation
198268 default:
199269 t.Fatalf("unexpected client message %T", message)
200270 return ports.BrokerOperationID{}
201271 }
202272 }
203273
204-// endpointOf extracts the target of one captured request.
274+// endpointOf extracts the target of one captured request. An AddHost names
275+// its endpoint directly; a RemoveHost or UpdateHostPolicy carries exact
276+// registration authority, whose endpoint is the target.
205277 func endpointOf(t *testing.T, message brokerwire.ClientMessage) string {
206278 t.Helper()
207279 switch m := message.(type) {
208280 case brokerwire.AddHost:
209281 return m.Endpoint
210282 case brokerwire.RemoveHost:
211- return m.Endpoint
283+ return m.Registration.Endpoint
284+ case brokerwire.UpdateHostPolicy:
285+ return m.Registration.Endpoint
212286 default:
213287 t.Fatalf("unexpected client message %T", message)
214288 return ""
215289 }
216290 }
217291
292+// registrationOf extracts the exact expected registration of one captured
293+// request. An AddHost carries no expected authority, only its policy.
294+func registrationOf(t *testing.T, message brokerwire.ClientMessage) (domain.RemoteRegistration, bool) {
295+ t.Helper()
296+ switch m := message.(type) {
297+ case brokerwire.RemoveHost:
298+ return m.Registration, true
299+ case brokerwire.UpdateHostPolicy:
300+ return m.Registration, true
301+ default:
302+ return domain.RemoteRegistration{}, false
303+ }
304+}
305+
306+// policyOf extracts the policy of one captured request.
307+func policyOf(t *testing.T, message brokerwire.ClientMessage) ports.BrokerPolicy {
308+ t.Helper()
309+ switch m := message.(type) {
310+ case brokerwire.AddHost:
311+ return m.Policy
312+ case brokerwire.UpdateHostPolicy:
313+ return m.Policy
314+ default:
315+ t.Fatalf("client message %T carries no policy", message)
316+ return ports.BrokerPolicy{}
317+ }
318+}
319+
218320 // pendingOperations snapshots the client's in-flight waiters.
219321 func pendingOperations(c *client) []ports.BrokerOperationID {
220322 c.mu.Lock()
@@ -226,19 +328,75 @@ func pendingOperations(c *client) []ports.BrokerOperationID {
226328 return out
227329 }
228330
229-// deliverResult plays one broker reply for a captured request.
230-func deliverResult(t *testing.T, c *client, operation ports.BrokerOperationID, outcome ports.BrokerMutationOutcome, removed bool) {
331+func requireNoPendingMutationState(t *testing.T, c *client) {
231332 t.Helper()
232- require.NoError(t, c.dispatch(brokerwire.OperationResult{
233- Epoch: c.scope.Epoch, Connection: c.scope.Connection,
234- Operation: operation, Outcome: outcome, Removed: removed,
235- }))
333+ c.mu.Lock()
334+ defer c.mu.Unlock()
335+ require.Empty(t, c.pending, "pending replies must be released")
336+ require.Empty(t, c.kinds, "pending mutation kinds must be released")
337+}
338+
339+// TestPublicMembershipAPIDelegatesExactAuthority proves the public membership
340+// surface reaches the carriage with the caller's exact authority and returns the
341+// broker's authority verbatim: an addition returns the minted registration, a
342+// policy update returns the generation-advanced registration, and a removal
343+// returns the exact bool. Each call sends exactly one request carrying the
344+// caller's policy or exact registration, and never re-derives one locally.
345+func TestPublicMembershipAPIDelegatesExactAuthority(t *testing.T) {
346+ policy := testPolicy()
347+ cases := []struct {
348+ name string
349+ index int
350+ }{
351+ {name: "AddHost", index: 0},
352+ {name: "RemoveHost", index: 1},
353+ {name: "UpdateHostPolicy", index: 2},
354+ }
355+ for _, tc := range cases {
356+ t.Run(tc.name, func(t *testing.T) {
357+ call := mutationCalls[tc.index]
358+ c, g := newGatedClient(t, Config{})
359+ ctx, cancel := context.WithCancel(context.Background())
360+ defer cancel()
361+
362+ results := make(chan mutationOutcome, 1)
363+ go func() {
364+ removed, registration, err := call.run(ctx, c)
365+ results <- mutationOutcome{removed: removed, registration: registration, err: err}
366+ }()
367+
368+ request := awaitSend(t, g)
369+ operation := operationOf(t, request)
370+ require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own")
371+ if expected, ok := registrationOf(t, request); ok {
372+ require.Equal(t, mutationRegistration(call.endpoint), expected,
373+ "the exact expected registration must travel unmodified")
374+ require.NotZero(t, expected.Generation, "a removal or update never drops its fencing generation")
375+ }
376+ if call.kind != brokerwire.MutationKindRemoveHost {
377+ require.Equal(t, policy, policyOf(t, request), "the caller's exact policy must travel")
378+ }
379+ kind, ok := brokerwire.MutationKindOf(request)
380+ require.True(t, ok, "the captured request must be a membership mutation")
381+ require.Equal(t, call.kind, kind, "the client must emit the request kind the table names")
382+ g.releaseSend(nil)
383+ call.deliverSuccess(t, c, operation)
384+
385+ got := awaitResult(t, results)
386+ require.NoError(t, got.err)
387+ require.Equal(t, call.wantRemoved, got.removed)
388+ want := call.success(operation).Registration
389+ require.Equal(t, want, got.registration, "the client returns the broker's authority verbatim")
390+ require.Equal(t, 1, g.sendCount(), "one delegation is exactly one wire request")
391+ require.Empty(t, pendingOperations(c))
392+ })
393+ }
236394 }
237395
238-// TestMutationSendUncertaintyTaxonomy pins the decision table for both mutating
239-// operations: a request that definitely never left is a definite error with zero
396+// TestMutationSendUncertaintyTaxonomy pins the decision table for every mutating
397+// operation: a request that definitely never left is a definite error with zero
240398 // sends; any failure observed after the attempt was launched is outcome-unknown
241-// with exactly one send.
399+// with exactly one send and no replay.
242400 func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
243401 scenarios := []struct {
244402 name string
@@ -272,6 +430,18 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
272430 wantSends: 1,
273431 wantUnknown: true,
274432 },
433+ {
434+ name: "reply lost after the request was sent",
435+ drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) {
436+ _ = awaitSend(t, g)
437+ g.releaseSend(nil)
438+ // The attempt completed, so the call now waits for its reply; only
439+ // the connection's terminal outcome ends that wait.
440+ require.NoError(t, c.Close())
441+ },
442+ wantSends: 1,
443+ wantUnknown: true,
444+ },
275445 {
276446 name: "Send fails after the request was captured",
277447 drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) {
@@ -281,6 +451,15 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
281451 wantSends: 1,
282452 wantUnknown: true,
283453 },
454+ {
455+ name: "carriage EOF after the request was captured",
456+ drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) {
457+ _ = awaitSend(t, g)
458+ g.releaseSend(io.EOF)
459+ },
460+ wantSends: 1,
461+ wantUnknown: true,
462+ },
284463 }
285464 for _, call := range mutationCalls {
286465 for _, tt := range scenarios {
@@ -293,14 +472,16 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
293472 }
294473 results := make(chan mutationOutcome, 1)
295474 go func() {
296- removed, err := call.call(ctx, c, call.endpoint)
297- results <- mutationOutcome{removed: removed, err: err}
475+ removed, registration, err := call.run(ctx, c)
476+ results <- mutationOutcome{removed: removed, registration: registration, err: err}
298477 }()
299478 if tt.drive != nil {
300479 tt.drive(t, c, g, cancel)
301480 }
302481 got := awaitResult(t, results)
303482 require.False(t, got.removed, "no reply means the target is never reported removed")
483+ require.Equal(t, domain.RemoteRegistration{}, got.registration,
484+ "no reply means no registration authority is invented")
304485 if tt.wantUnknown {
305486 var failure ports.BrokerError
306487 require.ErrorAs(t, got.err, &failure)
@@ -313,7 +494,7 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
313494 "a request that never left must not be reported as a broker outcome")
314495 }
315496 require.Equal(t, tt.wantSends, g.sendCount(), "the request must travel at most once")
316- require.Empty(t, pendingOperations(c), "every mutation releases its pending slot")
497+ requireNoPendingMutationState(t, c)
317498 })
318499 }
319500 }
@@ -327,37 +508,27 @@ func TestMutationDefiniteFailureRecordsAndReleases(t *testing.T) {
327508 ctx, cancel := context.WithCancel(context.Background())
328509 cancel()
329510
330- // Driving mutate directly makes the operation identity observable even
331- // though the request is never encoded for the wire.
332- var operation ports.BrokerOperationID
333- result, err := c.mutate(ctx, func(id ports.BrokerOperationID) brokerwire.ClientMessage {
334- operation = id
335- return brokerwire.AddHost{
336- Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: id, Endpoint: "pre@host:22",
337- }
338- })
511+ // Driving the public API with an already-cancelled caller is the definite
512+ // no-dispatch case: no request is encoded for the wire.
513+ added, err := c.AddHost(ctx, "pre@host:22", testPolicy())
339514 require.ErrorIs(t, err, context.Canceled)
340- require.Equal(t, operationResult{}, result)
341- require.NotZero(t, operation)
515+ require.Equal(t, domain.RemoteRegistration{}, added)
342516 require.Zero(t, g.sendCount(), "a pre-cancelled caller never enters the carriage")
343517 require.Empty(t, pendingOperations(c), "the definite failure releases its pending slot")
344518
345- // The identity is recorded with an outcome, not left in flight: admitting it
346- // again is deduped, which is only true once the tracker was completed.
347- require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted)
348-
349519 // The bound of one proves the slot is genuinely free: a fresh mutation is
350520 // admitted and completes normally.
351521 fresh, cancelFresh := context.WithCancel(context.Background())
352522 defer cancelFresh()
353523 results := make(chan mutationOutcome, 1)
354524 go func() {
355- removed, callErr := c.RemoveHost(fresh, "known")
356- results <- mutationOutcome{removed: removed, err: callErr}
525+ removed, registration, callErr := mutationCalls[1].run(fresh, c)
526+ results <- mutationOutcome{removed: removed, registration: registration, err: callErr}
357527 }()
358528 request := awaitSend(t, g)
529+ operation := operationOf(t, request)
359530 g.releaseSend(nil)
360- deliverResult(t, c, operationOf(t, request), ports.BrokerOutcomeOK, true)
531+ mutationCalls[1].deliverSuccess(t, c, operation)
361532 got := awaitResult(t, results)
362533 require.NoError(t, got.err)
363534 require.True(t, got.removed)
@@ -376,12 +547,13 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
376547
377548 results := make(chan mutationOutcome, 1)
378549 go func() {
379- removed, err := call.call(ctx, c, call.endpoint)
380- results <- mutationOutcome{removed: removed, err: err}
550+ removed, registration, err := call.run(ctx, c)
551+ results <- mutationOutcome{removed: removed, registration: registration, err: err}
381552 }()
382553 request := awaitSend(t, g)
383554 operation := operationOf(t, request)
384555 require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own")
556+ g.releaseSend(nil)
385557 cancel()
386558 got := awaitResult(t, results)
387559 var failure ports.BrokerError
@@ -389,19 +561,18 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
389561 require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)
390562 require.False(t, got.removed)
391563 require.Equal(t, 1, g.sendCount(), "an uncertain attempt is never resent")
392- // The cancelled attempt is still held inside the carriage. Hand it its
393- // result now, so the next mutation's attempt is the only one waiting
394- // on the shared release channel.
395- g.releaseSend(errTestCarriage)
396564
397565 // The identity was completed once, so its pending slot is released
398566 // rather than left in flight.
399567 require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted)
400568 require.Empty(t, pendingOperations(c))
401569
402- // A late result for the completed identity is dropped, and it never
403- // provokes another wire attempt.
404- deliverResult(t, c, operation, ports.BrokerOutcomeOK, true)
570+ // A late legal result for the completed identity is dropped, and it
571+ // never provokes another wire attempt.
572+ late := call.success(operation)
573+ late.Epoch = c.scope.Epoch
574+ late.Connection = c.scope.Connection
575+ deliverResult(t, c, late)
405576 require.Empty(t, pendingOperations(c))
406577 require.Equal(t, 1, g.sendCount())
407578
@@ -411,12 +582,12 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
411582 defer cancelFresh()
412583 next := make(chan mutationOutcome, 1)
413584 go func() {
414- removed, err := call.call(fresh, c, call.endpoint)
415- next <- mutationOutcome{removed: removed, err: err}
585+ removed, registration, err := call.run(fresh, c)
586+ next <- mutationOutcome{removed: removed, registration: registration, err: err}
416587 }()
417588 second := awaitSend(t, g)
418589 g.releaseSend(nil)
419- deliverResult(t, c, operationOf(t, second), ports.BrokerOutcomeOK, call.wantRemoved)
590+ call.deliverSuccess(t, c, operationOf(t, second))
420591 done := awaitResult(t, next)
421592 require.NoError(t, done.err)
422593 require.Equal(t, call.wantRemoved, done.removed)
@@ -426,7 +597,7 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
426597 }
427598
428599 // TestMutationDeliveredReplyIsDefinite proves an ordinary delivered reply stays
429-// the definite outcome for both operations, and a repeated result for the same
600+// the definite outcome for every operation, and a repeated result for the same
430601 // identity changes nothing.
431602 func TestMutationDeliveredReplyIsDefinite(t *testing.T) {
432603 for _, call := range mutationCalls {
@@ -437,14 +608,14 @@ func TestMutationDeliveredReplyIsDefinite(t *testing.T) {
437608
438609 results := make(chan mutationOutcome, 1)
439610 go func() {
440- removed, err := call.call(ctx, c, call.endpoint)
441- results <- mutationOutcome{removed: removed, err: err}
611+ removed, registration, err := call.run(ctx, c)
612+ results <- mutationOutcome{removed: removed, registration: registration, err: err}
442613 }()
443614 request := awaitSend(t, g)
444615 operation := operationOf(t, request)
445616 require.Equal(t, call.endpoint, endpointOf(t, request))
446617 g.releaseSend(nil)
447- deliverResult(t, c, operation, ports.BrokerOutcomeOK, call.wantRemoved)
618+ call.deliverSuccess(t, c, operation)
448619 got := awaitResult(t, results)
449620 require.NoError(t, got.err)
450621 require.Equal(t, call.wantRemoved, got.removed)
@@ -452,13 +623,224 @@ func TestMutationDeliveredReplyIsDefinite(t *testing.T) {
452623 require.Empty(t, pendingOperations(c))
453624
454625 // A replayed result for an already settled identity is dropped.
455- deliverResult(t, c, operation, ports.BrokerOutcomeUnknown, true)
626+ replay := call.success(operation)
627+ replay.Epoch = c.scope.Epoch
628+ replay.Connection = c.scope.Connection
629+ deliverResult(t, c, replay)
456630 require.Empty(t, pendingOperations(c))
457631 require.Equal(t, 1, g.sendCount())
458632 })
459633 }
460634 }
461635
636+// TestMutationInvalidResultIsOutcomeUnknownAndAbortsProtocol proves every result
637+// that cannot carry authority for its request is refused rather than applied: a
638+// result of the wrong kind, a success missing its registration authority, a
639+// removal success that also carries a registration, and a result with a
640+// malformed error detail each complete the caller as outcome-unknown and settle
641+// the connection with a malformed-frame protocol violation.
642+func TestMutationInvalidResultIsOutcomeUnknownAndAbortsProtocol(t *testing.T) {
643+ cases := []struct {
644+ name string
645+ call mutationCall
646+ result func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult
647+ }{
648+ {
649+ name: "wrong kind for an addition",
650+ call: mutationCalls[0],
651+ result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult {
652+ return brokerwire.OperationResult{
653+ Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation,
654+ Outcome: ports.BrokerOutcomeOK, Removed: true,
655+ }
656+ },
657+ },
658+ {
659+ name: "addition success missing its registration",
660+ call: mutationCalls[0],
661+ result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult {
662+ return brokerwire.OperationResult{
663+ Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation,
664+ Outcome: ports.BrokerOutcomeOK,
665+ }
666+ },
667+ },
668+ {
669+ name: "policy update success missing its registration",
670+ call: mutationCalls[2],
671+ result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult {
672+ return brokerwire.OperationResult{
673+ Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation,
674+ Outcome: ports.BrokerOutcomeOK,
675+ }
676+ },
677+ },
678+ {
679+ name: "removal success carrying a registration",
680+ call: mutationCalls[1],
681+ result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult {
682+ return brokerwire.OperationResult{
683+ Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation,
684+ Outcome: ports.BrokerOutcomeOK, Removed: true, Registration: mutationRegistration("known"),
685+ }
686+ },
687+ },
688+ {
689+ name: "malformed error detail",
690+ call: mutationCalls[0],
691+ result: func(c *client, operation ports.BrokerOperationID) brokerwire.OperationResult {
692+ return brokerwire.OperationResult{
693+ Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation,
694+ Outcome: ports.BrokerOutcomeFailed,
695+ Error: brokerwire.ErrorDetail{Code: ports.BrokerErrorCode(99), Text: "bogus"}, HasError: true,
696+ }
697+ },
698+ },
699+ }
700+ for _, tc := range cases {
701+ t.Run(tc.name, func(t *testing.T) {
702+ c, g := newGatedClient(t, Config{})
703+ ctx, cancel := context.WithCancel(context.Background())
704+ defer cancel()
705+
706+ results := make(chan mutationOutcome, 1)
707+ go func() {
708+ removed, registration, err := tc.call.run(ctx, c)
709+ results <- mutationOutcome{removed: removed, registration: registration, err: err}
710+ }()
711+ request := awaitSend(t, g)
712+ operation := operationOf(t, request)
713+ g.releaseSend(nil)
714+
715+ dispatchErr := c.dispatch(tc.result(c, operation))
716+ require.Error(t, dispatchErr, "an invalid result must settle the connection")
717+ require.ErrorIs(t, dispatchErr, ErrMalformedFrame, "an invalid result is a protocol violation")
718+
719+ got := awaitResult(t, results)
720+ var failure ports.BrokerError
721+ require.ErrorAs(t, got.err, &failure)
722+ require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code,
723+ "a result that cannot be applied must never look like a definite outcome")
724+ require.False(t, got.removed)
725+ require.Equal(t, domain.RemoteRegistration{}, got.registration)
726+ require.Equal(t, 1, g.sendCount(), "an invalid result must never provoke a replay")
727+ require.Empty(t, pendingOperations(c))
728+ })
729+ }
730+}
731+
732+// scriptedTransport is a client carriage whose single inbound frame can be
733+// injected: the reader goroutine consumes it and then blocks until the test
734+// closes the carriage. It lets a test drive the client's real run loop with one
735+// malformed frame and observe how a pending mutation is settled.
736+type scriptedTransport struct {
737+ ready chan wire.Envelope
738+ done chan struct{}
739+ once sync.Once
740+
741+ mu sync.Mutex
742+ sends []wire.Envelope
743+}
744+
745+func newScriptedTransport() *scriptedTransport {
746+ return &scriptedTransport{ready: make(chan wire.Envelope, 1), done: make(chan struct{})}
747+}
748+
749+func (t *scriptedTransport) deliver(envelope wire.Envelope) { t.ready <- envelope }
750+
751+func (t *scriptedTransport) Send(envelope wire.Envelope) error {
752+ t.mu.Lock()
753+ t.sends = append(t.sends, envelope)
754+ t.mu.Unlock()
755+ return nil
756+}
757+
758+func (t *scriptedTransport) RecvBounded(uint64) (wire.Envelope, error) {
759+ select {
760+ case envelope := <-t.ready:
761+ return envelope, nil
762+ case <-t.done:
763+ return wire.Envelope{}, io.EOF
764+ }
765+}
766+
767+func (t *scriptedTransport) Recv() (wire.Envelope, error) { return t.RecvBounded(0) }
768+
769+func (t *scriptedTransport) Close() error {
770+ t.once.Do(func() { close(t.done) })
771+ return nil
772+}
773+
774+func (t *scriptedTransport) sendCount() int {
775+ t.mu.Lock()
776+ defer t.mu.Unlock()
777+ return len(t.sends)
778+}
779+
780+// TestMalformedResultFrameSettlesPendingMutationAsUnknown proves a malformed
781+// server frame the reader refuses settles the connection and is reported to a
782+// pending mutation as outcome-unknown, never as a definite failure: the request
783+// was already sent, so the caller must refresh rather than replay it.
784+func TestMalformedResultFrameSettlesPendingMutationAsUnknown(t *testing.T) {
785+ transport := newScriptedTransport()
786+ scope := brokerwire.Scope{
787+ Epoch: ports.BrokerEpoch(0x72),
788+ Connection: ports.BrokerConnectionID{0x72, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x01},
789+ }
790+ conn, err := brokerwire.NewConnection(scope)
791+ require.NoError(t, err)
792+ require.NoError(t, conn.SignalPreamble())
793+ require.NoError(t, conn.Register())
794+ ctx, cancel := context.WithCancel(context.Background())
795+ defer cancel()
796+ c := &client{
797+ transport: transport,
798+ ceilings: brokerwire.DefaultCeilings(),
799+ scope: scope,
800+ conn: conn,
801+ cfg: Config{}.withDefaults(),
802+ ctx: ctx,
803+ cancel: cancel,
804+ pending: make(map[ports.BrokerOperationID]chan operationResult),
805+ kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind),
806+ streams: make(map[ports.BrokerStreamID]*clientStream),
807+ done: make(chan struct{}),
808+ }
809+ t.Cleanup(func() { _ = c.Close() })
810+ go c.run()
811+
812+ results := make(chan mutationOutcome, 1)
813+ go func() {
814+ removed, err := c.RemoveHost(context.Background(), mutationRegistration("known"))
815+ results <- mutationOutcome{removed: removed, err: err}
816+ }()
817+ require.Eventually(t, func() bool { return transport.sendCount() == 1 }, 5*time.Second, time.Millisecond,
818+ "the mutation request must be launched before the malformed frame arrives")
819+ c.mu.Lock()
820+ pending := len(c.pending)
821+ c.mu.Unlock()
822+ require.Equal(t, 1, pending, "the mutation must still be pending when the malformed frame arrives")
823+
824+ // One frame the strict scanner refuses: a payload that is not a valid server
825+ // envelope. The reader settles the connection on it.
826+ transport.deliver(wire.Envelope{Payload: []byte{0xFF, 0xFF, 0xFF}})
827+
828+ got := awaitResult(t, results)
829+ var failure ports.BrokerError
830+ require.ErrorAs(t, got.err, &failure)
831+ require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code,
832+ "a malformed frame after dispatch must never look like a definite outcome")
833+ require.False(t, got.removed)
834+ require.Equal(t, domain.RemoteRegistration{}, got.registration)
835+ require.Equal(t, 1, transport.sendCount(), "the request must have travelled exactly once")
836+ select {
837+ case <-c.Done():
838+ case <-time.After(5 * time.Second):
839+ t.Fatal("the malformed frame must settle the connection")
840+ }
841+ require.ErrorIs(t, c.Err(), ErrMalformedFrame)
842+}
843+
462844 // TestSendContextPreflightClassification pins the private helper's taxonomy
463845 // directly: an unencodable frame, an already-cancelled caller, and an
464846 // already-terminal connection are definitely not dispatched; only a launched
@@ -478,7 +860,7 @@ func TestSendContextPreflightClassification(t *testing.T) {
478860 cancel()
479861 attempted, err := c.sendContext(ctx, brokerwire.AddHost{
480862 Epoch: c.scope.Epoch, Connection: c.scope.Connection,
481- Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22",
863+ Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22", Policy: testPolicy(),
482864 })
483865 require.False(t, attempted)
484866 require.ErrorIs(t, err, context.Canceled)
@@ -490,7 +872,7 @@ func TestSendContextPreflightClassification(t *testing.T) {
490872 require.NoError(t, c.Close())
491873 attempted, err := c.sendContext(context.Background(), brokerwire.AddHost{
492874 Epoch: c.scope.Epoch, Connection: c.scope.Connection,
493- Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22",
875+ Operation: ports.BrokerOperationID{0x01}, Endpoint: "pre@host:22", Policy: testPolicy(),
494876 })
495877 require.False(t, attempted)
496878 require.ErrorIs(t, err, ErrConnectionClosed)
@@ -507,7 +889,7 @@ func TestSendContextPreflightClassification(t *testing.T) {
507889 go func() {
508890 attempted, err := c.sendContext(context.Background(), brokerwire.RemoveHost{
509891 Epoch: c.scope.Epoch, Connection: c.scope.Connection,
510- Operation: ports.BrokerOperationID{0x02}, Endpoint: "known",
892+ Operation: ports.BrokerOperationID{0x02}, Registration: mutationRegistration("known"),
511893 })
512894 done <- sendResult{attempted: attempted, err: err}
513895 }()
M internal/adapters/brokeripc/helpers_test.go +244 -16
@@ -3,6 +3,7 @@ package brokeripc
33 import (
44 "context"
55 "encoding/binary"
6+ "errors"
67 "fmt"
78 "io"
89 "os"
@@ -45,6 +46,81 @@ func testPolicy() ports.BrokerPolicy {
4546 }
4647 }
4748
49+// membershipProbeEndpoint is the endpoint every liveness probe adds through the
50+// real membership delegation path.
51+const membershipProbeEndpoint = "probe@host:22"
52+
53+// requireConnectionDelegates proves one connection still admits a full
54+// membership round trip after a stream-local or subscriber-local event: the
55+// probe adds one host through the same delegated path a real caller uses and
56+// asserts the registration comes back for exactly the requested endpoint.
57+func requireConnectionDelegates(t *testing.T, service ports.BrokerService) {
58+ t.Helper()
59+ require.NoError(t, connectionDelegates(context.Background(), service))
60+}
61+
62+// connectionDelegates is the error-returning form of requireConnectionDelegates,
63+// safe to call from a test goroutine where FailNow is not.
64+func connectionDelegates(ctx context.Context, service ports.BrokerService) error {
65+ ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
66+ defer cancel()
67+ registration, err := service.AddHost(ctx, membershipProbeEndpoint, testPolicy())
68+ if err != nil {
69+ return err
70+ }
71+ if registration.Endpoint != membershipProbeEndpoint {
72+ return fmt.Errorf("AddHost answered registration for %q, want %q", registration.Endpoint, membershipProbeEndpoint)
73+ }
74+ return nil
75+}
76+
77+// recvWithin reads one server message under an explicit bound, so a session that
78+// never answers settles as a bounded test failure instead of hanging the package
79+// on a read that can never complete.
80+func (r *rawCarriage) recvWithin(t *testing.T, timeout time.Duration) brokerwire.ServerMessage {
81+ t.Helper()
82+ type result struct {
83+ message brokerwire.ServerMessage
84+ err error
85+ }
86+ done := make(chan result, 1)
87+ go func() {
88+ envelope, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes)
89+ if err != nil {
90+ done <- result{nil, err}
91+ return
92+ }
93+ message, err := brokerwire.DecodeServer(envelope.Payload, r.ceilings.MaxReceiveEnvelopeBytes, r.ceilings.StreamChunkLimit)
94+ done <- result{message, err}
95+ }()
96+ select {
97+ case got := <-done:
98+ require.NoError(t, got.err, "the broker must answer with a decodable server frame")
99+ return got.message
100+ case <-time.After(timeout):
101+ t.Fatal("the broker did not answer within the bound")
102+ return nil
103+ }
104+}
105+
106+// awaitReadError proves one raw read fails under an explicit bound, so a session
107+// that never settles is a bounded test failure rather than a hang.
108+func (r *rawCarriage) awaitReadError(t *testing.T, timeout time.Duration) error {
109+ t.Helper()
110+ done := make(chan error, 1)
111+ go func() {
112+ _, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes)
113+ done <- err
114+ }()
115+ select {
116+ case err := <-done:
117+ return err
118+ case <-time.After(timeout):
119+ t.Fatal("the connection must settle within the bound")
120+ return nil
121+ }
122+}
123+
48124 // testDaemonAt builds one catalogue-valid remote daemon projection for index i.
49125 func testDaemonAt(i int) ports.BrokerDaemonObservation {
50126 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)
@@ -238,6 +314,32 @@ func (c *fakeLogicalConn) fail(err error) {
238314 _ = c.Close()
239315 }
240316
317+// addHostCall records one delegated membership addition: the exact arguments
318+// the admitted core received and the exact result it returned. Recording both
319+// is what lets a test prove the adapter delegated once with the caller's own
320+// authority instead of re-deriving a policy or registration on the way.
321+type addHostCall struct {
322+ Endpoint string
323+ Policy ports.BrokerPolicy
324+ Result domain.RemoteRegistration
325+ Err error
326+}
327+
328+// removeHostCall records one delegated exact-registration removal.
329+type removeHostCall struct {
330+ Expected domain.RemoteRegistration
331+ Removed bool
332+ Err error
333+}
334+
335+// updateHostPolicyCall records one delegated exact-registration policy update.
336+type updateHostPolicyCall struct {
337+ Expected domain.RemoteRegistration
338+ Policy ports.BrokerPolicy
339+ Result domain.RemoteRegistration
340+ Err error
341+}
342+
241343 // fakeCore is one admitted per-connection broker service.
242344 type fakeCore struct {
243345 mu sync.Mutex
@@ -246,14 +348,25 @@ type fakeCore struct {
246348 streams map[ports.BrokerStreamID]*fakeLogicalConn
247349 opens []ports.BrokerOpenStreamRequest
248350 closedIDs []ports.BrokerStreamID
249- added []string
250- removed []string
351+ added []addHostCall
352+ removed []removeHostCall
353+ updated []updateHostPolicyCall
251354 reconcile []string
252355 openErr error
253- blockAdd chan struct{}
254356 closed bool
255357 done chan struct{}
256358 once sync.Once
359+
360+ // Membership result hooks. Their zero values keep the deterministic
361+ // defaults that mirror the real registry: a fresh generation-1 registration
362+ // for a new endpoint, a generation advanced by exactly one for a policy
363+ // update, and removal reported from removeRemoved.
364+ addErr error
365+ removeErr error
366+ updateErr error
367+ addResult *domain.RemoteRegistration
368+ updateResult *domain.RemoteRegistration
369+ removeRemoved bool
257370 }
258371
259372 func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id }
@@ -298,31 +411,146 @@ func (c *fakeCore) CloseStream(connection ports.BrokerConnectionID, stream ports
298411 return conn.Close()
299412 }
300413
301-func (c *fakeCore) AddHost(ctx context.Context, target string) error {
302- if c.blockAdd != nil {
303- select {
304- case <-c.blockAdd:
305- case <-ctx.Done():
306- return ctx.Err()
414+// mapMembershipError mirrors the admitted core's own mapping of a registry
415+// refusal onto the closed broker error taxonomy. The real broker.Service already
416+// returns these typed codes, so the fake applies the same rule: a caller of the
417+// adapter must never see a bare registry sentinel as if it were an untyped
418+// failure.
419+func mapMembershipError(err error) error {
420+ if err == nil {
421+ return nil
422+ }
423+ code := ports.BrokerErrorCode(0)
424+ switch {
425+ case errors.Is(err, ports.ErrBrokerHostConflict):
426+ code = ports.BrokerErrorHostConflict
427+ case errors.Is(err, ports.ErrBrokerMembershipImmutable):
428+ code = ports.BrokerErrorMembershipImmutable
429+ default:
430+ var unknown ports.BrokerStoreOutcomeUnknownError
431+ var unknownPointer *ports.BrokerStoreOutcomeUnknownError
432+ if errors.As(err, &unknown) || errors.As(err, &unknownPointer) {
433+ code = ports.BrokerErrorOutcomeUnknown
307434 }
308435 }
309- if err := ctx.Err(); err != nil {
436+ if code == 0 {
310437 return err
311438 }
439+ return ports.BrokerError{Code: code, Cause: err}
440+}
441+
442+// AddHost records one delegated membership addition and returns its exact
443+// result. The default is a fresh generation-1 registration for the requested
444+// endpoint, exactly as the real registry mints one for a new endpoint.
445+func (c *fakeCore) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
446+ if err := ctx.Err(); err != nil {
447+ return domain.RemoteRegistration{}, err
448+ }
312449 c.mu.Lock()
313- c.added = append(c.added, target)
314- c.mu.Unlock()
315- return nil
450+ defer c.mu.Unlock()
451+ registration := domain.RemoteRegistration{}
452+ var err error
453+ switch {
454+ case c.addErr != nil:
455+ err = mapMembershipError(c.addErr)
456+ c.addErr = nil
457+ case c.addResult != nil:
458+ registration = *c.addResult
459+ default:
460+ registration, err = domain.NewRemoteRegistration(endpoint, [16]byte{0x01})
461+ }
462+ c.added = append(c.added, addHostCall{Endpoint: endpoint, Policy: policy, Result: registration, Err: err})
463+ return registration, err
316464 }
317465
318-func (c *fakeCore) RemoveHost(ctx context.Context, target string) (bool, error) {
466+// RemoveHost records one delegated exact-registration removal and reports
467+// whether the host is present. The fake owns no membership state, so the
468+// answer is the configured removeRemoved value for every exact registration.
469+func (c *fakeCore) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
319470 if err := ctx.Err(); err != nil {
320471 return false, err
321472 }
322473 c.mu.Lock()
323- c.removed = append(c.removed, target)
474+ defer c.mu.Unlock()
475+ removed := false
476+ var err error
477+ if c.removeErr != nil {
478+ err = mapMembershipError(c.removeErr)
479+ c.removeErr = nil
480+ } else {
481+ removed = c.removeRemoved
482+ }
483+ c.removed = append(c.removed, removeHostCall{Expected: expected, Removed: removed, Err: err})
484+ return removed, err
485+}
486+
487+// UpdateHostPolicy records one delegated exact-registration policy update. The
488+// default advances the generation by exactly one, mirroring the real registry's
489+// replacement-policy path, and preserves the expected incarnation.
490+func (c *fakeCore) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
491+ if err := ctx.Err(); err != nil {
492+ return domain.RemoteRegistration{}, err
493+ }
494+ c.mu.Lock()
495+ defer c.mu.Unlock()
496+ registration := domain.RemoteRegistration{}
497+ var err error
498+ switch {
499+ case c.updateErr != nil:
500+ err = mapMembershipError(c.updateErr)
501+ c.updateErr = nil
502+ case c.updateResult != nil:
503+ registration = *c.updateResult
504+ default:
505+ registration = expected
506+ if registration.Generation != ^domain.RemoteGeneration(0) {
507+ registration.Generation++
508+ }
509+ }
510+ c.updated = append(c.updated, updateHostPolicyCall{Expected: expected, Policy: policy, Result: registration, Err: err})
511+ return registration, err
512+}
513+
514+// addCalls snapshots the delegated additions.
515+func (c *fakeCore) addCalls() []addHostCall {
516+ c.mu.Lock()
517+ defer c.mu.Unlock()
518+ return append([]addHostCall(nil), c.added...)
519+}
520+
521+// removeCalls snapshots the delegated removals.
522+func (c *fakeCore) removeCalls() []removeHostCall {
523+ c.mu.Lock()
524+ defer c.mu.Unlock()
525+ return append([]removeHostCall(nil), c.removed...)
526+}
527+
528+// updateCalls snapshots the delegated policy updates.
529+func (c *fakeCore) updateCalls() []updateHostPolicyCall {
530+ c.mu.Lock()
531+ defer c.mu.Unlock()
532+ return append([]updateHostPolicyCall(nil), c.updated...)
533+}
534+
535+// refuseAddHost makes the next delegated addition fail with err. The refusal is
536+// one-shot: the fake owns no membership state, so a later call must be free to
537+// succeed rather than pin the whole connection as immutable forever.
538+func (c *fakeCore) refuseAddHost(err error) { c.mu.Lock(); c.addErr = err; c.mu.Unlock() }
539+
540+// refuseRemoveHost makes the next delegated removal fail with err, one-shot.
541+func (c *fakeCore) refuseRemoveHost(err error) { c.mu.Lock(); c.removeErr = err; c.mu.Unlock() }
542+
543+// reportRemoved configures the answer a delegated removal returns.
544+func (c *fakeCore) reportRemoved(removed bool) { c.mu.Lock(); c.removeRemoved = removed; c.mu.Unlock() }
545+
546+// zeroAddRegistration makes a delegated addition succeed without returning any
547+// registration authority, so a test can prove the server never publishes an
548+// addition success that cannot be fenced.
549+func (c *fakeCore) zeroAddRegistration() {
550+ c.mu.Lock()
551+ zero := domain.RemoteRegistration{}
552+ c.addResult = &zero
324553 c.mu.Unlock()
325- return target == "known", nil
326554 }
327555
328556 func (c *fakeCore) RequestReconcile(endpoint string) {
M internal/adapters/brokeripc/listen_test.go +2 -2
@@ -453,8 +453,8 @@ func TestConcurrentDialAndClose(t *testing.T) {
453453 t.Errorf("Dial: %v", err)
454454 return
455455 }
456- if err := client.AddHost(ctx, "late@host:22"); err != nil {
457- t.Errorf("AddHost: %v", err)
456+ if err := connectionDelegates(ctx, client); err != nil {
457+ t.Errorf("connection refused a membership round trip: %v", err)
458458 }
459459 _ = client.Close()
460460 }()
M internal/adapters/brokeripc/mutation.go +18 -5
@@ -5,6 +5,7 @@ import (
55 "errors"
66
77 "github.com/bnema/vev/internal/adapters/brokerwire"
8+ "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
910 )
1011
@@ -150,7 +151,7 @@ func (s *serverSession) wakePublisher() {
150151 // bound and runs it in its own goroutine. A duplicate admission is dropped:
151152 // exactly one completion ever travels for one operation identity, and the peer's
152153 // own tracker already fences replays.
153-func (s *serverSession) startMutation(operation ports.BrokerOperationID, run func(context.Context) (bool, error)) error {
154+func (s *serverSession) startMutation(operation ports.BrokerOperationID, kind brokerwire.RegisterMutationKind, run func(context.Context) (domain.RemoteRegistration, bool, error)) error {
154155 if err := s.conn.AdmitOperation(operation); err != nil {
155156 switch {
156157 case errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending):
@@ -165,21 +166,33 @@ func (s *serverSession) startMutation(operation ports.BrokerOperationID, run fun
165166 s.wg.Add(1)
166167 go func() {
167168 defer s.wg.Done()
168- removed, err := run(s.ctx)
169+ registration, removed, err := run(s.ctx)
169170 outcome := ports.BrokerOutcomeOK
170171 if err != nil {
171172 outcome = ports.BrokerOutcomeFailed
173+ var unknown ports.BrokerStoreOutcomeUnknownError
174+ var unknownPointer *ports.BrokerStoreOutcomeUnknownError
175+ if errors.As(err, &unknown) || errors.As(err, &unknownPointer) {
176+ outcome = ports.BrokerOutcomeUnknown
177+ }
178+ registration = domain.RemoteRegistration{}
179+ removed = false
172180 }
173181 _ = s.conn.CompleteOperation(operation, outcome)
174182 if s.ctx.Err() != nil {
175183 return
176184 }
177185 detail := errorDetail(err)
178- if err := s.send(brokerwire.OperationResult{
186+ result := brokerwire.OperationResult{
179187 Epoch: s.epoch, Connection: s.scope.Connection, Operation: operation,
180- Outcome: outcome, Removed: removed,
188+ Outcome: outcome, Removed: removed, Registration: registration,
181189 Error: detail, HasError: detail != (brokerwire.ErrorDetail{}),
182- }); err != nil {
190+ }
191+ if err := result.ValidateForMutation(kind); err != nil {
192+ s.abort(errors.Join(ErrProtocol, err))
193+ return
194+ }
195+ if err := s.send(result); err != nil {
183196 s.abort(err)
184197 }
185198 }()
M internal/adapters/brokeripc/protocol_test.go +6 -12
@@ -43,14 +43,11 @@ func (r *rawCarriage) send(t *testing.T, message brokerwire.ClientMessage) {
4343 require.NoError(t, r.transport.Send(wire.Envelope{Payload: payload}))
4444 }
4545
46-// recv reads and decodes one server message.
46+// recv reads and decodes one server message under the standard test bound, so a
47+// broken session fails as a bounded test error instead of hanging the package.
4748 func (r *rawCarriage) recv(t *testing.T) brokerwire.ServerMessage {
4849 t.Helper()
49- envelope, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes)
50- require.NoError(t, err)
51- message, err := brokerwire.DecodeServer(envelope.Payload, r.ceilings.MaxReceiveEnvelopeBytes, r.ceilings.StreamChunkLimit)
52- require.NoError(t, err)
53- return message
50+ return r.recvWithin(t, 5*time.Second)
5451 }
5552
5653 // register completes the registration exchange and returns the assigned scope.
@@ -127,8 +124,7 @@ func TestWrongDirectionFrameClosesConnection(t *testing.T) {
127124 require.NoError(t, err)
128125 require.NoError(t, raw.transport.Send(wire.Envelope{Payload: payload}))
129126
130- _, err = raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)
131- require.Error(t, err, "a wrong-direction frame must settle the connection")
127+ require.Error(t, raw.awaitReadError(t, 5*time.Second), "a wrong-direction frame must settle the connection")
132128
133129 // The listener keeps serving other clients.
134130 client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second})
@@ -144,8 +140,7 @@ func TestMalformedApplicationFrameClosesConnection(t *testing.T) {
144140 raw.register(t)
145141
146142 require.NoError(t, raw.transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}}))
147- _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)
148- require.Error(t, err)
143+ require.Error(t, raw.awaitReadError(t, 5*time.Second))
149144
150145 client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second})
151146 require.NoError(t, err)
@@ -214,8 +209,7 @@ func TestDuplicateRegisterClosesConnection(t *testing.T) {
214209 raw.register(t)
215210
216211 raw.send(t, brokerwire.Register{})
217- _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)
218- require.Error(t, err)
212+ require.Error(t, raw.awaitReadError(t, 5*time.Second))
219213 }
220214
221215 // TestMismatchedScopeIsFenced proves a frame carrying a connection identity that
M internal/adapters/brokeripc/server.go +24 -9
@@ -14,6 +14,7 @@ import (
1414
1515 "github.com/bnema/vev/internal/adapters/brokerwire"
1616 "github.com/bnema/vev/internal/adapters/ipc"
17+ "github.com/bnema/vev/internal/domain"
1718 "github.com/bnema/vev/internal/ports"
1819 "github.com/bnema/vev/internal/protocol/wire"
1920 )
@@ -705,16 +706,25 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error {
705706 if !s.scopeMatches(m.Epoch, m.Connection) {
706707 return s.refuseScope()
707708 }
708- return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) {
709- return false, s.core.AddHost(ctx, m.Endpoint)
709+ return s.startMutation(m.Operation, brokerwire.MutationKindAddHost, func(ctx context.Context) (domain.RemoteRegistration, bool, error) {
710+ registration, err := s.core.AddHost(ctx, m.Endpoint, m.Policy)
711+ return registration, false, err
710712 })
711713 case brokerwire.RemoveHost:
712714 if !s.scopeMatches(m.Epoch, m.Connection) {
713715 return s.refuseScope()
714716 }
715- return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) {
716- removed, err := s.core.RemoveHost(ctx, m.Endpoint)
717- return removed, err
717+ return s.startMutation(m.Operation, brokerwire.MutationKindRemoveHost, func(ctx context.Context) (domain.RemoteRegistration, bool, error) {
718+ removed, err := s.core.RemoveHost(ctx, m.Registration)
719+ return domain.RemoteRegistration{}, removed, err
720+ })
721+ case brokerwire.UpdateHostPolicy:
722+ if !s.scopeMatches(m.Epoch, m.Connection) {
723+ return s.refuseScope()
724+ }
725+ return s.startMutation(m.Operation, brokerwire.MutationKindUpdateHostPolicy, func(ctx context.Context) (domain.RemoteRegistration, bool, error) {
726+ registration, err := s.core.UpdateHostPolicy(ctx, m.Registration, m.Policy)
727+ return registration, false, err
718728 })
719729 case brokerwire.Reconcile:
720730 if !s.scopeMatches(m.Epoch, m.Connection) {
@@ -941,13 +951,18 @@ func (s *serverSession) CloseStream(connection ports.BrokerConnectionID, stream
941951 }
942952
943953 // AddHost delegates one mutating operation to the admitted core service.
944-func (s *serverSession) AddHost(ctx context.Context, target string) error {
945- return s.core.AddHost(ctx, target)
954+func (s *serverSession) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
955+ return s.core.AddHost(ctx, endpoint, policy)
946956 }
947957
948958 // RemoveHost delegates one mutating operation to the admitted core service.
949-func (s *serverSession) RemoveHost(ctx context.Context, target string) (bool, error) {
950- return s.core.RemoveHost(ctx, target)
959+func (s *serverSession) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
960+ return s.core.RemoveHost(ctx, expected)
961+}
962+
963+// UpdateHostPolicy delegates one mutating operation to the admitted core service.
964+func (s *serverSession) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
965+ return s.core.UpdateHostPolicy(ctx, expected, policy)
951966 }
952967
953968 // RequestReconcile delegates one reconcile hint to the admitted core service.
M internal/adapters/brokeripc/session_test.go +253 -35
@@ -362,9 +362,12 @@ func TestOpenStreamRefusedByCoreReachesClient(t *testing.T) {
362362 require.ErrorAs(t, err, &failure)
363363 require.Equal(t, ports.BrokerErrorIncompatible, failure.Code)
364364
365- // The connection survives a stream-local refusal: a later operation still
366- // completes.
367- require.NoError(t, client.AddHost(ctx, "after@refusal:22"))
365+ // The connection survives a stream-local refusal: once the core admits
366+ // streams again, a later operation still completes.
367+ core.mu.Lock()
368+ core.openErr = nil
369+ core.mu.Unlock()
370+ requireConnectionDelegates(t, client)
368371 }
369372
370373 // TestTerminalStreamFailureFailsOnlyThatStream proves one stream's terminal
@@ -404,7 +407,7 @@ func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) {
404407 case <-time.After(5 * time.Second):
405408 t.Fatal("sibling stream stopped carrying traffic")
406409 }
407- require.NoError(t, client.AddHost(ctx, "sibling@host:22"))
410+ requireConnectionDelegates(t, client)
408411 }
409412
410413 // TestCloseStreamRetiresBothSides proves a client close reaches the broker core
@@ -433,46 +436,263 @@ func TestCloseStreamRetiresBothSides(t *testing.T) {
433436 require.NoError(t, stream.Close(), "closing an already retired stream is idempotent")
434437 }
435438
436-// TestMutatingOperationsComplete proves AddHost and RemoveHost complete with
437-// typed results and reach the admitted core service.
438-func TestMutatingOperationsComplete(t *testing.T) {
439+// TestMembershipMutationsDelegateEndToEnd proves the whole membership contract
440+// travels the real AF_UNIX carriage in both directions: AddHost returns the
441+// registration the admitted core minted, UpdateHostPolicy returns the
442+// generation-advanced registration for the exact authority the caller named, and
443+// RemoveHost returns the core's exact bool. Each call delegates exactly once with
444+// the caller's own endpoint, policy, and exact registration, so the adapter never
445+// re-derives authority on the way.
446+func TestMembershipMutationsDelegateEndToEnd(t *testing.T) {
439447 e := startEndpoint(t, Config{})
440448 client, _ := e.pair()
441449 core := e.authority.last()
442450
443451 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
444452 defer cancel()
445- require.NoError(t, client.AddHost(ctx, "new@host:22"))
446- removed, err := client.RemoveHost(ctx, "known")
453+
454+ added, err := client.AddHost(ctx, "new@host:22", testPolicy())
447455 require.NoError(t, err)
448- require.True(t, removed)
449- removed, err = client.RemoveHost(ctx, "missing")
456+ require.Equal(t, "new@host:22", added.Endpoint)
457+ require.NotZero(t, added.Generation, "an addition answer is fenced authority")
458+ require.NoError(t, added.Validate())
459+
460+ updated, err := client.UpdateHostPolicy(ctx, added, testPolicy())
450461 require.NoError(t, err)
451- require.False(t, removed)
462+ require.Equal(t, added.Endpoint, updated.Endpoint)
463+ require.Equal(t, added.Incarnation, updated.Incarnation, "a policy update preserves identity")
464+ require.Equal(t, added.Generation+1, updated.Generation,
465+ "a policy update returns a registration advanced by exactly one generation")
452466
453- core.mu.Lock()
454- defer core.mu.Unlock()
455- require.Equal(t, []string{"new@host:22"}, core.added)
456- require.Equal(t, []string{"known", "missing"}, core.removed)
467+ core.reportRemoved(true)
468+ removed, err := client.RemoveHost(ctx, updated)
469+ require.NoError(t, err)
470+ require.True(t, removed, "a present exact registration must report removal")
471+
472+ core.reportRemoved(false)
473+ removed, err = client.RemoveHost(ctx, updated)
474+ require.NoError(t, err)
475+ require.False(t, removed, "an absent exact registration is an idempotent no-op, not an error")
476+
477+ adds := core.addCalls()
478+ require.Len(t, adds, 1, "one addition is exactly one delegation")
479+ require.Equal(t, "new@host:22", adds[0].Endpoint)
480+ require.Equal(t, testPolicy(), adds[0].Policy, "the caller's exact policy must be delegated unmodified")
481+ require.Equal(t, added, adds[0].Result)
482+
483+ updates := core.updateCalls()
484+ require.Len(t, updates, 1, "one policy update is exactly one delegation")
485+ require.Equal(t, added, updates[0].Expected, "the exact expected registration must be delegated unmodified")
486+ require.Equal(t, testPolicy(), updates[0].Policy, "the caller's exact replacement policy must be delegated")
487+ require.Equal(t, updated, updates[0].Result)
488+
489+ removes := core.removeCalls()
490+ require.Len(t, removes, 2, "each removal is exactly one delegation")
491+ require.Equal(t, updated, removes[0].Expected, "the exact expected registration must be delegated")
492+ require.Equal(t, updated, removes[1].Expected)
493+}
494+
495+// TestMembershipRefusalCodesEndToEnd proves the two membership refusal codes
496+// travel end to end: a stale generation removal is host conflict (11) and an
497+// immutable registry is membership-immutable (12). The refusal reaches the caller
498+// as a typed ports.BrokerError carrying the exact code and the matching sentinel
499+// as its cause, reports no authority, and never invents a registration or a
500+// removal.
501+func TestMembershipRefusalCodesEndToEnd(t *testing.T) {
502+ cases := []struct {
503+ name string
504+ configure func(core *fakeCore)
505+ call func(ctx context.Context, client ports.BrokerService) (domain.RemoteRegistration, bool, error)
506+ wantCode ports.BrokerErrorCode
507+ }{
508+ {
509+ name: "stale generation removal is host conflict 11",
510+ configure: func(core *fakeCore) { core.refuseRemoveHost(ports.ErrBrokerHostConflict) },
511+ call: func(ctx context.Context, client ports.BrokerService) (domain.RemoteRegistration, bool, error) {
512+ removed, err := client.RemoveHost(ctx, mutationRegistration("known"))
513+ return domain.RemoteRegistration{}, removed, err
514+ },
515+ wantCode: ports.BrokerErrorHostConflict,
516+ },
517+ {
518+ name: "immutable registry is membership immutable 12",
519+ configure: func(core *fakeCore) { core.refuseAddHost(ports.ErrBrokerMembershipImmutable) },
520+ call: func(ctx context.Context, client ports.BrokerService) (domain.RemoteRegistration, bool, error) {
521+ registration, err := client.AddHost(ctx, "new@host:22", testPolicy())
522+ return registration, false, err
523+ },
524+ wantCode: ports.BrokerErrorMembershipImmutable,
525+ },
526+ }
527+ require.Equal(t, ports.BrokerErrorCode(11), ports.BrokerErrorHostConflict)
528+ require.Equal(t, ports.BrokerErrorCode(12), ports.BrokerErrorMembershipImmutable)
529+ for _, tc := range cases {
530+ t.Run(tc.name, func(t *testing.T) {
531+ e := startEndpoint(t, Config{})
532+ client, _ := e.pair()
533+ core := e.authority.last()
534+ tc.configure(core)
535+
536+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
537+ defer cancel()
538+ registration, removed, err := tc.call(ctx, client)
539+ require.Equal(t, domain.RemoteRegistration{}, registration, "a refusal never invents authority")
540+ require.False(t, removed, "a refusal never reports the host gone")
541+ require.Error(t, err)
542+ var failure ports.BrokerError
543+ require.ErrorAs(t, err, &failure)
544+ require.Equal(t, tc.wantCode, failure.Code, "the exact refusal code must reach the caller")
545+ require.NotEqual(t, ports.BrokerErrorOutcomeUnknown, failure.Code,
546+ "a definite refusal is never mistaken for a refresh-required outcome")
547+
548+ // The connection survived the refusal: a later delegation still works.
549+ requireConnectionDelegates(t, client)
550+ })
551+ }
552+}
553+
554+// TestMembershipWireRefusalFraming proves the refusal framing the server emits is
555+// exactly the contract the wire validates: a failed result carrying its typed
556+// error and no authority, under the exact code, with the operation identity the
557+// caller used. It drives the raw carriage so the frames are observed as a peer
558+// would receive them.
559+func TestMembershipWireRefusalFraming(t *testing.T) {
560+ cases := []struct {
561+ name string
562+ configure func(core *fakeCore)
563+ send func(scope brokerwire.Scope) brokerwire.ClientMessage
564+ kind brokerwire.RegisterMutationKind
565+ wantCode ports.BrokerErrorCode
566+ }{
567+ {
568+ name: "remove conflict",
569+ configure: func(core *fakeCore) { core.refuseRemoveHost(ports.ErrBrokerHostConflict) },
570+ send: func(scope brokerwire.Scope) brokerwire.ClientMessage {
571+ return brokerwire.RemoveHost{
572+ Epoch: scope.Epoch, Connection: scope.Connection,
573+ Operation: ports.BrokerOperationID{0x31}, Registration: mutationRegistration("known"),
574+ }
575+ },
576+ kind: brokerwire.MutationKindRemoveHost,
577+ wantCode: ports.BrokerErrorHostConflict,
578+ },
579+ {
580+ name: "immutable addition",
581+ configure: func(core *fakeCore) { core.refuseAddHost(ports.ErrBrokerMembershipImmutable) },
582+ send: func(scope brokerwire.Scope) brokerwire.ClientMessage {
583+ return brokerwire.AddHost{
584+ Epoch: scope.Epoch, Connection: scope.Connection,
585+ Operation: ports.BrokerOperationID{0x32}, Endpoint: "new@host:22", Policy: testPolicy(),
586+ }
587+ },
588+ kind: brokerwire.MutationKindAddHost,
589+ wantCode: ports.BrokerErrorMembershipImmutable,
590+ },
591+ }
592+ for _, tc := range cases {
593+ t.Run(tc.name, func(t *testing.T) {
594+ e := startEndpoint(t, Config{})
595+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
596+ scope := raw.register(t)
597+ tc.configure(e.authority.last())
598+
599+ request := tc.send(scope)
600+ raw.send(t, request)
601+ result, ok := raw.recv(t).(brokerwire.OperationResult)
602+ require.True(t, ok, "a membership mutation must be answered with an OperationResult")
603+ require.Equal(t, scope.Connection, result.Connection)
604+ require.Equal(t, operationOf(t, request), result.Operation)
605+ require.Equal(t, ports.BrokerOutcomeFailed, result.Outcome)
606+ require.True(t, result.HasError, "a failure must carry its typed detail")
607+ require.Equal(t, tc.wantCode, result.Error.Code, "the exact refusal code must travel")
608+ require.False(t, result.Removed, "a failure carries no removal authority")
609+ require.Equal(t, domain.RemoteRegistration{}, result.Registration,
610+ "a failure carries no registration authority")
611+ // The emitted result satisfies the same rule the peer enforces for
612+ // this request kind.
613+ require.NoError(t, result.ValidateForMutation(tc.kind))
614+ })
615+ }
457616 }
458617
459-// TestLostOperationReplyIsOutcomeUnknown proves a mutating operation whose reply
460-// never arrives is reported as outcome-unknown rather than retried or reported
461-// as a plain failure.
462-func TestLostOperationReplyIsOutcomeUnknown(t *testing.T) {
618+// TestMembershipStoreOutcomeUnknownMapsToOutcomeUnknown proves an indeterminate
619+// durable write is never presented as a definite refusal: both the value and the
620+// pointer form of the store's outcome-unknown error become an outcome-unknown
621+// result carrying the outcome-unknown code and no authority, and the caller
622+// observes ports.BrokerErrorOutcomeUnknown so it refreshes rather than retries.
623+func TestMembershipStoreOutcomeUnknownMapsToOutcomeUnknown(t *testing.T) {
624+ cause := errors.New("torn write after the commit point")
625+ cases := map[string]error{
626+ "value": ports.BrokerStoreOutcomeUnknownError{Err: cause},
627+ "pointer": &ports.BrokerStoreOutcomeUnknownError{Err: cause},
628+ }
629+ for name, stored := range cases {
630+ t.Run(name, func(t *testing.T) {
631+ e := startEndpoint(t, Config{})
632+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
633+ scope := raw.register(t)
634+ core := e.authority.last()
635+ core.refuseAddHost(stored)
636+
637+ request := brokerwire.AddHost{
638+ Epoch: scope.Epoch, Connection: scope.Connection,
639+ Operation: ports.BrokerOperationID{0x41}, Endpoint: "new@host:22", Policy: testPolicy(),
640+ }
641+ raw.send(t, request)
642+ result, ok := raw.recv(t).(brokerwire.OperationResult)
643+ require.True(t, ok)
644+ require.Equal(t, ports.BrokerOutcomeUnknown, result.Outcome,
645+ "an indeterminate write is outcome-unknown, never a definite failure")
646+ require.Equal(t, ports.BrokerErrorOutcomeUnknown, result.Error.Code)
647+ require.False(t, result.Removed)
648+ require.Equal(t, domain.RemoteRegistration{}, result.Registration)
649+ require.NoError(t, result.ValidateForMutation(brokerwire.MutationKindAddHost))
650+
651+ // The same indeterminate write reaches the public caller as the
652+ // refresh-required code, so it is never mistaken for a retryable
653+ // conflict or a plain failure.
654+ client, _ := e.pair()
655+ fresh := e.authority.last()
656+ fresh.refuseAddHost(stored)
657+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
658+ defer cancel()
659+ added, err := client.AddHost(ctx, "new@host:22", testPolicy())
660+ require.Equal(t, domain.RemoteRegistration{}, added)
661+ var failure ports.BrokerError
662+ require.ErrorAs(t, err, &failure)
663+ require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)
664+ })
665+ }
666+}
667+
668+// TestServerAbortsResultMissingRequiredAuthority proves the server never
669+// publishes an addition success that cannot fence the mutation it answers: an
670+// admitted core that returns no registration for a successful AddHost is a
671+// protocol violation, so the session settles without answering the caller
672+// instead of sending a result the peer would refuse.
673+func TestServerAbortsResultMissingRequiredAuthority(t *testing.T) {
463674 e := startEndpoint(t, Config{})
464- client, _ := e.pair()
675+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
676+ scope := raw.register(t)
677+ session := e.accept()
465678 core := e.authority.last()
466- core.blockAdd = make(chan struct{})
467- t.Cleanup(func() { close(core.blockAdd) })
679+ core.zeroAddRegistration()
468680
469- ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
470- defer cancel()
471- err := client.AddHost(ctx, "slow@host:22")
472- require.Error(t, err)
473- var failure ports.BrokerError
474- require.ErrorAs(t, err, &failure)
475- require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)
681+ raw.send(t, brokerwire.AddHost{
682+ Epoch: scope.Epoch, Connection: scope.Connection,
683+ Operation: ports.BrokerOperationID{0x51}, Endpoint: "new@host:22", Policy: testPolicy(),
684+ })
685+
686+ // No result travels; the connection settles as a protocol violation.
687+ err := raw.awaitReadError(t, 5*time.Second)
688+ require.Error(t, err, "a success without its required authority must settle the connection")
689+ awaitSessionDone(t, session.(*serverSession))
690+ require.ErrorIs(t, session.(*serverSession).terminalErr(), ErrProtocol)
691+ require.Error(t, session.Close(), "a protocol violation is not an orderly end")
692+
693+ // The addition was delegated exactly once; the adapter refused to publish an
694+ // unfenceable answer rather than re-delegating or inventing one.
695+ require.Len(t, core.addCalls(), 1)
476696 }
477697
478698 // TestDisconnectCleanupReleasesEveryOwnedResource proves a client disconnect
@@ -532,9 +752,7 @@ func TestStalledSubscriberDoesNotBlockOtherClients(t *testing.T) {
532752 }
533753 require.Eventually(t, func() bool { return active.Snapshot().Revision == 20 }, 5*time.Second, 10*time.Millisecond)
534754
535- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
536- defer cancel()
537- require.NoError(t, active.AddHost(ctx, "active@host:22"))
755+ requireConnectionDelegates(t, active)
538756 }
539757
540758 // TestClientStreamBackpressureSettlesOnlyThatStream proves a local consumer that
@@ -573,7 +791,7 @@ func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) {
573791 }, 5*time.Second, 10*time.Millisecond, "the broker must be told to retire the stalled stream")
574792
575793 // The connection survives a stream-local stall.
576- require.NoError(t, client.AddHost(ctx, "after@stall:22"))
794+ requireConnectionDelegates(t, client)
577795 }
578796
579797 // TestOrderlyStreamCloseReportsNoError proves a local close is an orderly end:
@@ -703,7 +921,7 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) {
703921 case <-time.After(5 * time.Second):
704922 t.Fatal("a malformed inner frame must settle its stream")
705923 }
706- require.NoError(t, client.AddHost(ctx, "survivor@host:22"))
924+ requireConnectionDelegates(t, client)
707925
708926 // The broker core sees the stream retired.
709927 require.Eventually(t, func() bool {
M internal/adapters/brokerwire/codec.go +115 -9
@@ -175,6 +175,17 @@ func encodeClientEnvelope(message ClientMessage, maxChunkBytes uint64) (*wire.Br
175175 return nil, ErrInvalidMessage
176176 }
177177 return encodeClientEnvelope(*m, maxChunkBytes)
178+ case UpdateHostPolicy:
179+ converted, err := updateHostPolicyToWire(m)
180+ if err != nil {
181+ return nil, err
182+ }
183+ return &wire.BrokerClientEnvelope{Payload: &wire.BrokerClientEnvelope_UpdateHostPolicy{UpdateHostPolicy: converted}}, nil
184+ case *UpdateHostPolicy:
185+ if m == nil {
186+ return nil, ErrInvalidMessage
187+ }
188+ return encodeClientEnvelope(*m, maxChunkBytes)
178189 case Reconcile:
179190 converted, err := reconcileToWire(m)
180191 if err != nil {
@@ -251,6 +262,8 @@ func decodeClientEnvelope(envelope *wire.BrokerClientEnvelope, maxChunkBytes uin
251262 return addHostFromWire(payload.AddHost)
252263 case *wire.BrokerClientEnvelope_RemoveHost:
253264 return removeHostFromWire(payload.RemoveHost)
265+ case *wire.BrokerClientEnvelope_UpdateHostPolicy:
266+ return updateHostPolicyFromWire(payload.UpdateHostPolicy)
254267 case *wire.BrokerClientEnvelope_Reconcile:
255268 return reconcileFromWire(payload.Reconcile)
256269 case *wire.BrokerClientEnvelope_OpenStream:
@@ -284,10 +297,17 @@ func addHostToWire(m AddHost) (*wire.AddHost, error) {
284297 if err := validateBrokerEndpoint(m.Endpoint); err != nil {
285298 return nil, err
286299 }
300+ // Policy is required authority: a zero or invalid policy is refused
301+ // here rather than travelling as an absent message the peer must
302+ // interpret.
303+ if err := m.Policy.Validate(); err != nil {
304+ return nil, ErrInvalidMessage
305+ }
287306 return &wire.AddHost{
288307 Scope: scopeToWire(m.Epoch, m.Connection),
289308 OperationId: operationToWire(m.Operation),
290309 Endpoint: m.Endpoint,
310+ Policy: policyToWire(m.Policy),
291311 }, nil
292312 }
293313
@@ -307,7 +327,11 @@ func addHostFromWire(message *wire.AddHost) (AddHost, error) {
307327 if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil {
308328 return AddHost{}, err
309329 }
310- return AddHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint()}, nil
330+ policy, err := policyFromWire(message.GetPolicy())
331+ if err != nil {
332+ return AddHost{}, ErrInvalidMessage
333+ }
334+ return AddHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint(), Policy: policy}, nil
311335 }
312336
313337 func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) {
@@ -320,13 +344,15 @@ func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) {
320344 if err := m.Operation.Validate(); err != nil {
321345 return nil, ErrInvalidMessage
322346 }
323- if err := validateBrokerEndpoint(m.Endpoint); err != nil {
324- return nil, err
347+ // Removal carries exact authority, never a bare endpoint: a zero or
348+ // partial registration could not fence a remove/re-add race.
349+ if err := m.Registration.Validate(); err != nil {
350+ return nil, ErrInvalidMessage
325351 }
326352 return &wire.RemoveHost{
327- Scope: scopeToWire(m.Epoch, m.Connection),
328- OperationId: operationToWire(m.Operation),
329- Endpoint: m.Endpoint,
353+ Scope: scopeToWire(m.Epoch, m.Connection),
354+ OperationId: operationToWire(m.Operation),
355+ Registration: registrationToWire(m.Registration),
330356 }, nil
331357 }
332358
@@ -343,10 +369,59 @@ func removeHostFromWire(message *wire.RemoveHost) (RemoveHost, error) {
343369 if err != nil {
344370 return RemoveHost{}, ErrInvalidMessage
345371 }
346- if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil {
347- return RemoveHost{}, err
372+ registration, err := registrationFromWire(message.GetRegistration())
373+ if err != nil {
374+ return RemoveHost{}, ErrInvalidMessage
375+ }
376+ return RemoveHost{Epoch: epoch, Connection: connection, Operation: operation, Registration: registration}, nil
377+}
378+
379+func updateHostPolicyToWire(m UpdateHostPolicy) (*wire.UpdateHostPolicy, error) {
380+ if m.Epoch == 0 {
381+ return nil, ErrInvalidMessage
382+ }
383+ if err := m.Connection.Validate(); err != nil {
384+ return nil, ErrInvalidMessage
385+ }
386+ if err := m.Operation.Validate(); err != nil {
387+ return nil, ErrInvalidMessage
388+ }
389+ if err := m.Registration.Validate(); err != nil {
390+ return nil, ErrInvalidMessage
391+ }
392+ if err := m.Policy.Validate(); err != nil {
393+ return nil, ErrInvalidMessage
394+ }
395+ return &wire.UpdateHostPolicy{
396+ Scope: scopeToWire(m.Epoch, m.Connection),
397+ OperationId: operationToWire(m.Operation),
398+ Registration: registrationToWire(m.Registration),
399+ Policy: policyToWire(m.Policy),
400+ }, nil
401+}
402+
403+func updateHostPolicyFromWire(message *wire.UpdateHostPolicy) (UpdateHostPolicy, error) {
404+ var out UpdateHostPolicy
405+ if message == nil {
406+ return out, ErrInvalidMessage
407+ }
408+ epoch, connection, err := scopeFromWire(message.GetScope())
409+ if err != nil {
410+ return UpdateHostPolicy{}, ErrInvalidMessage
411+ }
412+ operation, err := operationFromWire(message.GetOperationId())
413+ if err != nil {
414+ return UpdateHostPolicy{}, ErrInvalidMessage
415+ }
416+ registration, err := registrationFromWire(message.GetRegistration())
417+ if err != nil {
418+ return UpdateHostPolicy{}, ErrInvalidMessage
419+ }
420+ policy, err := policyFromWire(message.GetPolicy())
421+ if err != nil {
422+ return UpdateHostPolicy{}, ErrInvalidMessage
348423 }
349- return RemoveHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint()}, nil
424+ return UpdateHostPolicy{Epoch: epoch, Connection: connection, Operation: operation, Registration: registration, Policy: policy}, nil
350425 }
351426
352427 func reconcileToWire(m Reconcile) (*wire.Reconcile, error) {
@@ -1220,10 +1295,29 @@ func operationResultToWire(m OperationResult) (*wire.OperationResult, error) {
12201295 if err := result.Validate(); err != nil {
12211296 return nil, ErrInvalidMessage
12221297 }
1298+ hasRegistration := m.Registration != (domain.RemoteRegistration{})
1299+ // The presence rules that hold without knowing the request kind: removal
1300+ // and registration never travel together, a failed or outcome-unknown
1301+ // result carries neither authority and must carry its typed error, and a
1302+ // successful result carries no error. The per-kind rule (an
1303+ // AddHost/UpdateHostPolicy success needs a registration, a RemoveHost
1304+ // success must not) is enforced where the pending request is known; the
1305+ // stateless codec has no such state.
1306+ if err := validateResultAuthority(m.Outcome, m.Removed, hasRegistration, m.HasError); err != nil {
1307+ return nil, err
1308+ }
1309+ var registration *wire.RemoteRegistration
1310+ if hasRegistration {
1311+ if err := m.Registration.Validate(); err != nil {
1312+ return nil, ErrInvalidMessage
1313+ }
1314+ registration = registrationToWire(m.Registration)
1315+ }
12231316 out := &wire.OperationResult{
12241317 Scope: scopeToWire(m.Epoch, m.Connection),
12251318 OperationId: operationToWire(m.Operation),
12261319 Outcome: outcome, Removed: m.Removed,
1320+ Registration: registration,
12271321 }
12281322 if m.HasError {
12291323 out.Error = errorDetailToWire(m.Error)
@@ -1255,9 +1349,18 @@ func operationResultFromWire(message *wire.OperationResult) (OperationResult, er
12551349 default:
12561350 return OperationResult{}, ErrInvalidMessage
12571351 }
1352+ var registration domain.RemoteRegistration
1353+ if message.GetRegistration() != nil {
1354+ converted, err := registrationFromWire(message.GetRegistration())
1355+ if err != nil {
1356+ return OperationResult{}, ErrInvalidMessage
1357+ }
1358+ registration = converted
1359+ }
12581360 result := OperationResult{
12591361 Epoch: epoch, Connection: connection, Operation: operation,
12601362 Outcome: outcome, Removed: message.GetRemoved(),
1363+ Registration: registration,
12611364 }
12621365 if message.GetError() != nil {
12631366 detail, err := errorDetailFromWire(message.GetError())
@@ -1267,6 +1370,9 @@ func operationResultFromWire(message *wire.OperationResult) (OperationResult, er
12671370 result.Error = detail
12681371 result.HasError = true
12691372 }
1373+ if err := validateResultAuthority(result.Outcome, result.Removed, registration != (domain.RemoteRegistration{}), result.HasError); err != nil {
1374+ return OperationResult{}, err
1375+ }
12701376 semantic := ports.BrokerOperationResult{Operation: result.Operation, Outcome: result.Outcome}
12711377 if result.HasError {
12721378 semantic.Text = result.Error.Text
M internal/adapters/brokerwire/codec_test.go +112 -6
@@ -351,14 +351,15 @@ func TestBrokerClientVariants(t *testing.T) {
351351 {"subscribe", Subscribe{Epoch: 7, Connection: connection, Generation: 1}, 102},
352352 {"resync", Resync{Epoch: 7, Connection: connection, Generation: 2}, 103},
353353 {"unsubscribe", Unsubscribe{Epoch: 7, Connection: connection, Generation: 3}, 104},
354- {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 105},
355- {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 106},
354+ {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105},
355+ {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106},
356356 {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107},
357357 {"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},
358358 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},
359359 {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},
360+ {"update_host_policy", UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}, 111},
360361 }
361- require.Len(t, messages, 10)
362+ require.Len(t, messages, 11)
362363 for _, tc := range messages {
363364 t.Run(tc.name, func(t *testing.T) {
364365 raw := mustEncodeClient(t, tc.message)
@@ -525,7 +526,9 @@ func TestBrokerServerVariants(t *testing.T) {
525526 {"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},
526527 {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 5, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202},
527528 {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 6, Part: SnapshotEnd{}}, 202},
528- {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true}, 203},
529+ {"operation_result", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}, 203},
530+ {"operation_result_add", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}, 203},
531+ {"operation_result_remove", OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Removed: true}, 203},
529532 {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204},
530533 {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205},
531534 {"stream_closed", StreamClosed{Epoch: 7, Connection: connection, Stream: 3, Error: testErrorDetail(), HasError: true}, 206},
@@ -811,7 +814,7 @@ func TestBrokerValidationNegatives(t *testing.T) {
811814 // declared detail still round-trips exactly.
812815 _, err := EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3}, testEnvelopeCeiling, testChunkCeiling)
813816 require.NoError(t, err)
814- _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK}, testEnvelopeCeiling, testChunkCeiling)
817+ _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}, testEnvelopeCeiling, testChunkCeiling)
815818 require.NoError(t, err)
816819 declared := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: detail, HasError: true}
817820 decoded, err := DecodeServer(mustEncodeServer(t, declared), testEnvelopeCeiling, testChunkCeiling)
@@ -846,8 +849,103 @@ func TestBrokerValidationNegatives(t *testing.T) {
846849 _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: boundary}, testEnvelopeCeiling, testChunkCeiling)
847850 require.NoError(t, err)
848851 })
852+ t.Run("membership request authority", func(t *testing.T) {
853+ base := AddHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Endpoint: "dev@host:22", Policy: testPolicy()}
854+ // An absent policy is not authority: it is refused exactly like an
855+ // invalid one rather than inherited from a default.
856+ missing := base
857+ missing.Policy = ports.BrokerPolicy{}
858+ _, err := EncodeClient(missing, testEnvelopeCeiling, testChunkCeiling)
859+ require.ErrorIs(t, err, ErrInvalidMessage)
860+
861+ // A partial registration is not exact authority either.
862+ _, err = EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: testOperationID(1)}, testEnvelopeCeiling, testChunkCeiling)
863+ require.ErrorIs(t, err, ErrInvalidMessage)
864+ partial := testRegistration()
865+ partial.Incarnation = [16]byte{}
866+ _, err = EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Registration: partial}, testEnvelopeCeiling, testChunkCeiling)
867+ require.ErrorIs(t, err, ErrInvalidMessage)
868+ partial = testRegistration()
869+ partial.Generation = 0
870+ _, err = EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Registration: partial}, testEnvelopeCeiling, testChunkCeiling)
871+ require.ErrorIs(t, err, ErrInvalidMessage)
872+
873+ update := UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: testOperationID(1), Registration: testRegistration(), Policy: testPolicy()}
874+ missingPolicy := update
875+ missingPolicy.Policy = ports.BrokerPolicy{}
876+ _, err = EncodeClient(missingPolicy, testEnvelopeCeiling, testChunkCeiling)
877+ require.ErrorIs(t, err, ErrInvalidMessage)
878+ missingRegistration := update
879+ missingRegistration.Registration = domain.RemoteRegistration{}
880+ _, err = EncodeClient(missingRegistration, testEnvelopeCeiling, testChunkCeiling)
881+ require.ErrorIs(t, err, ErrInvalidMessage)
882+ })
883+ t.Run("membership result presence", func(t *testing.T) {
884+ // A result may never carry both removal and registration, and a
885+ // failed or outcome-unknown result may carry neither authority and
886+ // must carry its typed error.
887+ both := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Removed: true, Registration: testRegistration()}
888+ _, err := EncodeServer(both, testEnvelopeCeiling, testChunkCeiling)
889+ require.ErrorIs(t, err, ErrInvalidMessage)
890+ for _, outcome := range []ports.BrokerMutationOutcome{ports.BrokerOutcomeFailed, ports.BrokerOutcomeUnknown} {
891+ _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: outcome, Registration: testRegistration(), Error: testErrorDetail(), HasError: true}, testEnvelopeCeiling, testChunkCeiling)
892+ require.ErrorIs(t, err, ErrInvalidMessage)
893+ _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: outcome, Removed: true, Error: testErrorDetail(), HasError: true}, testEnvelopeCeiling, testChunkCeiling)
894+ require.ErrorIs(t, err, ErrInvalidMessage)
895+ // Neither authority without an error is refused too: a failure
896+ // with no detail would read as an unexplained refusal.
897+ _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: outcome}, testEnvelopeCeiling, testChunkCeiling)
898+ require.ErrorIs(t, err, ErrInvalidMessage)
899+ }
900+ // A success carries no error at all.
901+ _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Registration: testRegistration(), Error: testErrorDetail(), HasError: true}, testEnvelopeCeiling, testChunkCeiling)
902+ require.ErrorIs(t, err, ErrInvalidMessage)
903+ // A partial registration is refused on the result path too.
904+ partial := testRegistration()
905+ partial.Incarnation = [16]byte{}
906+ _, err = EncodeServer(OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Registration: partial}, testEnvelopeCeiling, testChunkCeiling)
907+ require.ErrorIs(t, err, ErrInvalidMessage)
908+ })
909+ t.Run("membership result authority by mutation kind", func(t *testing.T) {
910+ registration := testRegistration()
911+ add := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Registration: registration}
912+ require.NoError(t, add.ValidateForMutation(MutationKindAddHost))
913+ require.ErrorIs(t, add.ValidateForMutation(MutationKindRemoveHost), ErrInvalidMessage)
914+ require.NoError(t, add.ValidateForMutation(MutationKindUpdateHostPolicy))
915+
916+ remove := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeOK, Removed: true}
917+ require.NoError(t, remove.ValidateForMutation(MutationKindRemoveHost))
918+ require.ErrorIs(t, remove.ValidateForMutation(MutationKindAddHost), ErrInvalidMessage)
919+ require.ErrorIs(t, remove.ValidateForMutation(MutationKindUpdateHostPolicy), ErrInvalidMessage)
920+
921+ failed := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}
922+ for _, kind := range []RegisterMutationKind{MutationKindAddHost, MutationKindRemoveHost, MutationKindUpdateHostPolicy} {
923+ require.NoError(t, failed.ValidateForMutation(kind))
924+ }
925+ // A kind outside the closed taxonomy is refused, never guessed.
926+ require.Error(t, add.ValidateForMutation(RegisterMutationKind(9)))
927+ // The request-side classifier names exactly the three mutations.
928+ for message, want := range map[ClientMessage]RegisterMutationKind{
929+ AddHost{}: MutationKindAddHost,
930+ RemoveHost{}: MutationKindRemoveHost,
931+ UpdateHostPolicy{}: MutationKindUpdateHostPolicy,
932+ Subscribe{}: 0,
933+ } {
934+ got, ok := MutationKindOf(message)
935+ if want == 0 {
936+ require.False(t, ok)
937+ continue
938+ }
939+ require.True(t, ok)
940+ require.Equal(t, want, got)
941+ }
942+ require.Equal(t, "add_host", MutationKindAddHost.String())
943+ require.Equal(t, "remove_host", MutationKindRemoveHost.String())
944+ require.Equal(t, "update_host_policy", MutationKindUpdateHostPolicy.String())
945+ require.Equal(t, "unknown", RegisterMutationKind(9).String())
946+ })
849947 t.Run("bad endpoint", func(t *testing.T) {
850- _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Endpoint: "not a host"}, testEnvelopeCeiling, testChunkCeiling)
948+ _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: testOperationID(1), Endpoint: "not a host", Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
851949 require.ErrorIs(t, err, ErrInvalidMessage)
852950 })
853951 t.Run("taxonomy switches", func(t *testing.T) {
@@ -974,8 +1072,12 @@ func TestBrokerWrongDirection(t *testing.T) {
9741072 func TestBrokerCorruptInputSmoke(t *testing.T) {
9751073 seeds := [][]byte{
9761074 mustEncodeClient(t, Register{}),
1075+ mustEncodeClient(t, AddHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x21), Endpoint: "dev@host:22", Policy: testPolicy()}),
1076+ mustEncodeClient(t, RemoveHost{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x22), Registration: testRegistration()}),
1077+ mustEncodeClient(t, UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x23), Registration: testRegistration(), Policy: testPolicy()}),
9771078 mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}),
9781079 mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}),
1080+ mustEncodeServer(t, OperationResult{Epoch: 7, Connection: testConnectionID(2), Operation: testOperationID(0x24), Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}),
9791081 mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}),
9801082 }
9811083 for _, seed := range seeds {
@@ -1003,6 +1105,9 @@ func fuzzClientSeeds(t testing.TB) [][]byte {
10031105 for _, message := range []ClientMessage{
10041106 Register{},
10051107 Subscribe{Epoch: 7, Connection: testConnectionID(0x11), Generation: 1},
1108+ AddHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x11), Endpoint: "dev@host:22", Policy: testPolicy()},
1109+ RemoveHost{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x12), Registration: testRegistration()},
1110+ UpdateHostPolicy{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x13), Registration: testRegistration(), Policy: testPolicy()},
10061111 OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()},
10071112 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},
10081113 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},
@@ -1021,6 +1126,7 @@ func fuzzServerSeeds(t testing.TB) [][]byte {
10211126 Registered{Epoch: 7, Connection: testConnectionID(0x11)},
10221127 SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 0, Part: SnapshotEnd{}},
10231128 SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}},
1129+ OperationResult{Epoch: 7, Connection: testConnectionID(0x11), Operation: testOperationID(0x14), Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()},
10241130 BrokerErrorMessage{Epoch: 7, Connection: testConnectionID(0x11), Error: testErrorDetail()},
10251131 Shutdown{Epoch: 7, Connection: testConnectionID(0x11), Reason: ShutdownTerminating},
10261132 } {
M internal/adapters/brokerwire/doc.go +1 -1
@@ -1,7 +1,7 @@
11 // Package brokerwire adapts typed broker messages to Protobuf envelopes.
22 //
33 // The broker is a separate conversation from the session protocol: client
4-// tags 101-110 and server tags 201-209 are disjoint from every session tag,
4+// tags 101-111 and server tags 201-209 are disjoint from every session tag,
55 // and each direction is its own closed oneof union. Every connection starts
66 // with the bounded broker preamble (roles 3/4); magic, epoch, exact
77 // protocol version, and negotiated limits are validated before application
A internal/adapters/brokerwire/membership_test.go +500 -0
@@ -0,0 +1,500 @@
1+package brokerwire
2+
3+// Membership wire contract (Phase C1).
4+//
5+// AddHost carries a required policy, RemoveHost carries exact registration
6+// authority instead of an endpoint name, UpdateHostPolicy carries exact
7+// authority plus the replacement policy, and every result carries the
8+// presence-carrying outcome of the request it completes. These tests pin the
9+// stateless codec's half of that contract: byte round trips, direction
10+// closure, strict scan before unmarshal, exact generation preservation, the
11+// request/result presence rules, and the typed error codes 11/12.
12+
13+import (
14+ "math"
15+ "testing"
16+
17+ "google.golang.org/protobuf/encoding/protowire"
18+ "google.golang.org/protobuf/proto"
19+
20+ "github.com/bnema/vev/internal/domain"
21+ "github.com/bnema/vev/internal/ports"
22+ "github.com/bnema/vev/internal/protocol/wire"
23+ "github.com/stretchr/testify/require"
24+)
25+
26+// membershipRequests are the three mutating client requests with their frozen
27+// envelope tags.
28+func membershipRequests(connection ports.BrokerConnectionID, operation ports.BrokerOperationID) []struct {
29+ name string
30+ message ClientMessage
31+ tag protowire.Number
32+} {
33+ return []struct {
34+ name string
35+ message ClientMessage
36+ tag protowire.Number
37+ }{
38+ {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105},
39+ {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106},
40+ {"update_host_policy", UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}, 111},
41+ }
42+}
43+
44+// TestMembershipRequestByteRoundTrip proves each mutating request travels on
45+// its frozen tag and survives an encode/decode/re-encode cycle byte for byte,
46+// including the generation that fences a removal and the full policy.
47+func TestMembershipRequestByteRoundTrip(t *testing.T) {
48+ connection := testConnectionID(0x21)
49+ operation := testOperationID(0x31)
50+ for _, tc := range membershipRequests(connection, operation) {
51+ t.Run(tc.name, func(t *testing.T) {
52+ raw := mustEncodeClient(t, tc.message)
53+ number, wireType, consumed := protowire.ConsumeTag(raw)
54+ require.Greater(t, consumed, 0)
55+ require.Equal(t, tc.tag, number)
56+ require.Equal(t, protowire.BytesType, wireType)
57+
58+ decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
59+ require.NoError(t, err)
60+ require.Equal(t, tc.message, decoded)
61+
62+ again, err := EncodeClient(decoded, testEnvelopeCeiling, testChunkCeiling)
63+ require.NoError(t, err)
64+ require.Equal(t, raw, again, "re-encode must be byte-for-byte identical")
65+ })
66+ }
67+}
68+
69+// TestMembershipResultByteRoundTrip proves every legal result shape round-trips
70+// byte for byte: an authoritative success carrying its registration, an
71+// idempotent removal success carrying none, and a failed result carrying only
72+// its typed error.
73+func TestMembershipResultByteRoundTrip(t *testing.T) {
74+ connection := testConnectionID(0x22)
75+ operation := testOperationID(0x32)
76+ messages := []struct {
77+ name string
78+ message OperationResult
79+ }{
80+ {"add success carries registration", OperationResult{
81+ Epoch: 7, Connection: connection, Operation: operation,
82+ Outcome: ports.BrokerOutcomeOK, Registration: testRegistration(),
83+ }},
84+ {"update success carries advanced registration", OperationResult{
85+ Epoch: 7, Connection: connection, Operation: operation,
86+ Outcome: ports.BrokerOutcomeOK, Registration: domain.RemoteRegistration{
87+ Endpoint: "dev@host:22", Incarnation: testRegistration().Incarnation, Generation: 10,
88+ },
89+ }},
90+ {"remove success reports removal only", OperationResult{
91+ Epoch: 7, Connection: connection, Operation: operation,
92+ Outcome: ports.BrokerOutcomeOK, Removed: true,
93+ }},
94+ {"idempotent remove reports not removed", OperationResult{
95+ Epoch: 7, Connection: connection, Operation: operation,
96+ Outcome: ports.BrokerOutcomeOK,
97+ }},
98+ {"failed carries only its error", OperationResult{
99+ Epoch: 7, Connection: connection, Operation: operation,
100+ Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true,
101+ }},
102+ {"unknown carries only its error", OperationResult{
103+ Epoch: 7, Connection: connection, Operation: operation,
104+ Outcome: ports.BrokerOutcomeUnknown,
105+ Error: ErrorDetail{Code: ports.BrokerErrorOutcomeUnknown}, HasError: true,
106+ }},
107+ }
108+ for _, tc := range messages {
109+ t.Run(tc.name, func(t *testing.T) {
110+ raw := mustEncodeServer(t, tc.message)
111+ number, wireType, consumed := protowire.ConsumeTag(raw)
112+ require.Greater(t, consumed, 0)
113+ require.Equal(t, protowire.Number(203), number)
114+ require.Equal(t, protowire.BytesType, wireType)
115+
116+ decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
117+ require.NoError(t, err)
118+ require.Equal(t, tc.message, decoded)
119+
120+ again, err := EncodeServer(decoded, testEnvelopeCeiling, testChunkCeiling)
121+ require.NoError(t, err)
122+ require.Equal(t, raw, again, "re-encode must be byte-for-byte identical")
123+ })
124+ }
125+}
126+
127+// TestMembershipExactGenerationPreserved proves the generation that fences a
128+// remove/re-add race survives the wire exactly, including its extremes: a
129+// generation is never widened, truncated, or re-derived from time.
130+func TestMembershipExactGenerationPreserved(t *testing.T) {
131+ connection := testConnectionID(0x23)
132+ operation := testOperationID(0x33)
133+ base := testRegistration()
134+ for name, generation := range map[string]domain.RemoteGeneration{
135+ "one": 1,
136+ "large": 1 << 40,
137+ "max uint64": math.MaxUint64,
138+ } {
139+ t.Run(name, func(t *testing.T) {
140+ registration := base
141+ registration.Generation = generation
142+
143+ request := RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: registration}
144+ decoded, err := DecodeClient(mustEncodeClient(t, request), testEnvelopeCeiling, testChunkCeiling)
145+ require.NoError(t, err)
146+ require.Equal(t, request, decoded)
147+ require.Equal(t, generation, decoded.(RemoveHost).Registration.Generation)
148+
149+ update := UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: registration, Policy: testPolicy()}
150+ decodedUpdate, err := DecodeClient(mustEncodeClient(t, update), testEnvelopeCeiling, testChunkCeiling)
151+ require.NoError(t, err)
152+ require.Equal(t, update, decodedUpdate)
153+ require.Equal(t, generation, decodedUpdate.(UpdateHostPolicy).Registration.Generation)
154+
155+ result := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: registration}
156+ decodedResult, err := DecodeServer(mustEncodeServer(t, result), testEnvelopeCeiling, testChunkCeiling)
157+ require.NoError(t, err)
158+ require.Equal(t, result, decodedResult)
159+ require.Equal(t, generation, decodedResult.(OperationResult).Registration.Generation)
160+ })
161+ }
162+}
163+
164+// TestMembershipWrongDirection proves direction stays structural: a mutating
165+// request never decodes as server output and a result never decodes as a
166+// request. Misuse is refused by the strict scanner (the tag is unknown to the
167+// other envelope) before generated unmarshal; the typed direction refusal is
168+// already covered by the closed directional interfaces and
169+// TestBrokerWrongDirection.
170+func TestMembershipWrongDirection(t *testing.T) {
171+ connection := testConnectionID(0x24)
172+ operation := testOperationID(0x34)
173+ for _, tc := range membershipRequests(connection, operation) {
174+ t.Run("request/"+tc.name, func(t *testing.T) {
175+ raw := mustEncodeClient(t, tc.message)
176+ decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
177+ require.Error(t, err)
178+ require.Nil(t, decoded, "no server message may be decoded from a client frame")
179+ })
180+ }
181+ result := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}
182+ raw := mustEncodeServer(t, result)
183+ decoded, err := DecodeClient(raw, testEnvelopeCeiling, testChunkCeiling)
184+ require.Error(t, err)
185+ require.Nil(t, decoded, "no client message may be decoded from a server frame")
186+}
187+
188+// TestMembershipMissingAuthorityRefused proves an absent policy or an absent
189+// registration is not authority: the decode path refuses both rather than
190+// inventing a default, on encode and on receive.
191+func TestMembershipMissingAuthorityRefused(t *testing.T) {
192+ connection := testConnectionID(0x25)
193+ operation := testOperationID(0x35)
194+
195+ clearFields := func(t *testing.T, message ClientMessage, clear func(*wire.BrokerClientEnvelope)) []byte {
196+ t.Helper()
197+ raw := mustEncodeClient(t, message)
198+ envelope := &wire.BrokerClientEnvelope{}
199+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
200+ require.NoError(t, proto.Unmarshal(raw, envelope))
201+ clear(envelope)
202+ mutated, err := proto.Marshal(envelope)
203+ require.NoError(t, err)
204+ return mutated
205+ }
206+
207+ t.Run("add_host without policy", func(t *testing.T) {
208+ _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, testEnvelopeCeiling, testChunkCeiling)
209+ require.ErrorIs(t, err, ErrInvalidMessage)
210+ mutated := clearFields(t, AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, func(envelope *wire.BrokerClientEnvelope) {
211+ envelope.GetAddHost().Policy = nil
212+ })
213+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
214+ require.ErrorIs(t, err, ErrInvalidMessage)
215+ })
216+
217+ t.Run("add_host with invalid policy", func(t *testing.T) {
218+ _, err := EncodeClient(AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: ports.BrokerPolicy{}}, testEnvelopeCeiling, testChunkCeiling)
219+ require.ErrorIs(t, err, ErrInvalidMessage)
220+ mutated := clearFields(t, AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, func(envelope *wire.BrokerClientEnvelope) {
221+ envelope.GetAddHost().GetPolicy().EnvironmentPolicy = 99
222+ })
223+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
224+ require.ErrorIs(t, err, ErrInvalidMessage)
225+ })
226+
227+ t.Run("remove_host without registration", func(t *testing.T) {
228+ _, err := EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: operation}, testEnvelopeCeiling, testChunkCeiling)
229+ require.ErrorIs(t, err, ErrInvalidMessage)
230+ mutated := clearFields(t, RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, func(envelope *wire.BrokerClientEnvelope) {
231+ envelope.GetRemoveHost().Registration = nil
232+ })
233+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
234+ require.ErrorIs(t, err, ErrInvalidMessage)
235+ })
236+
237+ t.Run("remove_host with partial registration", func(t *testing.T) {
238+ partial := testRegistration()
239+ partial.Incarnation = [16]byte{}
240+ _, err := EncodeClient(RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: partial}, testEnvelopeCeiling, testChunkCeiling)
241+ require.ErrorIs(t, err, ErrInvalidMessage)
242+
243+ shortIncarnation := clearFields(t, RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, func(envelope *wire.BrokerClientEnvelope) {
244+ envelope.GetRemoveHost().GetRegistration().Incarnation = []byte{1, 2, 3}
245+ })
246+ _, err = DecodeClient(shortIncarnation, testEnvelopeCeiling, testChunkCeiling)
247+ require.ErrorIs(t, err, ErrInvalidMessage)
248+
249+ zeroGeneration := clearFields(t, RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, func(envelope *wire.BrokerClientEnvelope) {
250+ envelope.GetRemoveHost().GetRegistration().Generation = 0
251+ })
252+ _, err = DecodeClient(zeroGeneration, testEnvelopeCeiling, testChunkCeiling)
253+ require.ErrorIs(t, err, ErrInvalidMessage)
254+
255+ badEndpoint := clearFields(t, RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, func(envelope *wire.BrokerClientEnvelope) {
256+ envelope.GetRemoveHost().GetRegistration().Endpoint = "two words"
257+ })
258+ _, err = DecodeClient(badEndpoint, testEnvelopeCeiling, testChunkCeiling)
259+ require.ErrorIs(t, err, ErrInvalidMessage)
260+ })
261+
262+ t.Run("update_host_policy missing registration or policy", func(t *testing.T) {
263+ _, err := EncodeClient(UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Policy: testPolicy()}, testEnvelopeCeiling, testChunkCeiling)
264+ require.ErrorIs(t, err, ErrInvalidMessage)
265+ _, err = EncodeClient(UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, testEnvelopeCeiling, testChunkCeiling)
266+ require.ErrorIs(t, err, ErrInvalidMessage)
267+
268+ update := UpdateHostPolicy{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration(), Policy: testPolicy()}
269+ missingPolicy := clearFields(t, update, func(envelope *wire.BrokerClientEnvelope) {
270+ envelope.GetUpdateHostPolicy().Policy = nil
271+ })
272+ _, err = DecodeClient(missingPolicy, testEnvelopeCeiling, testChunkCeiling)
273+ require.ErrorIs(t, err, ErrInvalidMessage)
274+ missingRegistration := clearFields(t, update, func(envelope *wire.BrokerClientEnvelope) {
275+ envelope.GetUpdateHostPolicy().Registration = nil
276+ })
277+ _, err = DecodeClient(missingRegistration, testEnvelopeCeiling, testChunkCeiling)
278+ require.ErrorIs(t, err, ErrInvalidMessage)
279+ })
280+}
281+
282+// TestMembershipResultPresenceMismatchRefused proves the result presence rules
283+// fail closed in both directions of the codec: removal never travels with a
284+// registration, a failed or outcome-unknown result carries neither authority
285+// and must carry its error, and a success carries no error.
286+func TestMembershipResultPresenceMismatchRefused(t *testing.T) {
287+ connection := testConnectionID(0x26)
288+ operation := testOperationID(0x36)
289+ clearRegistration := func(t *testing.T) []byte {
290+ t.Helper()
291+ raw := mustEncodeServer(t, OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()})
292+ envelope := &wire.BrokerServerEnvelope{}
293+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
294+ require.NoError(t, proto.Unmarshal(raw, envelope))
295+ envelope.GetOperationResult().Registration.Generation = 0
296+ mutated, err := proto.Marshal(envelope)
297+ require.NoError(t, err)
298+ return mutated
299+ }
300+
301+ for name, message := range map[string]OperationResult{
302+ "removed and registration together": {
303+ Epoch: 7, Connection: connection, Operation: operation,
304+ Outcome: ports.BrokerOutcomeOK, Removed: true, Registration: testRegistration(),
305+ },
306+ "failed with registration": {
307+ Epoch: 7, Connection: connection, Operation: operation,
308+ Outcome: ports.BrokerOutcomeFailed, Registration: testRegistration(), Error: testErrorDetail(), HasError: true,
309+ },
310+ "failed with removal": {
311+ Epoch: 7, Connection: connection, Operation: operation,
312+ Outcome: ports.BrokerOutcomeFailed, Removed: true, Error: testErrorDetail(), HasError: true,
313+ },
314+ "failed without error": {
315+ Epoch: 7, Connection: connection, Operation: operation,
316+ Outcome: ports.BrokerOutcomeFailed,
317+ },
318+ "unknown without error": {
319+ Epoch: 7, Connection: connection, Operation: operation,
320+ Outcome: ports.BrokerOutcomeUnknown,
321+ },
322+ "success with error": {
323+ Epoch: 7, Connection: connection, Operation: operation,
324+ Outcome: ports.BrokerOutcomeOK, Registration: testRegistration(), Error: testErrorDetail(), HasError: true,
325+ },
326+ "success with invalid registration": {
327+ Epoch: 7, Connection: connection, Operation: operation,
328+ Outcome: ports.BrokerOutcomeOK, Registration: domain.RemoteRegistration{Endpoint: "dev@host:22"},
329+ },
330+ } {
331+ t.Run("encode/"+name, func(t *testing.T) {
332+ _, err := EncodeServer(message, testEnvelopeCeiling, testChunkCeiling)
333+ require.ErrorIs(t, err, ErrInvalidMessage)
334+ })
335+ }
336+
337+ t.Run("decode refuses a registration that cannot fence", func(t *testing.T) {
338+ _, err := DecodeServer(clearRegistration(t), testEnvelopeCeiling, testChunkCeiling)
339+ require.ErrorIs(t, err, ErrInvalidMessage)
340+ })
341+}
342+
343+// TestMembershipResultAuthorityPerMutationKind proves the per-kind rule names
344+// the right authority for each request, so a removal success can never be read
345+// as an addition and an addition success can never be read as a removal.
346+func TestMembershipResultAuthorityPerMutationKind(t *testing.T) {
347+ connection := testConnectionID(0x27)
348+ operation := testOperationID(0x37)
349+ add := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()}
350+ remove := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Removed: true}
351+ removeNoop := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK}
352+ failed := OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true}
353+
354+ require.NoError(t, add.ValidateForMutation(MutationKindAddHost))
355+ require.NoError(t, add.ValidateForMutation(MutationKindUpdateHostPolicy))
356+ require.ErrorIs(t, add.ValidateForMutation(MutationKindRemoveHost), ErrInvalidMessage)
357+
358+ require.NoError(t, remove.ValidateForMutation(MutationKindRemoveHost))
359+ require.NoError(t, removeNoop.ValidateForMutation(MutationKindRemoveHost), "an absent host is an idempotent no-op success")
360+ require.ErrorIs(t, remove.ValidateForMutation(MutationKindAddHost), ErrInvalidMessage)
361+ require.ErrorIs(t, removeNoop.ValidateForMutation(MutationKindAddHost), ErrInvalidMessage, "an addition success must carry its registration")
362+ require.ErrorIs(t, removeNoop.ValidateForMutation(MutationKindUpdateHostPolicy), ErrInvalidMessage)
363+
364+ for _, kind := range []RegisterMutationKind{MutationKindAddHost, MutationKindRemoveHost, MutationKindUpdateHostPolicy} {
365+ require.NoError(t, failed.ValidateForMutation(kind))
366+ }
367+ require.ErrorIs(t, add.ValidateForMutation(RegisterMutationKind(0)), errConvertRange)
368+ require.ErrorIs(t, add.ValidateForMutation(RegisterMutationKind(9)), errConvertRange)
369+}
370+
371+// TestMembershipErrorCodes proves the two membership codes travel and return
372+// as the exact closed enumeration: 11 is host conflict and 12 is immutable
373+// membership, on both the encode and decode paths.
374+func TestMembershipErrorCodes(t *testing.T) {
375+ connection := testConnectionID(0x28)
376+ operation := testOperationID(0x38)
377+ require.Equal(t, ports.BrokerErrorCode(11), ports.BrokerErrorHostConflict)
378+ require.Equal(t, ports.BrokerErrorCode(12), ports.BrokerErrorMembershipImmutable)
379+
380+ for name, code := range map[string]ports.BrokerErrorCode{
381+ "host_conflict": ports.BrokerErrorHostConflict,
382+ "membership_immutable": ports.BrokerErrorMembershipImmutable,
383+ } {
384+ t.Run(name, func(t *testing.T) {
385+ message := OperationResult{
386+ Epoch: 7, Connection: connection, Operation: operation,
387+ Outcome: ports.BrokerOutcomeFailed,
388+ Error: ErrorDetail{Code: code, Text: "refused"}, HasError: true,
389+ }
390+ raw := mustEncodeServer(t, message)
391+ decoded, err := DecodeServer(raw, testEnvelopeCeiling, testChunkCeiling)
392+ require.NoError(t, err)
393+ require.Equal(t, message, decoded)
394+ require.Equal(t, code, decoded.(OperationResult).Error.Code)
395+
396+ envelope := &wire.BrokerServerEnvelope{}
397+ require.NoError(t, proto.Unmarshal(raw, envelope))
398+ require.Equal(t, uint32(code), envelope.GetOperationResult().GetError().GetCode())
399+ })
400+ }
401+}
402+
403+// TestMembershipMalformedTruncatedTrailing proves the strict scanner runs
404+// before generated unmarshal on the new variants too: unknown fields,
405+// truncation, trailing garbage, and an oversize envelope are all refused.
406+func TestMembershipMalformedTruncatedTrailing(t *testing.T) {
407+ connection := testConnectionID(0x29)
408+ operation := testOperationID(0x39)
409+ requests := membershipRequests(connection, operation)
410+
411+ for _, tc := range requests {
412+ t.Run("truncated/"+tc.name, func(t *testing.T) {
413+ raw := mustEncodeClient(t, tc.message)
414+ for size := range len(raw) {
415+ _, err := DecodeClient(raw[:size], testEnvelopeCeiling, testChunkCeiling)
416+ require.Error(t, err, "prefix[:%d] accepted", size)
417+ }
418+ })
419+ t.Run("trailing/"+tc.name, func(t *testing.T) {
420+ raw := mustEncodeClient(t, tc.message)
421+ _, err := DecodeClient(append(append([]byte(nil), raw...), 0xFF), testEnvelopeCeiling, testChunkCeiling)
422+ require.Error(t, err)
423+ concatenated := append(append([]byte(nil), raw...), raw...)
424+ _, err = DecodeClient(concatenated, testEnvelopeCeiling, testChunkCeiling)
425+ require.ErrorIs(t, err, wire.ErrScanDuplicate)
426+ })
427+ t.Run("oversize/"+tc.name, func(t *testing.T) {
428+ raw := mustEncodeClient(t, tc.message)
429+ _, err := DecodeClient(raw, uint64(len(raw)-1), testChunkCeiling)
430+ require.ErrorIs(t, err, wire.ErrScanLength)
431+ })
432+ }
433+
434+ t.Run("unknown nested field", func(t *testing.T) {
435+ // UpdateHostPolicy → RemoteRegistration with an undeclared field.
436+ registration := protowire.AppendTag(nil, 1, protowire.BytesType)
437+ registration = protowire.AppendBytes(registration, []byte("dev@host:22"))
438+ registration = protowire.AppendTag(registration, 9, protowire.VarintType)
439+ registration = protowire.AppendVarint(registration, 1)
440+ update := protowire.AppendTag(nil, 3, protowire.BytesType) // UpdateHostPolicy.registration
441+ update = protowire.AppendBytes(update, registration)
442+ outer := protowire.AppendTag(nil, 111, protowire.BytesType) // BrokerClientEnvelope.update_host_policy
443+ outer = protowire.AppendBytes(outer, update)
444+ _, err := DecodeClient(outer, testEnvelopeCeiling, testChunkCeiling)
445+ require.ErrorIs(t, err, wire.ErrScanUnknown)
446+ })
447+
448+ t.Run("unknown request field", func(t *testing.T) {
449+ raw := mustEncodeClient(t, AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()})
450+ envelope := &wire.BrokerClientEnvelope{}
451+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
452+ require.NoError(t, proto.Unmarshal(raw, envelope))
453+ envelope.GetAddHost().Policy = nil
454+ mutated, err := proto.Marshal(envelope)
455+ require.NoError(t, err)
456+ mutated = protowire.AppendTag(mutated, 9, protowire.VarintType)
457+ mutated = protowire.AppendVarint(mutated, 1)
458+ _, err = DecodeClient(mutated, testEnvelopeCeiling, testChunkCeiling)
459+ require.ErrorIs(t, err, wire.ErrScanUnknown)
460+ })
461+
462+ t.Run("wrong wire type on the new tag", func(t *testing.T) {
463+ wrongType := protowire.AppendTag(nil, 111, protowire.VarintType)
464+ wrongType = protowire.AppendVarint(wrongType, 1)
465+ _, err := DecodeClient(wrongType, testEnvelopeCeiling, testChunkCeiling)
466+ require.ErrorIs(t, err, wire.ErrScanWireType)
467+ })
468+
469+ t.Run("result truncated", func(t *testing.T) {
470+ raw := mustEncodeServer(t, OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeOK, Registration: testRegistration()})
471+ for size := range len(raw) {
472+ _, err := DecodeServer(raw[:size], testEnvelopeCeiling, testChunkCeiling)
473+ require.Error(t, err, "server prefix[:%d] accepted", size)
474+ }
475+ })
476+}
477+
478+// TestMembershipUnknownTaxonomyRefused proves an out-of-range request tag or a
479+// result outcome outside the closed taxonomy is refused rather than coerced.
480+func TestMembershipUnknownTaxonomyRefused(t *testing.T) {
481+ connection := testConnectionID(0x2A)
482+ operation := testOperationID(0x3A)
483+ raw := mustEncodeServer(t, OperationResult{Epoch: 7, Connection: connection, Operation: operation, Outcome: ports.BrokerOutcomeFailed, Error: testErrorDetail(), HasError: true})
484+ envelope := &wire.BrokerServerEnvelope{}
485+ require.NoError(t, wire.ScanEnvelope(envelope, raw))
486+ require.NoError(t, proto.Unmarshal(raw, envelope))
487+ envelope.GetOperationResult().Outcome = 9
488+ mutated, err := proto.Marshal(envelope)
489+ require.NoError(t, err)
490+ _, err = DecodeServer(mutated, testEnvelopeCeiling, testChunkCeiling)
491+ require.ErrorIs(t, err, ErrInvalidMessage)
492+
493+ // A malformed error code on a result is refused like one on an error frame.
494+ envelope.GetOperationResult().Outcome = 2
495+ envelope.GetOperationResult().GetError().Code = 99
496+ mutated, err = proto.Marshal(envelope)
497+ require.NoError(t, err)
498+ _, err = DecodeServer(mutated, testEnvelopeCeiling, testChunkCeiling)
499+ require.ErrorIs(t, err, ErrInvalidMessage)
500+}
M internal/adapters/brokerwire/messages.go +182 -17
@@ -67,27 +67,48 @@ type Unsubscribe struct {
6767
6868 func (Unsubscribe) brokerClientMessage() {}
6969
70-// AddHost adds one configured host registration. Operation is the
71-// idempotency identity of this mutating operation.
70+// AddHost adds one configured host registration under an exact policy.
71+// Operation is the idempotency identity of this mutating operation. Policy
72+// is required authority: an absent or invalid policy is refused rather than
73+// inherited, so a membership addition can never be authorized by a zero
74+// policy.
7275 type AddHost struct {
7376 Epoch ports.BrokerEpoch
7477 Connection ports.BrokerConnectionID
7578 Operation ports.BrokerOperationID
7679 Endpoint string
80+ Policy ports.BrokerPolicy
7781 }
7882
7983 func (AddHost) brokerClientMessage() {}
8084
81-// RemoveHost removes one configured host registration.
85+// RemoveHost removes exactly one configured host registration: the request
86+// carries the full expected authority (endpoint, incarnation, and
87+// generation), never a bare endpoint name, so a stale removal can never
88+// retire a re-added host.
8289 type RemoveHost struct {
83- Epoch ports.BrokerEpoch
84- Connection ports.BrokerConnectionID
85- Operation ports.BrokerOperationID
86- Endpoint string
90+ Epoch ports.BrokerEpoch
91+ Connection ports.BrokerConnectionID
92+ Operation ports.BrokerOperationID
93+ Registration domain.RemoteRegistration
8794 }
8895
8996 func (RemoveHost) brokerClientMessage() {}
9097
98+// UpdateHostPolicy replaces the policy of exactly one configured
99+// registration. Registration is the exact expected authority and Policy is
100+// the required replacement, so an update can neither target an endpoint
101+// name nor be authorized by a zero policy.
102+type UpdateHostPolicy struct {
103+ Epoch ports.BrokerEpoch
104+ Connection ports.BrokerConnectionID
105+ Operation ports.BrokerOperationID
106+ Registration domain.RemoteRegistration
107+ Policy ports.BrokerPolicy
108+}
109+
110+func (UpdateHostPolicy) brokerClientMessage() {}
111+
91112 // Reconcile hands the broker one exact registration to probe.
92113 type Reconcile struct {
93114 Epoch ports.BrokerEpoch
@@ -216,21 +237,165 @@ type SnapshotEnd struct{}
216237 func (SnapshotEnd) snapshotPartPayload() {}
217238
218239 // OperationResult is exactly one completion for one admitted mutating
219-// operation. Removed reports the host is gone; Error carries the typed
220-// failure when Outcome is failed or outcome-unknown. HasError=false means no
221-// detail at all: encode refuses a nonzero Error rather than dropping it.
240+// operation. Removed and Registration are presence-carrying authority: a
241+// successful addition or policy update carries the resulting registration,
242+// a successful removal reports Removed and carries no registration, and a
243+// failed or outcome-unknown result carries neither authority, only its
244+// typed error. Error carries the typed failure when Outcome is failed or
245+// outcome-unknown; HasError=false means no detail at all, and both a
246+// nonzero Error under HasError=false and an error on a successful outcome
247+// are refused rather than dropped.
222248 type OperationResult struct {
223- Epoch ports.BrokerEpoch
224- Connection ports.BrokerConnectionID
225- Operation ports.BrokerOperationID
226- Outcome ports.BrokerMutationOutcome
227- Removed bool
228- Error ErrorDetail
229- HasError bool
249+ Epoch ports.BrokerEpoch
250+ Connection ports.BrokerConnectionID
251+ Operation ports.BrokerOperationID
252+ Outcome ports.BrokerMutationOutcome
253+ Removed bool
254+ Registration domain.RemoteRegistration
255+ Error ErrorDetail
256+ HasError bool
230257 }
231258
232259 func (OperationResult) brokerServerMessage() {}
233260
261+// RegisterMutationKind is the closed taxonomy of membership mutations. A
262+// result's required authority depends on which request produced it, so the
263+// taxonomy is explicit instead of being re-derived from a message type at
264+// each result site.
265+type RegisterMutationKind uint8
266+
267+const (
268+ // MutationKindAddHost is an AddHost request: its success carries the
269+ // authoritative registration.
270+ MutationKindAddHost RegisterMutationKind = iota + 1
271+ // MutationKindRemoveHost is a RemoveHost request: its success reports
272+ // removal and carries no registration.
273+ MutationKindRemoveHost
274+ // MutationKindUpdateHostPolicy is an UpdateHostPolicy request: its
275+ // success carries the advanced registration.
276+ MutationKindUpdateHostPolicy
277+)
278+
279+func (k RegisterMutationKind) String() string {
280+ switch k {
281+ case MutationKindAddHost:
282+ return "add_host"
283+ case MutationKindRemoveHost:
284+ return "remove_host"
285+ case MutationKindUpdateHostPolicy:
286+ return "update_host_policy"
287+ default:
288+ return "unknown"
289+ }
290+}
291+
292+// Validate rejects a kind outside the closed membership-mutation taxonomy.
293+func (k RegisterMutationKind) Validate() error {
294+ switch k {
295+ case MutationKindAddHost, MutationKindRemoveHost, MutationKindUpdateHostPolicy:
296+ return nil
297+ default:
298+ return errConvertRange
299+ }
300+}
301+
302+// MutationKindOf classifies one client message that mutates host membership.
303+// It is the request-side authority a per-kind result rule needs; pending
304+// operation tracking is deliberately not wired here.
305+func MutationKindOf(message ClientMessage) (RegisterMutationKind, bool) {
306+ switch m := message.(type) {
307+ case AddHost:
308+ return MutationKindAddHost, true
309+ case *AddHost:
310+ if m == nil {
311+ return 0, false
312+ }
313+ return MutationKindAddHost, true
314+ case RemoveHost:
315+ return MutationKindRemoveHost, true
316+ case *RemoveHost:
317+ if m == nil {
318+ return 0, false
319+ }
320+ return MutationKindRemoveHost, true
321+ case UpdateHostPolicy:
322+ return MutationKindUpdateHostPolicy, true
323+ case *UpdateHostPolicy:
324+ if m == nil {
325+ return 0, false
326+ }
327+ return MutationKindUpdateHostPolicy, true
328+ default:
329+ return 0, false
330+ }
331+}
332+
333+// validateResultAuthority enforces the presence rules of one mutating result
334+// that hold without knowing which request produced it: removal and
335+// registration never travel together, a successful result never carries an
336+// error, and a failed or outcome-unknown result carries neither authority,
337+// only its typed error. The per-kind rule (an AddHost or UpdateHostPolicy
338+// success must carry a registration, a RemoveHost success must not) is
339+// enforced by ValidateForMutation at the owning site, because the stateless
340+// codec has no pending-operation state to consult. A successful removal with
341+// no authority is legal: an absent host is an idempotent no-op, not an error.
342+func validateResultAuthority(outcome ports.BrokerMutationOutcome, removed, hasRegistration, hasError bool) error {
343+ if err := outcome.Validate(); err != nil {
344+ return ErrInvalidMessage
345+ }
346+ if removed && hasRegistration {
347+ return ErrInvalidMessage
348+ }
349+ if outcome == ports.BrokerOutcomeOK {
350+ if hasError {
351+ return ErrInvalidMessage
352+ }
353+ return nil
354+ }
355+ if removed || hasRegistration || !hasError {
356+ return ErrInvalidMessage
357+ }
358+ return nil
359+}
360+
361+// ValidateForMutation enforces the full authority rule of one mutating result
362+// against the kind of request it completes. AddHost and UpdateHostPolicy
363+// succeed with the authoritative registration, RemoveHost succeeds with
364+// removal and no registration, and every failed or outcome-unknown result
365+// carries neither authority, only its validated typed error.
366+func (r OperationResult) ValidateForMutation(kind RegisterMutationKind) error {
367+ if err := kind.Validate(); err != nil {
368+ return err
369+ }
370+ hasRegistration := r.Registration != (domain.RemoteRegistration{})
371+ if err := validateResultAuthority(r.Outcome, r.Removed, hasRegistration, r.HasError); err != nil {
372+ return err
373+ }
374+ if r.HasError {
375+ if err := r.Error.validate(); err != nil {
376+ return err
377+ }
378+ }
379+ switch r.Outcome {
380+ case ports.BrokerOutcomeOK:
381+ switch kind {
382+ case MutationKindRemoveHost:
383+ if hasRegistration {
384+ return ErrInvalidMessage
385+ }
386+ default:
387+ if !hasRegistration {
388+ return ErrInvalidMessage
389+ }
390+ }
391+ default:
392+ if r.Removed || hasRegistration || !r.HasError {
393+ return ErrInvalidMessage
394+ }
395+ }
396+ return nil
397+}
398+
234399 // StreamOpened confirms one logical stream is established.
235400 type StreamOpened struct {
236401 Epoch ports.BrokerEpoch
M internal/ports/broker.go +14 -3
@@ -563,6 +563,8 @@ const (
563563 BrokerErrorConflictingPolicy
564564 BrokerErrorExplicitExit
565565 BrokerErrorFatalTerminal
566+ BrokerErrorHostConflict
567+ BrokerErrorMembershipImmutable
566568 )
567569
568570 func (c BrokerErrorCode) String() string {
@@ -587,6 +589,10 @@ func (c BrokerErrorCode) String() string {
587589 return "explicit_exit"
588590 case BrokerErrorFatalTerminal:
589591 return "fatal_terminal"
592+ case BrokerErrorHostConflict:
593+ return "host_conflict"
594+ case BrokerErrorMembershipImmutable:
595+ return "membership_immutable"
590596 default:
591597 return "unknown"
592598 }
@@ -597,7 +603,8 @@ func (c BrokerErrorCode) Validate() error {
597603 case BrokerErrorUnavailable, BrokerErrorIncompatible, BrokerErrorTimeout,
598604 BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost,
599605 BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy,
600- BrokerErrorExplicitExit, BrokerErrorFatalTerminal:
606+ BrokerErrorExplicitExit, BrokerErrorFatalTerminal,
607+ BrokerErrorHostConflict, BrokerErrorMembershipImmutable:
601608 return nil
602609 default:
603610 return errors.New("ports: invalid broker error code")
@@ -698,8 +705,12 @@ type BrokerService interface {
698705 Subscribe() (BrokerSubscription, error)
699706 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)
700707 CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error
701- AddHost(ctx context.Context, target string) error
702- RemoveHost(ctx context.Context, target string) (bool, error)
708+ // AddHost durably adds or pins endpoint under policy and returns its authority.
709+ AddHost(ctx context.Context, endpoint string, policy BrokerPolicy) (domain.RemoteRegistration, error)
710+ // RemoveHost removes only the exact expected registration.
711+ RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error)
712+ // UpdateHostPolicy changes policy only for exact expected authority and returns the advanced registration.
713+ UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy BrokerPolicy) (domain.RemoteRegistration, error)
703714 RequestReconcile(endpoint string)
704715 Close() error
705716 }
M internal/ports/broker_test.go +2 -0
@@ -510,6 +510,8 @@ func TestBrokerErrorTerminalOutcomes(t *testing.T) {
510510 {code: BrokerErrorConflictingPolicy, wantTerminal: false},
511511 {code: BrokerErrorExplicitExit, wantTerminal: true},
512512 {code: BrokerErrorFatalTerminal, wantTerminal: true},
513+ {code: BrokerErrorHostConflict, wantTerminal: false},
514+ {code: BrokerErrorMembershipImmutable, wantTerminal: false},
513515 }
514516 for _, tt := range tests {
515517 t.Run(tt.code.String(), func(t *testing.T) {
M internal/ports/mocks/broker_mock.go +116 -29
@@ -146,20 +146,29 @@ func (_m *MockBrokerService) EXPECT() *MockBrokerService_Expecter {
146146 }
147147
148148 // AddHost provides a mock function for the type MockBrokerService
149-func (_mock *MockBrokerService) AddHost(ctx context.Context, target string) error {
150- ret := _mock.Called(ctx, target)
149+func (_mock *MockBrokerService) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
150+ ret := _mock.Called(ctx, endpoint, policy)
151151
152152 if len(ret) == 0 {
153153 panic("no return value specified for AddHost")
154154 }
155155
156- var r0 error
157- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
158- r0 = returnFunc(ctx, target)
156+ var r0 domain.RemoteRegistration
157+ var r1 error
158+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error)); ok {
159+ return returnFunc(ctx, endpoint, policy)
160+ }
161+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, ports.BrokerPolicy) domain.RemoteRegistration); ok {
162+ r0 = returnFunc(ctx, endpoint, policy)
159163 } else {
160- r0 = ret.Error(0)
164+ r0 = ret.Get(0).(domain.RemoteRegistration)
161165 }
162- return r0
166+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, ports.BrokerPolicy) error); ok {
167+ r1 = returnFunc(ctx, endpoint, policy)
168+ } else {
169+ r1 = ret.Error(1)
170+ }
171+ return r0, r1
163172 }
164173
165174 // MockBrokerService_AddHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddHost'
@@ -169,12 +178,13 @@ type MockBrokerService_AddHost_Call struct {
169178
170179 // AddHost is a helper method to define mock.On call
171180 // - ctx context.Context
172-// - target string
173-func (_e *MockBrokerService_Expecter) AddHost(ctx any, target any) *MockBrokerService_AddHost_Call {
174- return &MockBrokerService_AddHost_Call{Call: _e.mock.On("AddHost", ctx, target)}
181+// - endpoint string
182+// - policy ports.BrokerPolicy
183+func (_e *MockBrokerService_Expecter) AddHost(ctx any, endpoint any, policy any) *MockBrokerService_AddHost_Call {
184+ return &MockBrokerService_AddHost_Call{Call: _e.mock.On("AddHost", ctx, endpoint, policy)}
175185 }
176186
177-func (_c *MockBrokerService_AddHost_Call) Run(run func(ctx context.Context, target string)) *MockBrokerService_AddHost_Call {
187+func (_c *MockBrokerService_AddHost_Call) Run(run func(ctx context.Context, endpoint string, policy ports.BrokerPolicy)) *MockBrokerService_AddHost_Call {
178188 _c.Call.Run(func(args mock.Arguments) {
179189 var arg0 context.Context
180190 if args[0] != nil {
@@ -184,20 +194,25 @@ func (_c *MockBrokerService_AddHost_Call) Run(run func(ctx context.Context, targ
184194 if args[1] != nil {
185195 arg1 = args[1].(string)
186196 }
197+ var arg2 ports.BrokerPolicy
198+ if args[2] != nil {
199+ arg2 = args[2].(ports.BrokerPolicy)
200+ }
187201 run(
188202 arg0,
189203 arg1,
204+ arg2,
190205 )
191206 })
192207 return _c
193208 }
194209
195-func (_c *MockBrokerService_AddHost_Call) Return(err error) *MockBrokerService_AddHost_Call {
196- _c.Call.Return(err)
210+func (_c *MockBrokerService_AddHost_Call) Return(remoteRegistration domain.RemoteRegistration, err error) *MockBrokerService_AddHost_Call {
211+ _c.Call.Return(remoteRegistration, err)
197212 return _c
198213 }
199214
200-func (_c *MockBrokerService_AddHost_Call) RunAndReturn(run func(ctx context.Context, target string) error) *MockBrokerService_AddHost_Call {
215+func (_c *MockBrokerService_AddHost_Call) RunAndReturn(run func(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error)) *MockBrokerService_AddHost_Call {
201216 _c.Call.Return(run)
202217 return _c
203218 }
@@ -508,8 +523,8 @@ func (_c *MockBrokerService_OpenStream_Call) RunAndReturn(run func(ctx context.C
508523 }
509524
510525 // RemoveHost provides a mock function for the type MockBrokerService
511-func (_mock *MockBrokerService) RemoveHost(ctx context.Context, target string) (bool, error) {
512- ret := _mock.Called(ctx, target)
526+func (_mock *MockBrokerService) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
527+ ret := _mock.Called(ctx, expected)
513528
514529 if len(ret) == 0 {
515530 panic("no return value specified for RemoveHost")
@@ -517,16 +532,16 @@ func (_mock *MockBrokerService) RemoveHost(ctx context.Context, target string) (
517532
518533 var r0 bool
519534 var r1 error
520- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok {
521- return returnFunc(ctx, target)
535+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) (bool, error)); ok {
536+ return returnFunc(ctx, expected)
522537 }
523- if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {
524- r0 = returnFunc(ctx, target)
538+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) bool); ok {
539+ r0 = returnFunc(ctx, expected)
525540 } else {
526541 r0 = ret.Get(0).(bool)
527542 }
528- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
529- r1 = returnFunc(ctx, target)
543+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration) error); ok {
544+ r1 = returnFunc(ctx, expected)
530545 } else {
531546 r1 = ret.Error(1)
532547 }
@@ -540,20 +555,20 @@ type MockBrokerService_RemoveHost_Call struct {
540555
541556 // RemoveHost is a helper method to define mock.On call
542557 // - ctx context.Context
543-// - target string
544-func (_e *MockBrokerService_Expecter) RemoveHost(ctx any, target any) *MockBrokerService_RemoveHost_Call {
545- return &MockBrokerService_RemoveHost_Call{Call: _e.mock.On("RemoveHost", ctx, target)}
558+// - expected domain.RemoteRegistration
559+func (_e *MockBrokerService_Expecter) RemoveHost(ctx any, expected any) *MockBrokerService_RemoveHost_Call {
560+ return &MockBrokerService_RemoveHost_Call{Call: _e.mock.On("RemoveHost", ctx, expected)}
546561 }
547562
548-func (_c *MockBrokerService_RemoveHost_Call) Run(run func(ctx context.Context, target string)) *MockBrokerService_RemoveHost_Call {
563+func (_c *MockBrokerService_RemoveHost_Call) Run(run func(ctx context.Context, expected domain.RemoteRegistration)) *MockBrokerService_RemoveHost_Call {
549564 _c.Call.Run(func(args mock.Arguments) {
550565 var arg0 context.Context
551566 if args[0] != nil {
552567 arg0 = args[0].(context.Context)
553568 }
554- var arg1 string
569+ var arg1 domain.RemoteRegistration
555570 if args[1] != nil {
556- arg1 = args[1].(string)
571+ arg1 = args[1].(domain.RemoteRegistration)
557572 }
558573 run(
559574 arg0,
@@ -568,7 +583,7 @@ func (_c *MockBrokerService_RemoveHost_Call) Return(b bool, err error) *MockBrok
568583 return _c
569584 }
570585
571-func (_c *MockBrokerService_RemoveHost_Call) RunAndReturn(run func(ctx context.Context, target string) (bool, error)) *MockBrokerService_RemoveHost_Call {
586+func (_c *MockBrokerService_RemoveHost_Call) RunAndReturn(run func(ctx context.Context, expected domain.RemoteRegistration) (bool, error)) *MockBrokerService_RemoveHost_Call {
572587 _c.Call.Return(run)
573588 return _c
574589 }
@@ -712,6 +727,78 @@ func (_c *MockBrokerService_Subscribe_Call) RunAndReturn(run func() (ports.Broke
712727 return _c
713728 }
714729
730+// UpdateHostPolicy provides a mock function for the type MockBrokerService
731+func (_mock *MockBrokerService) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
732+ ret := _mock.Called(ctx, expected, policy)
733+
734+ if len(ret) == 0 {
735+ panic("no return value specified for UpdateHostPolicy")
736+ }
737+
738+ var r0 domain.RemoteRegistration
739+ var r1 error
740+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error)); ok {
741+ return returnFunc(ctx, expected, policy)
742+ }
743+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) domain.RemoteRegistration); ok {
744+ r0 = returnFunc(ctx, expected, policy)
745+ } else {
746+ r0 = ret.Get(0).(domain.RemoteRegistration)
747+ }
748+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) error); ok {
749+ r1 = returnFunc(ctx, expected, policy)
750+ } else {
751+ r1 = ret.Error(1)
752+ }
753+ return r0, r1
754+}
755+
756+// MockBrokerService_UpdateHostPolicy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateHostPolicy'
757+type MockBrokerService_UpdateHostPolicy_Call struct {
758+ *mock.Call
759+}
760+
761+// UpdateHostPolicy is a helper method to define mock.On call
762+// - ctx context.Context
763+// - expected domain.RemoteRegistration
764+// - policy ports.BrokerPolicy
765+func (_e *MockBrokerService_Expecter) UpdateHostPolicy(ctx any, expected any, policy any) *MockBrokerService_UpdateHostPolicy_Call {
766+ return &MockBrokerService_UpdateHostPolicy_Call{Call: _e.mock.On("UpdateHostPolicy", ctx, expected, policy)}
767+}
768+
769+func (_c *MockBrokerService_UpdateHostPolicy_Call) Run(run func(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy)) *MockBrokerService_UpdateHostPolicy_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ var arg0 context.Context
772+ if args[0] != nil {
773+ arg0 = args[0].(context.Context)
774+ }
775+ var arg1 domain.RemoteRegistration
776+ if args[1] != nil {
777+ arg1 = args[1].(domain.RemoteRegistration)
778+ }
779+ var arg2 ports.BrokerPolicy
780+ if args[2] != nil {
781+ arg2 = args[2].(ports.BrokerPolicy)
782+ }
783+ run(
784+ arg0,
785+ arg1,
786+ arg2,
787+ )
788+ })
789+ return _c
790+}
791+
792+func (_c *MockBrokerService_UpdateHostPolicy_Call) Return(remoteRegistration domain.RemoteRegistration, err error) *MockBrokerService_UpdateHostPolicy_Call {
793+ _c.Call.Return(remoteRegistration, err)
794+ return _c
795+}
796+
797+func (_c *MockBrokerService_UpdateHostPolicy_Call) RunAndReturn(run func(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error)) *MockBrokerService_UpdateHostPolicy_Call {
798+ _c.Call.Return(run)
799+ return _c
800+}
801+
715802 // NewMockBrokerSnapshotStore creates a new instance of MockBrokerSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
716803 // The first argument is typically a *testing.T value.
717804 func NewMockBrokerSnapshotStore(t interface {
M internal/protocol/session.go +1 -1
@@ -16,7 +16,7 @@ import (
1616 // daemonmux physical preamble conversation (multiplex.proto), whose
1717 // negotiated ceilings and accepted daemon binding ride the same wire
1818 // version as the session and broker conversations.
19-const Version uint16 = 55
19+const Version uint16 = 56
2020
2121 // HandshakeTimeout bounds every transport handshake from connect through the
2222 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
1010 )
1111
1212 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(55), Version)
13+ require.Equal(t, uint16(56), Version)
1414 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
1515 }
1616
M internal/protocol/wire/broker.pb.go +289 -161
@@ -1,7 +1,7 @@
11 // Broker wire schema: the client/server broker conversations.
22 //
33 // The broker is an independently versioned conversation from the session
4-// protocol: client tags 101-110 and server tags 201-209 are disjoint from
4+// protocol: client tags 101-111 and server tags 201-209 are disjoint from
55 // every session tag, and each direction is its own closed oneof union.
66 // Scope (broker epoch plus client connection) is carried explicitly on
77 // every payload so stream IDs and events never cross a scope. Semantic
@@ -245,7 +245,7 @@ type BrokerErrorDetail struct {
245245 // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout,
246246 // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost,
247247 // 7 = stale_epoch, 8 = conflicting_policy, 9 = explicit_exit,
248- // 10 = fatal_terminal.
248+ // 10 = fatal_terminal, 11 = host_conflict, 12 = membership_immutable.
249249 Code uint32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
250250 Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"`
251251 // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the
@@ -387,6 +387,7 @@ type BrokerClientEnvelope struct {
387387 // *BrokerClientEnvelope_OpenStream
388388 // *BrokerClientEnvelope_ClientStreamData
389389 // *BrokerClientEnvelope_CloseStream
390+ // *BrokerClientEnvelope_UpdateHostPolicy
390391 Payload isBrokerClientEnvelope_Payload `protobuf_oneof:"payload"`
391392 unknownFields protoimpl.UnknownFields
392393 sizeCache protoimpl.SizeCache
@@ -519,6 +520,15 @@ func (x *BrokerClientEnvelope) GetCloseStream() *CloseStream {
519520 return nil
520521 }
521522
523+func (x *BrokerClientEnvelope) GetUpdateHostPolicy() *UpdateHostPolicy {
524+ if x != nil {
525+ if x, ok := x.Payload.(*BrokerClientEnvelope_UpdateHostPolicy); ok {
526+ return x.UpdateHostPolicy
527+ }
528+ }
529+ return nil
530+}
531+
522532 type isBrokerClientEnvelope_Payload interface {
523533 isBrokerClientEnvelope_Payload()
524534 }
@@ -563,6 +573,10 @@ type BrokerClientEnvelope_CloseStream struct {
563573 CloseStream *CloseStream `protobuf:"bytes,110,opt,name=close_stream,json=closeStream,proto3,oneof"`
564574 }
565575
576+type BrokerClientEnvelope_UpdateHostPolicy struct {
577+ UpdateHostPolicy *UpdateHostPolicy `protobuf:"bytes,111,opt,name=update_host_policy,json=updateHostPolicy,proto3,oneof"`
578+}
579+
566580 func (*BrokerClientEnvelope_Register) isBrokerClientEnvelope_Payload() {}
567581
568582 func (*BrokerClientEnvelope_Subscribe) isBrokerClientEnvelope_Payload() {}
@@ -583,6 +597,8 @@ func (*BrokerClientEnvelope_ClientStreamData) isBrokerClientEnvelope_Payload() {
583597
584598 func (*BrokerClientEnvelope_CloseStream) isBrokerClientEnvelope_Payload() {}
585599
600+func (*BrokerClientEnvelope_UpdateHostPolicy) isBrokerClientEnvelope_Payload() {}
601+
586602 // BrokerServerEnvelope is the closed server-to-client broker union. Exactly
587603 // one variant must be set per envelope.
588604 type BrokerServerEnvelope struct {
@@ -977,12 +993,15 @@ func (x *Unsubscribe) GetGeneration() uint64 {
977993 }
978994
979995 // AddHost adds one configured host registration. operation_id is the
980-// idempotency identity of this mutating operation.
996+// idempotency identity of this mutating operation. policy is mandatory:
997+// the request is refused unless it carries an exact valid policy, so a
998+// membership addition can never be authorized by an absent policy.
981999 type AddHost struct {
9821000 state protoimpl.MessageState `protogen:"open.v1"`
9831001 Scope *BrokerScope `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"`
9841002 OperationId []byte `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // exactly 16 bytes
9851003 Endpoint string `protobuf:"bytes,3,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
1004+ Policy *BrokerWirePolicy `protobuf:"bytes,4,opt,name=policy,proto3" json:"policy,omitempty"`
9861005 unknownFields protoimpl.UnknownFields
9871006 sizeCache protoimpl.SizeCache
9881007 }
@@ -1038,12 +1057,22 @@ func (x *AddHost) GetEndpoint() string {
10381057 return ""
10391058 }
10401059
1041-// RemoveHost removes one configured host registration.
1060+func (x *AddHost) GetPolicy() *BrokerWirePolicy {
1061+ if x != nil {
1062+ return x.Policy
1063+ }
1064+ return nil
1065+}
1066+
1067+// RemoveHost removes exactly the registration carried by registration
1068+// (endpoint, incarnation, and generation). A request whose authority does
1069+// not exactly match the current registration is refused rather than
1070+// applied to an endpoint name.
10421071 type RemoveHost struct {
10431072 state protoimpl.MessageState `protogen:"open.v1"`
10441073 Scope *BrokerScope `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"`
10451074 OperationId []byte `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // exactly 16 bytes
1046- Endpoint string `protobuf:"bytes,3,opt,name=endpoint,proto3" json:"endpoint,omitempty"`
1075+ Registration *RemoteRegistration `protobuf:"bytes,3,opt,name=registration,proto3" json:"registration,omitempty"`
10471076 unknownFields protoimpl.UnknownFields
10481077 sizeCache protoimpl.SizeCache
10491078 }
@@ -1092,11 +1121,82 @@ func (x *RemoveHost) GetOperationId() []byte {
10921121 return nil
10931122 }
10941123
1095-func (x *RemoveHost) GetEndpoint() string {
1124+func (x *RemoveHost) GetRegistration() *RemoteRegistration {
10961125 if x != nil {
1097- return x.Endpoint
1126+ return x.Registration
10981127 }
1099- return ""
1128+ return nil
1129+}
1130+
1131+// UpdateHostPolicy replaces the policy of exactly the registration carried
1132+// by registration and answers with the advanced registration authority.
1133+// policy is mandatory: an absent policy is refused like an invalid one.
1134+type UpdateHostPolicy struct {
1135+ state protoimpl.MessageState `protogen:"open.v1"`
1136+ Scope *BrokerScope `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"`
1137+ OperationId []byte `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // exactly 16 bytes
1138+ Registration *RemoteRegistration `protobuf:"bytes,3,opt,name=registration,proto3" json:"registration,omitempty"`
1139+ Policy *BrokerWirePolicy `protobuf:"bytes,4,opt,name=policy,proto3" json:"policy,omitempty"`
1140+ unknownFields protoimpl.UnknownFields
1141+ sizeCache protoimpl.SizeCache
1142+}
1143+
1144+func (x *UpdateHostPolicy) Reset() {
1145+ *x = UpdateHostPolicy{}
1146+ mi := &file_broker_proto_msgTypes[13]
1147+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1148+ ms.StoreMessageInfo(mi)
1149+}
1150+
1151+func (x *UpdateHostPolicy) String() string {
1152+ return protoimpl.X.MessageStringOf(x)
1153+}
1154+
1155+func (*UpdateHostPolicy) ProtoMessage() {}
1156+
1157+func (x *UpdateHostPolicy) ProtoReflect() protoreflect.Message {
1158+ mi := &file_broker_proto_msgTypes[13]
1159+ if x != nil {
1160+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
1161+ if ms.LoadMessageInfo() == nil {
1162+ ms.StoreMessageInfo(mi)
1163+ }
1164+ return ms
1165+ }
1166+ return mi.MessageOf(x)
1167+}
1168+
1169+// Deprecated: Use UpdateHostPolicy.ProtoReflect.Descriptor instead.
1170+func (*UpdateHostPolicy) Descriptor() ([]byte, []int) {
1171+ return file_broker_proto_rawDescGZIP(), []int{13}
1172+}
1173+
1174+func (x *UpdateHostPolicy) GetScope() *BrokerScope {
1175+ if x != nil {
1176+ return x.Scope
1177+ }
1178+ return nil
1179+}
1180+
1181+func (x *UpdateHostPolicy) GetOperationId() []byte {
1182+ if x != nil {
1183+ return x.OperationId
1184+ }
1185+ return nil
1186+}
1187+
1188+func (x *UpdateHostPolicy) GetRegistration() *RemoteRegistration {
1189+ if x != nil {
1190+ return x.Registration
1191+ }
1192+ return nil
1193+}
1194+
1195+func (x *UpdateHostPolicy) GetPolicy() *BrokerWirePolicy {
1196+ if x != nil {
1197+ return x.Policy
1198+ }
1199+ return nil
11001200 }
11011201
11021202 // Reconcile hands the broker one exact registration to probe.
@@ -1110,7 +1210,7 @@ type Reconcile struct {
11101210
11111211 func (x *Reconcile) Reset() {
11121212 *x = Reconcile{}
1113- mi := &file_broker_proto_msgTypes[13]
1213+ mi := &file_broker_proto_msgTypes[14]
11141214 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
11151215 ms.StoreMessageInfo(mi)
11161216 }
@@ -1122,7 +1222,7 @@ func (x *Reconcile) String() string {
11221222 func (*Reconcile) ProtoMessage() {}
11231223
11241224 func (x *Reconcile) ProtoReflect() protoreflect.Message {
1125- mi := &file_broker_proto_msgTypes[13]
1225+ mi := &file_broker_proto_msgTypes[14]
11261226 if x != nil {
11271227 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
11281228 if ms.LoadMessageInfo() == nil {
@@ -1135,7 +1235,7 @@ func (x *Reconcile) ProtoReflect() protoreflect.Message {
11351235
11361236 // Deprecated: Use Reconcile.ProtoReflect.Descriptor instead.
11371237 func (*Reconcile) Descriptor() ([]byte, []int) {
1138- return file_broker_proto_rawDescGZIP(), []int{13}
1238+ return file_broker_proto_rawDescGZIP(), []int{14}
11391239 }
11401240
11411241 func (x *Reconcile) GetScope() *BrokerScope {
@@ -1184,7 +1284,7 @@ type OpenStream struct {
11841284
11851285 func (x *OpenStream) Reset() {
11861286 *x = OpenStream{}
1187- mi := &file_broker_proto_msgTypes[14]
1287+ mi := &file_broker_proto_msgTypes[15]
11881288 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
11891289 ms.StoreMessageInfo(mi)
11901290 }
@@ -1196,7 +1296,7 @@ func (x *OpenStream) String() string {
11961296 func (*OpenStream) ProtoMessage() {}
11971297
11981298 func (x *OpenStream) ProtoReflect() protoreflect.Message {
1199- mi := &file_broker_proto_msgTypes[14]
1299+ mi := &file_broker_proto_msgTypes[15]
12001300 if x != nil {
12011301 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
12021302 if ms.LoadMessageInfo() == nil {
@@ -1209,7 +1309,7 @@ func (x *OpenStream) ProtoReflect() protoreflect.Message {
12091309
12101310 // Deprecated: Use OpenStream.ProtoReflect.Descriptor instead.
12111311 func (*OpenStream) Descriptor() ([]byte, []int) {
1212- return file_broker_proto_rawDescGZIP(), []int{14}
1312+ return file_broker_proto_rawDescGZIP(), []int{15}
12131313 }
12141314
12151315 func (x *OpenStream) GetRef() *BrokerStreamRef {
@@ -1293,7 +1393,7 @@ type ClientStreamData struct {
12931393
12941394 func (x *ClientStreamData) Reset() {
12951395 *x = ClientStreamData{}
1296- mi := &file_broker_proto_msgTypes[15]
1396+ mi := &file_broker_proto_msgTypes[16]
12971397 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
12981398 ms.StoreMessageInfo(mi)
12991399 }
@@ -1305,7 +1405,7 @@ func (x *ClientStreamData) String() string {
13051405 func (*ClientStreamData) ProtoMessage() {}
13061406
13071407 func (x *ClientStreamData) ProtoReflect() protoreflect.Message {
1308- mi := &file_broker_proto_msgTypes[15]
1408+ mi := &file_broker_proto_msgTypes[16]
13091409 if x != nil {
13101410 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
13111411 if ms.LoadMessageInfo() == nil {
@@ -1318,7 +1418,7 @@ func (x *ClientStreamData) ProtoReflect() protoreflect.Message {
13181418
13191419 // Deprecated: Use ClientStreamData.ProtoReflect.Descriptor instead.
13201420 func (*ClientStreamData) Descriptor() ([]byte, []int) {
1321- return file_broker_proto_rawDescGZIP(), []int{15}
1421+ return file_broker_proto_rawDescGZIP(), []int{16}
13221422 }
13231423
13241424 func (x *ClientStreamData) GetRef() *BrokerStreamRef {
@@ -1345,7 +1445,7 @@ type CloseStream struct {
13451445
13461446 func (x *CloseStream) Reset() {
13471447 *x = CloseStream{}
1348- mi := &file_broker_proto_msgTypes[16]
1448+ mi := &file_broker_proto_msgTypes[17]
13491449 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
13501450 ms.StoreMessageInfo(mi)
13511451 }
@@ -1357,7 +1457,7 @@ func (x *CloseStream) String() string {
13571457 func (*CloseStream) ProtoMessage() {}
13581458
13591459 func (x *CloseStream) ProtoReflect() protoreflect.Message {
1360- mi := &file_broker_proto_msgTypes[16]
1460+ mi := &file_broker_proto_msgTypes[17]
13611461 if x != nil {
13621462 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
13631463 if ms.LoadMessageInfo() == nil {
@@ -1370,7 +1470,7 @@ func (x *CloseStream) ProtoReflect() protoreflect.Message {
13701470
13711471 // Deprecated: Use CloseStream.ProtoReflect.Descriptor instead.
13721472 func (*CloseStream) Descriptor() ([]byte, []int) {
1373- return file_broker_proto_rawDescGZIP(), []int{16}
1473+ return file_broker_proto_rawDescGZIP(), []int{17}
13741474 }
13751475
13761476 func (x *CloseStream) GetRef() *BrokerStreamRef {
@@ -1390,7 +1490,7 @@ type Registered struct {
13901490
13911491 func (x *Registered) Reset() {
13921492 *x = Registered{}
1393- mi := &file_broker_proto_msgTypes[17]
1493+ mi := &file_broker_proto_msgTypes[18]
13941494 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
13951495 ms.StoreMessageInfo(mi)
13961496 }
@@ -1402,7 +1502,7 @@ func (x *Registered) String() string {
14021502 func (*Registered) ProtoMessage() {}
14031503
14041504 func (x *Registered) ProtoReflect() protoreflect.Message {
1405- mi := &file_broker_proto_msgTypes[17]
1505+ mi := &file_broker_proto_msgTypes[18]
14061506 if x != nil {
14071507 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
14081508 if ms.LoadMessageInfo() == nil {
@@ -1415,7 +1515,7 @@ func (x *Registered) ProtoReflect() protoreflect.Message {
14151515
14161516 // Deprecated: Use Registered.ProtoReflect.Descriptor instead.
14171517 func (*Registered) Descriptor() ([]byte, []int) {
1418- return file_broker_proto_rawDescGZIP(), []int{17}
1518+ return file_broker_proto_rawDescGZIP(), []int{18}
14191519 }
14201520
14211521 func (x *Registered) GetScope() *BrokerScope {
@@ -1448,7 +1548,7 @@ type SnapshotPart struct {
14481548
14491549 func (x *SnapshotPart) Reset() {
14501550 *x = SnapshotPart{}
1451- mi := &file_broker_proto_msgTypes[18]
1551+ mi := &file_broker_proto_msgTypes[19]
14521552 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
14531553 ms.StoreMessageInfo(mi)
14541554 }
@@ -1460,7 +1560,7 @@ func (x *SnapshotPart) String() string {
14601560 func (*SnapshotPart) ProtoMessage() {}
14611561
14621562 func (x *SnapshotPart) ProtoReflect() protoreflect.Message {
1463- mi := &file_broker_proto_msgTypes[18]
1563+ mi := &file_broker_proto_msgTypes[19]
14641564 if x != nil {
14651565 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
14661566 if ms.LoadMessageInfo() == nil {
@@ -1473,7 +1573,7 @@ func (x *SnapshotPart) ProtoReflect() protoreflect.Message {
14731573
14741574 // Deprecated: Use SnapshotPart.ProtoReflect.Descriptor instead.
14751575 func (*SnapshotPart) Descriptor() ([]byte, []int) {
1476- return file_broker_proto_rawDescGZIP(), []int{18}
1576+ return file_broker_proto_rawDescGZIP(), []int{19}
14771577 }
14781578
14791579 func (x *SnapshotPart) GetScope() *BrokerScope {
@@ -1606,7 +1706,7 @@ type SnapshotBegin struct {
16061706
16071707 func (x *SnapshotBegin) Reset() {
16081708 *x = SnapshotBegin{}
1609- mi := &file_broker_proto_msgTypes[19]
1709+ mi := &file_broker_proto_msgTypes[20]
16101710 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
16111711 ms.StoreMessageInfo(mi)
16121712 }
@@ -1618,7 +1718,7 @@ func (x *SnapshotBegin) String() string {
16181718 func (*SnapshotBegin) ProtoMessage() {}
16191719
16201720 func (x *SnapshotBegin) ProtoReflect() protoreflect.Message {
1621- mi := &file_broker_proto_msgTypes[19]
1721+ mi := &file_broker_proto_msgTypes[20]
16221722 if x != nil {
16231723 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
16241724 if ms.LoadMessageInfo() == nil {
@@ -1631,7 +1731,7 @@ func (x *SnapshotBegin) ProtoReflect() protoreflect.Message {
16311731
16321732 // Deprecated: Use SnapshotBegin.ProtoReflect.Descriptor instead.
16331733 func (*SnapshotBegin) Descriptor() ([]byte, []int) {
1634- return file_broker_proto_rawDescGZIP(), []int{19}
1734+ return file_broker_proto_rawDescGZIP(), []int{20}
16351735 }
16361736
16371737 func (x *SnapshotBegin) GetHostCount() uint32 {
@@ -1706,7 +1806,7 @@ type SnapshotDaemon struct {
17061806
17071807 func (x *SnapshotDaemon) Reset() {
17081808 *x = SnapshotDaemon{}
1709- mi := &file_broker_proto_msgTypes[20]
1809+ mi := &file_broker_proto_msgTypes[21]
17101810 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
17111811 ms.StoreMessageInfo(mi)
17121812 }
@@ -1718,7 +1818,7 @@ func (x *SnapshotDaemon) String() string {
17181818 func (*SnapshotDaemon) ProtoMessage() {}
17191819
17201820 func (x *SnapshotDaemon) ProtoReflect() protoreflect.Message {
1721- mi := &file_broker_proto_msgTypes[20]
1821+ mi := &file_broker_proto_msgTypes[21]
17221822 if x != nil {
17231823 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
17241824 if ms.LoadMessageInfo() == nil {
@@ -1731,7 +1831,7 @@ func (x *SnapshotDaemon) ProtoReflect() protoreflect.Message {
17311831
17321832 // Deprecated: Use SnapshotDaemon.ProtoReflect.Descriptor instead.
17331833 func (*SnapshotDaemon) Descriptor() ([]byte, []int) {
1734- return file_broker_proto_rawDescGZIP(), []int{20}
1834+ return file_broker_proto_rawDescGZIP(), []int{21}
17351835 }
17361836
17371837 func (x *SnapshotDaemon) GetHostIndex() uint32 {
@@ -1896,7 +1996,7 @@ type SnapshotSession struct {
18961996
18971997 func (x *SnapshotSession) Reset() {
18981998 *x = SnapshotSession{}
1899- mi := &file_broker_proto_msgTypes[21]
1999+ mi := &file_broker_proto_msgTypes[22]
19002000 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
19012001 ms.StoreMessageInfo(mi)
19022002 }
@@ -1908,7 +2008,7 @@ func (x *SnapshotSession) String() string {
19082008 func (*SnapshotSession) ProtoMessage() {}
19092009
19102010 func (x *SnapshotSession) ProtoReflect() protoreflect.Message {
1911- mi := &file_broker_proto_msgTypes[21]
2011+ mi := &file_broker_proto_msgTypes[22]
19122012 if x != nil {
19132013 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
19142014 if ms.LoadMessageInfo() == nil {
@@ -1921,7 +2021,7 @@ func (x *SnapshotSession) ProtoReflect() protoreflect.Message {
19212021
19222022 // Deprecated: Use SnapshotSession.ProtoReflect.Descriptor instead.
19232023 func (*SnapshotSession) Descriptor() ([]byte, []int) {
1924- return file_broker_proto_rawDescGZIP(), []int{21}
2024+ return file_broker_proto_rawDescGZIP(), []int{22}
19252025 }
19262026
19272027 func (x *SnapshotSession) GetHostIndex() uint32 {
@@ -1965,7 +2065,7 @@ type SnapshotTombstone struct {
19652065
19662066 func (x *SnapshotTombstone) Reset() {
19672067 *x = SnapshotTombstone{}
1968- mi := &file_broker_proto_msgTypes[22]
2068+ mi := &file_broker_proto_msgTypes[23]
19692069 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
19702070 ms.StoreMessageInfo(mi)
19712071 }
@@ -1977,7 +2077,7 @@ func (x *SnapshotTombstone) String() string {
19772077 func (*SnapshotTombstone) ProtoMessage() {}
19782078
19792079 func (x *SnapshotTombstone) ProtoReflect() protoreflect.Message {
1980- mi := &file_broker_proto_msgTypes[22]
2080+ mi := &file_broker_proto_msgTypes[23]
19812081 if x != nil {
19822082 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
19832083 if ms.LoadMessageInfo() == nil {
@@ -1990,7 +2090,7 @@ func (x *SnapshotTombstone) ProtoReflect() protoreflect.Message {
19902090
19912091 // Deprecated: Use SnapshotTombstone.ProtoReflect.Descriptor instead.
19922092 func (*SnapshotTombstone) Descriptor() ([]byte, []int) {
1993- return file_broker_proto_rawDescGZIP(), []int{22}
2093+ return file_broker_proto_rawDescGZIP(), []int{23}
19942094 }
19952095
19962096 func (x *SnapshotTombstone) GetTombstoneIndex() uint32 {
@@ -2023,7 +2123,7 @@ type SnapshotEnd struct {
20232123
20242124 func (x *SnapshotEnd) Reset() {
20252125 *x = SnapshotEnd{}
2026- mi := &file_broker_proto_msgTypes[23]
2126+ mi := &file_broker_proto_msgTypes[24]
20272127 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
20282128 ms.StoreMessageInfo(mi)
20292129 }
@@ -2035,7 +2135,7 @@ func (x *SnapshotEnd) String() string {
20352135 func (*SnapshotEnd) ProtoMessage() {}
20362136
20372137 func (x *SnapshotEnd) ProtoReflect() protoreflect.Message {
2038- mi := &file_broker_proto_msgTypes[23]
2138+ mi := &file_broker_proto_msgTypes[24]
20392139 if x != nil {
20402140 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
20412141 if ms.LoadMessageInfo() == nil {
@@ -2048,26 +2148,30 @@ func (x *SnapshotEnd) ProtoReflect() protoreflect.Message {
20482148
20492149 // Deprecated: Use SnapshotEnd.ProtoReflect.Descriptor instead.
20502150 func (*SnapshotEnd) Descriptor() ([]byte, []int) {
2051- return file_broker_proto_rawDescGZIP(), []int{23}
2151+ return file_broker_proto_rawDescGZIP(), []int{24}
20522152 }
20532153
20542154 // OperationResult is exactly one completion for one admitted mutating
2055-// operation.
2155+// operation. removed and registration are presence-carrying authority:
2156+// an authoritative success carries the resulting registration, a removal
2157+// success carries none, and a failed or outcome-unknown result carries
2158+// neither removal nor registration, only its typed error.
20562159 type OperationResult struct {
20572160 state protoimpl.MessageState `protogen:"open.v1"`
20582161 Scope *BrokerScope `protobuf:"bytes,1,opt,name=scope,proto3" json:"scope,omitempty"`
20592162 OperationId []byte `protobuf:"bytes,2,opt,name=operation_id,json=operationId,proto3" json:"operation_id,omitempty"` // exactly 16 bytes
20602163 // Closed taxonomy: 1 = ok, 2 = failed, 3 = outcome_unknown.
2061- Outcome uint32 `protobuf:"varint,3,opt,name=outcome,proto3" json:"outcome,omitempty"`
2062- Removed bool `protobuf:"varint,4,opt,name=removed,proto3" json:"removed,omitempty"`
2063- Error *BrokerErrorDetail `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
2164+ Outcome uint32 `protobuf:"varint,3,opt,name=outcome,proto3" json:"outcome,omitempty"`
2165+ Removed bool `protobuf:"varint,4,opt,name=removed,proto3" json:"removed,omitempty"`
2166+ Error *BrokerErrorDetail `protobuf:"bytes,5,opt,name=error,proto3" json:"error,omitempty"`
2167+ Registration *RemoteRegistration `protobuf:"bytes,6,opt,name=registration,proto3" json:"registration,omitempty"`
20642168 unknownFields protoimpl.UnknownFields
20652169 sizeCache protoimpl.SizeCache
20662170 }
20672171
20682172 func (x *OperationResult) Reset() {
20692173 *x = OperationResult{}
2070- mi := &file_broker_proto_msgTypes[24]
2174+ mi := &file_broker_proto_msgTypes[25]
20712175 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
20722176 ms.StoreMessageInfo(mi)
20732177 }
@@ -2079,7 +2183,7 @@ func (x *OperationResult) String() string {
20792183 func (*OperationResult) ProtoMessage() {}
20802184
20812185 func (x *OperationResult) ProtoReflect() protoreflect.Message {
2082- mi := &file_broker_proto_msgTypes[24]
2186+ mi := &file_broker_proto_msgTypes[25]
20832187 if x != nil {
20842188 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
20852189 if ms.LoadMessageInfo() == nil {
@@ -2092,7 +2196,7 @@ func (x *OperationResult) ProtoReflect() protoreflect.Message {
20922196
20932197 // Deprecated: Use OperationResult.ProtoReflect.Descriptor instead.
20942198 func (*OperationResult) Descriptor() ([]byte, []int) {
2095- return file_broker_proto_rawDescGZIP(), []int{24}
2199+ return file_broker_proto_rawDescGZIP(), []int{25}
20962200 }
20972201
20982202 func (x *OperationResult) GetScope() *BrokerScope {
@@ -2130,6 +2234,13 @@ func (x *OperationResult) GetError() *BrokerErrorDetail {
21302234 return nil
21312235 }
21322236
2237+func (x *OperationResult) GetRegistration() *RemoteRegistration {
2238+ if x != nil {
2239+ return x.Registration
2240+ }
2241+ return nil
2242+}
2243+
21332244 // StreamOpened confirms one logical stream is established.
21342245 type StreamOpened struct {
21352246 state protoimpl.MessageState `protogen:"open.v1"`
@@ -2140,7 +2251,7 @@ type StreamOpened struct {
21402251
21412252 func (x *StreamOpened) Reset() {
21422253 *x = StreamOpened{}
2143- mi := &file_broker_proto_msgTypes[25]
2254+ mi := &file_broker_proto_msgTypes[26]
21442255 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
21452256 ms.StoreMessageInfo(mi)
21462257 }
@@ -2152,7 +2263,7 @@ func (x *StreamOpened) String() string {
21522263 func (*StreamOpened) ProtoMessage() {}
21532264
21542265 func (x *StreamOpened) ProtoReflect() protoreflect.Message {
2155- mi := &file_broker_proto_msgTypes[25]
2266+ mi := &file_broker_proto_msgTypes[26]
21562267 if x != nil {
21572268 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
21582269 if ms.LoadMessageInfo() == nil {
@@ -2165,7 +2276,7 @@ func (x *StreamOpened) ProtoReflect() protoreflect.Message {
21652276
21662277 // Deprecated: Use StreamOpened.ProtoReflect.Descriptor instead.
21672278 func (*StreamOpened) Descriptor() ([]byte, []int) {
2168- return file_broker_proto_rawDescGZIP(), []int{25}
2279+ return file_broker_proto_rawDescGZIP(), []int{26}
21692280 }
21702281
21712282 func (x *StreamOpened) GetRef() *BrokerStreamRef {
@@ -2186,7 +2297,7 @@ type ServerStreamData struct {
21862297
21872298 func (x *ServerStreamData) Reset() {
21882299 *x = ServerStreamData{}
2189- mi := &file_broker_proto_msgTypes[26]
2300+ mi := &file_broker_proto_msgTypes[27]
21902301 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
21912302 ms.StoreMessageInfo(mi)
21922303 }
@@ -2198,7 +2309,7 @@ func (x *ServerStreamData) String() string {
21982309 func (*ServerStreamData) ProtoMessage() {}
21992310
22002311 func (x *ServerStreamData) ProtoReflect() protoreflect.Message {
2201- mi := &file_broker_proto_msgTypes[26]
2312+ mi := &file_broker_proto_msgTypes[27]
22022313 if x != nil {
22032314 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
22042315 if ms.LoadMessageInfo() == nil {
@@ -2211,7 +2322,7 @@ func (x *ServerStreamData) ProtoReflect() protoreflect.Message {
22112322
22122323 // Deprecated: Use ServerStreamData.ProtoReflect.Descriptor instead.
22132324 func (*ServerStreamData) Descriptor() ([]byte, []int) {
2214- return file_broker_proto_rawDescGZIP(), []int{26}
2325+ return file_broker_proto_rawDescGZIP(), []int{27}
22152326 }
22162327
22172328 func (x *ServerStreamData) GetRef() *BrokerStreamRef {
@@ -2240,7 +2351,7 @@ type StreamClosed struct {
22402351
22412352 func (x *StreamClosed) Reset() {
22422353 *x = StreamClosed{}
2243- mi := &file_broker_proto_msgTypes[27]
2354+ mi := &file_broker_proto_msgTypes[28]
22442355 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
22452356 ms.StoreMessageInfo(mi)
22462357 }
@@ -2252,7 +2363,7 @@ func (x *StreamClosed) String() string {
22522363 func (*StreamClosed) ProtoMessage() {}
22532364
22542365 func (x *StreamClosed) ProtoReflect() protoreflect.Message {
2255- mi := &file_broker_proto_msgTypes[27]
2366+ mi := &file_broker_proto_msgTypes[28]
22562367 if x != nil {
22572368 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
22582369 if ms.LoadMessageInfo() == nil {
@@ -2265,7 +2376,7 @@ func (x *StreamClosed) ProtoReflect() protoreflect.Message {
22652376
22662377 // Deprecated: Use StreamClosed.ProtoReflect.Descriptor instead.
22672378 func (*StreamClosed) Descriptor() ([]byte, []int) {
2268- return file_broker_proto_rawDescGZIP(), []int{27}
2379+ return file_broker_proto_rawDescGZIP(), []int{28}
22692380 }
22702381
22712382 func (x *StreamClosed) GetRef() *BrokerStreamRef {
@@ -2294,7 +2405,7 @@ type Progress struct {
22942405
22952406 func (x *Progress) Reset() {
22962407 *x = Progress{}
2297- mi := &file_broker_proto_msgTypes[28]
2408+ mi := &file_broker_proto_msgTypes[29]
22982409 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
22992410 ms.StoreMessageInfo(mi)
23002411 }
@@ -2306,7 +2417,7 @@ func (x *Progress) String() string {
23062417 func (*Progress) ProtoMessage() {}
23072418
23082419 func (x *Progress) ProtoReflect() protoreflect.Message {
2309- mi := &file_broker_proto_msgTypes[28]
2420+ mi := &file_broker_proto_msgTypes[29]
23102421 if x != nil {
23112422 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
23122423 if ms.LoadMessageInfo() == nil {
@@ -2319,7 +2430,7 @@ func (x *Progress) ProtoReflect() protoreflect.Message {
23192430
23202431 // Deprecated: Use Progress.ProtoReflect.Descriptor instead.
23212432 func (*Progress) Descriptor() ([]byte, []int) {
2322- return file_broker_proto_rawDescGZIP(), []int{28}
2433+ return file_broker_proto_rawDescGZIP(), []int{29}
23232434 }
23242435
23252436 func (x *Progress) GetRef() *BrokerStreamRef {
@@ -2354,7 +2465,7 @@ type BrokerErrorMessage struct {
23542465
23552466 func (x *BrokerErrorMessage) Reset() {
23562467 *x = BrokerErrorMessage{}
2357- mi := &file_broker_proto_msgTypes[29]
2468+ mi := &file_broker_proto_msgTypes[30]
23582469 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
23592470 ms.StoreMessageInfo(mi)
23602471 }
@@ -2366,7 +2477,7 @@ func (x *BrokerErrorMessage) String() string {
23662477 func (*BrokerErrorMessage) ProtoMessage() {}
23672478
23682479 func (x *BrokerErrorMessage) ProtoReflect() protoreflect.Message {
2369- mi := &file_broker_proto_msgTypes[29]
2480+ mi := &file_broker_proto_msgTypes[30]
23702481 if x != nil {
23712482 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
23722483 if ms.LoadMessageInfo() == nil {
@@ -2379,7 +2490,7 @@ func (x *BrokerErrorMessage) ProtoReflect() protoreflect.Message {
23792490
23802491 // Deprecated: Use BrokerErrorMessage.ProtoReflect.Descriptor instead.
23812492 func (*BrokerErrorMessage) Descriptor() ([]byte, []int) {
2382- return file_broker_proto_rawDescGZIP(), []int{29}
2493+ return file_broker_proto_rawDescGZIP(), []int{30}
23832494 }
23842495
23852496 func (x *BrokerErrorMessage) GetScope() *BrokerScope {
@@ -2409,7 +2520,7 @@ type Shutdown struct {
24092520
24102521 func (x *Shutdown) Reset() {
24112522 *x = Shutdown{}
2412- mi := &file_broker_proto_msgTypes[30]
2523+ mi := &file_broker_proto_msgTypes[31]
24132524 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
24142525 ms.StoreMessageInfo(mi)
24152526 }
@@ -2421,7 +2532,7 @@ func (x *Shutdown) String() string {
24212532 func (*Shutdown) ProtoMessage() {}
24222533
24232534 func (x *Shutdown) ProtoReflect() protoreflect.Message {
2424- mi := &file_broker_proto_msgTypes[30]
2535+ mi := &file_broker_proto_msgTypes[31]
24252536 if x != nil {
24262537 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
24272538 if ms.LoadMessageInfo() == nil {
@@ -2434,7 +2545,7 @@ func (x *Shutdown) ProtoReflect() protoreflect.Message {
24342545
24352546 // Deprecated: Use Shutdown.ProtoReflect.Descriptor instead.
24362547 func (*Shutdown) Descriptor() ([]byte, []int) {
2437- return file_broker_proto_rawDescGZIP(), []int{30}
2548+ return file_broker_proto_rawDescGZIP(), []int{31}
24382549 }
24392550
24402551 func (x *Shutdown) GetScope() *BrokerScope {
@@ -2480,7 +2591,7 @@ type BrokerCatalogSession struct {
24802591
24812592 func (x *BrokerCatalogSession) Reset() {
24822593 *x = BrokerCatalogSession{}
2483- mi := &file_broker_proto_msgTypes[31]
2594+ mi := &file_broker_proto_msgTypes[32]
24842595 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
24852596 ms.StoreMessageInfo(mi)
24862597 }
@@ -2492,7 +2603,7 @@ func (x *BrokerCatalogSession) String() string {
24922603 func (*BrokerCatalogSession) ProtoMessage() {}
24932604
24942605 func (x *BrokerCatalogSession) ProtoReflect() protoreflect.Message {
2495- mi := &file_broker_proto_msgTypes[31]
2606+ mi := &file_broker_proto_msgTypes[32]
24962607 if x != nil {
24972608 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
24982609 if ms.LoadMessageInfo() == nil {
@@ -2505,7 +2616,7 @@ func (x *BrokerCatalogSession) ProtoReflect() protoreflect.Message {
25052616
25062617 // Deprecated: Use BrokerCatalogSession.ProtoReflect.Descriptor instead.
25072618 func (*BrokerCatalogSession) Descriptor() ([]byte, []int) {
2508- return file_broker_proto_rawDescGZIP(), []int{31}
2619+ return file_broker_proto_rawDescGZIP(), []int{32}
25092620 }
25102621
25112622 func (x *BrokerCatalogSession) GetLifecycleId() []byte {
@@ -2585,7 +2696,7 @@ type BrokerCatalogTab struct {
25852696
25862697 func (x *BrokerCatalogTab) Reset() {
25872698 *x = BrokerCatalogTab{}
2588- mi := &file_broker_proto_msgTypes[32]
2699+ mi := &file_broker_proto_msgTypes[33]
25892700 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
25902701 ms.StoreMessageInfo(mi)
25912702 }
@@ -2597,7 +2708,7 @@ func (x *BrokerCatalogTab) String() string {
25972708 func (*BrokerCatalogTab) ProtoMessage() {}
25982709
25992710 func (x *BrokerCatalogTab) ProtoReflect() protoreflect.Message {
2600- mi := &file_broker_proto_msgTypes[32]
2711+ mi := &file_broker_proto_msgTypes[33]
26012712 if x != nil {
26022713 ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
26032714 if ms.LoadMessageInfo() == nil {
@@ -2610,7 +2721,7 @@ func (x *BrokerCatalogTab) ProtoReflect() protoreflect.Message {
26102721
26112722 // Deprecated: Use BrokerCatalogTab.ProtoReflect.Descriptor instead.
26122723 func (*BrokerCatalogTab) Descriptor() ([]byte, []int) {
2613- return file_broker_proto_rawDescGZIP(), []int{32}
2724+ return file_broker_proto_rawDescGZIP(), []int{33}
26142725 }
26152726
26162727 func (x *BrokerCatalogTab) GetId() string {
@@ -2674,7 +2785,7 @@ const file_broker_proto_rawDesc = "" +
26742785 "\ffailure_kind\x18\x04 \x01(\rR\vfailureKind\"A\n" +
26752786 "\x0fBrokerTimestamp\x12\x18\n" +
26762787 "\aseconds\x18\x01 \x01(\x03R\aseconds\x12\x14\n" +
2677- "\x05nanos\x18\x02 \x01(\x05R\x05nanos\"\xec\x04\n" +
2788+ "\x05nanos\x18\x02 \x01(\x05R\x05nanos\"\xbb\x05\n" +
26782789 "\x14BrokerClientEnvelope\x123\n" +
26792790 "\bregister\x18e \x01(\v2\x15.vev.wire.v1.RegisterH\x00R\bregister\x126\n" +
26802791 "\tsubscribe\x18f \x01(\v2\x16.vev.wire.v1.SubscribeH\x00R\tsubscribe\x12-\n" +
@@ -2687,7 +2798,8 @@ const file_broker_proto_rawDesc = "" +
26872798 "\vopen_stream\x18l \x01(\v2\x17.vev.wire.v1.OpenStreamH\x00R\n" +
26882799 "openStream\x12M\n" +
26892800 "\x12client_stream_data\x18m \x01(\v2\x1d.vev.wire.v1.ClientStreamDataH\x00R\x10clientStreamData\x12=\n" +
2690- "\fclose_stream\x18n \x01(\v2\x18.vev.wire.v1.CloseStreamH\x00R\vcloseStreamB\t\n" +
2801+ "\fclose_stream\x18n \x01(\v2\x18.vev.wire.v1.CloseStreamH\x00R\vcloseStream\x12M\n" +
2802+ "\x12update_host_policy\x18o \x01(\v2\x1d.vev.wire.v1.UpdateHostPolicyH\x00R\x10updateHostPolicyB\t\n" +
26912803 "\apayload\"\x84\x05\n" +
26922804 "\x14BrokerServerEnvelope\x12:\n" +
26932805 "\n" +
@@ -2718,16 +2830,22 @@ const file_broker_proto_rawDesc = "" +
27182830 "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12\x1e\n" +
27192831 "\n" +
27202832 "generation\x18\x02 \x01(\x04R\n" +
2721- "generation\"x\n" +
2833+ "generation\"\xaf\x01\n" +
27222834 "\aAddHost\x12.\n" +
27232835 "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12!\n" +
27242836 "\foperation_id\x18\x02 \x01(\fR\voperationId\x12\x1a\n" +
2725- "\bendpoint\x18\x03 \x01(\tR\bendpoint\"{\n" +
2837+ "\bendpoint\x18\x03 \x01(\tR\bendpoint\x125\n" +
2838+ "\x06policy\x18\x04 \x01(\v2\x1d.vev.wire.v1.BrokerWirePolicyR\x06policy\"\xa4\x01\n" +
27262839 "\n" +
27272840 "RemoveHost\x12.\n" +
27282841 "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12!\n" +
2729- "\foperation_id\x18\x02 \x01(\fR\voperationId\x12\x1a\n" +
2730- "\bendpoint\x18\x03 \x01(\tR\bendpoint\"\x80\x01\n" +
2842+ "\foperation_id\x18\x02 \x01(\fR\voperationId\x12C\n" +
2843+ "\fregistration\x18\x03 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\"\xe1\x01\n" +
2844+ "\x10UpdateHostPolicy\x12.\n" +
2845+ "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12!\n" +
2846+ "\foperation_id\x18\x02 \x01(\fR\voperationId\x12C\n" +
2847+ "\fregistration\x18\x03 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\x125\n" +
2848+ "\x06policy\x18\x04 \x01(\v2\x1d.vev.wire.v1.BrokerWirePolicyR\x06policy\"\x80\x01\n" +
27312849 "\tReconcile\x12.\n" +
27322850 "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12C\n" +
27332851 "\fregistration\x18\x02 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\"\xfa\x02\n" +
@@ -2806,13 +2924,14 @@ const file_broker_proto_rawDesc = "" +
28062924 "\x0ftombstone_index\x18\x01 \x01(\rR\x0etombstoneIndex\x12C\n" +
28072925 "\fregistration\x18\x02 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\x12)\n" +
28082926 "\x10retired_revision\x18\x03 \x01(\x04R\x0fretiredRevision\"\r\n" +
2809- "\vSnapshotEnd\"\xce\x01\n" +
2927+ "\vSnapshotEnd\"\x93\x02\n" +
28102928 "\x0fOperationResult\x12.\n" +
28112929 "\x05scope\x18\x01 \x01(\v2\x18.vev.wire.v1.BrokerScopeR\x05scope\x12!\n" +
28122930 "\foperation_id\x18\x02 \x01(\fR\voperationId\x12\x18\n" +
28132931 "\aoutcome\x18\x03 \x01(\rR\aoutcome\x12\x18\n" +
28142932 "\aremoved\x18\x04 \x01(\bR\aremoved\x124\n" +
2815- "\x05error\x18\x05 \x01(\v2\x1e.vev.wire.v1.BrokerErrorDetailR\x05error\">\n" +
2933+ "\x05error\x18\x05 \x01(\v2\x1e.vev.wire.v1.BrokerErrorDetailR\x05error\x12C\n" +
2934+ "\fregistration\x18\x06 \x01(\v2\x1f.vev.wire.v1.RemoteRegistrationR\fregistration\">\n" +
28162935 "\fStreamOpened\x12.\n" +
28172936 "\x03ref\x18\x01 \x01(\v2\x1c.vev.wire.v1.BrokerStreamRefR\x03ref\"V\n" +
28182937 "\x10ServerStreamData\x12.\n" +
@@ -2861,7 +2980,7 @@ func file_broker_proto_rawDescGZIP() []byte {
28612980 return file_broker_proto_rawDescData
28622981 }
28632982
2864-var file_broker_proto_msgTypes = make([]protoimpl.MessageInfo, 33)
2983+var file_broker_proto_msgTypes = make([]protoimpl.MessageInfo, 34)
28652984 var file_broker_proto_goTypes = []any{
28662985 (*BrokerScope)(nil), // 0: vev.wire.v1.BrokerScope
28672986 (*BrokerStreamRef)(nil), // 1: vev.wire.v1.BrokerStreamRef
@@ -2876,28 +2995,29 @@ var file_broker_proto_goTypes = []any{
28762995 (*Unsubscribe)(nil), // 10: vev.wire.v1.Unsubscribe
28772996 (*AddHost)(nil), // 11: vev.wire.v1.AddHost
28782997 (*RemoveHost)(nil), // 12: vev.wire.v1.RemoveHost
2879- (*Reconcile)(nil), // 13: vev.wire.v1.Reconcile
2880- (*OpenStream)(nil), // 14: vev.wire.v1.OpenStream
2881- (*ClientStreamData)(nil), // 15: vev.wire.v1.ClientStreamData
2882- (*CloseStream)(nil), // 16: vev.wire.v1.CloseStream
2883- (*Registered)(nil), // 17: vev.wire.v1.Registered
2884- (*SnapshotPart)(nil), // 18: vev.wire.v1.SnapshotPart
2885- (*SnapshotBegin)(nil), // 19: vev.wire.v1.SnapshotBegin
2886- (*SnapshotDaemon)(nil), // 20: vev.wire.v1.SnapshotDaemon
2887- (*SnapshotSession)(nil), // 21: vev.wire.v1.SnapshotSession
2888- (*SnapshotTombstone)(nil), // 22: vev.wire.v1.SnapshotTombstone
2889- (*SnapshotEnd)(nil), // 23: vev.wire.v1.SnapshotEnd
2890- (*OperationResult)(nil), // 24: vev.wire.v1.OperationResult
2891- (*StreamOpened)(nil), // 25: vev.wire.v1.StreamOpened
2892- (*ServerStreamData)(nil), // 26: vev.wire.v1.ServerStreamData
2893- (*StreamClosed)(nil), // 27: vev.wire.v1.StreamClosed
2894- (*Progress)(nil), // 28: vev.wire.v1.Progress
2895- (*BrokerErrorMessage)(nil), // 29: vev.wire.v1.BrokerErrorMessage
2896- (*Shutdown)(nil), // 30: vev.wire.v1.Shutdown
2897- (*BrokerCatalogSession)(nil), // 31: vev.wire.v1.BrokerCatalogSession
2898- (*BrokerCatalogTab)(nil), // 32: vev.wire.v1.BrokerCatalogTab
2899- (*RemoteRegistration)(nil), // 33: vev.wire.v1.RemoteRegistration
2900- (*ExactTarget)(nil), // 34: vev.wire.v1.ExactTarget
2998+ (*UpdateHostPolicy)(nil), // 13: vev.wire.v1.UpdateHostPolicy
2999+ (*Reconcile)(nil), // 14: vev.wire.v1.Reconcile
3000+ (*OpenStream)(nil), // 15: vev.wire.v1.OpenStream
3001+ (*ClientStreamData)(nil), // 16: vev.wire.v1.ClientStreamData
3002+ (*CloseStream)(nil), // 17: vev.wire.v1.CloseStream
3003+ (*Registered)(nil), // 18: vev.wire.v1.Registered
3004+ (*SnapshotPart)(nil), // 19: vev.wire.v1.SnapshotPart
3005+ (*SnapshotBegin)(nil), // 20: vev.wire.v1.SnapshotBegin
3006+ (*SnapshotDaemon)(nil), // 21: vev.wire.v1.SnapshotDaemon
3007+ (*SnapshotSession)(nil), // 22: vev.wire.v1.SnapshotSession
3008+ (*SnapshotTombstone)(nil), // 23: vev.wire.v1.SnapshotTombstone
3009+ (*SnapshotEnd)(nil), // 24: vev.wire.v1.SnapshotEnd
3010+ (*OperationResult)(nil), // 25: vev.wire.v1.OperationResult
3011+ (*StreamOpened)(nil), // 26: vev.wire.v1.StreamOpened
3012+ (*ServerStreamData)(nil), // 27: vev.wire.v1.ServerStreamData
3013+ (*StreamClosed)(nil), // 28: vev.wire.v1.StreamClosed
3014+ (*Progress)(nil), // 29: vev.wire.v1.Progress
3015+ (*BrokerErrorMessage)(nil), // 30: vev.wire.v1.BrokerErrorMessage
3016+ (*Shutdown)(nil), // 31: vev.wire.v1.Shutdown
3017+ (*BrokerCatalogSession)(nil), // 32: vev.wire.v1.BrokerCatalogSession
3018+ (*BrokerCatalogTab)(nil), // 33: vev.wire.v1.BrokerCatalogTab
3019+ (*RemoteRegistration)(nil), // 34: vev.wire.v1.RemoteRegistration
3020+ (*ExactTarget)(nil), // 35: vev.wire.v1.ExactTarget
29013021 }
29023022 var file_broker_proto_depIdxs = []int32{
29033023 0, // 0: vev.wire.v1.BrokerStreamRef.scope:type_name -> vev.wire.v1.BrokerScope
@@ -2907,62 +3027,69 @@ var file_broker_proto_depIdxs = []int32{
29073027 10, // 4: vev.wire.v1.BrokerClientEnvelope.unsubscribe:type_name -> vev.wire.v1.Unsubscribe
29083028 11, // 5: vev.wire.v1.BrokerClientEnvelope.add_host:type_name -> vev.wire.v1.AddHost
29093029 12, // 6: vev.wire.v1.BrokerClientEnvelope.remove_host:type_name -> vev.wire.v1.RemoveHost
2910- 13, // 7: vev.wire.v1.BrokerClientEnvelope.reconcile:type_name -> vev.wire.v1.Reconcile
2911- 14, // 8: vev.wire.v1.BrokerClientEnvelope.open_stream:type_name -> vev.wire.v1.OpenStream
2912- 15, // 9: vev.wire.v1.BrokerClientEnvelope.client_stream_data:type_name -> vev.wire.v1.ClientStreamData
2913- 16, // 10: vev.wire.v1.BrokerClientEnvelope.close_stream:type_name -> vev.wire.v1.CloseStream
2914- 17, // 11: vev.wire.v1.BrokerServerEnvelope.registered:type_name -> vev.wire.v1.Registered
2915- 18, // 12: vev.wire.v1.BrokerServerEnvelope.snapshot_part:type_name -> vev.wire.v1.SnapshotPart
2916- 24, // 13: vev.wire.v1.BrokerServerEnvelope.operation_result:type_name -> vev.wire.v1.OperationResult
2917- 25, // 14: vev.wire.v1.BrokerServerEnvelope.stream_opened:type_name -> vev.wire.v1.StreamOpened
2918- 26, // 15: vev.wire.v1.BrokerServerEnvelope.server_stream_data:type_name -> vev.wire.v1.ServerStreamData
2919- 27, // 16: vev.wire.v1.BrokerServerEnvelope.stream_closed:type_name -> vev.wire.v1.StreamClosed
2920- 28, // 17: vev.wire.v1.BrokerServerEnvelope.progress:type_name -> vev.wire.v1.Progress
2921- 29, // 18: vev.wire.v1.BrokerServerEnvelope.broker_error_message:type_name -> vev.wire.v1.BrokerErrorMessage
2922- 30, // 19: vev.wire.v1.BrokerServerEnvelope.shutdown:type_name -> vev.wire.v1.Shutdown
2923- 0, // 20: vev.wire.v1.Subscribe.scope:type_name -> vev.wire.v1.BrokerScope
2924- 0, // 21: vev.wire.v1.Resync.scope:type_name -> vev.wire.v1.BrokerScope
2925- 0, // 22: vev.wire.v1.Unsubscribe.scope:type_name -> vev.wire.v1.BrokerScope
2926- 0, // 23: vev.wire.v1.AddHost.scope:type_name -> vev.wire.v1.BrokerScope
2927- 0, // 24: vev.wire.v1.RemoveHost.scope:type_name -> vev.wire.v1.BrokerScope
2928- 0, // 25: vev.wire.v1.Reconcile.scope:type_name -> vev.wire.v1.BrokerScope
2929- 33, // 26: vev.wire.v1.Reconcile.registration:type_name -> vev.wire.v1.RemoteRegistration
2930- 1, // 27: vev.wire.v1.OpenStream.ref:type_name -> vev.wire.v1.BrokerStreamRef
2931- 33, // 28: vev.wire.v1.OpenStream.registration:type_name -> vev.wire.v1.RemoteRegistration
2932- 34, // 29: vev.wire.v1.OpenStream.target:type_name -> vev.wire.v1.ExactTarget
2933- 2, // 30: vev.wire.v1.OpenStream.policy:type_name -> vev.wire.v1.BrokerWirePolicy
2934- 1, // 31: vev.wire.v1.ClientStreamData.ref:type_name -> vev.wire.v1.BrokerStreamRef
2935- 1, // 32: vev.wire.v1.CloseStream.ref:type_name -> vev.wire.v1.BrokerStreamRef
2936- 0, // 33: vev.wire.v1.Registered.scope:type_name -> vev.wire.v1.BrokerScope
2937- 0, // 34: vev.wire.v1.SnapshotPart.scope:type_name -> vev.wire.v1.BrokerScope
2938- 19, // 35: vev.wire.v1.SnapshotPart.begin:type_name -> vev.wire.v1.SnapshotBegin
2939- 20, // 36: vev.wire.v1.SnapshotPart.daemon:type_name -> vev.wire.v1.SnapshotDaemon
2940- 21, // 37: vev.wire.v1.SnapshotPart.session:type_name -> vev.wire.v1.SnapshotSession
2941- 22, // 38: vev.wire.v1.SnapshotPart.tombstone:type_name -> vev.wire.v1.SnapshotTombstone
2942- 23, // 39: vev.wire.v1.SnapshotPart.end:type_name -> vev.wire.v1.SnapshotEnd
2943- 33, // 40: vev.wire.v1.SnapshotDaemon.registration:type_name -> vev.wire.v1.RemoteRegistration
2944- 2, // 41: vev.wire.v1.SnapshotDaemon.policy:type_name -> vev.wire.v1.BrokerWirePolicy
2945- 4, // 42: vev.wire.v1.SnapshotDaemon.last_attempt:type_name -> vev.wire.v1.BrokerTimestamp
2946- 4, // 43: vev.wire.v1.SnapshotDaemon.last_success:type_name -> vev.wire.v1.BrokerTimestamp
2947- 4, // 44: vev.wire.v1.SnapshotDaemon.next_due:type_name -> vev.wire.v1.BrokerTimestamp
2948- 31, // 45: vev.wire.v1.SnapshotSession.session:type_name -> vev.wire.v1.BrokerCatalogSession
2949- 33, // 46: vev.wire.v1.SnapshotTombstone.registration:type_name -> vev.wire.v1.RemoteRegistration
2950- 0, // 47: vev.wire.v1.OperationResult.scope:type_name -> vev.wire.v1.BrokerScope
2951- 3, // 48: vev.wire.v1.OperationResult.error:type_name -> vev.wire.v1.BrokerErrorDetail
2952- 1, // 49: vev.wire.v1.StreamOpened.ref:type_name -> vev.wire.v1.BrokerStreamRef
2953- 1, // 50: vev.wire.v1.ServerStreamData.ref:type_name -> vev.wire.v1.BrokerStreamRef
2954- 1, // 51: vev.wire.v1.StreamClosed.ref:type_name -> vev.wire.v1.BrokerStreamRef
2955- 3, // 52: vev.wire.v1.StreamClosed.error:type_name -> vev.wire.v1.BrokerErrorDetail
2956- 1, // 53: vev.wire.v1.Progress.ref:type_name -> vev.wire.v1.BrokerStreamRef
2957- 0, // 54: vev.wire.v1.BrokerErrorMessage.scope:type_name -> vev.wire.v1.BrokerScope
2958- 3, // 55: vev.wire.v1.BrokerErrorMessage.error:type_name -> vev.wire.v1.BrokerErrorDetail
2959- 0, // 56: vev.wire.v1.Shutdown.scope:type_name -> vev.wire.v1.BrokerScope
2960- 32, // 57: vev.wire.v1.BrokerCatalogSession.tabs:type_name -> vev.wire.v1.BrokerCatalogTab
2961- 58, // [58:58] is the sub-list for method output_type
2962- 58, // [58:58] is the sub-list for method input_type
2963- 58, // [58:58] is the sub-list for extension type_name
2964- 58, // [58:58] is the sub-list for extension extendee
2965- 0, // [0:58] is the sub-list for field type_name
3030+ 14, // 7: vev.wire.v1.BrokerClientEnvelope.reconcile:type_name -> vev.wire.v1.Reconcile
3031+ 15, // 8: vev.wire.v1.BrokerClientEnvelope.open_stream:type_name -> vev.wire.v1.OpenStream
3032+ 16, // 9: vev.wire.v1.BrokerClientEnvelope.client_stream_data:type_name -> vev.wire.v1.ClientStreamData
3033+ 17, // 10: vev.wire.v1.BrokerClientEnvelope.close_stream:type_name -> vev.wire.v1.CloseStream
3034+ 13, // 11: vev.wire.v1.BrokerClientEnvelope.update_host_policy:type_name -> vev.wire.v1.UpdateHostPolicy
3035+ 18, // 12: vev.wire.v1.BrokerServerEnvelope.registered:type_name -> vev.wire.v1.Registered
3036+ 19, // 13: vev.wire.v1.BrokerServerEnvelope.snapshot_part:type_name -> vev.wire.v1.SnapshotPart
3037+ 25, // 14: vev.wire.v1.BrokerServerEnvelope.operation_result:type_name -> vev.wire.v1.OperationResult
3038+ 26, // 15: vev.wire.v1.BrokerServerEnvelope.stream_opened:type_name -> vev.wire.v1.StreamOpened
3039+ 27, // 16: vev.wire.v1.BrokerServerEnvelope.server_stream_data:type_name -> vev.wire.v1.ServerStreamData
3040+ 28, // 17: vev.wire.v1.BrokerServerEnvelope.stream_closed:type_name -> vev.wire.v1.StreamClosed
3041+ 29, // 18: vev.wire.v1.BrokerServerEnvelope.progress:type_name -> vev.wire.v1.Progress
3042+ 30, // 19: vev.wire.v1.BrokerServerEnvelope.broker_error_message:type_name -> vev.wire.v1.BrokerErrorMessage
3043+ 31, // 20: vev.wire.v1.BrokerServerEnvelope.shutdown:type_name -> vev.wire.v1.Shutdown
3044+ 0, // 21: vev.wire.v1.Subscribe.scope:type_name -> vev.wire.v1.BrokerScope
3045+ 0, // 22: vev.wire.v1.Resync.scope:type_name -> vev.wire.v1.BrokerScope
3046+ 0, // 23: vev.wire.v1.Unsubscribe.scope:type_name -> vev.wire.v1.BrokerScope
3047+ 0, // 24: vev.wire.v1.AddHost.scope:type_name -> vev.wire.v1.BrokerScope
3048+ 2, // 25: vev.wire.v1.AddHost.policy:type_name -> vev.wire.v1.BrokerWirePolicy
3049+ 0, // 26: vev.wire.v1.RemoveHost.scope:type_name -> vev.wire.v1.BrokerScope
3050+ 34, // 27: vev.wire.v1.RemoveHost.registration:type_name -> vev.wire.v1.RemoteRegistration
3051+ 0, // 28: vev.wire.v1.UpdateHostPolicy.scope:type_name -> vev.wire.v1.BrokerScope
3052+ 34, // 29: vev.wire.v1.UpdateHostPolicy.registration:type_name -> vev.wire.v1.RemoteRegistration
3053+ 2, // 30: vev.wire.v1.UpdateHostPolicy.policy:type_name -> vev.wire.v1.BrokerWirePolicy
3054+ 0, // 31: vev.wire.v1.Reconcile.scope:type_name -> vev.wire.v1.BrokerScope
3055+ 34, // 32: vev.wire.v1.Reconcile.registration:type_name -> vev.wire.v1.RemoteRegistration
3056+ 1, // 33: vev.wire.v1.OpenStream.ref:type_name -> vev.wire.v1.BrokerStreamRef
3057+ 34, // 34: vev.wire.v1.OpenStream.registration:type_name -> vev.wire.v1.RemoteRegistration
3058+ 35, // 35: vev.wire.v1.OpenStream.target:type_name -> vev.wire.v1.ExactTarget
3059+ 2, // 36: vev.wire.v1.OpenStream.policy:type_name -> vev.wire.v1.BrokerWirePolicy
3060+ 1, // 37: vev.wire.v1.ClientStreamData.ref:type_name -> vev.wire.v1.BrokerStreamRef
3061+ 1, // 38: vev.wire.v1.CloseStream.ref:type_name -> vev.wire.v1.BrokerStreamRef
3062+ 0, // 39: vev.wire.v1.Registered.scope:type_name -> vev.wire.v1.BrokerScope
3063+ 0, // 40: vev.wire.v1.SnapshotPart.scope:type_name -> vev.wire.v1.BrokerScope
3064+ 20, // 41: vev.wire.v1.SnapshotPart.begin:type_name -> vev.wire.v1.SnapshotBegin
3065+ 21, // 42: vev.wire.v1.SnapshotPart.daemon:type_name -> vev.wire.v1.SnapshotDaemon
3066+ 22, // 43: vev.wire.v1.SnapshotPart.session:type_name -> vev.wire.v1.SnapshotSession
3067+ 23, // 44: vev.wire.v1.SnapshotPart.tombstone:type_name -> vev.wire.v1.SnapshotTombstone
3068+ 24, // 45: vev.wire.v1.SnapshotPart.end:type_name -> vev.wire.v1.SnapshotEnd
3069+ 34, // 46: vev.wire.v1.SnapshotDaemon.registration:type_name -> vev.wire.v1.RemoteRegistration
3070+ 2, // 47: vev.wire.v1.SnapshotDaemon.policy:type_name -> vev.wire.v1.BrokerWirePolicy
3071+ 4, // 48: vev.wire.v1.SnapshotDaemon.last_attempt:type_name -> vev.wire.v1.BrokerTimestamp
3072+ 4, // 49: vev.wire.v1.SnapshotDaemon.last_success:type_name -> vev.wire.v1.BrokerTimestamp
3073+ 4, // 50: vev.wire.v1.SnapshotDaemon.next_due:type_name -> vev.wire.v1.BrokerTimestamp
3074+ 32, // 51: vev.wire.v1.SnapshotSession.session:type_name -> vev.wire.v1.BrokerCatalogSession
3075+ 34, // 52: vev.wire.v1.SnapshotTombstone.registration:type_name -> vev.wire.v1.RemoteRegistration
3076+ 0, // 53: vev.wire.v1.OperationResult.scope:type_name -> vev.wire.v1.BrokerScope
3077+ 3, // 54: vev.wire.v1.OperationResult.error:type_name -> vev.wire.v1.BrokerErrorDetail
3078+ 34, // 55: vev.wire.v1.OperationResult.registration:type_name -> vev.wire.v1.RemoteRegistration
3079+ 1, // 56: vev.wire.v1.StreamOpened.ref:type_name -> vev.wire.v1.BrokerStreamRef
3080+ 1, // 57: vev.wire.v1.ServerStreamData.ref:type_name -> vev.wire.v1.BrokerStreamRef
3081+ 1, // 58: vev.wire.v1.StreamClosed.ref:type_name -> vev.wire.v1.BrokerStreamRef
3082+ 3, // 59: vev.wire.v1.StreamClosed.error:type_name -> vev.wire.v1.BrokerErrorDetail
3083+ 1, // 60: vev.wire.v1.Progress.ref:type_name -> vev.wire.v1.BrokerStreamRef
3084+ 0, // 61: vev.wire.v1.BrokerErrorMessage.scope:type_name -> vev.wire.v1.BrokerScope
3085+ 3, // 62: vev.wire.v1.BrokerErrorMessage.error:type_name -> vev.wire.v1.BrokerErrorDetail
3086+ 0, // 63: vev.wire.v1.Shutdown.scope:type_name -> vev.wire.v1.BrokerScope
3087+ 33, // 64: vev.wire.v1.BrokerCatalogSession.tabs:type_name -> vev.wire.v1.BrokerCatalogTab
3088+ 65, // [65:65] is the sub-list for method output_type
3089+ 65, // [65:65] is the sub-list for method input_type
3090+ 65, // [65:65] is the sub-list for extension type_name
3091+ 65, // [65:65] is the sub-list for extension extendee
3092+ 0, // [0:65] is the sub-list for field type_name
29663093 }
29673094
29683095 func init() { file_broker_proto_init() }
@@ -2982,6 +3109,7 @@ func file_broker_proto_init() {
29823109 (*BrokerClientEnvelope_OpenStream)(nil),
29833110 (*BrokerClientEnvelope_ClientStreamData)(nil),
29843111 (*BrokerClientEnvelope_CloseStream)(nil),
3112+ (*BrokerClientEnvelope_UpdateHostPolicy)(nil),
29853113 }
29863114 file_broker_proto_msgTypes[6].OneofWrappers = []any{
29873115 (*BrokerServerEnvelope_Registered)(nil),
@@ -2994,7 +3122,7 @@ func file_broker_proto_init() {
29943122 (*BrokerServerEnvelope_BrokerErrorMessage)(nil),
29953123 (*BrokerServerEnvelope_Shutdown)(nil),
29963124 }
2997- file_broker_proto_msgTypes[18].OneofWrappers = []any{
3125+ file_broker_proto_msgTypes[19].OneofWrappers = []any{
29983126 (*SnapshotPart_Begin)(nil),
29993127 (*SnapshotPart_Daemon)(nil),
30003128 (*SnapshotPart_Session)(nil),
@@ -3007,7 +3135,7 @@ func file_broker_proto_init() {
30073135 GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
30083136 RawDescriptor: unsafe.Slice(unsafe.StringData(file_broker_proto_rawDesc), len(file_broker_proto_rawDesc)),
30093137 NumEnums: 0,
3010- NumMessages: 33,
3138+ NumMessages: 34,
30113139 NumExtensions: 0,
30123140 NumServices: 0,
30133141 },
M internal/protocol/wire/broker_test.go +75 -5
@@ -19,7 +19,7 @@ import (
1919 var brokerClientTags = map[string]protoreflect.FieldNumber{
2020 "register": 101, "subscribe": 102, "resync": 103, "unsubscribe": 104,
2121 "add_host": 105, "remove_host": 106, "reconcile": 107, "open_stream": 108,
22- "client_stream_data": 109, "close_stream": 110,
22+ "client_stream_data": 109, "close_stream": 110, "update_host_policy": 111,
2323 }
2424
2525 // brokerServerTags is the frozen server tag inventory.
@@ -151,9 +151,14 @@ func brokerClientSamples() map[string]*BrokerClientEnvelope {
151151 "unsubscribe": {Payload: &BrokerClientEnvelope_Unsubscribe{Unsubscribe: &Unsubscribe{Scope: brokerScope(), Generation: 1 << 42}}},
152152 "add_host": {Payload: &BrokerClientEnvelope_AddHost{AddHost: &AddHost{
153153 Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x44}, 16), Endpoint: "dev@host:22",
154+ Policy: brokerPolicy(),
154155 }}},
155156 "remove_host": {Payload: &BrokerClientEnvelope_RemoveHost{RemoveHost: &RemoveHost{
156- Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x45}, 16), Endpoint: "dev@host:23",
157+ Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x45}, 16), Registration: brokerRegistration(),
158+ }}},
159+ "update_host_policy": {Payload: &BrokerClientEnvelope_UpdateHostPolicy{UpdateHostPolicy: &UpdateHostPolicy{
160+ Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x46}, 16),
161+ Registration: brokerRegistration(), Policy: brokerPolicy(),
157162 }}},
158163 "reconcile": {Payload: &BrokerClientEnvelope_Reconcile{Reconcile: &Reconcile{
159164 Scope: brokerScope(), Registration: brokerRegistration(),
@@ -180,9 +185,9 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope {
180185 Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9,
181186 Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{
182187 HostIndex: 1, Endpoint: "dev@host:22", DisplayOrigin: "dev@host",
183- Rank: 2, Registration: brokerRegistration(), Policy: &BrokerWirePolicy{ProtocolVersion: 55},
188+ Rank: 2, Registration: brokerRegistration(), Policy: &BrokerWirePolicy{ProtocolVersion: 56},
184189 DaemonIdentity: "authed-daemon", DaemonIncarnation: bytes.Repeat([]byte{0x9}, 16),
185- ProtocolVersion: 55, Capabilities: 1,
190+ ProtocolVersion: 56, Capabilities: 1,
186191 Availability: 1,
187192 Checking: true,
188193 LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1},
@@ -193,7 +198,7 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope {
193198 }},
194199 }}},
195200 "operation_result": {Payload: &BrokerServerEnvelope_OperationResult{OperationResult: &OperationResult{
196- Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x77}, 16), Outcome: 2, Removed: true,
201+ Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x77}, 16), Outcome: 2,
197202 Error: &BrokerErrorDetail{Code: 2, Text: "incompatible", AdmissionCode: 1, FailureKind: 3},
198203 }}},
199204 "stream_opened": {Payload: &BrokerServerEnvelope_StreamOpened{StreamOpened: &StreamOpened{Ref: brokerRef()}}},
@@ -440,3 +445,68 @@ func TestBrokerEnvelopeTrailingGarbage(t *testing.T) {
440445 }
441446 }
442447 }
448+
449+// TestBrokerMembershipSchemaShape pins the Phase C1 membership layout: the new
450+// client variant, the required policy on AddHost/UpdateHostPolicy, the exact
451+// registration on RemoveHost/UpdateHostPolicy, and the registration member of
452+// OperationResult. A field that moves would silently change the meaning of a
453+// mutating request without changing its tag.
454+func TestBrokerMembershipSchemaShape(t *testing.T) {
455+ fields := func(message proto.Message) map[string]protoreflect.FieldNumber {
456+ t.Helper()
457+ descriptor := message.ProtoReflect().Descriptor()
458+ out := make(map[string]protoreflect.FieldNumber, descriptor.Fields().Len())
459+ for i := range descriptor.Fields().Len() {
460+ field := descriptor.Fields().Get(i)
461+ out[string(field.Name())] = field.Number()
462+ }
463+ return out
464+ }
465+
466+ if got, want := oneofTags(t, &BrokerClientEnvelope{})["update_host_policy"], protoreflect.FieldNumber(111); got != want {
467+ t.Fatalf("BrokerClientEnvelope.update_host_policy = %d, want %d", got, want)
468+ }
469+
470+ addHost := fields(&AddHost{})
471+ if addHost["policy"] != 4 {
472+ t.Fatalf("AddHost.policy = %d, want 4 (required authority)", addHost["policy"])
473+ }
474+ removeHost := fields(&RemoveHost{})
475+ if removeHost["registration"] != 3 {
476+ t.Fatalf("RemoveHost.registration = %d, want 3", removeHost["registration"])
477+ }
478+ if _, present := removeHost["endpoint"]; present {
479+ t.Fatal("RemoveHost still carries an endpoint field; removal is exact-registration authority")
480+ }
481+ update := fields(&UpdateHostPolicy{})
482+ if update["registration"] != 3 || update["policy"] != 4 {
483+ t.Fatalf("UpdateHostPolicy fields = %v, want registration 3 and policy 4", update)
484+ }
485+ result := fields(&OperationResult{})
486+ if result["registration"] != 6 {
487+ t.Fatalf("OperationResult.registration = %d, want 6", result["registration"])
488+ }
489+
490+ // Every membership sample survives strict scan, unmarshal, and equality,
491+ // and every strict prefix of it is refused.
492+ for name, sample := range brokerClientSamples() {
493+ switch name {
494+ case "add_host", "remove_host", "update_host_policy":
495+ default:
496+ continue
497+ }
498+ raw := mustMarshalBroker(t, sample)
499+ decoded := &BrokerClientEnvelope{}
500+ if err := validatedUnmarshal(decoded, raw); err != nil {
501+ t.Fatalf("%s: validatedUnmarshal() = %v", name, err)
502+ }
503+ if !proto.Equal(sample, decoded) {
504+ t.Fatalf("%s: roundtrip mismatch:\n got %v\nwant %v", name, decoded, sample)
505+ }
506+ for size := range len(raw) {
507+ if err := ScanEnvelope(&BrokerClientEnvelope{}, raw[:size]); err == nil {
508+ t.Fatalf("%s prefix[:%d] accepted", name, size)
509+ }
510+ }
511+ }
512+}
M internal/protocol/wire/envelope_inventory_test.go +2 -2
@@ -15,8 +15,8 @@ import (
1515 // shares a payload type except route navigation failure, and the preamble
1616 // constants agree with the schema authority.
1717 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 55 {
19- t.Fatalf("protocol.Version = %d, want 55", protocol.Version)
18+ if protocol.Version != 56 {
19+ t.Fatalf("protocol.Version = %d, want 56", protocol.Version)
2020 }
2121 if ProtocolEpoch != 1 {
2222 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
M internal/protocol/wire/multiplex.pb.go +1 -1 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +27 -6
@@ -1,7 +1,7 @@
11 // Broker wire schema: the client/server broker conversations.
22 //
33 // The broker is an independently versioned conversation from the session
4-// protocol: client tags 101-110 and server tags 201-209 are disjoint from
4+// protocol: client tags 101-111 and server tags 201-209 are disjoint from
55 // every session tag, and each direction is its own closed oneof union.
66 // Scope (broker epoch plus client connection) is carried explicitly on
77 // every payload so stream IDs and events never cross a scope. Semantic
@@ -52,7 +52,7 @@ message BrokerErrorDetail {
5252 // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout,
5353 // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost,
5454 // 7 = stale_epoch, 8 = conflicting_policy, 9 = explicit_exit,
55- // 10 = fatal_terminal.
55+ // 10 = fatal_terminal, 11 = host_conflict, 12 = membership_immutable.
5656 uint32 code = 1;
5757 string text = 2;
5858 // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the
@@ -85,6 +85,7 @@ message BrokerClientEnvelope {
8585 OpenStream open_stream = 108;
8686 ClientStreamData client_stream_data = 109;
8787 CloseStream close_stream = 110;
88+ UpdateHostPolicy update_host_policy = 111;
8889 }
8990 }
9091
@@ -127,18 +128,34 @@ message Unsubscribe {
127128 }
128129
129130 // AddHost adds one configured host registration. operation_id is the
130-// idempotency identity of this mutating operation.
131+// idempotency identity of this mutating operation. policy is mandatory:
132+// the request is refused unless it carries an exact valid policy, so a
133+// membership addition can never be authorized by an absent policy.
131134 message AddHost {
132135 BrokerScope scope = 1;
133136 bytes operation_id = 2; // exactly 16 bytes
134137 string endpoint = 3;
138+ BrokerWirePolicy policy = 4;
135139 }
136140
137-// RemoveHost removes one configured host registration.
141+// RemoveHost removes exactly the registration carried by registration
142+// (endpoint, incarnation, and generation). A request whose authority does
143+// not exactly match the current registration is refused rather than
144+// applied to an endpoint name.
138145 message RemoveHost {
139146 BrokerScope scope = 1;
140147 bytes operation_id = 2; // exactly 16 bytes
141- string endpoint = 3;
148+ RemoteRegistration registration = 3;
149+}
150+
151+// UpdateHostPolicy replaces the policy of exactly the registration carried
152+// by registration and answers with the advanced registration authority.
153+// policy is mandatory: an absent policy is refused like an invalid one.
154+message UpdateHostPolicy {
155+ BrokerScope scope = 1;
156+ bytes operation_id = 2; // exactly 16 bytes
157+ RemoteRegistration registration = 3;
158+ BrokerWirePolicy policy = 4;
142159 }
143160
144161 // Reconcile hands the broker one exact registration to probe.
@@ -279,7 +296,10 @@ message SnapshotTombstone {
279296 message SnapshotEnd {}
280297
281298 // OperationResult is exactly one completion for one admitted mutating
282-// operation.
299+// operation. removed and registration are presence-carrying authority:
300+// an authoritative success carries the resulting registration, a removal
301+// success carries none, and a failed or outcome-unknown result carries
302+// neither removal nor registration, only its typed error.
283303 message OperationResult {
284304 BrokerScope scope = 1;
285305 bytes operation_id = 2; // exactly 16 bytes
@@ -287,6 +307,7 @@ message OperationResult {
287307 uint32 outcome = 3;
288308 bool removed = 4;
289309 BrokerErrorDetail error = 5;
310+ RemoteRegistration registration = 6;
290311 }
291312
292313 // StreamOpened confirms one logical stream is established.
M internal/protocol/wire/schema/multiplex.proto +1 -1
@@ -3,7 +3,7 @@
33 // physical connection.
44 //
55 // daemonmux is a third conversation, separate from the session protocol
6-// (tags 1-32) and the broker protocol (client 101-110, server 201-209):
6+// (tags 1-32) and the broker protocol (client 101-111, server 201-209):
77 // client tags 301-304 and server tags 401-405 are disjoint from every other
88 // tag, and each direction is its own closed oneof union. The physical
99 // preamble is a separate adapter message pair, not an envelope variant:
M internal/usecase/broker/registry_local.go +0 -11
@@ -75,17 +75,6 @@ func (l *LocalObservation) validate() error {
7575 return nil
7676 }
7777
78-// observesLocalOnly reports whether this registry observes only the broker's
79-// own daemon: observation is enabled, a local binding is configured, and no
80-// remote membership is projected. It is the registry-side answer an authority
81-// needs, because an authority whose connections expose immutable membership may
82-// serve an observer only when there is no remote membership to manage.
83-func (r *Registry) observesLocalOnly() bool {
84- r.mu.Lock()
85- defer r.mu.Unlock()
86- return !r.observationDisabled && r.local != nil && len(r.hosts) == 0
87-}
88-
8978 // dispatchLocalLocked admits at most one local probe attempt when its schedule
9079 // says it is due. Callers must hold r.mu. It reports whether an attempt
9180 // started, so dispatch publishes the transient Checking state exactly once.
M internal/usecase/broker/service.go +76 -39
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "sync"
77
8+ "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
910 )
1011
@@ -21,24 +22,9 @@ import (
2122 // admission only and is never retained; the admitted service owns a
2223 // connection-lived context derived from the supervisor root.
2324 //
24-// This composition is deliberately offline and immutable: its registry runs
25-// observation-disabled, so the returned service publishes and subscribes to
26-// durable snapshots but never probes or reconciles, and host membership is
27-// refused rather than mutated. Live membership mutation is composed in a later
28-// slice.
29-
30-// ErrOfflineMembership is the typed refusal returned by AddHost and RemoveHost
31-// on an offline broker connection: membership is immutable for the
32-// connection's lifetime and the refusal never touches durable authority.
33-// Callers classify it with errors.Is instead of matching message text.
34-var ErrOfflineMembership = errors.New("broker: offline membership is immutable")
35-
36-// offlineMembershipRefusal is the typed, presentation-safe refusal. It is a
37-// ports.BrokerError so the wire layer classifies it without message matching,
38-// and it carries ErrOfflineMembership for errors.Is.
39-func offlineMembershipRefusal() error {
40- return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Text: ErrOfflineMembership.Error(), Cause: ErrOfflineMembership}
41-}
25+// Registry construction determines observation and membership policy. The
26+// authority preserves those guards and exposes admitted mutations through the
27+// connection-scoped service.
4228
4329 // Authority admits one accepted client connection to the broker core. It owns
4430 // no lifetime of its own: the supervisor owns the broker lifetime, the registry
@@ -60,13 +46,6 @@ var _ ports.BrokerAuthority = (*Authority)(nil)
6046 // NewAuthority composes one connection-scoped broker authority over an existing
6147 // registry, pool, and supervisor. All three must be live and share epoch.
6248 //
63-// The authority's connections expose immutable membership: they neither add nor
64-// remove hosts. It therefore accepts an observation-disabled registry, or an
65-// observer whose observation is limited to the broker's own daemon, and refuses
66-// a registry that observes configured hosts the service would refuse to manage.
67-// A local-only observer has no remote membership to manage: its local entry is
68-// configured authority supplied by the composition, not membership.
69-//
7049 // The caller owns the dependencies' lifecycles. In particular it must start the
7150 // registry's single Registry.Run to own and drain the durable writer, and must
7251 // cancel that run context and join Run before the store is closed. NewAuthority
@@ -80,9 +59,6 @@ func NewAuthority(epoch ports.BrokerEpoch, registry *Registry, pool *Pool, super
8059 if registry.epoch != epoch || pool.epoch != epoch {
8160 return nil, errors.New("broker: authority epoch mismatch")
8261 }
83- if !registry.observationDisabled && !registry.observesLocalOnly() {
84- return nil, errors.New("broker: authority requires an observation-disabled registry or a local-only observer")
85- }
8662 return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil
8763 }
8864
@@ -132,8 +108,8 @@ type Service struct {
132108 mu sync.Mutex
133109 closed bool
134110 subs map[*serviceSubscription]struct{}
135- // wg counts in-flight OpenStream calls so Close drains them, and their
136- // operation leases, before it releases the connection resources.
111+ // wg counts in-flight stream opens and membership mutations so Close drains
112+ // them, and their operation leases, before releasing connection resources.
137113 wg sync.WaitGroup
138114
139115 closeOnce sync.Once
@@ -265,18 +241,79 @@ func (s *Service) CloseStream(connection ports.BrokerConnectionID, stream ports.
265241 return s.pool.CloseStream(s.id, stream)
266242 }
267243
268-// AddHost refuses host admission: this connection has immutable offline
269-// membership, so no mutation is attempted.
270-func (s *Service) AddHost(context.Context, string) error { return offlineMembershipRefusal() }
244+// AddHost delegates one admitted membership mutation to the registry.
245+func (s *Service) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
246+ return serviceMutation(s, ctx, func(ctx context.Context) (domain.RemoteRegistration, error) {
247+ return s.registry.AddHost(ctx, endpoint, policy)
248+ })
249+}
250+
251+// RemoveHost delegates one admitted exact-registration removal to the registry.
252+func (s *Service) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
253+ return serviceMutation(s, ctx, func(ctx context.Context) (bool, error) {
254+ return s.registry.RemoveHost(ctx, expected)
255+ })
256+}
257+
258+// UpdateHostPolicy delegates one admitted exact-registration policy update.
259+func (s *Service) UpdateHostPolicy(ctx context.Context, expected domain.RemoteRegistration, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
260+ return serviceMutation(s, ctx, func(ctx context.Context) (domain.RemoteRegistration, error) {
261+ return s.registry.UpdateHostPolicy(ctx, expected, policy)
262+ })
263+}
264+
265+func serviceMutation[T any](s *Service, ctx context.Context, mutate func(context.Context) (T, error)) (T, error) {
266+ var zero T
267+ s.mu.Lock()
268+ if s.closed {
269+ s.mu.Unlock()
270+ return zero, ports.BrokerAdmissionClosed
271+ }
272+ s.wg.Add(1)
273+ s.mu.Unlock()
274+ defer s.wg.Done()
275+
276+ operation, err := s.supervisor.AdmitOperation()
277+ if err != nil {
278+ return zero, err
279+ }
280+ defer operation.Release()
281+
282+ operationCtx, release := s.openContext(ctx)
283+ defer release()
284+ value, err := mutate(operationCtx)
285+ return value, membershipError(err)
286+}
271287
272-// RemoveHost refuses host removal without mutating authority. The result is the
273-// explicit false/refusal pair.
274-func (s *Service) RemoveHost(context.Context, string) (bool, error) {
275- return false, offlineMembershipRefusal()
288+func membershipError(err error) error {
289+ if err == nil {
290+ return nil
291+ }
292+ code := ports.BrokerErrorCode(0)
293+ text := ""
294+ var brokerError ports.BrokerError
295+ switch {
296+ case errors.Is(err, ports.ErrBrokerHostConflict):
297+ code, text = ports.BrokerErrorHostConflict, "host registration conflict"
298+ case errors.Is(err, ports.ErrBrokerMembershipImmutable):
299+ code, text = ports.BrokerErrorMembershipImmutable, "broker membership is immutable"
300+ case errors.As(err, &brokerError) && brokerError.Code == ports.BrokerErrorConflictingPolicy:
301+ code, text = ports.BrokerErrorConflictingPolicy, "host policy conflicts with existing policy"
302+ default:
303+ var unknown ports.BrokerStoreOutcomeUnknownError
304+ var unknownPointer *ports.BrokerStoreOutcomeUnknownError
305+ if errors.As(err, &unknown) || errors.As(err, &unknownPointer) {
306+ code, text = ports.BrokerErrorOutcomeUnknown, "mutation outcome is unknown"
307+ }
308+ }
309+ if code == 0 {
310+ return err
311+ }
312+ return ports.BrokerError{Code: code, Text: text, Cause: err}
276313 }
277314
278-// RequestReconcile is deliberately a no-op: an offline connection has no
279-// observation to reconcile, so the hint starts no probe, timer, or reconcile.
315+// RequestReconcile remains a no-op until the observation service exposes a
316+// connection-safe reconcile seam.
280317 func (s *Service) RequestReconcile(string) {}
281318
282319 // Close cancels and drains every owned resource exactly once: in-flight setup
M internal/usecase/broker/service_test.go +357 -31
@@ -2,10 +2,13 @@ package broker
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
7+ "strings"
68 "sync"
79 "testing"
810 "time"
11+ "unicode/utf8"
912
1013 "github.com/bnema/vev/internal/domain"
1114 "github.com/bnema/vev/internal/ports"
@@ -13,23 +16,32 @@ import (
1316 )
1417
1518 // newTestAuthority composes one offline authority over an observation-disabled
16-// registry, a bounded pool, and a supervisor on a long grace. Every dependency
17-// is drained at test end so no run goroutine leaks between tests: the registry
18-// run is canceled and joined (which flushes and stops its durable writer), the
19-// pool is closed, and the supervisor is closed.
19+// registry, a bounded pool, and a supervisor on a long grace.
2020 func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerHostProbe, connector poolConnector) (*Authority, *Registry, *Pool, *Supervisor, *manualClock, *testStore) {
2121 t.Helper()
2222 clock := newManualClock(time.Unix(0, 0))
2323 store := newTestStore()
2424 registry, err := NewRegistryWithConfig(epoch, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true})
2525 require.NoError(t, err)
26+ authority, pool, supervisor, _ := composeTestAuthority(t, epoch, registry, connector, clock)
27+ return authority, registry, pool, supervisor, clock, store
28+}
29+
30+// composeTestAuthority wires one existing registry into a bounded pool and a
31+// supervisor on a long grace, and owns the single Registry.Run. Every dependency
32+// is drained at test end so no run goroutine leaks between tests: the registry
33+// run is canceled and joined (which flushes and stops its durable writer), the
34+// pool is closed, and the supervisor is closed. The supervisor's own manual
35+// clock is returned so a test can drive the idle lifecycle.
36+func composeTestAuthority(t testing.TB, epoch ports.BrokerEpoch, registry *Registry, connector poolConnector, clock *manualClock) (*Authority, *Pool, *Supervisor, *manualClock) {
37+ t.Helper()
2638 resolver := poolResolver(func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
2739 return ports.BrokerResolvedEndpoint{Identity: "canonical", Policy: r.Policy, Address: "fake"}, nil
2840 })
2941 pool, err := NewPool(epoch, resolver, connector, clock, PoolLimits{Physical: 4, Clients: 8, Streams: 16, StreamsPerClient: 16, Idle: time.Minute})
3042 require.NoError(t, err)
3143 t.Cleanup(func() { require.NoError(t, pool.Close()) })
32- supervisor, _ := newTestSupervisor(t, time.Hour)
44+ supervisor, idleClock := newTestSupervisor(t, time.Hour)
3345 authority, err := NewAuthority(epoch, registry, pool, supervisor)
3446 require.NoError(t, err)
3547 // The caller owns Registry.Run (see NewAuthority): start the single run and
@@ -39,7 +51,70 @@ func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerH
3951 registryDone := make(chan struct{})
4052 go func() { defer close(registryDone); registry.Run(registryCtx) }()
4153 t.Cleanup(func() { stopRegistry(); <-registryDone })
42- return authority, registry, pool, supervisor, clock, store
54+ return authority, pool, supervisor, idleClock
55+}
56+
57+// newServiceMembershipFixture composes one admitted authority over an
58+// observation-disabled registry with the requested membership mode. The store is
59+// the caller's, so a test can observe the exact CAS count and inject failures.
60+func newServiceMembershipFixture(t testing.TB, store ports.BrokerHostStore, mode MembershipMode) (*Authority, *Registry, *Supervisor, *manualClock) {
61+ t.Helper()
62+ clock := newManualClock(time.Unix(0, 0))
63+ registry, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{
64+ MembershipMode: mode,
65+ ObservationDisabled: true,
66+ IncarnationGenerator: (&incarnationSequencer{}).generate,
67+ })
68+ require.NoError(t, err)
69+ registry.jitter = identityJitter
70+ authority, _, supervisor, idleClock := composeTestAuthority(t, 1, registry, immediateConnector, clock)
71+ return authority, registry, supervisor, idleClock
72+}
73+
74+// requireImmutableRefusal asserts one service-level membership mutation was
75+// refused with the typed immutable code 12 and the ports sentinel.
76+func requireImmutableRefusal(t *testing.T, err error) {
77+ t.Helper()
78+ require.Error(t, err)
79+ var refusal ports.BrokerError
80+ require.ErrorAs(t, err, &refusal)
81+ require.Equal(t, ports.BrokerErrorMembershipImmutable, refusal.Code)
82+ require.Equal(t, ports.BrokerErrorCode(12), refusal.Code, "immutable membership is the exact code 12")
83+ require.ErrorIs(t, err, ports.ErrBrokerMembershipImmutable)
84+}
85+
86+// casThenGateStore commits its membership CAS and then parks the mutation until
87+// release, so a test can make a caller cancellation arrive strictly after the
88+// durable commit point but before the mutation returns.
89+type casThenGateStore struct {
90+ *testStore
91+ committed chan struct{}
92+ release chan struct{}
93+}
94+
95+func newCASThenGateStore() *casThenGateStore {
96+ return &casThenGateStore{testStore: newTestStore(), committed: make(chan struct{}, 1), release: make(chan struct{})}
97+}
98+
99+func (s *casThenGateStore) ReplaceHosts(expected uint64, hosts []ports.BrokerHostRecord) error {
100+ if err := s.testStore.ReplaceHosts(expected, hosts); err != nil {
101+ return err
102+ }
103+ select {
104+ case s.committed <- struct{}{}:
105+ default:
106+ }
107+ <-s.release
108+ return nil
109+}
110+
111+// releaseCAS lets a parked post-commit mutation return.
112+func (s *casThenGateStore) releaseCAS() {
113+ select {
114+ case <-s.release:
115+ default:
116+ close(s.release)
117+ }
43118 }
44119
45120 // immediateConnector returns one healthy physical connection for any endpoint.
@@ -121,34 +196,38 @@ func TestAuthorityAdmissionContextNotRetained(t *testing.T) {
121196 func TestAuthorityValidation(t *testing.T) {
122197 _, _, pool, supervisor, clock, _ := newTestAuthority(t, 1, nil, immediateConnector)
123198
124- observing, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil)
199+ // Live membership mutation is composed in this slice, so the authority no
200+ // longer refuses a registry that owns remote membership its connections can
201+ // now manage: a mutable remote registry is served.
202+ mutableRemote, err := NewRegistryWithConfig(1, newTestStore(), newTestProbe(1), clock, nil, RegistryConfig{MembershipMode: MembershipMutable})
125203 require.NoError(t, err)
126- _, err = NewAuthority(1, observing, pool, supervisor)
127- require.Error(t, err, "an observing registry must be refused by an offline authority")
128-
129- // An observer that is limited to the broker's own daemon is served: it has
130- // no remote membership for the authority's immutable-membership connections
131- // to manage, and its local entry is configured authority rather than
132- // membership.
204+ t.Cleanup(mutableRemote.settle)
205+ require.NoError(t, mutableRemote.setHosts(hostRecords(registration(t, "user@host:22", 1))))
206+ _, err = NewAuthority(1, mutableRemote, pool, supervisor)
207+ require.NoError(t, err, "a mutable remote registry must be served")
208+
209+ // A local-only observer is served: its entry is configured authority rather
210+ // than membership, so it has nothing for the authority's connections to
211+ // manage either.
133212 localOnly, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: &LocalObservation{
134213 DisplayOrigin: "local",
135214 Policy: poolPolicy(),
136215 Probe: newScriptedLocalProbe(1),
137216 }})
138217 require.NoError(t, err)
218+ t.Cleanup(localOnly.settle)
139219 _, err = NewAuthority(1, localOnly, pool, supervisor)
140220 require.NoError(t, err, "a local-only observer must be served")
141221
142- // The same observer stops being served as soon as it owns remote membership
143- // the authority's connections could never manage.
144- observerWithMembership, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil)
145- require.NoError(t, err)
146- require.NoError(t, observerWithMembership.setHosts(hostRecords(registration(t, "user@host:22", 1))))
147- _, err = NewAuthority(1, observerWithMembership, pool, supervisor)
148- require.Error(t, err, "an observer that owns remote membership must be refused")
222+ // Construction still rejects remote observation without a probe: a registry
223+ // that would observe remotes but owns no probe cannot be constructed, so no
224+ // authority ever receives one.
225+ _, err = NewRegistry(1, newTestStore(), nil, clock, nil)
226+ require.ErrorContains(t, err, "invalid registry dependencies", "remote observation without a probe must be refused")
149227
150228 disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true})
151229 require.NoError(t, err)
230+ t.Cleanup(disabled.settle)
152231 _, err = NewAuthority(2, disabled, pool, supervisor)
153232 require.Error(t, err, "an epoch mismatch must be refused")
154233
@@ -449,27 +528,274 @@ func TestServiceShutdownTerminatesOwnedWork(t *testing.T) {
449528 require.Zero(t, clients)
450529 }
451530
452-func TestServiceOfflineMembershipRefusal(t *testing.T) {
453- authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector)
531+// TestServiceImmutableMembershipRefusesEveryMutation proves the immutable
532+// connection refuses all three membership mutations with the typed immutable
533+// code 12 and the ports sentinel before any durable authority moves: no CAS is
534+// attempted, no publication changes, and reconcile stays a no-op.
535+func TestServiceImmutableMembershipRefusesEveryMutation(t *testing.T) {
536+ store := newMembershipStore()
537+ authority, registry, _, _ := newServiceMembershipFixture(t, store, MembershipImmutable)
454538 service, err := authority.AdmitClient(context.Background())
455539 require.NoError(t, err)
456540 t.Cleanup(func() { require.NoError(t, service.Close()) })
457541
458- err = service.AddHost(context.Background(), "new@host:22")
459- require.ErrorIs(t, err, ErrOfflineMembership)
460- var typed ports.BrokerError
461- require.ErrorAs(t, err, &typed)
542+ before := registry.Snapshot()
543+ _, err = service.AddHost(context.Background(), "new@host:22", poolPolicy())
544+ requireImmutableRefusal(t, err)
462545
463- removed, err := service.RemoveHost(context.Background(), "new@host:22")
546+ removed, err := service.RemoveHost(context.Background(), registration(t, "new@host:22", 1))
464547 require.False(t, removed)
465- require.ErrorIs(t, err, ErrOfflineMembership)
548+ requireImmutableRefusal(t, err)
549+
550+ updated, err := service.UpdateHostPolicy(context.Background(), registration(t, "new@host:22", 1), policyWithTrust("changed-trust"))
551+ require.Equal(t, domain.RemoteRegistration{}, updated)
552+ requireImmutableRefusal(t, err)
553+
554+ require.Equal(t, 0, store.casCalls(), "an immutable refusal never reaches the store CAS")
555+ require.Equal(t, before, registry.Snapshot(), "a refused mutation must not touch authority")
466556
467- require.Empty(t, registry.Snapshot().Daemons, "a refused mutation must not touch authority")
468- before := registry.Snapshot()
469557 service.RequestReconcile("new@host:22")
470558 require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op")
471559 }
472560
561+// TestServiceMutableMembershipDelegatesOnce proves an admitted mutable
562+// connection delegates each mutation exactly once to durable authority: each
563+// call crosses the store CAS exactly once and returns the registry's own
564+// result.
565+func TestServiceMutableMembershipDelegatesOnce(t *testing.T) {
566+ store := newMembershipStore()
567+ authority, registry, _, _ := newServiceMembershipFixture(t, store, MembershipMutable)
568+ service, err := authority.AdmitClient(context.Background())
569+ require.NoError(t, err)
570+ t.Cleanup(func() { require.NoError(t, service.Close()) })
571+
572+ added, err := service.AddHost(context.Background(), "user@host:22", poolPolicy())
573+ require.NoError(t, err)
574+ require.False(t, added.IsZero())
575+ require.Equal(t, 1, store.casCalls(), "one addition crosses the store CAS exactly once")
576+
577+ updated, err := service.UpdateHostPolicy(context.Background(), added, policyWithTrust("changed-trust"))
578+ require.NoError(t, err)
579+ require.Equal(t, added.Generation+1, updated.Generation)
580+ require.Equal(t, added.Incarnation, updated.Incarnation)
581+ require.Equal(t, 2, store.casCalls(), "one policy update crosses the store CAS exactly once")
582+
583+ removed, err := service.RemoveHost(context.Background(), updated)
584+ require.NoError(t, err)
585+ require.True(t, removed)
586+ require.Equal(t, 3, store.casCalls(), "one removal crosses the store CAS exactly once")
587+ require.Empty(t, registry.Snapshot().Daemons)
588+}
589+
590+// TestServiceMutationLeasePinsIdleLifecycle proves an in-flight membership
591+// mutation holds one operation lease: the idle timer stays disarmed while the
592+// mutation is parked past the grace, and the broker expires only after it
593+// returns and the lease is released.
594+func TestServiceMutationLeasePinsIdleLifecycle(t *testing.T) {
595+ store := newCASThenGateStore()
596+ authority, _, supervisor, idleClock := newServiceMembershipFixture(t, store, MembershipMutable)
597+ service, err := authority.AdmitClient(context.Background())
598+ require.NoError(t, err)
599+ t.Cleanup(func() { require.NoError(t, service.Close()) })
600+
601+ type outcome struct {
602+ registration domain.RemoteRegistration
603+ err error
604+ }
605+ result := make(chan outcome, 1)
606+ go func() {
607+ registration, err := service.AddHost(context.Background(), "user@host:22", poolPolicy())
608+ result <- outcome{registration: registration, err: err}
609+ }()
610+
611+ // The CAS has committed and the mutation is parked, so its operation lease is
612+ // held: the idle timer is disarmed even well past the grace.
613+ select {
614+ case <-store.committed:
615+ case <-time.After(5 * time.Second):
616+ t.Fatal("the mutation never reached the store CAS")
617+ }
618+ _, operations, armed, _ := supervisorState(supervisor)
619+ require.Equal(t, 1, operations, "the in-flight mutation holds one operation lease")
620+ require.False(t, armed, "a held operation lease disarms the idle timer")
621+ idleClock.Advance(time.Hour * 24)
622+ requireSupervisorOpen(t, supervisor)
623+
624+ store.releaseCAS()
625+ got := <-result
626+ require.NoError(t, got.err)
627+ require.False(t, got.registration.IsZero())
628+
629+ // The lease is released: the idle timer re-arms and the broker expires a
630+ // plain grace later. The connection still holds its client lease, so drop it
631+ // first.
632+ require.NoError(t, service.Close())
633+ _, operations, armed, _ = supervisorState(supervisor)
634+ require.Zero(t, operations)
635+ require.True(t, armed)
636+ idleClock.Advance(time.Hour)
637+ await(t, supervisor.Done())
638+}
639+
640+// TestServiceCloseCancelsInFlightRegistryMutation proves Close cancels and
641+// drains an in-flight registry mutation: the connection context reaches the
642+// registry's mutation context, the caller observes the typed cancellation, and
643+// Close returns only after the mutation has joined.
644+func TestServiceCloseCancelsInFlightRegistryMutation(t *testing.T) {
645+ store := newMembershipStore()
646+ authority, registry, supervisor, _ := newServiceMembershipFixture(t, store, MembershipMutable)
647+ service, err := authority.AdmitClient(context.Background())
648+ require.NoError(t, err)
649+
650+ // Hold the registry lock so the mutation parks before it consults its
651+ // context, and Close can cancel the connection context while it waits.
652+ registry.mu.Lock()
653+ released := false
654+ t.Cleanup(func() {
655+ if !released {
656+ registry.mu.Unlock()
657+ }
658+ })
659+
660+ type outcome struct {
661+ removed bool
662+ err error
663+ }
664+ result := make(chan outcome, 1)
665+ go func() {
666+ removed, err := service.RemoveHost(context.Background(), registration(t, "user@host:22", 1))
667+ result <- outcome{removed: removed, err: err}
668+ }()
669+
670+ // Wait until the mutation holds its operation lease, which is strictly after
671+ // it registered with the connection's drain group.
672+ require.Eventually(t, func() bool {
673+ _, operations, _, _ := supervisorState(supervisor)
674+ return operations == 1
675+ }, 5*time.Second, time.Millisecond, "the mutation never reached its operation lease")
676+
677+ // Close cancels the connection context and waits for the in-flight mutation
678+ // to drain. It cannot return while the mutation is parked.
679+ closed := make(chan error, 1)
680+ go func() { closed <- service.Close() }()
681+ select {
682+ case <-closed:
683+ t.Fatal("Close returned before the in-flight mutation drained")
684+ case <-time.After(50 * time.Millisecond):
685+ }
686+
687+ // Release the registry lock: the mutation now observes the cancelled
688+ // connection context instead of committing.
689+ registry.mu.Unlock()
690+ released = true
691+
692+ got := <-result
693+ require.ErrorIs(t, got.err, context.Canceled, "the in-flight mutation observes the connection cancellation")
694+ require.False(t, got.removed)
695+ select {
696+ case err := <-closed:
697+ require.NoError(t, err)
698+ case <-time.After(5 * time.Second):
699+ t.Fatal("Close never returned after the mutation drained")
700+ }
701+ require.Zero(t, store.casCalls(), "a cancelled mutation never reaches the store CAS")
702+ require.Empty(t, registry.Snapshot().Daemons, "a cancelled mutation never mutates authority")
703+}
704+
705+// TestServiceMutationCancellationAfterCommitStillReportsSuccess proves a caller
706+// cancellation that arrives strictly after the durable CAS still reports the
707+// committed success: the registry's mutation context is not consulted after
708+// commit, so the value crosses the CAS and returns rather than surfacing a
709+// cancellation for a mutation that did commit.
710+func TestServiceMutationCancellationAfterCommitStillReportsSuccess(t *testing.T) {
711+ store := newCASThenGateStore()
712+ authority, _, _, _ := newServiceMembershipFixture(t, store, MembershipMutable)
713+ service, err := authority.AdmitClient(context.Background())
714+ require.NoError(t, err)
715+ t.Cleanup(func() { require.NoError(t, service.Close()) })
716+
717+ ctx, cancel := context.WithCancel(context.Background())
718+ type outcome struct {
719+ registration domain.RemoteRegistration
720+ err error
721+ }
722+ result := make(chan outcome, 1)
723+ go func() {
724+ registration, err := service.AddHost(ctx, "user@host:22", poolPolicy())
725+ result <- outcome{registration: registration, err: err}
726+ }()
727+
728+ select {
729+ case <-store.committed:
730+ case <-time.After(5 * time.Second):
731+ t.Fatal("the mutation never reached the store CAS")
732+ }
733+ // The commit already happened; a cancellation now cannot un-commit it.
734+ cancel()
735+ store.releaseCAS()
736+
737+ got := <-result
738+ require.NoError(t, got.err, "a cancellation after the CAS must not turn a committed mutation into a failure")
739+ require.False(t, got.registration.IsZero())
740+}
741+
742+// TestServiceMembershipErrorMapping proves the service classifies a durable
743+// store outcome-unknown as the typed unknown code with its cause preserved, and
744+// surfaces the exact conflict code for a conflicting authority change.
745+func TestServiceMembershipErrorTextIsWireSafe(t *testing.T) {
746+ secretPath := "/home/alice/.config/vev/private-hosts.json"
747+ cause := errors.New(secretPath + "\x00\n" + strings.Repeat("é", ports.BrokerMaxErrorBytes))
748+ err := membershipError(ports.BrokerStoreOutcomeUnknownError{Err: cause})
749+ var failure ports.BrokerError
750+ require.ErrorAs(t, err, &failure)
751+ require.NoError(t, failure.Validate())
752+ require.LessOrEqual(t, len(failure.Text), ports.BrokerMaxErrorBytes)
753+ require.NotContains(t, failure.Text, "\x00")
754+ require.NotContains(t, failure.Text, "\n")
755+ require.NotContains(t, failure.Text, secretPath)
756+ require.Equal(t, "mutation outcome is unknown", failure.Text)
757+ require.ErrorIs(t, failure, cause)
758+ require.True(t, utf8.ValidString(failure.Text))
759+}
760+
761+func TestServiceMembershipErrorMapping(t *testing.T) {
762+ t.Run("outcome unknown maps to outcome-unknown with its cause", func(t *testing.T) {
763+ cause := errors.New("power loss")
764+ store := newMembershipStore()
765+ store.failCAS(ports.BrokerStoreOutcomeUnknownError{Err: cause})
766+ authority, _, _, _ := newServiceMembershipFixture(t, store, MembershipMutable)
767+ service, err := authority.AdmitClient(context.Background())
768+ require.NoError(t, err)
769+ t.Cleanup(func() { require.NoError(t, service.Close()) })
770+
771+ _, err = service.AddHost(context.Background(), "user@host:22", poolPolicy())
772+ require.Error(t, err)
773+ var failure ports.BrokerError
774+ require.ErrorAs(t, err, &failure)
775+ require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)
776+ require.ErrorIs(t, err, cause, "the underlying store cause stays inspectable")
777+ var unknown ports.BrokerStoreOutcomeUnknownError
778+ require.ErrorAs(t, err, &unknown)
779+ })
780+
781+ t.Run("an exact conflict maps to code 11", func(t *testing.T) {
782+ store := newMembershipStore()
783+ store.failCAS(ports.ErrBrokerHostConflict)
784+ authority, _, _, _ := newServiceMembershipFixture(t, store, MembershipMutable)
785+ service, err := authority.AdmitClient(context.Background())
786+ require.NoError(t, err)
787+ t.Cleanup(func() { require.NoError(t, service.Close()) })
788+
789+ _, err = service.UpdateHostPolicy(context.Background(), registration(t, "user@host:22", 1), poolPolicy())
790+ require.Error(t, err)
791+ var failure ports.BrokerError
792+ require.ErrorAs(t, err, &failure)
793+ require.Equal(t, ports.BrokerErrorHostConflict, failure.Code)
794+ require.Equal(t, ports.BrokerErrorCode(11), failure.Code, "host conflict is the exact code 11")
795+ require.ErrorIs(t, err, ports.ErrBrokerHostConflict)
796+ })
797+}
798+
473799 func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) {
474800 clock := newManualClock(time.Unix(0, 0))
475801 store := newTestStore()
M internal/usecase/client/supervisor_integration_test.go +11 -3
@@ -13,6 +13,7 @@ import (
1313 "github.com/stretchr/testify/require"
1414
1515 "github.com/bnema/vev/internal/adapters/brokeripc"
16+ "github.com/bnema/vev/internal/domain"
1617 "github.com/bnema/vev/internal/ports"
1718 )
1819
@@ -55,9 +56,16 @@ func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequ
5556 func (c *integrationCore) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
5657 return nil
5758 }
58-func (c *integrationCore) AddHost(context.Context, string) error { return nil }
59-func (c *integrationCore) RemoveHost(context.Context, string) (bool, error) { return false, nil }
60-func (c *integrationCore) RequestReconcile(string) {}
59+func (c *integrationCore) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
60+ return domain.RemoteRegistration{}, nil
61+}
62+func (c *integrationCore) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
63+ return false, nil
64+}
65+func (c *integrationCore) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
66+ return domain.RemoteRegistration{}, nil
67+}
68+func (c *integrationCore) RequestReconcile(string) {}
6169
6270 func (c *integrationCore) Close() error {
6371 c.once.Do(func() {
M internal/usecase/client/supervisor_test.go +8 -2
@@ -390,12 +390,18 @@ func (s *supervisorTestService) CloseStream(ports.BrokerConnectionID, ports.Brok
390390 return nil
391391 }
392392
393-func (s *supervisorTestService) AddHost(context.Context, string) error { return nil }
393+func (s *supervisorTestService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
394+ return domain.RemoteRegistration{}, nil
395+}
394396
395-func (s *supervisorTestService) RemoveHost(context.Context, string) (bool, error) {
397+func (s *supervisorTestService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
396398 return false, nil
397399 }
398400
401+func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
402+ return domain.RemoteRegistration{}, nil
403+}
404+
399405 func (s *supervisorTestService) RequestReconcile(string) {}
400406
401407 func (s *supervisorTestService) Close() error {

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