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
23 decode-failure classification.23 decode-failure classification.
24 - `internal/adapters/brokerwire`: translates between typed broker messages24 - `internal/adapters/brokerwire`: translates between typed broker messages
25 and the separate broker Protobuf conversation (`schema/broker.proto`;25 and the separate broker Protobuf conversation (`schema/broker.proto`;
26- client tags 101-110, server tags 201-209, disjoint from every session26+ client tags 101-111, server tags 201-209, disjoint from every session
27 tag, each direction its own closed `oneof`). It owns the stateless27 tag, each direction its own closed `oneof`). It owns the stateless
28 broker codec over `wire.ScanEnvelope`, the broker preamble (roles 3/4,28 broker codec over `wire.ScanEnvelope`, the broker preamble (roles 3/4,
29 exact `protocol.Version`, 1 MiB..16 MiB / 1..64 KiB ceilings, 4 KiB29 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.
197 `go tool buf generate`, and handle the new variant in `sessionwire`.197 `go tool buf generate`, and handle the new variant in `sessionwire`.
198 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.198 Never edit generated `*.pb.go`; never add manual IDs or dispatch tables.
199 - Implement I/O, queues, workers, environment integration, or technology selection in an adapter or `internal/app`.199 - 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.
201 201
202 ## Broker wire core (Plan 001 P3.1, not activated)202 ## Broker wire core (Plan 001 P3.1, not activated)
203 203
204 `internal/adapters/brokerwire` owns the broker's separate Protobuf204 `internal/adapters/brokerwire` owns the broker's separate Protobuf
205-conversation end to end: client tags 101-110 and server tags 201-209 are205+conversation end to end: client tags 101-111 and server tags 201-209 are
206 disjoint from every session tag, and each direction is its own closed206 disjoint from every session tag, and each direction is its own closed
207 directional `oneof` union. A broker connection opens with exactly one207 directional `oneof` union. A broker connection opens with exactly one
208 broker preamble (roles 3/4) that carries the same magic, epoch, exact208 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`.
97 ## Broker negotiation97 ## Broker negotiation
98 98
99 Broker connections are a separate conversation from the session protocol.99 Broker connections are a separate conversation from the session protocol.
100-`internal/adapters/brokerwire` owns it: client tags 101-110 and server tags100+`internal/adapters/brokerwire` owns it: client tags 101-111 and server tags
101 201-209 are disjoint from every session tag, and each direction is its own101 201-209 are disjoint from every session tag, and each direction is its own
102 closed directional `oneof` union. A broker connection opens with exactly one102 closed directional `oneof` union. A broker connection opens with exactly one
103 broker preamble (`PreambleRequest` client → server, `PreambleResponse` server103 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) {
176 time.Sleep(400 * time.Millisecond)176 time.Sleep(400 * time.Millisecond)
177 require.ErrorIs(t, setupCtx.Err(), context.DeadlineExceeded, "the setup context must have expired")177 require.ErrorIs(t, setupCtx.Err(), context.DeadlineExceeded, "the setup context must have expired")
178 178
179- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)179+ requireConnectionDelegates(t, client)
180- defer cancel()
181- require.NoError(t, client.AddHost(ctx, "after@setup:22"), "a registered connection must outlive its setup context")
182 }180 }
183 181
184 // TestServerRegistrationBudgetSettlesSilentPeer proves a same-user peer cannot182 // TestServerRegistrationBudgetSettlesSilentPeer proves a same-user peer cannot
@@ -279,9 +277,7 @@ func TestServerRegistrationBudgetLeavesRegisteredSessionHealthy(t *testing.T) {
279 277
280 // Outlive the accept-time registration budget.278 // Outlive the accept-time registration budget.
281 time.Sleep(400 * time.Millisecond)279 time.Sleep(400 * time.Millisecond)
282- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)280+ requireConnectionDelegates(t, client)
283- defer cancel()
284- require.NoError(t, client.AddHost(ctx, "after@budget:22"))
285 select {281 select {
286 case <-core.done:282 case <-core.done:
287 t.Fatal("a session that registered in time must not be settled by the registration budget")283 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 (
9 "github.com/bnema/vev/internal/adapters/brokerwire"9 "github.com/bnema/vev/internal/adapters/brokerwire"
10 "github.com/bnema/vev/internal/adapters/ipc"10 "github.com/bnema/vev/internal/adapters/ipc"
11 "github.com/bnema/vev/internal/adapters/streamframe"11 "github.com/bnema/vev/internal/adapters/streamframe"
12+ "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/protocol/wire"14 "github.com/bnema/vev/internal/protocol/wire"
14 )15 )
@@ -121,6 +122,7 @@ func dial(ctx context.Context, path string, cfg Config, verify ipc.PeerVerifier)
121 ctx: ctx,122 ctx: ctx,
122 cancel: cancelSession,123 cancel: cancelSession,
123 pending: make(map[ports.BrokerOperationID]chan operationResult),124 pending: make(map[ports.BrokerOperationID]chan operationResult),
125+ kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind),
124 streams: make(map[ports.BrokerStreamID]*clientStream),126 streams: make(map[ports.BrokerStreamID]*clientStream),
125 done: make(chan struct{}),127 done: make(chan struct{}),
126 }128 }
@@ -192,9 +194,10 @@ func exchangeRegister(transport wire.BoundedTransport, ceilings brokerwire.Ceili
192 194
193 // operationResult is one reply to a mutating operation.195 // operationResult is one reply to a mutating operation.
194 type operationResult struct {196 type operationResult struct {
195- outcome ports.BrokerMutationOutcome197+ outcome ports.BrokerMutationOutcome
196- removed bool198+ removed bool
197- detail brokerwire.ErrorDetail199+ registration domain.RemoteRegistration
200+ detail brokerwire.ErrorDetail
198 }201 }
199 202
200 // client implements ports.BrokerService over one broker IPC connection.203 // client implements ports.BrokerService over one broker IPC connection.
@@ -217,6 +220,7 @@ type client struct {
217 nextStream ports.BrokerStreamID220 nextStream ports.BrokerStreamID
218 snapshot ports.BrokerSnapshot221 snapshot ports.BrokerSnapshot
219 pending map[ports.BrokerOperationID]chan operationResult222 pending map[ports.BrokerOperationID]chan operationResult
223+ kinds map[ports.BrokerOperationID]brokerwire.RegisterMutationKind
220 streams map[ports.BrokerStreamID]*clientStream224 streams map[ports.BrokerStreamID]*clientStream
221 closeOnce sync.Once225 closeOnce sync.Once
222 done chan struct{}226 done chan struct{}
@@ -267,10 +271,21 @@ func (c *client) dispatch(message brokerwire.ServerMessage) error {
267 if !c.scopeMatches(m.Epoch, m.Connection) {271 if !c.scopeMatches(m.Epoch, m.Connection) {
268 return errors.Join(ErrScopeMismatch, ErrProtocol)272 return errors.Join(ErrScopeMismatch, ErrProtocol)
269 }273 }
270- // The operation's waiter records the tracker completion exactly once;274+ c.mu.Lock()
271- // a result with no waiter (an abandoned or replayed operation) is275+ _, ok := c.pending[m.Operation]
272- // dropped here.276+ kind := c.kinds[m.Operation]
273- c.completeOperation(m.Operation, operationResult{outcome: m.Outcome, removed: m.Removed, detail: m.Error})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})
274 return nil289 return nil
275 case brokerwire.StreamOpened:290 case brokerwire.StreamOpened:
276 if !c.scopeMatches(m.Epoch, m.Connection) {291 if !c.scopeMatches(m.Epoch, m.Connection) {
@@ -361,17 +376,26 @@ func (c *client) publish(snapshot ports.BrokerSnapshot) {
361 }376 }
362 }377 }
363 378
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+
364 // completeOperation hands one result to the operation's waiter, if any.387 // completeOperation hands one result to the operation's waiter, if any.
365 func (c *client) completeOperation(operation ports.BrokerOperationID, result operationResult) {388 func (c *client) completeOperation(operation ports.BrokerOperationID, result operationResult) {
366 c.mu.Lock()389 c.mu.Lock()
367- ch := c.pending[operation]390+ reply, ok := c.pending[operation]
368 delete(c.pending, operation)391 delete(c.pending, operation)
392+ delete(c.kinds, operation)
369 c.mu.Unlock()393 c.mu.Unlock()
370- if ch == nil {394+ if !ok {
371 return395 return
372 }396 }
373 select {397 select {
374- case ch <- result:398+ case reply <- result:
375 default:399 default:
376 }400 }
377 }401 }
@@ -533,30 +557,37 @@ func (c *client) CloseStream(connection ports.BrokerConnectionID, stream ports.B
533 return nil557 return nil
534 }558 }
535 559
536-// AddHost requests one host registration. A lost reply is reported as560+// AddHost sends one exact endpoint and policy mutation.
537-// outcome-unknown, never as an invitation to replay blindly.561+func (c *client) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
538-func (c *client) AddHost(ctx context.Context, target string) error {562+ result, err := c.mutate(ctx, brokerwire.MutationKindAddHost, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
539- result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {563+ return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: endpoint, Policy: policy}
540- return brokerwire.AddHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target}
541 })564 })
542 if err != nil {565 if err != nil {
543- return err566+ return domain.RemoteRegistration{}, err
544 }567 }
545- return mutationFailure(result)568+ return result.registration, mutationFailure(result)
546 }569 }
547 570
548-// RemoveHost requests one host removal and reports whether the host was present.571+// RemoveHost sends one exact-registration mutation.
549-func (c *client) RemoveHost(ctx context.Context, target string) (bool, error) {572+func (c *client) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
550- result, err := c.mutate(ctx, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {573+ result, err := c.mutate(ctx, brokerwire.MutationKindRemoveHost, func(operation ports.BrokerOperationID) brokerwire.ClientMessage {
551- return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Endpoint: target}574+ return brokerwire.RemoveHost{Epoch: c.scope.Epoch, Connection: c.scope.Connection, Operation: operation, Registration: expected}
552 })575 })
553 if err != nil {576 if err != nil {
554 return false, err577 return false, err
555 }578 }
556- if err := mutationFailure(result); err != nil {579+ return result.removed, mutationFailure(result)
557- return false, err580+}
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
558 }589 }
559- return result.removed, nil590+ return result.registration, mutationFailure(result)
560 }591 }
561 592
562 // RequestReconcile asks the broker to re-observe one endpoint. The port carries593 // 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
631 // error and records a failed outcome; once the wire attempt was launched, every662 // error and records a failed outcome; once the wire attempt was launched, every
632 // failure is reported as outcome-unknown, so a caller refreshes authoritative663 // failure is reported as outcome-unknown, so a caller refreshes authoritative
633 // state instead of replaying a non-idempotent mutation blindly.664 // 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) {
635 operation, err := newOperationID()666 operation, err := newOperationID()
636 if err != nil {667 if err != nil {
637 return operationResult{}, err668 return operationResult{}, err
@@ -652,14 +683,16 @@ func (c *client) mutate(ctx context.Context, build func(ports.BrokerOperationID)
652 return operationResult{}, admissionError(err)683 return operationResult{}, admissionError(err)
653 }684 }
654 c.pending[operation] = reply685 c.pending[operation] = reply
686+ if c.kinds == nil {
687+ c.kinds = make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind)
688+ }
689+ c.kinds[operation] = kind
655 c.mu.Unlock()690 c.mu.Unlock()
656 // No result observed is exactly the outcome-unknown case; a request that691 // No result observed is exactly the outcome-unknown case; a request that
657 // was never launched is recorded as failed instead.692 // was never launched is recorded as failed instead.
658 outcome := ports.BrokerOutcomeUnknown693 outcome := ports.BrokerOutcomeUnknown
659 defer func() {694 defer func() {
660- c.mu.Lock()695+ c.releaseOperation(operation)
661- delete(c.pending, operation)
662- c.mu.Unlock()
663 _ = c.conn.CompleteOperation(operation, outcome)696 _ = c.conn.CompleteOperation(operation, outcome)
664 }()697 }()
665 attempted, err := c.sendContext(ctx, build(operation))698 attempted, err := c.sendContext(ctx, build(operation))
M internal/adapters/brokeripc/client_mutation_test.go +443 -61
@@ -11,6 +11,7 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 12
13 "github.com/bnema/vev/internal/adapters/brokerwire"13 "github.com/bnema/vev/internal/adapters/brokerwire"
14+ "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/protocol/wire"16 "github.com/bnema/vev/internal/protocol/wire"
16 )17 )
@@ -122,6 +123,7 @@ func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) {
122 ctx: ctx,123 ctx: ctx,
123 cancel: cancel,124 cancel: cancel,
124 pending: make(map[ports.BrokerOperationID]chan operationResult),125 pending: make(map[ports.BrokerOperationID]chan operationResult),
126+ kinds: make(map[ports.BrokerOperationID]brokerwire.RegisterMutationKind),
125 streams: make(map[ports.BrokerStreamID]*clientStream),127 streams: make(map[ports.BrokerStreamID]*clientStream),
126 done: make(chan struct{}),128 done: make(chan struct{}),
127 }129 }
@@ -129,38 +131,104 @@ func newGatedClient(t *testing.T, cfg Config) (*client, *gatedTransport) {
129 return c, transport131 return c, transport
130 }132 }
131 133
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.
133 type mutationOutcome struct {137 type mutationOutcome struct {
134- removed bool138+ removed bool
135- err error139+ registration domain.RemoteRegistration
140+ err error
136 }141 }
137 142
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.
139 type mutationCall struct {146 type mutationCall struct {
140 name string147 name string
141 endpoint string148 endpoint string
142 wantRemoved bool149 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
144 }155 }
145 156
146 var mutationCalls = []mutationCall{157 var mutationCalls = []mutationCall{
147 {158 {
148 name: "AddHost",159 name: "AddHost",
149 endpoint: "new@host:22",160 endpoint: "new@host:22",
150- call: func(ctx context.Context, c *client, endpoint string) (bool, error) {161+ kind: brokerwire.MutationKindAddHost,
151- return false, c.AddHost(ctx, endpoint)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+ }
152 },171 },
153 },172 },
154 {173 {
155 name: "RemoveHost",174 name: "RemoveHost",
156 endpoint: "known",175 endpoint: "known",
157 wantRemoved: true,176 wantRemoved: true,
158- call: func(ctx context.Context, c *client, endpoint string) (bool, error) {177+ kind: brokerwire.MutationKindRemoveHost,
159- return c.RemoveHost(ctx, endpoint)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}
160 },198 },
161 },199 },
162 }200 }
163 201
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+
164 // awaitResult waits for one mutation call to return.232 // awaitResult waits for one mutation call to return.
165 func awaitResult(t *testing.T, results <-chan mutationOutcome) mutationOutcome {233 func awaitResult(t *testing.T, results <-chan mutationOutcome) mutationOutcome {
166 t.Helper()234 t.Helper()
@@ -195,26 +263,60 @@ func operationOf(t *testing.T, message brokerwire.ClientMessage) ports.BrokerOpe
195 return m.Operation263 return m.Operation
196 case brokerwire.RemoveHost:264 case brokerwire.RemoveHost:
197 return m.Operation265 return m.Operation
266+ case brokerwire.UpdateHostPolicy:
267+ return m.Operation
198 default:268 default:
199 t.Fatalf("unexpected client message %T", message)269 t.Fatalf("unexpected client message %T", message)
200 return ports.BrokerOperationID{}270 return ports.BrokerOperationID{}
201 }271 }
202 }272 }
203 273
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.
205 func endpointOf(t *testing.T, message brokerwire.ClientMessage) string {277 func endpointOf(t *testing.T, message brokerwire.ClientMessage) string {
206 t.Helper()278 t.Helper()
207 switch m := message.(type) {279 switch m := message.(type) {
208 case brokerwire.AddHost:280 case brokerwire.AddHost:
209 return m.Endpoint281 return m.Endpoint
210 case brokerwire.RemoveHost:282 case brokerwire.RemoveHost:
211- return m.Endpoint283+ return m.Registration.Endpoint
284+ case brokerwire.UpdateHostPolicy:
285+ return m.Registration.Endpoint
212 default:286 default:
213 t.Fatalf("unexpected client message %T", message)287 t.Fatalf("unexpected client message %T", message)
214 return ""288 return ""
215 }289 }
216 }290 }
217 291
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+
218 // pendingOperations snapshots the client's in-flight waiters.320 // pendingOperations snapshots the client's in-flight waiters.
219 func pendingOperations(c *client) []ports.BrokerOperationID {321 func pendingOperations(c *client) []ports.BrokerOperationID {
220 c.mu.Lock()322 c.mu.Lock()
@@ -226,19 +328,75 @@ func pendingOperations(c *client) []ports.BrokerOperationID {
226 return out328 return out
227 }329 }
228 330
229-// deliverResult plays one broker reply for a captured request.331+func requireNoPendingMutationState(t *testing.T, c *client) {
230-func deliverResult(t *testing.T, c *client, operation ports.BrokerOperationID, outcome ports.BrokerMutationOutcome, removed bool) {
231 t.Helper()332 t.Helper()
232- require.NoError(t, c.dispatch(brokerwire.OperationResult{333+ c.mu.Lock()
233- Epoch: c.scope.Epoch, Connection: c.scope.Connection,334+ defer c.mu.Unlock()
234- Operation: operation, Outcome: outcome, Removed: removed,335+ require.Empty(t, c.pending, "pending replies must be released")
235- }))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+ }
236 }394 }
237 395
238-// TestMutationSendUncertaintyTaxonomy pins the decision table for both mutating396+// TestMutationSendUncertaintyTaxonomy pins the decision table for every mutating
239-// operations: a request that definitely never left is a definite error with zero397+// operation: a request that definitely never left is a definite error with zero
240 // sends; any failure observed after the attempt was launched is outcome-unknown398 // 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.
242 func TestMutationSendUncertaintyTaxonomy(t *testing.T) {400 func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
243 scenarios := []struct {401 scenarios := []struct {
244 name string402 name string
@@ -272,6 +430,18 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
272 wantSends: 1,430 wantSends: 1,
273 wantUnknown: true,431 wantUnknown: true,
274 },432 },
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+ },
275 {445 {
276 name: "Send fails after the request was captured",446 name: "Send fails after the request was captured",
277 drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) {447 drive: func(t *testing.T, c *client, g *gatedTransport, cancel context.CancelFunc) {
@@ -281,6 +451,15 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
281 wantSends: 1,451 wantSends: 1,
282 wantUnknown: true,452 wantUnknown: true,
283 },453 },
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+ },
284 }463 }
285 for _, call := range mutationCalls {464 for _, call := range mutationCalls {
286 for _, tt := range scenarios {465 for _, tt := range scenarios {
@@ -293,14 +472,16 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
293 }472 }
294 results := make(chan mutationOutcome, 1)473 results := make(chan mutationOutcome, 1)
295 go func() {474 go func() {
296- removed, err := call.call(ctx, c, call.endpoint)475+ removed, registration, err := call.run(ctx, c)
297- results <- mutationOutcome{removed: removed, err: err}476+ results <- mutationOutcome{removed: removed, registration: registration, err: err}
298 }()477 }()
299 if tt.drive != nil {478 if tt.drive != nil {
300 tt.drive(t, c, g, cancel)479 tt.drive(t, c, g, cancel)
301 }480 }
302 got := awaitResult(t, results)481 got := awaitResult(t, results)
303 require.False(t, got.removed, "no reply means the target is never reported removed")482 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")
304 if tt.wantUnknown {485 if tt.wantUnknown {
305 var failure ports.BrokerError486 var failure ports.BrokerError
306 require.ErrorAs(t, got.err, &failure)487 require.ErrorAs(t, got.err, &failure)
@@ -313,7 +494,7 @@ func TestMutationSendUncertaintyTaxonomy(t *testing.T) {
313 "a request that never left must not be reported as a broker outcome")494 "a request that never left must not be reported as a broker outcome")
314 }495 }
315 require.Equal(t, tt.wantSends, g.sendCount(), "the request must travel at most once")496 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)
317 })498 })
318 }499 }
319 }500 }
@@ -327,37 +508,27 @@ func TestMutationDefiniteFailureRecordsAndReleases(t *testing.T) {
327 ctx, cancel := context.WithCancel(context.Background())508 ctx, cancel := context.WithCancel(context.Background())
328 cancel()509 cancel()
329 510
330- // Driving mutate directly makes the operation identity observable even511+ // Driving the public API with an already-cancelled caller is the definite
331- // though the request is never encoded for the wire.512+ // no-dispatch case: no request is encoded for the wire.
332- var operation ports.BrokerOperationID513+ added, err := c.AddHost(ctx, "pre@host:22", testPolicy())
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- })
339 require.ErrorIs(t, err, context.Canceled)514 require.ErrorIs(t, err, context.Canceled)
340- require.Equal(t, operationResult{}, result)515+ require.Equal(t, domain.RemoteRegistration{}, added)
341- require.NotZero(t, operation)
342 require.Zero(t, g.sendCount(), "a pre-cancelled caller never enters the carriage")516 require.Zero(t, g.sendCount(), "a pre-cancelled caller never enters the carriage")
343 require.Empty(t, pendingOperations(c), "the definite failure releases its pending slot")517 require.Empty(t, pendingOperations(c), "the definite failure releases its pending slot")
344 518
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-
349 // The bound of one proves the slot is genuinely free: a fresh mutation is519 // The bound of one proves the slot is genuinely free: a fresh mutation is
350 // admitted and completes normally.520 // admitted and completes normally.
351 fresh, cancelFresh := context.WithCancel(context.Background())521 fresh, cancelFresh := context.WithCancel(context.Background())
352 defer cancelFresh()522 defer cancelFresh()
353 results := make(chan mutationOutcome, 1)523 results := make(chan mutationOutcome, 1)
354 go func() {524 go func() {
355- removed, callErr := c.RemoveHost(fresh, "known")525+ removed, registration, callErr := mutationCalls[1].run(fresh, c)
356- results <- mutationOutcome{removed: removed, err: callErr}526+ results <- mutationOutcome{removed: removed, registration: registration, err: callErr}
357 }()527 }()
358 request := awaitSend(t, g)528 request := awaitSend(t, g)
529+ operation := operationOf(t, request)
359 g.releaseSend(nil)530 g.releaseSend(nil)
360- deliverResult(t, c, operationOf(t, request), ports.BrokerOutcomeOK, true)531+ mutationCalls[1].deliverSuccess(t, c, operation)
361 got := awaitResult(t, results)532 got := awaitResult(t, results)
362 require.NoError(t, got.err)533 require.NoError(t, got.err)
363 require.True(t, got.removed)534 require.True(t, got.removed)
@@ -376,12 +547,13 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
376 547
377 results := make(chan mutationOutcome, 1)548 results := make(chan mutationOutcome, 1)
378 go func() {549 go func() {
379- removed, err := call.call(ctx, c, call.endpoint)550+ removed, registration, err := call.run(ctx, c)
380- results <- mutationOutcome{removed: removed, err: err}551+ results <- mutationOutcome{removed: removed, registration: registration, err: err}
381 }()552 }()
382 request := awaitSend(t, g)553 request := awaitSend(t, g)
383 operation := operationOf(t, request)554 operation := operationOf(t, request)
384 require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own")555 require.Equal(t, call.endpoint, endpointOf(t, request), "the captured request is the caller's own")
556+ g.releaseSend(nil)
385 cancel()557 cancel()
386 got := awaitResult(t, results)558 got := awaitResult(t, results)
387 var failure ports.BrokerError559 var failure ports.BrokerError
@@ -389,19 +561,18 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
389 require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)561 require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)
390 require.False(t, got.removed)562 require.False(t, got.removed)
391 require.Equal(t, 1, g.sendCount(), "an uncertain attempt is never resent")563 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)
396 564
397 // The identity was completed once, so its pending slot is released565 // The identity was completed once, so its pending slot is released
398 // rather than left in flight.566 // rather than left in flight.
399 require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted)567 require.ErrorIs(t, c.conn.AdmitOperation(operation), brokerwire.ErrOperationCompleted)
400 require.Empty(t, pendingOperations(c))568 require.Empty(t, pendingOperations(c))
401 569
402- // A late result for the completed identity is dropped, and it never570+ // A late legal result for the completed identity is dropped, and it
403- // provokes another wire attempt.571+ // never provokes another wire attempt.
404- deliverResult(t, c, operation, ports.BrokerOutcomeOK, true)572+ late := call.success(operation)
573+ late.Epoch = c.scope.Epoch
574+ late.Connection = c.scope.Connection
575+ deliverResult(t, c, late)
405 require.Empty(t, pendingOperations(c))576 require.Empty(t, pendingOperations(c))
406 require.Equal(t, 1, g.sendCount())577 require.Equal(t, 1, g.sendCount())
407 578
@@ -411,12 +582,12 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
411 defer cancelFresh()582 defer cancelFresh()
412 next := make(chan mutationOutcome, 1)583 next := make(chan mutationOutcome, 1)
413 go func() {584 go func() {
414- removed, err := call.call(fresh, c, call.endpoint)585+ removed, registration, err := call.run(fresh, c)
415- next <- mutationOutcome{removed: removed, err: err}586+ next <- mutationOutcome{removed: removed, registration: registration, err: err}
416 }()587 }()
417 second := awaitSend(t, g)588 second := awaitSend(t, g)
418 g.releaseSend(nil)589 g.releaseSend(nil)
419- deliverResult(t, c, operationOf(t, second), ports.BrokerOutcomeOK, call.wantRemoved)590+ call.deliverSuccess(t, c, operationOf(t, second))
420 done := awaitResult(t, next)591 done := awaitResult(t, next)
421 require.NoError(t, done.err)592 require.NoError(t, done.err)
422 require.Equal(t, call.wantRemoved, done.removed)593 require.Equal(t, call.wantRemoved, done.removed)
@@ -426,7 +597,7 @@ func TestMutationUnknownOutcomeReleasesWithoutResend(t *testing.T) {
426 }597 }
427 598
428 // TestMutationDeliveredReplyIsDefinite proves an ordinary delivered reply stays599 // TestMutationDeliveredReplyIsDefinite proves an ordinary delivered reply stays
429-// the definite outcome for both operations, and a repeated result for the same600+// the definite outcome for every operation, and a repeated result for the same
430 // identity changes nothing.601 // identity changes nothing.
431 func TestMutationDeliveredReplyIsDefinite(t *testing.T) {602 func TestMutationDeliveredReplyIsDefinite(t *testing.T) {
432 for _, call := range mutationCalls {603 for _, call := range mutationCalls {
@@ -437,14 +608,14 @@ func TestMutationDeliveredReplyIsDefinite(t *testing.T) {
437 608
438 results := make(chan mutationOutcome, 1)609 results := make(chan mutationOutcome, 1)
439 go func() {610 go func() {
440- removed, err := call.call(ctx, c, call.endpoint)611+ removed, registration, err := call.run(ctx, c)
441- results <- mutationOutcome{removed: removed, err: err}612+ results <- mutationOutcome{removed: removed, registration: registration, err: err}
442 }()613 }()
443 request := awaitSend(t, g)614 request := awaitSend(t, g)
444 operation := operationOf(t, request)615 operation := operationOf(t, request)
445 require.Equal(t, call.endpoint, endpointOf(t, request))616 require.Equal(t, call.endpoint, endpointOf(t, request))
446 g.releaseSend(nil)617 g.releaseSend(nil)
447- deliverResult(t, c, operation, ports.BrokerOutcomeOK, call.wantRemoved)618+ call.deliverSuccess(t, c, operation)
448 got := awaitResult(t, results)619 got := awaitResult(t, results)
449 require.NoError(t, got.err)620 require.NoError(t, got.err)
450 require.Equal(t, call.wantRemoved, got.removed)621 require.Equal(t, call.wantRemoved, got.removed)
@@ -452,13 +623,224 @@ func TestMutationDeliveredReplyIsDefinite(t *testing.T) {
452 require.Empty(t, pendingOperations(c))623 require.Empty(t, pendingOperations(c))
453 624
454 // A replayed result for an already settled identity is dropped.625 // 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)
456 require.Empty(t, pendingOperations(c))630 require.Empty(t, pendingOperations(c))
457 require.Equal(t, 1, g.sendCount())631 require.Equal(t, 1, g.sendCount())
458 })632 })
459 }633 }
460 }634 }
461 635
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+
462 // TestSendContextPreflightClassification pins the private helper's taxonomy844 // TestSendContextPreflightClassification pins the private helper's taxonomy
463 // directly: an unencodable frame, an already-cancelled caller, and an845 // directly: an unencodable frame, an already-cancelled caller, and an
464 // already-terminal connection are definitely not dispatched; only a launched846 // already-terminal connection are definitely not dispatched; only a launched
@@ -478,7 +860,7 @@ func TestSendContextPreflightClassification(t *testing.T) {
478 cancel()860 cancel()
479 attempted, err := c.sendContext(ctx, brokerwire.AddHost{861 attempted, err := c.sendContext(ctx, brokerwire.AddHost{
480 Epoch: c.scope.Epoch, Connection: c.scope.Connection,862 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(),
482 })864 })
483 require.False(t, attempted)865 require.False(t, attempted)
484 require.ErrorIs(t, err, context.Canceled)866 require.ErrorIs(t, err, context.Canceled)
@@ -490,7 +872,7 @@ func TestSendContextPreflightClassification(t *testing.T) {
490 require.NoError(t, c.Close())872 require.NoError(t, c.Close())
491 attempted, err := c.sendContext(context.Background(), brokerwire.AddHost{873 attempted, err := c.sendContext(context.Background(), brokerwire.AddHost{
492 Epoch: c.scope.Epoch, Connection: c.scope.Connection,874 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(),
494 })876 })
495 require.False(t, attempted)877 require.False(t, attempted)
496 require.ErrorIs(t, err, ErrConnectionClosed)878 require.ErrorIs(t, err, ErrConnectionClosed)
@@ -507,7 +889,7 @@ func TestSendContextPreflightClassification(t *testing.T) {
507 go func() {889 go func() {
508 attempted, err := c.sendContext(context.Background(), brokerwire.RemoveHost{890 attempted, err := c.sendContext(context.Background(), brokerwire.RemoveHost{
509 Epoch: c.scope.Epoch, Connection: c.scope.Connection,891 Epoch: c.scope.Epoch, Connection: c.scope.Connection,
510- Operation: ports.BrokerOperationID{0x02}, Endpoint: "known",892+ Operation: ports.BrokerOperationID{0x02}, Registration: mutationRegistration("known"),
511 })893 })
512 done <- sendResult{attempted: attempted, err: err}894 done <- sendResult{attempted: attempted, err: err}
513 }()895 }()
M internal/adapters/brokeripc/helpers_test.go +244 -16
@@ -3,6 +3,7 @@ package brokeripc
3 import (3 import (
4 "context"4 "context"
5 "encoding/binary"5 "encoding/binary"
6+ "errors"
6 "fmt"7 "fmt"
7 "io"8 "io"
8 "os"9 "os"
@@ -45,6 +46,81 @@ func testPolicy() ports.BrokerPolicy {
45 }46 }
46 }47 }
47 48
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+
48 // testDaemonAt builds one catalogue-valid remote daemon projection for index i.124 // testDaemonAt builds one catalogue-valid remote daemon projection for index i.
49 func testDaemonAt(i int) ports.BrokerDaemonObservation {125 func testDaemonAt(i int) ports.BrokerDaemonObservation {
50 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)126 endpoint := fmt.Sprintf("user%d@host%d:22", i, i)
@@ -238,6 +314,32 @@ func (c *fakeLogicalConn) fail(err error) {
238 _ = c.Close()314 _ = c.Close()
239 }315 }
240 316
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+
241 // fakeCore is one admitted per-connection broker service.343 // fakeCore is one admitted per-connection broker service.
242 type fakeCore struct {344 type fakeCore struct {
243 mu sync.Mutex345 mu sync.Mutex
@@ -246,14 +348,25 @@ type fakeCore struct {
246 streams map[ports.BrokerStreamID]*fakeLogicalConn348 streams map[ports.BrokerStreamID]*fakeLogicalConn
247 opens []ports.BrokerOpenStreamRequest349 opens []ports.BrokerOpenStreamRequest
248 closedIDs []ports.BrokerStreamID350 closedIDs []ports.BrokerStreamID
249- added []string351+ added []addHostCall
250- removed []string352+ removed []removeHostCall
353+ updated []updateHostPolicyCall
251 reconcile []string354 reconcile []string
252 openErr error355 openErr error
253- blockAdd chan struct{}
254 closed bool356 closed bool
255 done chan struct{}357 done chan struct{}
256 once sync.Once358 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
257 }370 }
258 371
259 func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id }372 func (c *fakeCore) ConnectionID() ports.BrokerConnectionID { return c.id }
@@ -298,31 +411,146 @@ func (c *fakeCore) CloseStream(connection ports.BrokerConnectionID, stream ports
298 return conn.Close()411 return conn.Close()
299 }412 }
300 413
301-func (c *fakeCore) AddHost(ctx context.Context, target string) error {414+// mapMembershipError mirrors the admitted core's own mapping of a registry
302- if c.blockAdd != nil {415+// refusal onto the closed broker error taxonomy. The real broker.Service already
303- select {416+// returns these typed codes, so the fake applies the same rule: a caller of the
304- case <-c.blockAdd:417+// adapter must never see a bare registry sentinel as if it were an untyped
305- case <-ctx.Done():418+// failure.
306- return ctx.Err()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
307 }434 }
308 }435 }
309- if err := ctx.Err(); err != nil {436+ if code == 0 {
310 return err437 return err
311 }438 }
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+ }
312 c.mu.Lock()449 c.mu.Lock()
313- c.added = append(c.added, target)450+ defer c.mu.Unlock()
314- c.mu.Unlock()451+ registration := domain.RemoteRegistration{}
315- return nil452+ 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
316 }464 }
317 465
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) {
319 if err := ctx.Err(); err != nil {470 if err := ctx.Err(); err != nil {
320 return false, err471 return false, err
321 }472 }
322 c.mu.Lock()473 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
324 c.mu.Unlock()553 c.mu.Unlock()
325- return target == "known", nil
326 }554 }
327 555
328 func (c *fakeCore) RequestReconcile(endpoint string) {556 func (c *fakeCore) RequestReconcile(endpoint string) {
M internal/adapters/brokeripc/listen_test.go +2 -2
@@ -453,8 +453,8 @@ func TestConcurrentDialAndClose(t *testing.T) {
453 t.Errorf("Dial: %v", err)453 t.Errorf("Dial: %v", err)
454 return454 return
455 }455 }
456- if err := client.AddHost(ctx, "late@host:22"); err != nil {456+ if err := connectionDelegates(ctx, client); err != nil {
457- t.Errorf("AddHost: %v", err)457+ t.Errorf("connection refused a membership round trip: %v", err)
458 }458 }
459 _ = client.Close()459 _ = client.Close()
460 }()460 }()
M internal/adapters/brokeripc/mutation.go +18 -5
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 6
7 "github.com/bnema/vev/internal/adapters/brokerwire"7 "github.com/bnema/vev/internal/adapters/brokerwire"
8+ "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 )10 )
10 11
@@ -150,7 +151,7 @@ func (s *serverSession) wakePublisher() {
150 // bound and runs it in its own goroutine. A duplicate admission is dropped:151 // bound and runs it in its own goroutine. A duplicate admission is dropped:
151 // exactly one completion ever travels for one operation identity, and the peer's152 // exactly one completion ever travels for one operation identity, and the peer's
152 // own tracker already fences replays.153 // 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 {
154 if err := s.conn.AdmitOperation(operation); err != nil {155 if err := s.conn.AdmitOperation(operation); err != nil {
155 switch {156 switch {
156 case errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending):157 case errors.Is(err, brokerwire.ErrOperationCompleted), errors.Is(err, brokerwire.ErrOperationPending):
@@ -165,21 +166,33 @@ func (s *serverSession) startMutation(operation ports.BrokerOperationID, run fun
165 s.wg.Add(1)166 s.wg.Add(1)
166 go func() {167 go func() {
167 defer s.wg.Done()168 defer s.wg.Done()
168- removed, err := run(s.ctx)169+ registration, removed, err := run(s.ctx)
169 outcome := ports.BrokerOutcomeOK170 outcome := ports.BrokerOutcomeOK
170 if err != nil {171 if err != nil {
171 outcome = ports.BrokerOutcomeFailed172 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
172 }180 }
173 _ = s.conn.CompleteOperation(operation, outcome)181 _ = s.conn.CompleteOperation(operation, outcome)
174 if s.ctx.Err() != nil {182 if s.ctx.Err() != nil {
175 return183 return
176 }184 }
177 detail := errorDetail(err)185 detail := errorDetail(err)
178- if err := s.send(brokerwire.OperationResult{186+ result := brokerwire.OperationResult{
179 Epoch: s.epoch, Connection: s.scope.Connection, Operation: operation,187 Epoch: s.epoch, Connection: s.scope.Connection, Operation: operation,
180- Outcome: outcome, Removed: removed,188+ Outcome: outcome, Removed: removed, Registration: registration,
181 Error: detail, HasError: detail != (brokerwire.ErrorDetail{}),189 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 {
183 s.abort(err)196 s.abort(err)
184 }197 }
185 }()198 }()
M internal/adapters/brokeripc/protocol_test.go +6 -12
@@ -43,14 +43,11 @@ func (r *rawCarriage) send(t *testing.T, message brokerwire.ClientMessage) {
43 require.NoError(t, r.transport.Send(wire.Envelope{Payload: payload}))43 require.NoError(t, r.transport.Send(wire.Envelope{Payload: payload}))
44 }44 }
45 45
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.
47 func (r *rawCarriage) recv(t *testing.T) brokerwire.ServerMessage {48 func (r *rawCarriage) recv(t *testing.T) brokerwire.ServerMessage {
48 t.Helper()49 t.Helper()
49- envelope, err := r.transport.RecvBounded(r.ceilings.MaxReceiveEnvelopeBytes)50+ return r.recvWithin(t, 5*time.Second)
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
54 }51 }
55 52
56 // register completes the registration exchange and returns the assigned scope.53 // register completes the registration exchange and returns the assigned scope.
@@ -127,8 +124,7 @@ func TestWrongDirectionFrameClosesConnection(t *testing.T) {
127 require.NoError(t, err)124 require.NoError(t, err)
128 require.NoError(t, raw.transport.Send(wire.Envelope{Payload: payload}))125 require.NoError(t, raw.transport.Send(wire.Envelope{Payload: payload}))
129 126
130- _, err = raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)127+ require.Error(t, raw.awaitReadError(t, 5*time.Second), "a wrong-direction frame must settle the connection")
131- require.Error(t, err, "a wrong-direction frame must settle the connection")
132 128
133 // The listener keeps serving other clients.129 // The listener keeps serving other clients.
134 client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second})130 client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second})
@@ -144,8 +140,7 @@ func TestMalformedApplicationFrameClosesConnection(t *testing.T) {
144 raw.register(t)140 raw.register(t)
145 141
146 require.NoError(t, raw.transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}}))142 require.NoError(t, raw.transport.Send(wire.Envelope{Payload: []byte{0x78, 0x01}}))
147- _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)143+ require.Error(t, raw.awaitReadError(t, 5*time.Second))
148- require.Error(t, err)
149 144
150 client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second})145 client, err := Dial(context.Background(), e.path, Config{HandshakeTimeout: time.Second})
151 require.NoError(t, err)146 require.NoError(t, err)
@@ -214,8 +209,7 @@ func TestDuplicateRegisterClosesConnection(t *testing.T) {
214 raw.register(t)209 raw.register(t)
215 210
216 raw.send(t, brokerwire.Register{})211 raw.send(t, brokerwire.Register{})
217- _, err := raw.transport.RecvBounded(raw.ceilings.MaxReceiveEnvelopeBytes)212+ require.Error(t, raw.awaitReadError(t, 5*time.Second))
218- require.Error(t, err)
219 }213 }
220 214
221 // TestMismatchedScopeIsFenced proves a frame carrying a connection identity that215 // TestMismatchedScopeIsFenced proves a frame carrying a connection identity that
M internal/adapters/brokeripc/server.go +24 -9
@@ -14,6 +14,7 @@ import (
14 14
15 "github.com/bnema/vev/internal/adapters/brokerwire"15 "github.com/bnema/vev/internal/adapters/brokerwire"
16 "github.com/bnema/vev/internal/adapters/ipc"16 "github.com/bnema/vev/internal/adapters/ipc"
17+ "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"18 "github.com/bnema/vev/internal/ports"
18 "github.com/bnema/vev/internal/protocol/wire"19 "github.com/bnema/vev/internal/protocol/wire"
19 )20 )
@@ -705,16 +706,25 @@ func (s *serverSession) dispatch(message brokerwire.ClientMessage) error {
705 if !s.scopeMatches(m.Epoch, m.Connection) {706 if !s.scopeMatches(m.Epoch, m.Connection) {
706 return s.refuseScope()707 return s.refuseScope()
707 }708 }
708- return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) {709+ return s.startMutation(m.Operation, brokerwire.MutationKindAddHost, func(ctx context.Context) (domain.RemoteRegistration, bool, error) {
709- return false, s.core.AddHost(ctx, m.Endpoint)710+ registration, err := s.core.AddHost(ctx, m.Endpoint, m.Policy)
711+ return registration, false, err
710 })712 })
711 case brokerwire.RemoveHost:713 case brokerwire.RemoveHost:
712 if !s.scopeMatches(m.Epoch, m.Connection) {714 if !s.scopeMatches(m.Epoch, m.Connection) {
713 return s.refuseScope()715 return s.refuseScope()
714 }716 }
715- return s.startMutation(m.Operation, func(ctx context.Context) (bool, error) {717+ return s.startMutation(m.Operation, brokerwire.MutationKindRemoveHost, func(ctx context.Context) (domain.RemoteRegistration, bool, error) {
716- removed, err := s.core.RemoveHost(ctx, m.Endpoint)718+ removed, err := s.core.RemoveHost(ctx, m.Registration)
717- return removed, err719+ 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
718 })728 })
719 case brokerwire.Reconcile:729 case brokerwire.Reconcile:
720 if !s.scopeMatches(m.Epoch, m.Connection) {730 if !s.scopeMatches(m.Epoch, m.Connection) {
@@ -941,13 +951,18 @@ func (s *serverSession) CloseStream(connection ports.BrokerConnectionID, stream
941 }951 }
942 952
943 // AddHost delegates one mutating operation to the admitted core service.953 // AddHost delegates one mutating operation to the admitted core service.
944-func (s *serverSession) AddHost(ctx context.Context, target string) error {954+func (s *serverSession) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
945- return s.core.AddHost(ctx, target)955+ return s.core.AddHost(ctx, endpoint, policy)
946 }956 }
947 957
948 // RemoveHost delegates one mutating operation to the admitted core service.958 // RemoveHost delegates one mutating operation to the admitted core service.
949-func (s *serverSession) RemoveHost(ctx context.Context, target string) (bool, error) {959+func (s *serverSession) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
950- return s.core.RemoveHost(ctx, target)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)
951 }966 }
952 967
953 // RequestReconcile delegates one reconcile hint to the admitted core service.968 // 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) {
362 require.ErrorAs(t, err, &failure)362 require.ErrorAs(t, err, &failure)
363 require.Equal(t, ports.BrokerErrorIncompatible, failure.Code)363 require.Equal(t, ports.BrokerErrorIncompatible, failure.Code)
364 364
365- // The connection survives a stream-local refusal: a later operation still365+ // The connection survives a stream-local refusal: once the core admits
366- // completes.366+ // streams again, a later operation still completes.
367- require.NoError(t, client.AddHost(ctx, "after@refusal:22"))367+ core.mu.Lock()
368+ core.openErr = nil
369+ core.mu.Unlock()
370+ requireConnectionDelegates(t, client)
368 }371 }
369 372
370 // TestTerminalStreamFailureFailsOnlyThatStream proves one stream's terminal373 // TestTerminalStreamFailureFailsOnlyThatStream proves one stream's terminal
@@ -404,7 +407,7 @@ func TestTerminalStreamFailureFailsOnlyThatStream(t *testing.T) {
404 case <-time.After(5 * time.Second):407 case <-time.After(5 * time.Second):
405 t.Fatal("sibling stream stopped carrying traffic")408 t.Fatal("sibling stream stopped carrying traffic")
406 }409 }
407- require.NoError(t, client.AddHost(ctx, "sibling@host:22"))410+ requireConnectionDelegates(t, client)
408 }411 }
409 412
410 // TestCloseStreamRetiresBothSides proves a client close reaches the broker core413 // TestCloseStreamRetiresBothSides proves a client close reaches the broker core
@@ -433,46 +436,263 @@ func TestCloseStreamRetiresBothSides(t *testing.T) {
433 require.NoError(t, stream.Close(), "closing an already retired stream is idempotent")436 require.NoError(t, stream.Close(), "closing an already retired stream is idempotent")
434 }437 }
435 438
436-// TestMutatingOperationsComplete proves AddHost and RemoveHost complete with439+// TestMembershipMutationsDelegateEndToEnd proves the whole membership contract
437-// typed results and reach the admitted core service.440+// travels the real AF_UNIX carriage in both directions: AddHost returns the
438-func TestMutatingOperationsComplete(t *testing.T) {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) {
439 e := startEndpoint(t, Config{})447 e := startEndpoint(t, Config{})
440 client, _ := e.pair()448 client, _ := e.pair()
441 core := e.authority.last()449 core := e.authority.last()
442 450
443 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)451 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
444 defer cancel()452 defer cancel()
445- require.NoError(t, client.AddHost(ctx, "new@host:22"))453+
446- removed, err := client.RemoveHost(ctx, "known")454+ added, err := client.AddHost(ctx, "new@host:22", testPolicy())
447 require.NoError(t, err)455 require.NoError(t, err)
448- require.True(t, removed)456+ require.Equal(t, "new@host:22", added.Endpoint)
449- removed, err = client.RemoveHost(ctx, "missing")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())
450 require.NoError(t, err)461 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")
452 466
453- core.mu.Lock()467+ core.reportRemoved(true)
454- defer core.mu.Unlock()468+ removed, err := client.RemoveHost(ctx, updated)
455- require.Equal(t, []string{"new@host:22"}, core.added)469+ require.NoError(t, err)
456- require.Equal(t, []string{"known", "missing"}, core.removed)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+ }
457 }616 }
458 617
459-// TestLostOperationReplyIsOutcomeUnknown proves a mutating operation whose reply618+// TestMembershipStoreOutcomeUnknownMapsToOutcomeUnknown proves an indeterminate
460-// never arrives is reported as outcome-unknown rather than retried or reported619+// durable write is never presented as a definite refusal: both the value and the
461-// as a plain failure.620+// pointer form of the store's outcome-unknown error become an outcome-unknown
462-func TestLostOperationReplyIsOutcomeUnknown(t *testing.T) {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) {
463 e := startEndpoint(t, Config{})674 e := startEndpoint(t, Config{})
464- client, _ := e.pair()675+ raw := rawDial(t, e, brokerwire.DefaultCeilings())
676+ scope := raw.register(t)
677+ session := e.accept()
465 core := e.authority.last()678 core := e.authority.last()
466- core.blockAdd = make(chan struct{})679+ core.zeroAddRegistration()
467- t.Cleanup(func() { close(core.blockAdd) })
468 680
469- ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)681+ raw.send(t, brokerwire.AddHost{
470- defer cancel()682+ Epoch: scope.Epoch, Connection: scope.Connection,
471- err := client.AddHost(ctx, "slow@host:22")683+ Operation: ports.BrokerOperationID{0x51}, Endpoint: "new@host:22", Policy: testPolicy(),
472- require.Error(t, err)684+ })
473- var failure ports.BrokerError685+
474- require.ErrorAs(t, err, &failure)686+ // No result travels; the connection settles as a protocol violation.
475- require.Equal(t, ports.BrokerErrorOutcomeUnknown, failure.Code)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)
476 }696 }
477 697
478 // TestDisconnectCleanupReleasesEveryOwnedResource proves a client disconnect698 // TestDisconnectCleanupReleasesEveryOwnedResource proves a client disconnect
@@ -532,9 +752,7 @@ func TestStalledSubscriberDoesNotBlockOtherClients(t *testing.T) {
532 }752 }
533 require.Eventually(t, func() bool { return active.Snapshot().Revision == 20 }, 5*time.Second, 10*time.Millisecond)753 require.Eventually(t, func() bool { return active.Snapshot().Revision == 20 }, 5*time.Second, 10*time.Millisecond)
534 754
535- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)755+ requireConnectionDelegates(t, active)
536- defer cancel()
537- require.NoError(t, active.AddHost(ctx, "active@host:22"))
538 }756 }
539 757
540 // TestClientStreamBackpressureSettlesOnlyThatStream proves a local consumer that758 // TestClientStreamBackpressureSettlesOnlyThatStream proves a local consumer that
@@ -573,7 +791,7 @@ func TestClientStreamBackpressureSettlesOnlyThatStream(t *testing.T) {
573 }, 5*time.Second, 10*time.Millisecond, "the broker must be told to retire the stalled stream")791 }, 5*time.Second, 10*time.Millisecond, "the broker must be told to retire the stalled stream")
574 792
575 // The connection survives a stream-local stall.793 // The connection survives a stream-local stall.
576- require.NoError(t, client.AddHost(ctx, "after@stall:22"))794+ requireConnectionDelegates(t, client)
577 }795 }
578 796
579 // TestOrderlyStreamCloseReportsNoError proves a local close is an orderly end:797 // TestOrderlyStreamCloseReportsNoError proves a local close is an orderly end:
@@ -703,7 +921,7 @@ func TestSessionHandshakeFailureIsStreamLocal(t *testing.T) {
703 case <-time.After(5 * time.Second):921 case <-time.After(5 * time.Second):
704 t.Fatal("a malformed inner frame must settle its stream")922 t.Fatal("a malformed inner frame must settle its stream")
705 }923 }
706- require.NoError(t, client.AddHost(ctx, "survivor@host:22"))924+ requireConnectionDelegates(t, client)
707 925
708 // The broker core sees the stream retired.926 // The broker core sees the stream retired.
709 require.Eventually(t, func() bool {927 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
175 return nil, ErrInvalidMessage175 return nil, ErrInvalidMessage
176 }176 }
177 return encodeClientEnvelope(*m, maxChunkBytes)177 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)
178 case Reconcile:189 case Reconcile:
179 converted, err := reconcileToWire(m)190 converted, err := reconcileToWire(m)
180 if err != nil {191 if err != nil {
@@ -251,6 +262,8 @@ func decodeClientEnvelope(envelope *wire.BrokerClientEnvelope, maxChunkBytes uin
251 return addHostFromWire(payload.AddHost)262 return addHostFromWire(payload.AddHost)
252 case *wire.BrokerClientEnvelope_RemoveHost:263 case *wire.BrokerClientEnvelope_RemoveHost:
253 return removeHostFromWire(payload.RemoveHost)264 return removeHostFromWire(payload.RemoveHost)
265+ case *wire.BrokerClientEnvelope_UpdateHostPolicy:
266+ return updateHostPolicyFromWire(payload.UpdateHostPolicy)
254 case *wire.BrokerClientEnvelope_Reconcile:267 case *wire.BrokerClientEnvelope_Reconcile:
255 return reconcileFromWire(payload.Reconcile)268 return reconcileFromWire(payload.Reconcile)
256 case *wire.BrokerClientEnvelope_OpenStream:269 case *wire.BrokerClientEnvelope_OpenStream:
@@ -284,10 +297,17 @@ func addHostToWire(m AddHost) (*wire.AddHost, error) {
284 if err := validateBrokerEndpoint(m.Endpoint); err != nil {297 if err := validateBrokerEndpoint(m.Endpoint); err != nil {
285 return nil, err298 return nil, err
286 }299 }
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+ }
287 return &wire.AddHost{306 return &wire.AddHost{
288 Scope: scopeToWire(m.Epoch, m.Connection),307 Scope: scopeToWire(m.Epoch, m.Connection),
289 OperationId: operationToWire(m.Operation),308 OperationId: operationToWire(m.Operation),
290 Endpoint: m.Endpoint,309 Endpoint: m.Endpoint,
310+ Policy: policyToWire(m.Policy),
291 }, nil311 }, nil
292 }312 }
293 313
@@ -307,7 +327,11 @@ func addHostFromWire(message *wire.AddHost) (AddHost, error) {
307 if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil {327 if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil {
308 return AddHost{}, err328 return AddHost{}, err
309 }329 }
310- return AddHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint()}, nil330+ 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
311 }335 }
312 336
313 func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) {337 func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) {
@@ -320,13 +344,15 @@ func removeHostToWire(m RemoveHost) (*wire.RemoveHost, error) {
320 if err := m.Operation.Validate(); err != nil {344 if err := m.Operation.Validate(); err != nil {
321 return nil, ErrInvalidMessage345 return nil, ErrInvalidMessage
322 }346 }
323- if err := validateBrokerEndpoint(m.Endpoint); err != nil {347+ // Removal carries exact authority, never a bare endpoint: a zero or
324- return nil, err348+ // partial registration could not fence a remove/re-add race.
349+ if err := m.Registration.Validate(); err != nil {
350+ return nil, ErrInvalidMessage
325 }351 }
326 return &wire.RemoveHost{352 return &wire.RemoveHost{
327- Scope: scopeToWire(m.Epoch, m.Connection),353+ Scope: scopeToWire(m.Epoch, m.Connection),
328- OperationId: operationToWire(m.Operation),354+ OperationId: operationToWire(m.Operation),
329- Endpoint: m.Endpoint,355+ Registration: registrationToWire(m.Registration),
330 }, nil356 }, nil
331 }357 }
332 358
@@ -343,10 +369,59 @@ func removeHostFromWire(message *wire.RemoveHost) (RemoveHost, error) {
343 if err != nil {369 if err != nil {
344 return RemoveHost{}, ErrInvalidMessage370 return RemoveHost{}, ErrInvalidMessage
345 }371 }
346- if err := validateBrokerEndpoint(message.GetEndpoint()); err != nil {372+ registration, err := registrationFromWire(message.GetRegistration())
347- return RemoveHost{}, err373+ 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
348 }423 }
349- return RemoveHost{Epoch: epoch, Connection: connection, Operation: operation, Endpoint: message.GetEndpoint()}, nil424+ return UpdateHostPolicy{Epoch: epoch, Connection: connection, Operation: operation, Registration: registration, Policy: policy}, nil
350 }425 }
351 426
352 func reconcileToWire(m Reconcile) (*wire.Reconcile, error) {427 func reconcileToWire(m Reconcile) (*wire.Reconcile, error) {
@@ -1220,10 +1295,29 @@ func operationResultToWire(m OperationResult) (*wire.OperationResult, error) {
1220 if err := result.Validate(); err != nil {1295 if err := result.Validate(); err != nil {
1221 return nil, ErrInvalidMessage1296 return nil, ErrInvalidMessage
1222 }1297 }
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+ }
1223 out := &wire.OperationResult{1316 out := &wire.OperationResult{
1224 Scope: scopeToWire(m.Epoch, m.Connection),1317 Scope: scopeToWire(m.Epoch, m.Connection),
1225 OperationId: operationToWire(m.Operation),1318 OperationId: operationToWire(m.Operation),
1226 Outcome: outcome, Removed: m.Removed,1319 Outcome: outcome, Removed: m.Removed,
1320+ Registration: registration,
1227 }1321 }
1228 if m.HasError {1322 if m.HasError {
1229 out.Error = errorDetailToWire(m.Error)1323 out.Error = errorDetailToWire(m.Error)
@@ -1255,9 +1349,18 @@ func operationResultFromWire(message *wire.OperationResult) (OperationResult, er
1255 default:1349 default:
1256 return OperationResult{}, ErrInvalidMessage1350 return OperationResult{}, ErrInvalidMessage
1257 }1351 }
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+ }
1258 result := OperationResult{1360 result := OperationResult{
1259 Epoch: epoch, Connection: connection, Operation: operation,1361 Epoch: epoch, Connection: connection, Operation: operation,
1260 Outcome: outcome, Removed: message.GetRemoved(),1362 Outcome: outcome, Removed: message.GetRemoved(),
1363+ Registration: registration,
1261 }1364 }
1262 if message.GetError() != nil {1365 if message.GetError() != nil {
1263 detail, err := errorDetailFromWire(message.GetError())1366 detail, err := errorDetailFromWire(message.GetError())
@@ -1267,6 +1370,9 @@ func operationResultFromWire(message *wire.OperationResult) (OperationResult, er
1267 result.Error = detail1370 result.Error = detail
1268 result.HasError = true1371 result.HasError = true
1269 }1372 }
1373+ if err := validateResultAuthority(result.Outcome, result.Removed, registration != (domain.RemoteRegistration{}), result.HasError); err != nil {
1374+ return OperationResult{}, err
1375+ }
1270 semantic := ports.BrokerOperationResult{Operation: result.Operation, Outcome: result.Outcome}1376 semantic := ports.BrokerOperationResult{Operation: result.Operation, Outcome: result.Outcome}
1271 if result.HasError {1377 if result.HasError {
1272 semantic.Text = result.Error.Text1378 semantic.Text = result.Error.Text
M internal/adapters/brokerwire/codec_test.go +112 -6
@@ -351,14 +351,15 @@ func TestBrokerClientVariants(t *testing.T) {
351 {"subscribe", Subscribe{Epoch: 7, Connection: connection, Generation: 1}, 102},351 {"subscribe", Subscribe{Epoch: 7, Connection: connection, Generation: 1}, 102},
352 {"resync", Resync{Epoch: 7, Connection: connection, Generation: 2}, 103},352 {"resync", Resync{Epoch: 7, Connection: connection, Generation: 2}, 103},
353 {"unsubscribe", Unsubscribe{Epoch: 7, Connection: connection, Generation: 3}, 104},353 {"unsubscribe", Unsubscribe{Epoch: 7, Connection: connection, Generation: 3}, 104},
354- {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 105},354+ {"add_host", AddHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22", Policy: testPolicy()}, 105},
355- {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Endpoint: "dev@host:22"}, 106},355+ {"remove_host", RemoveHost{Epoch: 7, Connection: connection, Operation: operation, Registration: testRegistration()}, 106},
356 {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107},356 {"reconcile", Reconcile{Epoch: 7, Connection: connection, Registration: testRegistration()}, 107},
357 {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108},357 {"open_stream", OpenStream{Epoch: 7, Connection: connection, Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Env: []string{"TERM=xterm"}, Policy: testPolicy()}, 108},
358 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},358 {"client_stream_data", ClientStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 109},
359 {"close_stream", CloseStream{Epoch: 7, Connection: connection, Stream: 3}, 110},359 {"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},
360 }361 }
361- require.Len(t, messages, 10)362+ require.Len(t, messages, 11)
362 for _, tc := range messages {363 for _, tc := range messages {
363 t.Run(tc.name, func(t *testing.T) {364 t.Run(tc.name, func(t *testing.T) {
364 raw := mustEncodeClient(t, tc.message)365 raw := mustEncodeClient(t, tc.message)
@@ -525,7 +526,9 @@ func TestBrokerServerVariants(t *testing.T) {
525 {"snapshot_session_local", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}, 202},526 {"snapshot_session_local", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 4, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Local: true, Session: testCatalogSession()}}, 202},
526 {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 5, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202},527 {"snapshot_tombstone", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 5, Part: SnapshotTombstonePart{TombstoneIndex: 0, Registration: testRegistration(), RetiredRevision: 4}}, 202},
527 {"snapshot_end", SnapshotPart{Epoch: 7, Connection: connection, Generation: 2, Revision: 5, Index: 6, Part: SnapshotEnd{}}, 202},528 {"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},
529 {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204},532 {"stream_opened", StreamOpened{Epoch: 7, Connection: connection, Stream: 3}, 204},
530 {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205},533 {"server_stream_data", ServerStreamData{Epoch: 7, Connection: connection, Stream: 3, Data: []byte("frame")}, 205},
531 {"stream_closed", StreamClosed{Epoch: 7, Connection: connection, Stream: 3, Error: testErrorDetail(), HasError: true}, 206},534 {"stream_closed", StreamClosed{Epoch: 7, Connection: connection, Stream: 3, Error: testErrorDetail(), HasError: true}, 206},
@@ -811,7 +814,7 @@ func TestBrokerValidationNegatives(t *testing.T) {
811 // declared detail still round-trips exactly.814 // declared detail still round-trips exactly.
812 _, err := EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3}, testEnvelopeCeiling, testChunkCeiling)815 _, err := EncodeServer(StreamClosed{Epoch: 7, Connection: connection, Stream: 3}, testEnvelopeCeiling, testChunkCeiling)
813 require.NoError(t, err)816 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)
815 require.NoError(t, err)818 require.NoError(t, err)
816 declared := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: detail, HasError: true}819 declared := OperationResult{Epoch: 7, Connection: connection, Operation: testOperationID(1), Outcome: ports.BrokerOutcomeFailed, Error: detail, HasError: true}
817 decoded, err := DecodeServer(mustEncodeServer(t, declared), testEnvelopeCeiling, testChunkCeiling)820 decoded, err := DecodeServer(mustEncodeServer(t, declared), testEnvelopeCeiling, testChunkCeiling)
@@ -846,8 +849,103 @@ func TestBrokerValidationNegatives(t *testing.T) {
846 _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: boundary}, testEnvelopeCeiling, testChunkCeiling)849 _, err := EncodeServer(BrokerErrorMessage{Epoch: 7, Connection: connection, Error: boundary}, testEnvelopeCeiling, testChunkCeiling)
847 require.NoError(t, err)850 require.NoError(t, err)
848 })851 })
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+ })
849 t.Run("bad endpoint", func(t *testing.T) {947 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)
851 require.ErrorIs(t, err, ErrInvalidMessage)949 require.ErrorIs(t, err, ErrInvalidMessage)
852 })950 })
853 t.Run("taxonomy switches", func(t *testing.T) {951 t.Run("taxonomy switches", func(t *testing.T) {
@@ -974,8 +1072,12 @@ func TestBrokerWrongDirection(t *testing.T) {
974 func TestBrokerCorruptInputSmoke(t *testing.T) {1072 func TestBrokerCorruptInputSmoke(t *testing.T) {
975 seeds := [][]byte{1073 seeds := [][]byte{
976 mustEncodeClient(t, Register{}),1074 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()}),
977 mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}),1078 mustEncodeClient(t, OpenStream{Epoch: 7, Connection: testConnectionID(2), Stream: 3, Purpose: ports.BrokerStreamControl, Local: true, Policy: testPolicy()}),
978 mustEncodeServer(t, SnapshotPart{Epoch: 7, Connection: testConnectionID(2), Generation: 1, Revision: 1, Index: 0, Part: SnapshotEnd{}}),1079 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}),
979 mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}),1081 mustEncodeServer(t, Shutdown{Epoch: 7, Connection: testConnectionID(2), Reason: ShutdownIdleExit}),
980 }1082 }
981 for _, seed := range seeds {1083 for _, seed := range seeds {
@@ -1003,6 +1105,9 @@ func fuzzClientSeeds(t testing.TB) [][]byte {
1003 for _, message := range []ClientMessage{1105 for _, message := range []ClientMessage{
1004 Register{},1106 Register{},
1005 Subscribe{Epoch: 7, Connection: testConnectionID(0x11), Generation: 1},1107 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()},
1006 OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()},1111 OpenStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Purpose: ports.BrokerStreamAttachment, Admission: ports.BrokerAdmissionExact, Endpoint: "dev@host:22", Registration: testRegistration(), Target: testTarget(), Policy: testPolicy()},
1007 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},1112 ClientStreamData{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3, Data: []byte("frame")},
1008 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},1113 CloseStream{Epoch: 7, Connection: testConnectionID(0x11), Stream: 3},
@@ -1021,6 +1126,7 @@ func fuzzServerSeeds(t testing.TB) [][]byte {
1021 Registered{Epoch: 7, Connection: testConnectionID(0x11)},1126 Registered{Epoch: 7, Connection: testConnectionID(0x11)},
1022 SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 0, Part: SnapshotEnd{}},1127 SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 0, Part: SnapshotEnd{}},
1023 SnapshotPart{Epoch: 7, Connection: testConnectionID(0x11), Generation: 2, Revision: 5, Index: 2, Part: SnapshotSessionPart{HostIndex: 0, SessionIndex: 0, Session: testCatalogSession()}},1128 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()},
1024 BrokerErrorMessage{Epoch: 7, Connection: testConnectionID(0x11), Error: testErrorDetail()},1130 BrokerErrorMessage{Epoch: 7, Connection: testConnectionID(0x11), Error: testErrorDetail()},
1025 Shutdown{Epoch: 7, Connection: testConnectionID(0x11), Reason: ShutdownTerminating},1131 Shutdown{Epoch: 7, Connection: testConnectionID(0x11), Reason: ShutdownTerminating},
1026 } {1132 } {
M internal/adapters/brokerwire/doc.go +1 -1
@@ -1,7 +1,7 @@
1 // Package brokerwire adapts typed broker messages to Protobuf envelopes.1 // Package brokerwire adapts typed broker messages to Protobuf envelopes.
2 //2 //
3 // The broker is a separate conversation from the session protocol: client3 // 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,
5 // and each direction is its own closed oneof union. Every connection starts5 // and each direction is its own closed oneof union. Every connection starts
6 // with the bounded broker preamble (roles 3/4); magic, epoch, exact6 // with the bounded broker preamble (roles 3/4); magic, epoch, exact
7 // protocol version, and negotiated limits are validated before application7 // 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 {
67 67
68 func (Unsubscribe) brokerClientMessage() {}68 func (Unsubscribe) brokerClientMessage() {}
69 69
70-// AddHost adds one configured host registration. Operation is the70+// AddHost adds one configured host registration under an exact policy.
71-// idempotency identity of this mutating operation.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.
72 type AddHost struct {75 type AddHost struct {
73 Epoch ports.BrokerEpoch76 Epoch ports.BrokerEpoch
74 Connection ports.BrokerConnectionID77 Connection ports.BrokerConnectionID
75 Operation ports.BrokerOperationID78 Operation ports.BrokerOperationID
76 Endpoint string79 Endpoint string
80+ Policy ports.BrokerPolicy
77 }81 }
78 82
79 func (AddHost) brokerClientMessage() {}83 func (AddHost) brokerClientMessage() {}
80 84
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.
82 type RemoveHost struct {89 type RemoveHost struct {
83- Epoch ports.BrokerEpoch90+ Epoch ports.BrokerEpoch
84- Connection ports.BrokerConnectionID91+ Connection ports.BrokerConnectionID
85- Operation ports.BrokerOperationID92+ Operation ports.BrokerOperationID
86- Endpoint string93+ Registration domain.RemoteRegistration
87 }94 }
88 95
89 func (RemoveHost) brokerClientMessage() {}96 func (RemoveHost) brokerClientMessage() {}
90 97
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+
91 // Reconcile hands the broker one exact registration to probe.112 // Reconcile hands the broker one exact registration to probe.
92 type Reconcile struct {113 type Reconcile struct {
93 Epoch ports.BrokerEpoch114 Epoch ports.BrokerEpoch
@@ -216,21 +237,165 @@ type SnapshotEnd struct{}
216 func (SnapshotEnd) snapshotPartPayload() {}237 func (SnapshotEnd) snapshotPartPayload() {}
217 238
218 // OperationResult is exactly one completion for one admitted mutating239 // OperationResult is exactly one completion for one admitted mutating
219-// operation. Removed reports the host is gone; Error carries the typed240+// operation. Removed and Registration are presence-carrying authority: a
220-// failure when Outcome is failed or outcome-unknown. HasError=false means no241+// successful addition or policy update carries the resulting registration,
221-// detail at all: encode refuses a nonzero Error rather than dropping it.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.
222 type OperationResult struct {248 type OperationResult struct {
223- Epoch ports.BrokerEpoch249+ Epoch ports.BrokerEpoch
224- Connection ports.BrokerConnectionID250+ Connection ports.BrokerConnectionID
225- Operation ports.BrokerOperationID251+ Operation ports.BrokerOperationID
226- Outcome ports.BrokerMutationOutcome252+ Outcome ports.BrokerMutationOutcome
227- Removed bool253+ Removed bool
228- Error ErrorDetail254+ Registration domain.RemoteRegistration
229- HasError bool255+ Error ErrorDetail
256+ HasError bool
230 }257 }
231 258
232 func (OperationResult) brokerServerMessage() {}259 func (OperationResult) brokerServerMessage() {}
233 260
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+
234 // StreamOpened confirms one logical stream is established.399 // StreamOpened confirms one logical stream is established.
235 type StreamOpened struct {400 type StreamOpened struct {
236 Epoch ports.BrokerEpoch401 Epoch ports.BrokerEpoch
M internal/ports/broker.go +14 -3
@@ -563,6 +563,8 @@ const (
563 BrokerErrorConflictingPolicy563 BrokerErrorConflictingPolicy
564 BrokerErrorExplicitExit564 BrokerErrorExplicitExit
565 BrokerErrorFatalTerminal565 BrokerErrorFatalTerminal
566+ BrokerErrorHostConflict
567+ BrokerErrorMembershipImmutable
566 )568 )
567 569
568 func (c BrokerErrorCode) String() string {570 func (c BrokerErrorCode) String() string {
@@ -587,6 +589,10 @@ func (c BrokerErrorCode) String() string {
587 return "explicit_exit"589 return "explicit_exit"
588 case BrokerErrorFatalTerminal:590 case BrokerErrorFatalTerminal:
589 return "fatal_terminal"591 return "fatal_terminal"
592+ case BrokerErrorHostConflict:
593+ return "host_conflict"
594+ case BrokerErrorMembershipImmutable:
595+ return "membership_immutable"
590 default:596 default:
591 return "unknown"597 return "unknown"
592 }598 }
@@ -597,7 +603,8 @@ func (c BrokerErrorCode) Validate() error {
597 case BrokerErrorUnavailable, BrokerErrorIncompatible, BrokerErrorTimeout,603 case BrokerErrorUnavailable, BrokerErrorIncompatible, BrokerErrorTimeout,
598 BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost,604 BrokerErrorCancelled, BrokerErrorOutcomeUnknown, BrokerErrorAttachmentLost,
599 BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy,605 BrokerErrorStaleEpoch, BrokerErrorConflictingPolicy,
600- BrokerErrorExplicitExit, BrokerErrorFatalTerminal:606+ BrokerErrorExplicitExit, BrokerErrorFatalTerminal,
607+ BrokerErrorHostConflict, BrokerErrorMembershipImmutable:
601 return nil608 return nil
602 default:609 default:
603 return errors.New("ports: invalid broker error code")610 return errors.New("ports: invalid broker error code")
@@ -698,8 +705,12 @@ type BrokerService interface {
698 Subscribe() (BrokerSubscription, error)705 Subscribe() (BrokerSubscription, error)
699 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)706 OpenStream(ctx context.Context, request BrokerOpenStreamRequest) (BrokerLogicalConnection, error)
700 CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error707 CloseStream(connection BrokerConnectionID, stream BrokerStreamID) error
701- AddHost(ctx context.Context, target string) error708+ // AddHost durably adds or pins endpoint under policy and returns its authority.
702- RemoveHost(ctx context.Context, target string) (bool, error)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)
703 RequestReconcile(endpoint string)714 RequestReconcile(endpoint string)
704 Close() error715 Close() error
705 }716 }
M internal/ports/broker_test.go +2 -0
@@ -510,6 +510,8 @@ func TestBrokerErrorTerminalOutcomes(t *testing.T) {
510 {code: BrokerErrorConflictingPolicy, wantTerminal: false},510 {code: BrokerErrorConflictingPolicy, wantTerminal: false},
511 {code: BrokerErrorExplicitExit, wantTerminal: true},511 {code: BrokerErrorExplicitExit, wantTerminal: true},
512 {code: BrokerErrorFatalTerminal, wantTerminal: true},512 {code: BrokerErrorFatalTerminal, wantTerminal: true},
513+ {code: BrokerErrorHostConflict, wantTerminal: false},
514+ {code: BrokerErrorMembershipImmutable, wantTerminal: false},
513 }515 }
514 for _, tt := range tests {516 for _, tt := range tests {
515 t.Run(tt.code.String(), func(t *testing.T) {517 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 {
146 }146 }
147 147
148 // AddHost provides a mock function for the type MockBrokerService148 // AddHost provides a mock function for the type MockBrokerService
149-func (_mock *MockBrokerService) AddHost(ctx context.Context, target string) error {149+func (_mock *MockBrokerService) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
150- ret := _mock.Called(ctx, target)150+ ret := _mock.Called(ctx, endpoint, policy)
151 151
152 if len(ret) == 0 {152 if len(ret) == 0 {
153 panic("no return value specified for AddHost")153 panic("no return value specified for AddHost")
154 }154 }
155 155
156- var r0 error156+ var r0 domain.RemoteRegistration
157- if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {157+ var r1 error
158- r0 = returnFunc(ctx, target)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)
159 } else {163 } else {
160- r0 = ret.Error(0)164+ r0 = ret.Get(0).(domain.RemoteRegistration)
161 }165 }
162- return r0166+ 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
163 }172 }
164 173
165 // MockBrokerService_AddHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddHost'174 // 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 {
169 178
170 // AddHost is a helper method to define mock.On call179 // AddHost is a helper method to define mock.On call
171 // - ctx context.Context180 // - ctx context.Context
172-// - target string181+// - endpoint string
173-func (_e *MockBrokerService_Expecter) AddHost(ctx any, target any) *MockBrokerService_AddHost_Call {182+// - policy ports.BrokerPolicy
174- return &MockBrokerService_AddHost_Call{Call: _e.mock.On("AddHost", ctx, target)}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)}
175 }185 }
176 186
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 {
178 _c.Call.Run(func(args mock.Arguments) {188 _c.Call.Run(func(args mock.Arguments) {
179 var arg0 context.Context189 var arg0 context.Context
180 if args[0] != nil {190 if args[0] != nil {
@@ -184,20 +194,25 @@ func (_c *MockBrokerService_AddHost_Call) Run(run func(ctx context.Context, targ
184 if args[1] != nil {194 if args[1] != nil {
185 arg1 = args[1].(string)195 arg1 = args[1].(string)
186 }196 }
197+ var arg2 ports.BrokerPolicy
198+ if args[2] != nil {
199+ arg2 = args[2].(ports.BrokerPolicy)
200+ }
187 run(201 run(
188 arg0,202 arg0,
189 arg1,203 arg1,
204+ arg2,
190 )205 )
191 })206 })
192 return _c207 return _c
193 }208 }
194 209
195-func (_c *MockBrokerService_AddHost_Call) Return(err error) *MockBrokerService_AddHost_Call {210+func (_c *MockBrokerService_AddHost_Call) Return(remoteRegistration domain.RemoteRegistration, err error) *MockBrokerService_AddHost_Call {
196- _c.Call.Return(err)211+ _c.Call.Return(remoteRegistration, err)
197 return _c212 return _c
198 }213 }
199 214
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 {
201 _c.Call.Return(run)216 _c.Call.Return(run)
202 return _c217 return _c
203 }218 }
@@ -508,8 +523,8 @@ func (_c *MockBrokerService_OpenStream_Call) RunAndReturn(run func(ctx context.C
508 }523 }
509 524
510 // RemoveHost provides a mock function for the type MockBrokerService525 // RemoveHost provides a mock function for the type MockBrokerService
511-func (_mock *MockBrokerService) RemoveHost(ctx context.Context, target string) (bool, error) {526+func (_mock *MockBrokerService) RemoveHost(ctx context.Context, expected domain.RemoteRegistration) (bool, error) {
512- ret := _mock.Called(ctx, target)527+ ret := _mock.Called(ctx, expected)
513 528
514 if len(ret) == 0 {529 if len(ret) == 0 {
515 panic("no return value specified for RemoveHost")530 panic("no return value specified for RemoveHost")
@@ -517,16 +532,16 @@ func (_mock *MockBrokerService) RemoveHost(ctx context.Context, target string) (
517 532
518 var r0 bool533 var r0 bool
519 var r1 error534 var r1 error
520- if returnFunc, ok := ret.Get(0).(func(context.Context, string) (bool, error)); ok {535+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) (bool, error)); ok {
521- return returnFunc(ctx, target)536+ return returnFunc(ctx, expected)
522 }537 }
523- if returnFunc, ok := ret.Get(0).(func(context.Context, string) bool); ok {538+ if returnFunc, ok := ret.Get(0).(func(context.Context, domain.RemoteRegistration) bool); ok {
524- r0 = returnFunc(ctx, target)539+ r0 = returnFunc(ctx, expected)
525 } else {540 } else {
526 r0 = ret.Get(0).(bool)541 r0 = ret.Get(0).(bool)
527 }542 }
528- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {543+ if returnFunc, ok := ret.Get(1).(func(context.Context, domain.RemoteRegistration) error); ok {
529- r1 = returnFunc(ctx, target)544+ r1 = returnFunc(ctx, expected)
530 } else {545 } else {
531 r1 = ret.Error(1)546 r1 = ret.Error(1)
532 }547 }
@@ -540,20 +555,20 @@ type MockBrokerService_RemoveHost_Call struct {
540 555
541 // RemoveHost is a helper method to define mock.On call556 // RemoveHost is a helper method to define mock.On call
542 // - ctx context.Context557 // - ctx context.Context
543-// - target string558+// - expected domain.RemoteRegistration
544-func (_e *MockBrokerService_Expecter) RemoveHost(ctx any, target any) *MockBrokerService_RemoveHost_Call {559+func (_e *MockBrokerService_Expecter) RemoveHost(ctx any, expected any) *MockBrokerService_RemoveHost_Call {
545- return &MockBrokerService_RemoveHost_Call{Call: _e.mock.On("RemoveHost", ctx, target)}560+ return &MockBrokerService_RemoveHost_Call{Call: _e.mock.On("RemoveHost", ctx, expected)}
546 }561 }
547 562
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 {
549 _c.Call.Run(func(args mock.Arguments) {564 _c.Call.Run(func(args mock.Arguments) {
550 var arg0 context.Context565 var arg0 context.Context
551 if args[0] != nil {566 if args[0] != nil {
552 arg0 = args[0].(context.Context)567 arg0 = args[0].(context.Context)
553 }568 }
554- var arg1 string569+ var arg1 domain.RemoteRegistration
555 if args[1] != nil {570 if args[1] != nil {
556- arg1 = args[1].(string)571+ arg1 = args[1].(domain.RemoteRegistration)
557 }572 }
558 run(573 run(
559 arg0,574 arg0,
@@ -568,7 +583,7 @@ func (_c *MockBrokerService_RemoveHost_Call) Return(b bool, err error) *MockBrok
568 return _c583 return _c
569 }584 }
570 585
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 {
572 _c.Call.Return(run)587 _c.Call.Return(run)
573 return _c588 return _c
574 }589 }
@@ -712,6 +727,78 @@ func (_c *MockBrokerService_Subscribe_Call) RunAndReturn(run func() (ports.Broke
712 return _c727 return _c
713 }728 }
714 729
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+
715 // 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.802 // 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.
716 // The first argument is typically a *testing.T value.803 // The first argument is typically a *testing.T value.
717 func NewMockBrokerSnapshotStore(t interface {804 func NewMockBrokerSnapshotStore(t interface {
M internal/protocol/session.go +1 -1
@@ -16,7 +16,7 @@ import (
16 // daemonmux physical preamble conversation (multiplex.proto), whose16 // daemonmux physical preamble conversation (multiplex.proto), whose
17 // negotiated ceilings and accepted daemon binding ride the same wire17 // negotiated ceilings and accepted daemon binding ride the same wire
18 // version as the session and broker conversations.18 // version as the session and broker conversations.
19-const Version uint16 = 5519+const Version uint16 = 56
20 20
21 // HandshakeTimeout bounds every transport handshake from connect through the21 // HandshakeTimeout bounds every transport handshake from connect through the
22 // first committed publication. It excludes the preceding client-local22 // first committed publication. It excludes the preceding client-local
M internal/protocol/session_test.go +1 -1
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestVersionRemainsWireCompatible(t *testing.T) {12 func TestVersionRemainsWireCompatible(t *testing.T) {
13- require.Equal(t, uint16(55), Version)13+ require.Equal(t, uint16(56), Version)
14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)14 require.Equal(t, (16<<20)-407, MaxOutputDataLen)
15 }15 }
16 16
M internal/protocol/wire/broker.pb.go +289 -161 generated file: collapsed
M internal/protocol/wire/broker_test.go +75 -5
@@ -19,7 +19,7 @@ import (
19 var brokerClientTags = map[string]protoreflect.FieldNumber{19 var brokerClientTags = map[string]protoreflect.FieldNumber{
20 "register": 101, "subscribe": 102, "resync": 103, "unsubscribe": 104,20 "register": 101, "subscribe": 102, "resync": 103, "unsubscribe": 104,
21 "add_host": 105, "remove_host": 106, "reconcile": 107, "open_stream": 108,21 "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,
23 }23 }
24 24
25 // brokerServerTags is the frozen server tag inventory.25 // brokerServerTags is the frozen server tag inventory.
@@ -151,9 +151,14 @@ func brokerClientSamples() map[string]*BrokerClientEnvelope {
151 "unsubscribe": {Payload: &BrokerClientEnvelope_Unsubscribe{Unsubscribe: &Unsubscribe{Scope: brokerScope(), Generation: 1 << 42}}},151 "unsubscribe": {Payload: &BrokerClientEnvelope_Unsubscribe{Unsubscribe: &Unsubscribe{Scope: brokerScope(), Generation: 1 << 42}}},
152 "add_host": {Payload: &BrokerClientEnvelope_AddHost{AddHost: &AddHost{152 "add_host": {Payload: &BrokerClientEnvelope_AddHost{AddHost: &AddHost{
153 Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x44}, 16), Endpoint: "dev@host:22",153 Scope: brokerScope(), OperationId: bytes.Repeat([]byte{0x44}, 16), Endpoint: "dev@host:22",
154+ Policy: brokerPolicy(),
154 }}},155 }}},
155 "remove_host": {Payload: &BrokerClientEnvelope_RemoveHost{RemoveHost: &RemoveHost{156 "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(),
157 }}},162 }}},
158 "reconcile": {Payload: &BrokerClientEnvelope_Reconcile{Reconcile: &Reconcile{163 "reconcile": {Payload: &BrokerClientEnvelope_Reconcile{Reconcile: &Reconcile{
159 Scope: brokerScope(), Registration: brokerRegistration(),164 Scope: brokerScope(), Registration: brokerRegistration(),
@@ -180,9 +185,9 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope {
180 Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9,185 Scope: brokerScope(), Generation: 2, Revision: 5, Index: 9,
181 Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{186 Part: &SnapshotPart_Daemon{Daemon: &SnapshotDaemon{
182 HostIndex: 1, Endpoint: "dev@host:22", DisplayOrigin: "dev@host",187 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},
184 DaemonIdentity: "authed-daemon", DaemonIncarnation: bytes.Repeat([]byte{0x9}, 16),189 DaemonIdentity: "authed-daemon", DaemonIncarnation: bytes.Repeat([]byte{0x9}, 16),
185- ProtocolVersion: 55, Capabilities: 1,190+ ProtocolVersion: 56, Capabilities: 1,
186 Availability: 1,191 Availability: 1,
187 Checking: true,192 Checking: true,
188 LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1},193 LastAttempt: &BrokerTimestamp{Seconds: 1700000000, Nanos: 1},
@@ -193,7 +198,7 @@ func brokerServerSamples() map[string]*BrokerServerEnvelope {
193 }},198 }},
194 }}},199 }}},
195 "operation_result": {Payload: &BrokerServerEnvelope_OperationResult{OperationResult: &OperationResult{200 "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,
197 Error: &BrokerErrorDetail{Code: 2, Text: "incompatible", AdmissionCode: 1, FailureKind: 3},202 Error: &BrokerErrorDetail{Code: 2, Text: "incompatible", AdmissionCode: 1, FailureKind: 3},
198 }}},203 }}},
199 "stream_opened": {Payload: &BrokerServerEnvelope_StreamOpened{StreamOpened: &StreamOpened{Ref: brokerRef()}}},204 "stream_opened": {Payload: &BrokerServerEnvelope_StreamOpened{StreamOpened: &StreamOpened{Ref: brokerRef()}}},
@@ -440,3 +445,68 @@ func TestBrokerEnvelopeTrailingGarbage(t *testing.T) {
440 }445 }
441 }446 }
442 }447 }
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 (
15 // shares a payload type except route navigation failure, and the preamble15 // shares a payload type except route navigation failure, and the preamble
16 // constants agree with the schema authority.16 // constants agree with the schema authority.
17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {17 func TestEnvelopeInventoryProvesDirectionAndSemanticPath(t *testing.T) {
18- if protocol.Version != 55 {18+ if protocol.Version != 56 {
19- t.Fatalf("protocol.Version = %d, want 55", protocol.Version)19+ t.Fatalf("protocol.Version = %d, want 56", protocol.Version)
20 }20 }
21 if ProtocolEpoch != 1 {21 if ProtocolEpoch != 1 {
22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)22 t.Fatalf("ProtocolEpoch = %d, want 1", ProtocolEpoch)
M internal/protocol/wire/multiplex.pb.go +1 -1 generated file: collapsed
M internal/protocol/wire/schema/broker.proto +27 -6
@@ -1,7 +1,7 @@
1 // Broker wire schema: the client/server broker conversations.1 // Broker wire schema: the client/server broker conversations.
2 //2 //
3 // The broker is an independently versioned conversation from the session3 // The broker is an independently versioned conversation from the session
4-// protocol: client tags 101-110 and server tags 201-209 are disjoint from4+// protocol: client tags 101-111 and server tags 201-209 are disjoint from
5 // every session tag, and each direction is its own closed oneof union.5 // every session tag, and each direction is its own closed oneof union.
6 // Scope (broker epoch plus client connection) is carried explicitly on6 // Scope (broker epoch plus client connection) is carried explicitly on
7 // every payload so stream IDs and events never cross a scope. Semantic7 // every payload so stream IDs and events never cross a scope. Semantic
@@ -52,7 +52,7 @@ message BrokerErrorDetail {
52 // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout,52 // Closed taxonomy: 1 = unavailable, 2 = incompatible, 3 = timeout,
53 // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost,53 // 4 = cancelled, 5 = outcome_unknown, 6 = attachment_lost,
54 // 7 = stale_epoch, 8 = conflicting_policy, 9 = explicit_exit,54 // 7 = stale_epoch, 8 = conflicting_policy, 9 = explicit_exit,
55- // 10 = fatal_terminal.55+ // 10 = fatal_terminal, 11 = host_conflict, 12 = membership_immutable.
56 uint32 code = 1;56 uint32 code = 1;
57 string text = 2;57 string text = 2;
58 // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the58 // Closed admission-refusal taxonomy; 0 = none. Codes are assigned by the
@@ -85,6 +85,7 @@ message BrokerClientEnvelope {
85 OpenStream open_stream = 108;85 OpenStream open_stream = 108;
86 ClientStreamData client_stream_data = 109;86 ClientStreamData client_stream_data = 109;
87 CloseStream close_stream = 110;87 CloseStream close_stream = 110;
88+ UpdateHostPolicy update_host_policy = 111;
88 }89 }
89 }90 }
90 91
@@ -127,18 +128,34 @@ message Unsubscribe {
127 }128 }
128 129
129 // AddHost adds one configured host registration. operation_id is the130 // 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.
131 message AddHost {134 message AddHost {
132 BrokerScope scope = 1;135 BrokerScope scope = 1;
133 bytes operation_id = 2; // exactly 16 bytes136 bytes operation_id = 2; // exactly 16 bytes
134 string endpoint = 3;137 string endpoint = 3;
138+ BrokerWirePolicy policy = 4;
135 }139 }
136 140
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.
138 message RemoveHost {145 message RemoveHost {
139 BrokerScope scope = 1;146 BrokerScope scope = 1;
140 bytes operation_id = 2; // exactly 16 bytes147 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;
142 }159 }
143 160
144 // Reconcile hands the broker one exact registration to probe.161 // Reconcile hands the broker one exact registration to probe.
@@ -279,7 +296,10 @@ message SnapshotTombstone {
279 message SnapshotEnd {}296 message SnapshotEnd {}
280 297
281 // OperationResult is exactly one completion for one admitted mutating298 // 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.
283 message OperationResult {303 message OperationResult {
284 BrokerScope scope = 1;304 BrokerScope scope = 1;
285 bytes operation_id = 2; // exactly 16 bytes305 bytes operation_id = 2; // exactly 16 bytes
@@ -287,6 +307,7 @@ message OperationResult {
287 uint32 outcome = 3;307 uint32 outcome = 3;
288 bool removed = 4;308 bool removed = 4;
289 BrokerErrorDetail error = 5;309 BrokerErrorDetail error = 5;
310+ RemoteRegistration registration = 6;
290 }311 }
291 312
292 // StreamOpened confirms one logical stream is established.313 // StreamOpened confirms one logical stream is established.
M internal/protocol/wire/schema/multiplex.proto +1 -1
@@ -3,7 +3,7 @@
3 // physical connection.3 // physical connection.
4 //4 //
5 // daemonmux is a third conversation, separate from the session protocol5 // 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):
7 // client tags 301-304 and server tags 401-405 are disjoint from every other7 // client tags 301-304 and server tags 401-405 are disjoint from every other
8 // tag, and each direction is its own closed oneof union. The physical8 // tag, and each direction is its own closed oneof union. The physical
9 // preamble is a separate adapter message pair, not an envelope variant:9 // 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 {
75 return nil75 return nil
76 }76 }
77 77
78-// observesLocalOnly reports whether this registry observes only the broker's
79-// own daemon: observation is enabled, a local binding is configured, and no
80-// remote membership is projected. It is the registry-side answer an authority
81-// needs, because an authority whose connections expose immutable membership may
82-// serve an observer only when there is no remote membership to manage.
83-func (r *Registry) observesLocalOnly() bool {
84- r.mu.Lock()
85- defer r.mu.Unlock()
86- return !r.observationDisabled && r.local != nil && len(r.hosts) == 0
87-}
88-
89 // dispatchLocalLocked admits at most one local probe attempt when its schedule78 // dispatchLocalLocked admits at most one local probe attempt when its schedule
90 // says it is due. Callers must hold r.mu. It reports whether an attempt79 // says it is due. Callers must hold r.mu. It reports whether an attempt
91 // started, so dispatch publishes the transient Checking state exactly once.80 // started, so dispatch publishes the transient Checking state exactly once.
M internal/usecase/broker/service.go +76 -39
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "sync"6 "sync"
7 7
8+ "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 )10 )
10 11
@@ -21,24 +22,9 @@ import (
21 // admission only and is never retained; the admitted service owns a22 // admission only and is never retained; the admitted service owns a
22 // connection-lived context derived from the supervisor root.23 // connection-lived context derived from the supervisor root.
23 //24 //
24-// This composition is deliberately offline and immutable: its registry runs25+// Registry construction determines observation and membership policy. The
25-// observation-disabled, so the returned service publishes and subscribes to26+// authority preserves those guards and exposes admitted mutations through the
26-// durable snapshots but never probes or reconciles, and host membership is27+// connection-scoped service.
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-}
42 28
43 // Authority admits one accepted client connection to the broker core. It owns29 // Authority admits one accepted client connection to the broker core. It owns
44 // no lifetime of its own: the supervisor owns the broker lifetime, the registry30 // no lifetime of its own: the supervisor owns the broker lifetime, the registry
@@ -60,13 +46,6 @@ var _ ports.BrokerAuthority = (*Authority)(nil)
60 // NewAuthority composes one connection-scoped broker authority over an existing46 // NewAuthority composes one connection-scoped broker authority over an existing
61 // registry, pool, and supervisor. All three must be live and share epoch.47 // registry, pool, and supervisor. All three must be live and share epoch.
62 //48 //
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-//
70 // The caller owns the dependencies' lifecycles. In particular it must start the49 // The caller owns the dependencies' lifecycles. In particular it must start the
71 // registry's single Registry.Run to own and drain the durable writer, and must50 // registry's single Registry.Run to own and drain the durable writer, and must
72 // cancel that run context and join Run before the store is closed. NewAuthority51 // 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
80 if registry.epoch != epoch || pool.epoch != epoch {59 if registry.epoch != epoch || pool.epoch != epoch {
81 return nil, errors.New("broker: authority epoch mismatch")60 return nil, errors.New("broker: authority epoch mismatch")
82 }61 }
83- if !registry.observationDisabled && !registry.observesLocalOnly() {
84- return nil, errors.New("broker: authority requires an observation-disabled registry or a local-only observer")
85- }
86 return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil62 return &Authority{epoch: epoch, registry: registry, pool: pool, supervisor: supervisor}, nil
87 }63 }
88 64
@@ -132,8 +108,8 @@ type Service struct {
132 mu sync.Mutex108 mu sync.Mutex
133 closed bool109 closed bool
134 subs map[*serviceSubscription]struct{}110 subs map[*serviceSubscription]struct{}
135- // wg counts in-flight OpenStream calls so Close drains them, and their111+ // wg counts in-flight stream opens and membership mutations so Close drains
136- // operation leases, before it releases the connection resources.112+ // them, and their operation leases, before releasing connection resources.
137 wg sync.WaitGroup113 wg sync.WaitGroup
138 114
139 closeOnce sync.Once115 closeOnce sync.Once
@@ -265,18 +241,79 @@ func (s *Service) CloseStream(connection ports.BrokerConnectionID, stream ports.
265 return s.pool.CloseStream(s.id, stream)241 return s.pool.CloseStream(s.id, stream)
266 }242 }
267 243
268-// AddHost refuses host admission: this connection has immutable offline244+// AddHost delegates one admitted membership mutation to the registry.
269-// membership, so no mutation is attempted.245+func (s *Service) AddHost(ctx context.Context, endpoint string, policy ports.BrokerPolicy) (domain.RemoteRegistration, error) {
270-func (s *Service) AddHost(context.Context, string) error { return offlineMembershipRefusal() }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+}
271 287
272-// RemoveHost refuses host removal without mutating authority. The result is the288+func membershipError(err error) error {
273-// explicit false/refusal pair.289+ if err == nil {
274-func (s *Service) RemoveHost(context.Context, string) (bool, error) {290+ return nil
275- return false, offlineMembershipRefusal()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}
276 }313 }
277 314
278-// RequestReconcile is deliberately a no-op: an offline connection has no315+// RequestReconcile remains a no-op until the observation service exposes a
279-// observation to reconcile, so the hint starts no probe, timer, or reconcile.316+// connection-safe reconcile seam.
280 func (s *Service) RequestReconcile(string) {}317 func (s *Service) RequestReconcile(string) {}
281 318
282 // Close cancels and drains every owned resource exactly once: in-flight setup319 // 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
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
7+ "strings"
6 "sync"8 "sync"
7 "testing"9 "testing"
8 "time"10 "time"
11+ "unicode/utf8"
9 12
10 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
@@ -13,23 +16,32 @@ import (
13 )16 )
14 17
15 // newTestAuthority composes one offline authority over an observation-disabled18 // newTestAuthority composes one offline authority over an observation-disabled
16-// registry, a bounded pool, and a supervisor on a long grace. Every dependency19+// registry, a bounded pool, and a supervisor on a long grace.
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.
20 func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerHostProbe, connector poolConnector) (*Authority, *Registry, *Pool, *Supervisor, *manualClock, *testStore) {20 func newTestAuthority(t testing.TB, epoch ports.BrokerEpoch, probe ports.BrokerHostProbe, connector poolConnector) (*Authority, *Registry, *Pool, *Supervisor, *manualClock, *testStore) {
21 t.Helper()21 t.Helper()
22 clock := newManualClock(time.Unix(0, 0))22 clock := newManualClock(time.Unix(0, 0))
23 store := newTestStore()23 store := newTestStore()
24 registry, err := NewRegistryWithConfig(epoch, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true})24 registry, err := NewRegistryWithConfig(epoch, store, probe, clock, nil, RegistryConfig{ObservationDisabled: true})
25 require.NoError(t, err)25 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()
26 resolver := poolResolver(func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {38 resolver := poolResolver(func(_ context.Context, r ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
27 return ports.BrokerResolvedEndpoint{Identity: "canonical", Policy: r.Policy, Address: "fake"}, nil39 return ports.BrokerResolvedEndpoint{Identity: "canonical", Policy: r.Policy, Address: "fake"}, nil
28 })40 })
29 pool, err := NewPool(epoch, resolver, connector, clock, PoolLimits{Physical: 4, Clients: 8, Streams: 16, StreamsPerClient: 16, Idle: time.Minute})41 pool, err := NewPool(epoch, resolver, connector, clock, PoolLimits{Physical: 4, Clients: 8, Streams: 16, StreamsPerClient: 16, Idle: time.Minute})
30 require.NoError(t, err)42 require.NoError(t, err)
31 t.Cleanup(func() { require.NoError(t, pool.Close()) })43 t.Cleanup(func() { require.NoError(t, pool.Close()) })
32- supervisor, _ := newTestSupervisor(t, time.Hour)44+ supervisor, idleClock := newTestSupervisor(t, time.Hour)
33 authority, err := NewAuthority(epoch, registry, pool, supervisor)45 authority, err := NewAuthority(epoch, registry, pool, supervisor)
34 require.NoError(t, err)46 require.NoError(t, err)
35 // The caller owns Registry.Run (see NewAuthority): start the single run and47 // 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
39 registryDone := make(chan struct{})51 registryDone := make(chan struct{})
40 go func() { defer close(registryDone); registry.Run(registryCtx) }()52 go func() { defer close(registryDone); registry.Run(registryCtx) }()
41 t.Cleanup(func() { stopRegistry(); <-registryDone })53 t.Cleanup(func() { stopRegistry(); <-registryDone })
42- return authority, registry, pool, supervisor, clock, store54+ 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+ }
43 }118 }
44 119
45 // immediateConnector returns one healthy physical connection for any endpoint.120 // immediateConnector returns one healthy physical connection for any endpoint.
@@ -121,34 +196,38 @@ func TestAuthorityAdmissionContextNotRetained(t *testing.T) {
121 func TestAuthorityValidation(t *testing.T) {196 func TestAuthorityValidation(t *testing.T) {
122 _, _, pool, supervisor, clock, _ := newTestAuthority(t, 1, nil, immediateConnector)197 _, _, pool, supervisor, clock, _ := newTestAuthority(t, 1, nil, immediateConnector)
123 198
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})
125 require.NoError(t, err)203 require.NoError(t, err)
126- _, err = NewAuthority(1, observing, pool, supervisor)204+ t.Cleanup(mutableRemote.settle)
127- require.Error(t, err, "an observing registry must be refused by an offline authority")205+ require.NoError(t, mutableRemote.setHosts(hostRecords(registration(t, "user@host:22", 1))))
128-206+ _, err = NewAuthority(1, mutableRemote, pool, supervisor)
129- // An observer that is limited to the broker's own daemon is served: it has207+ require.NoError(t, err, "a mutable remote registry must be served")
130- // no remote membership for the authority's immutable-membership connections208+
131- // to manage, and its local entry is configured authority rather than209+ // A local-only observer is served: its entry is configured authority rather
132- // membership.210+ // than membership, so it has nothing for the authority's connections to
211+ // manage either.
133 localOnly, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: &LocalObservation{212 localOnly, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: &LocalObservation{
134 DisplayOrigin: "local",213 DisplayOrigin: "local",
135 Policy: poolPolicy(),214 Policy: poolPolicy(),
136 Probe: newScriptedLocalProbe(1),215 Probe: newScriptedLocalProbe(1),
137 }})216 }})
138 require.NoError(t, err)217 require.NoError(t, err)
218+ t.Cleanup(localOnly.settle)
139 _, err = NewAuthority(1, localOnly, pool, supervisor)219 _, err = NewAuthority(1, localOnly, pool, supervisor)
140 require.NoError(t, err, "a local-only observer must be served")220 require.NoError(t, err, "a local-only observer must be served")
141 221
142- // The same observer stops being served as soon as it owns remote membership222+ // Construction still rejects remote observation without a probe: a registry
143- // the authority's connections could never manage.223+ // that would observe remotes but owns no probe cannot be constructed, so no
144- observerWithMembership, err := NewRegistry(1, newTestStore(), newTestProbe(1), clock, nil)224+ // authority ever receives one.
145- require.NoError(t, err)225+ _, err = NewRegistry(1, newTestStore(), nil, clock, nil)
146- require.NoError(t, observerWithMembership.setHosts(hostRecords(registration(t, "user@host:22", 1))))226+ require.ErrorContains(t, err, "invalid registry dependencies", "remote observation without a probe must be refused")
147- _, err = NewAuthority(1, observerWithMembership, pool, supervisor)
148- require.Error(t, err, "an observer that owns remote membership must be refused")
149 227
150 disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true})228 disabled, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{ObservationDisabled: true})
151 require.NoError(t, err)229 require.NoError(t, err)
230+ t.Cleanup(disabled.settle)
152 _, err = NewAuthority(2, disabled, pool, supervisor)231 _, err = NewAuthority(2, disabled, pool, supervisor)
153 require.Error(t, err, "an epoch mismatch must be refused")232 require.Error(t, err, "an epoch mismatch must be refused")
154 233
@@ -449,27 +528,274 @@ func TestServiceShutdownTerminatesOwnedWork(t *testing.T) {
449 require.Zero(t, clients)528 require.Zero(t, clients)
450 }529 }
451 530
452-func TestServiceOfflineMembershipRefusal(t *testing.T) {531+// TestServiceImmutableMembershipRefusesEveryMutation proves the immutable
453- authority, registry, _, _, _, _ := newTestAuthority(t, 1, nil, immediateConnector)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)
454 service, err := authority.AdmitClient(context.Background())538 service, err := authority.AdmitClient(context.Background())
455 require.NoError(t, err)539 require.NoError(t, err)
456 t.Cleanup(func() { require.NoError(t, service.Close()) })540 t.Cleanup(func() { require.NoError(t, service.Close()) })
457 541
458- err = service.AddHost(context.Background(), "new@host:22")542+ before := registry.Snapshot()
459- require.ErrorIs(t, err, ErrOfflineMembership)543+ _, err = service.AddHost(context.Background(), "new@host:22", poolPolicy())
460- var typed ports.BrokerError544+ requireImmutableRefusal(t, err)
461- require.ErrorAs(t, err, &typed)
462 545
463- removed, err := service.RemoveHost(context.Background(), "new@host:22")546+ removed, err := service.RemoveHost(context.Background(), registration(t, "new@host:22", 1))
464 require.False(t, removed)547 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")
466 556
467- require.Empty(t, registry.Snapshot().Daemons, "a refused mutation must not touch authority")
468- before := registry.Snapshot()
469 service.RequestReconcile("new@host:22")557 service.RequestReconcile("new@host:22")
470 require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op")558 require.Equal(t, before, registry.Snapshot(), "reconcile must be a no-op")
471 }559 }
472 560
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+
473 func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) {799 func TestRegistryObservationDisabledRestoresAndPublishes(t *testing.T) {
474 clock := newManualClock(time.Unix(0, 0))800 clock := newManualClock(time.Unix(0, 0))
475 store := newTestStore()801 store := newTestStore()
M internal/usecase/client/supervisor_integration_test.go +11 -3
@@ -13,6 +13,7 @@ import (
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15 "github.com/bnema/vev/internal/adapters/brokeripc"15 "github.com/bnema/vev/internal/adapters/brokeripc"
16+ "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
17 )18 )
18 19
@@ -55,9 +56,16 @@ func (c *integrationCore) OpenStream(context.Context, ports.BrokerOpenStreamRequ
55 func (c *integrationCore) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {56 func (c *integrationCore) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
56 return nil57 return nil
57 }58 }
58-func (c *integrationCore) AddHost(context.Context, string) error { return nil }59+func (c *integrationCore) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
59-func (c *integrationCore) RemoveHost(context.Context, string) (bool, error) { return false, nil }60+ return domain.RemoteRegistration{}, nil
60-func (c *integrationCore) RequestReconcile(string) {}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) {}
61 69
62 func (c *integrationCore) Close() error {70 func (c *integrationCore) Close() error {
63 c.once.Do(func() {71 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
390 return nil390 return nil
391 }391 }
392 392
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+}
394 396
395-func (s *supervisorTestService) RemoveHost(context.Context, string) (bool, error) {397+func (s *supervisorTestService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
396 return false, nil398 return false, nil
397 }399 }
398 400
401+func (s *supervisorTestService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
402+ return domain.RemoteRegistration{}, nil
403+}
404+
399 func (s *supervisorTestService) RequestReconcile(string) {}405 func (s *supervisorTestService) RequestReconcile(string) {}
400 406
401 func (s *supervisorTestService) Close() error {407 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