detached 5d9061b

feat(broker): own the local route and local observation

Give the broker its own carriage to this machine's daemon, so local and remote
logical streams reach a daemon the same way. Offline and unactivated: the
sandbox composition keeps running observation-disabled.

- brokerconfig accepts an optional top-level local object (identity, optional
  displayOrigin, route, policy). A local request carries no endpoint and no
  registration, so the binding is the only authority a local stream can be
  fenced against: identity and policy are checked before any dial, the route
  must be a Unix daemonmux carriage this process dials directly, and a refusal
  is a typed broker error that never dials or spawns.
- Local streams pool under (identity, policy) on that one carriage, so every
  local logical stream shares a single physical daemonmux connection.
- The registry can observe the local daemon: the entry is published at index 0,
  its configured authority comes from the binding, and observed identity,
  incarnation, protocol version, availability, freshness, and catalogue come
  only from the probe. It is never durable and never invented.
- The composition's probe completes the daemonmux physical preamble and closes
  the carriage: it opens no logical stream and has no spawn path, so observing
  never attaches and never starts a stopped daemon. It re-checks the binding's
  route for itself rather than trusting the exported value.
- docs/architecture.md documents the local route and corrects the claim that
  the local entry's authority comes from durable membership.
commit
5d9061b112efccd710fa91df57fc09eaafea760d
author
brice · · 2026-09-19 09:53 UTC
parent
b6528f0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +1802 -34
M docs/architecture.md +50 -10
@@ -270,25 +270,65 @@ These are P2.2 port contracts, not a wire migration or production activation.
270270 `internal/usecase/broker.Registry` owns configured hosts and their immutable
271271 observation projections. `ports.BrokerDaemonObservation` is the broker-native
272272 projection of one daemon, local or remote: configured authority (local flag,
273-endpoint, registration, policy, display origin, rank) is stamped by the
274-registry from durable membership and never taken from a probe, while observed
275-identity, process incarnation, protocol version, capability bitmask,
276-availability, failure/freshness state, and the exact session catalogue come
277-only from probe results. Unknown identity or version is zero, never invented;
278-an observed identity always carries a protocol version, and availability is
279-never zero (an unobserved daemon carries `RemoteAvailabilityUnknown`). A
273+endpoint, registration, policy, display origin, rank) is never taken from a
274+probe, while observed identity, process incarnation, protocol version,
275+capability bitmask, availability, failure/freshness state, and the exact
276+session catalogue come only from probe results. A remote host's authority is
277+stamped by the registry from durable membership; the local entry's authority is
278+stamped from the broker's configured local binding, because the local daemon
279+has no registration and no durable membership. Unknown identity or version is
280+zero, never invented; an observed identity always carries a protocol version,
281+and availability is never zero (an unobserved daemon carries
282+`RemoteAvailabilityUnknown`). A
280283 `ports.BrokerSnapshot` publishes these observations (`Daemons`, local entry
281284 first when present, then remotes in registration order) plus the tombstone
282285 set; `brokerwire` mirrors them through `SnapshotBegin`/`SnapshotDaemon`/
283286 `SnapshotSession` parts. Local observations are never durable: the snapshot
284-store persists and reloads remote observations only, with policy re-stamped
285-from membership on load, so membership stays the single policy authority.
286-Observing a daemon never attaches to it and never starts a stopped one.
287+store persists and reloads remote observations only, with remote policy
288+re-stamped from membership on load, so membership stays the single policy
289+authority for remotes. Observing a daemon never attaches to it and never starts
290+a stopped one.
287291 Attachment streams declare one closed admission variant
288292 (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral
289293 creation) validated identically at the ports, broker IPC, and daemonmux
290294 boundaries.
291295
296+## Broker-owned local route (Plan 001 P5.3a, not activated)
297+
298+The broker reaches its own machine daemon, which is not a configured host, over
299+one broker-owned local binding. `internal/adapters/brokerconfig` parses an
300+optional top-level `local` object beside the registrations: the required
301+`identity`, `route`, and `policy`, plus an optional `displayOrigin` that is
302+sanitized and defaults to `local`. The route must be a Unix daemonmux carriage
303+this process dials directly, so an SSH or QUIC route is refused and the binding
304+can never name another machine. The identity, policy, and route are the only
305+source of local authority, and `Config.LocalBinding` alone publishes them. The
306+binding's own carriage is deliberately excluded from `Config.LocalMuxRoute`:
307+that route is the single registration carriage a remote-side mux helper bridges
308+to, and no helper ever bridges the broker's own local carriage.
309+
310+A local open-stream request carries no endpoint and no registration, so the
311+resolver fences it against the binding's identity and policy instead: a request
312+whose policy conflicts with the provisioned local policy is refused, and a
313+configuration with no local object provisions no local route. The resolved
314+(identity, policy) pair is the pool key, so every local logical stream shares
315+one physical daemonmux carriage to the local daemon, exactly as every stream to
316+one remote identity does; the route's opaque address selects that carriage and
317+never participates in the key.
318+
319+The registry publishes a local observation whenever a local producer is
320+configured, always at index zero ahead of every remote and never more than one.
321+Its configured authority (local flag, display origin, policy, rank) is stamped
322+from the binding and never from durable membership, since the local daemon has
323+no membership; its observed identity, incarnation, protocol version,
324+availability, and session catalogue come only from the local probe. The local
325+entry is never durable: the snapshot store persists and reloads remote
326+observations only. Observing the local daemon completes only the daemonmux
327+physical preamble and closes the carriage, so it never opens a logical stream
328+(never attaches) and only ever dials an existing local Unix socket, so it never
329+starts a stopped daemon. Nothing in `internal/app` or `main` activates this
330+composition for ordinary operation; P7 performs the coordinated cutover.
331+
292332 ## Broker local IPC (Plan 001 P3.3, not activated)
293333
294334 `internal/adapters/brokeripc` carries the P3.1 broker conversation over one
@@ -270,25 +270,65 @@ These are P2.2 port contracts, not a wire migration or production activation.
270 `internal/usecase/broker.Registry` owns configured hosts and their immutable270 `internal/usecase/broker.Registry` owns configured hosts and their immutable
271 observation projections. `ports.BrokerDaemonObservation` is the broker-native271 observation projections. `ports.BrokerDaemonObservation` is the broker-native
272 projection of one daemon, local or remote: configured authority (local flag,272 projection of one daemon, local or remote: configured authority (local flag,
273-endpoint, registration, policy, display origin, rank) is stamped by the273+endpoint, registration, policy, display origin, rank) is never taken from a
274-registry from durable membership and never taken from a probe, while observed274+probe, while observed identity, process incarnation, protocol version,
275-identity, process incarnation, protocol version, capability bitmask,275+capability bitmask, availability, failure/freshness state, and the exact
276-availability, failure/freshness state, and the exact session catalogue come276+session catalogue come only from probe results. A remote host's authority is
277-only from probe results. Unknown identity or version is zero, never invented;277+stamped by the registry from durable membership; the local entry's authority is
278-an observed identity always carries a protocol version, and availability is278+stamped from the broker's configured local binding, because the local daemon
279-never zero (an unobserved daemon carries `RemoteAvailabilityUnknown`). A279+has no registration and no durable membership. Unknown identity or version is
280+zero, never invented; an observed identity always carries a protocol version,
281+and availability is never zero (an unobserved daemon carries
282+`RemoteAvailabilityUnknown`). A
280 `ports.BrokerSnapshot` publishes these observations (`Daemons`, local entry283 `ports.BrokerSnapshot` publishes these observations (`Daemons`, local entry
281 first when present, then remotes in registration order) plus the tombstone284 first when present, then remotes in registration order) plus the tombstone
282 set; `brokerwire` mirrors them through `SnapshotBegin`/`SnapshotDaemon`/285 set; `brokerwire` mirrors them through `SnapshotBegin`/`SnapshotDaemon`/
283 `SnapshotSession` parts. Local observations are never durable: the snapshot286 `SnapshotSession` parts. Local observations are never durable: the snapshot
284-store persists and reloads remote observations only, with policy re-stamped287+store persists and reloads remote observations only, with remote policy
285-from membership on load, so membership stays the single policy authority.288+re-stamped from membership on load, so membership stays the single policy
286-Observing a daemon never attaches to it and never starts a stopped one.289+authority for remotes. Observing a daemon never attaches to it and never starts
290+a stopped one.
287 Attachment streams declare one closed admission variant291 Attachment streams declare one closed admission variant
288 (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral292 (`ports.BrokerStreamAdmission`: exact attach/resume, named creation, ephemeral
289 creation) validated identically at the ports, broker IPC, and daemonmux293 creation) validated identically at the ports, broker IPC, and daemonmux
290 boundaries.294 boundaries.
291 295
296+## Broker-owned local route (Plan 001 P5.3a, not activated)
297+
298+The broker reaches its own machine daemon, which is not a configured host, over
299+one broker-owned local binding. `internal/adapters/brokerconfig` parses an
300+optional top-level `local` object beside the registrations: the required
301+`identity`, `route`, and `policy`, plus an optional `displayOrigin` that is
302+sanitized and defaults to `local`. The route must be a Unix daemonmux carriage
303+this process dials directly, so an SSH or QUIC route is refused and the binding
304+can never name another machine. The identity, policy, and route are the only
305+source of local authority, and `Config.LocalBinding` alone publishes them. The
306+binding's own carriage is deliberately excluded from `Config.LocalMuxRoute`:
307+that route is the single registration carriage a remote-side mux helper bridges
308+to, and no helper ever bridges the broker's own local carriage.
309+
310+A local open-stream request carries no endpoint and no registration, so the
311+resolver fences it against the binding's identity and policy instead: a request
312+whose policy conflicts with the provisioned local policy is refused, and a
313+configuration with no local object provisions no local route. The resolved
314+(identity, policy) pair is the pool key, so every local logical stream shares
315+one physical daemonmux carriage to the local daemon, exactly as every stream to
316+one remote identity does; the route's opaque address selects that carriage and
317+never participates in the key.
318+
319+The registry publishes a local observation whenever a local producer is
320+configured, always at index zero ahead of every remote and never more than one.
321+Its configured authority (local flag, display origin, policy, rank) is stamped
322+from the binding and never from durable membership, since the local daemon has
323+no membership; its observed identity, incarnation, protocol version,
324+availability, and session catalogue come only from the local probe. The local
325+entry is never durable: the snapshot store persists and reloads remote
326+observations only. Observing the local daemon completes only the daemonmux
327+physical preamble and closes the carriage, so it never opens a logical stream
328+(never attaches) and only ever dials an existing local Unix socket, so it never
329+starts a stopped daemon. Nothing in `internal/app` or `main` activates this
330+composition for ordinary operation; P7 performs the coordinated cutover.
331+
292 ## Broker local IPC (Plan 001 P3.3, not activated)332 ## Broker local IPC (Plan 001 P3.3, not activated)
293 333
294 `internal/adapters/brokeripc` carries the P3.1 broker conversation over one334 `internal/adapters/brokeripc` carries the P3.1 broker conversation over one
M internal/adapters/brokerconfig/config.go +126 -6
@@ -15,7 +15,10 @@
1515 // addresses, identities, and policies. A client request can name a configured
1616 // endpoint and must carry that endpoint's exact registration and compatible
1717 // policy; it can never supply an address, identity, secret, command, or policy
18-// of its own, and an unknown or stale request is refused without dialing. The
18+// of its own, and an unknown or stale request is refused without dialing. A
19+// local request carries no endpoint or registration and is instead fenced
20+// against the optional broker-owned local binding (identity, policy, and Unix
21+// daemonmux route), so it too can never supply authority of its own. The
1922 // route's pool address is an opaque digest, so no route detail is exposed to the
2023 // pooling layer.
2124 //
@@ -80,9 +83,13 @@ var (
8083 // errStaleRegistration reports a request whose registration no longer
8184 // matches the provisioned immutable registration.
8285 errStaleRegistration = errors.New("brokerconfig: stale broker registration")
83- // errNoLocalRoute reports a local request: the offline sandbox provisions
84- // remote Unix mux routes only, so it owns no local service binding.
86+ // errNoLocalRoute reports a local request when the configuration provisions
87+ // no broker-owned local binding, so the broker owns no local route.
8588 errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route")
89+ // errLocalPolicyConflict reports a local request whose policy is not exactly
90+ // compatible with the provisioned local policy. The provisioned policy stays
91+ // authoritative: the request can never widen, narrow, or replace it.
92+ errLocalPolicyConflict = errors.New("brokerconfig: local request policy conflicts with the provisioned local policy")
8693 )
8794
8895 // Layout names the sandbox-owned paths derived from one offline root. Every
@@ -218,6 +225,30 @@ type Registration struct {
218225 Route Route
219226 }
220227
228+// LocalBinding is the broker-owned binding for the broker's own machine
229+// daemon (Plan 001 P5.3a). Unlike a remote Registration it carries no
230+// registration: the local daemon is not a configured host, so a local stream
231+// request is fenced only against this binding's identity, policy, and route.
232+// The identity and policy are authoritative here and are the exact source of
233+// the resolved endpoint and of the published local observation's configured
234+// authority, so a local request can never select an identity or policy of its
235+// own.
236+type LocalBinding struct {
237+ // Identity is the stable authenticated daemon identity the local
238+ // daemonmux carriage must present during the physical preamble.
239+ Identity ports.BrokerDaemonIdentity
240+ // DisplayOrigin is the presentation hint published for the local
241+ // observation. It is sanitized at load time and is never routing authority.
242+ DisplayOrigin string
243+ // Policy is the exact connection policy the local carriage negotiates.
244+ Policy ports.BrokerPolicy
245+ // Route is the broker-owned local daemonmux carriage. It is always a Unix
246+ // route this process dials directly (Route.IsLocal() is true). The broker
247+ // dials it itself, so it is never a remote-side mux helper route
248+ // (Config.LocalMuxRoute).
249+ Route Route
250+}
251+
221252 // pooledIdentity is the exact (authenticated identity, policy) pair the broker
222253 // pool keys one physical transport by. The pool deliberately ignores the opaque
223254 // route address, so two registrations that share a pair share one pooled
@@ -233,6 +264,10 @@ type Config struct {
233264 byAddress map[string]Route
234265 unixRoutes []Route
235266 order []string
267+ // local is the optional broker-owned local binding. It is nil when the
268+ // configuration provisions no local route, in which case a local request is
269+ // refused and no local observation is produced.
270+ local *LocalBinding
236271 // idleGrace is the provisioned effective idle grace; hasIdleGrace records
237272 // whether the file provisioned one, so a caller can distinguish "no
238273 // provisioning, use the built-in default" from "provisioned".
@@ -279,7 +314,7 @@ func Load(layout Layout) (*Config, error) {
279314 }
280315 config.idleGrace, config.hasIdleGrace = grace, true
281316 }
282- pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations))
317+ pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)+1)
283318 for i, entry := range document.Registrations {
284319 registration, err := entry.validate()
285320 if err != nil {
@@ -310,9 +345,66 @@ func Load(layout Layout) (*Config, error) {
310345 }
311346 config.order = append(config.order, registration.Registration.Endpoint)
312347 }
348+ local, err := parseLocal(document.Local)
349+ if err != nil {
350+ return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
351+ }
352+ if local != nil {
353+ if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil {
354+ return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
355+ }
356+ // The pool keys one physical transport by (identity, policy) and ignores
357+ // the route address, so a local binding that shares a remote (or another
358+ // local) pair must agree on the one route that transport dials. Refuse a
359+ // second route for the same pair, exactly as the registration loop does.
360+ key := pooledIdentity{identity: local.Identity, policy: local.Policy}
361+ address := local.Route.Address()
362+ if existing, present := pooledRoutes[key]; present && existing != address {
363+ return nil, fmt.Errorf("brokerconfig: %s: local identity %q and policy already provisioned on a different route", ConfigFileName, local.Identity)
364+ }
365+ pooledRoutes[key] = address
366+ config.byAddress[address] = local.Route
367+ config.local = local
368+ }
313369 return config, nil
314370 }
315371
372+// parseLocal strictly parses the optional local binding. A nil document
373+// provisions no local route. The identity and policy are required and
374+// validated; the route must be a local Unix daemonmux carriage, because the
375+// local daemon is reached in-process over a private Unix socket and never
376+// through an SSH or QUIC carriage. The display origin is sanitized and
377+// defaults to the broker-owned value "local", so an absent or wholly
378+// undisplayable origin never yields an empty publication.
379+func parseLocal(document *localDocument) (*LocalBinding, error) {
380+ if document == nil {
381+ return nil, nil
382+ }
383+ identity := ports.BrokerDaemonIdentity(document.Identity)
384+ if err := identity.Validate(); err != nil {
385+ return nil, err
386+ }
387+ policy, err := document.Policy.validate()
388+ if err != nil {
389+ return nil, err
390+ }
391+ route, err := parseRoute(document.Route)
392+ if err != nil {
393+ return nil, err
394+ }
395+ if !route.IsLocal() {
396+ return nil, errors.New("local route must be a Unix daemonmux carriage")
397+ }
398+ origin := ports.SanitizeBrokerDisplayText(document.DisplayOrigin)
399+ if origin == "" {
400+ origin = LocalDisplayOrigin
401+ }
402+ if len(origin) > ports.BrokerMaxDisplayOriginBytes {
403+ return nil, fmt.Errorf("local display origin exceeds %d bytes", ports.BrokerMaxDisplayOriginBytes)
404+ }
405+ return &LocalBinding{Identity: identity, DisplayOrigin: origin, Policy: policy, Route: route}, nil
406+}
407+
316408 // appendUnixRouteOnce adds a local Unix route unless an identical route (the
317409 // same opaque address, hence the same bounded contents) is already present, so
318410 // endpoint aliases of one route never make LocalMuxRoute report more than one
@@ -326,6 +418,20 @@ func appendUnixRouteOnce(routes []Route, route Route) []Route {
326418 return append(routes, route)
327419 }
328420
421+// LocalDisplayOrigin is the broker-owned default presentation hint for the
422+// broker's own machine daemon when the configuration provisions none.
423+const LocalDisplayOrigin = "local"
424+
425+// LocalBinding returns the provisioned broker-owned local binding and whether
426+// one exists. A nil configuration or one without a local object provisions
427+// nothing, and a local request is then refused.
428+func (c *Config) LocalBinding() (LocalBinding, bool) {
429+ if c == nil || c.local == nil {
430+ return LocalBinding{}, false
431+ }
432+ return *c.local, true
433+}
434+
329435 // Endpoints returns the configured endpoints in file order. The result is a
330436 // copy; the configuration is immutable.
331437 func (c *Config) Endpoints() []string {
@@ -358,7 +464,8 @@ func (c *Config) RouteByAddress(address string) (Route, bool) {
358464 // LocalMuxRoute returns the single provisioned Unix route a remote-side mux
359465 // helper bridges to. A configuration that provisions no Unix route, or more
360466 // than one, is refused: a helper must never guess which daemon-mux endpoint it
361-// serves.
467+// serves. The local binding's own carriage is deliberately excluded: the broker
468+// dials it itself, and no remote-side helper ever bridges it.
362469 func (c *Config) LocalMuxRoute() (Route, error) {
363470 if c == nil {
364471 return Route{}, errors.New("brokerconfig: no configuration")
@@ -378,13 +485,16 @@ func (c *Config) Resolver() *Resolver {
378485 if c == nil {
379486 return &Resolver{byEndpoint: map[string]Registration{}}
380487 }
381- return &Resolver{byEndpoint: c.byEndpoint}
488+ return &Resolver{byEndpoint: c.byEndpoint, local: c.local}
382489 }
383490
384491 // configDocument is the strict on-disk shape. Unknown fields are refused by the
385492 // decoder, so the file cannot smuggle additional behavior.
386493 type configDocument struct {
387494 Marker string `json:"marker"`
495+ // Local optionally provisions the broker-owned local binding. It is the
496+ // only source of the local identity, policy, and daemonmux route.
497+ Local *localDocument `json:"local,omitempty"`
388498 // IdleGrace optionally provisions the sandbox's effective idle grace as a Go
389499 // duration string (for example "90s"). It is the single deterministic source
390500 // for the idle grace, because a status probe cannot read a running broker's
@@ -393,6 +503,16 @@ type configDocument struct {
393503 Registrations []registrationRaw `json:"registrations"`
394504 }
395505
506+// localDocument is the strict on-disk shape of the broker-owned local binding.
507+// The route must be a local Unix daemonmux carriage, the identity and policy
508+// are required, and the display origin is optional (defaulting to "local").
509+type localDocument struct {
510+ Identity string `json:"identity"`
511+ DisplayOrigin string `json:"displayOrigin,omitempty"`
512+ Route json.RawMessage `json:"route"`
513+ Policy policyRaw `json:"policy"`
514+}
515+
396516 // registrationRaw is the raw JSON registration before semantic validation.
397517 type registrationRaw struct {
398518 Endpoint string `json:"endpoint"`
@@ -15,7 +15,10 @@
15 // addresses, identities, and policies. A client request can name a configured15 // addresses, identities, and policies. A client request can name a configured
16 // endpoint and must carry that endpoint's exact registration and compatible16 // endpoint and must carry that endpoint's exact registration and compatible
17 // policy; it can never supply an address, identity, secret, command, or policy17 // policy; it can never supply an address, identity, secret, command, or policy
18-// of its own, and an unknown or stale request is refused without dialing. The18+// of its own, and an unknown or stale request is refused without dialing. A
19+// local request carries no endpoint or registration and is instead fenced
20+// against the optional broker-owned local binding (identity, policy, and Unix
21+// daemonmux route), so it too can never supply authority of its own. The
19 // route's pool address is an opaque digest, so no route detail is exposed to the22 // route's pool address is an opaque digest, so no route detail is exposed to the
20 // pooling layer.23 // pooling layer.
21 //24 //
@@ -80,9 +83,13 @@ var (
80 // errStaleRegistration reports a request whose registration no longer83 // errStaleRegistration reports a request whose registration no longer
81 // matches the provisioned immutable registration.84 // matches the provisioned immutable registration.
82 errStaleRegistration = errors.New("brokerconfig: stale broker registration")85 errStaleRegistration = errors.New("brokerconfig: stale broker registration")
83- // errNoLocalRoute reports a local request: the offline sandbox provisions86+ // errNoLocalRoute reports a local request when the configuration provisions
84- // remote Unix mux routes only, so it owns no local service binding.87+ // no broker-owned local binding, so the broker owns no local route.
85 errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route")88 errNoLocalRoute = errors.New("brokerconfig: offline broker has no local route")
89+ // errLocalPolicyConflict reports a local request whose policy is not exactly
90+ // compatible with the provisioned local policy. The provisioned policy stays
91+ // authoritative: the request can never widen, narrow, or replace it.
92+ errLocalPolicyConflict = errors.New("brokerconfig: local request policy conflicts with the provisioned local policy")
86 )93 )
87 94
88 // Layout names the sandbox-owned paths derived from one offline root. Every95 // Layout names the sandbox-owned paths derived from one offline root. Every
@@ -218,6 +225,30 @@ type Registration struct {
218 Route Route225 Route Route
219 }226 }
220 227
228+// LocalBinding is the broker-owned binding for the broker's own machine
229+// daemon (Plan 001 P5.3a). Unlike a remote Registration it carries no
230+// registration: the local daemon is not a configured host, so a local stream
231+// request is fenced only against this binding's identity, policy, and route.
232+// The identity and policy are authoritative here and are the exact source of
233+// the resolved endpoint and of the published local observation's configured
234+// authority, so a local request can never select an identity or policy of its
235+// own.
236+type LocalBinding struct {
237+ // Identity is the stable authenticated daemon identity the local
238+ // daemonmux carriage must present during the physical preamble.
239+ Identity ports.BrokerDaemonIdentity
240+ // DisplayOrigin is the presentation hint published for the local
241+ // observation. It is sanitized at load time and is never routing authority.
242+ DisplayOrigin string
243+ // Policy is the exact connection policy the local carriage negotiates.
244+ Policy ports.BrokerPolicy
245+ // Route is the broker-owned local daemonmux carriage. It is always a Unix
246+ // route this process dials directly (Route.IsLocal() is true). The broker
247+ // dials it itself, so it is never a remote-side mux helper route
248+ // (Config.LocalMuxRoute).
249+ Route Route
250+}
251+
221 // pooledIdentity is the exact (authenticated identity, policy) pair the broker252 // pooledIdentity is the exact (authenticated identity, policy) pair the broker
222 // pool keys one physical transport by. The pool deliberately ignores the opaque253 // pool keys one physical transport by. The pool deliberately ignores the opaque
223 // route address, so two registrations that share a pair share one pooled254 // route address, so two registrations that share a pair share one pooled
@@ -233,6 +264,10 @@ type Config struct {
233 byAddress map[string]Route264 byAddress map[string]Route
234 unixRoutes []Route265 unixRoutes []Route
235 order []string266 order []string
267+ // local is the optional broker-owned local binding. It is nil when the
268+ // configuration provisions no local route, in which case a local request is
269+ // refused and no local observation is produced.
270+ local *LocalBinding
236 // idleGrace is the provisioned effective idle grace; hasIdleGrace records271 // idleGrace is the provisioned effective idle grace; hasIdleGrace records
237 // whether the file provisioned one, so a caller can distinguish "no272 // whether the file provisioned one, so a caller can distinguish "no
238 // provisioning, use the built-in default" from "provisioned".273 // provisioning, use the built-in default" from "provisioned".
@@ -279,7 +314,7 @@ func Load(layout Layout) (*Config, error) {
279 }314 }
280 config.idleGrace, config.hasIdleGrace = grace, true315 config.idleGrace, config.hasIdleGrace = grace, true
281 }316 }
282- pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations))317+ pooledRoutes := make(map[pooledIdentity]string, len(document.Registrations)+1)
283 for i, entry := range document.Registrations {318 for i, entry := range document.Registrations {
284 registration, err := entry.validate()319 registration, err := entry.validate()
285 if err != nil {320 if err != nil {
@@ -310,9 +345,66 @@ func Load(layout Layout) (*Config, error) {
310 }345 }
311 config.order = append(config.order, registration.Registration.Endpoint)346 config.order = append(config.order, registration.Registration.Endpoint)
312 }347 }
348+ local, err := parseLocal(document.Local)
349+ if err != nil {
350+ return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
351+ }
352+ if local != nil {
353+ if err := rejectRouteOverlap(local.Route.Path(), layout.reserved); err != nil {
354+ return nil, fmt.Errorf("brokerconfig: %s: local: %w", ConfigFileName, err)
355+ }
356+ // The pool keys one physical transport by (identity, policy) and ignores
357+ // the route address, so a local binding that shares a remote (or another
358+ // local) pair must agree on the one route that transport dials. Refuse a
359+ // second route for the same pair, exactly as the registration loop does.
360+ key := pooledIdentity{identity: local.Identity, policy: local.Policy}
361+ address := local.Route.Address()
362+ if existing, present := pooledRoutes[key]; present && existing != address {
363+ return nil, fmt.Errorf("brokerconfig: %s: local identity %q and policy already provisioned on a different route", ConfigFileName, local.Identity)
364+ }
365+ pooledRoutes[key] = address
366+ config.byAddress[address] = local.Route
367+ config.local = local
368+ }
313 return config, nil369 return config, nil
314 }370 }
315 371
372+// parseLocal strictly parses the optional local binding. A nil document
373+// provisions no local route. The identity and policy are required and
374+// validated; the route must be a local Unix daemonmux carriage, because the
375+// local daemon is reached in-process over a private Unix socket and never
376+// through an SSH or QUIC carriage. The display origin is sanitized and
377+// defaults to the broker-owned value "local", so an absent or wholly
378+// undisplayable origin never yields an empty publication.
379+func parseLocal(document *localDocument) (*LocalBinding, error) {
380+ if document == nil {
381+ return nil, nil
382+ }
383+ identity := ports.BrokerDaemonIdentity(document.Identity)
384+ if err := identity.Validate(); err != nil {
385+ return nil, err
386+ }
387+ policy, err := document.Policy.validate()
388+ if err != nil {
389+ return nil, err
390+ }
391+ route, err := parseRoute(document.Route)
392+ if err != nil {
393+ return nil, err
394+ }
395+ if !route.IsLocal() {
396+ return nil, errors.New("local route must be a Unix daemonmux carriage")
397+ }
398+ origin := ports.SanitizeBrokerDisplayText(document.DisplayOrigin)
399+ if origin == "" {
400+ origin = LocalDisplayOrigin
401+ }
402+ if len(origin) > ports.BrokerMaxDisplayOriginBytes {
403+ return nil, fmt.Errorf("local display origin exceeds %d bytes", ports.BrokerMaxDisplayOriginBytes)
404+ }
405+ return &LocalBinding{Identity: identity, DisplayOrigin: origin, Policy: policy, Route: route}, nil
406+}
407+
316 // appendUnixRouteOnce adds a local Unix route unless an identical route (the408 // appendUnixRouteOnce adds a local Unix route unless an identical route (the
317 // same opaque address, hence the same bounded contents) is already present, so409 // same opaque address, hence the same bounded contents) is already present, so
318 // endpoint aliases of one route never make LocalMuxRoute report more than one410 // endpoint aliases of one route never make LocalMuxRoute report more than one
@@ -326,6 +418,20 @@ func appendUnixRouteOnce(routes []Route, route Route) []Route {
326 return append(routes, route)418 return append(routes, route)
327 }419 }
328 420
421+// LocalDisplayOrigin is the broker-owned default presentation hint for the
422+// broker's own machine daemon when the configuration provisions none.
423+const LocalDisplayOrigin = "local"
424+
425+// LocalBinding returns the provisioned broker-owned local binding and whether
426+// one exists. A nil configuration or one without a local object provisions
427+// nothing, and a local request is then refused.
428+func (c *Config) LocalBinding() (LocalBinding, bool) {
429+ if c == nil || c.local == nil {
430+ return LocalBinding{}, false
431+ }
432+ return *c.local, true
433+}
434+
329 // Endpoints returns the configured endpoints in file order. The result is a435 // Endpoints returns the configured endpoints in file order. The result is a
330 // copy; the configuration is immutable.436 // copy; the configuration is immutable.
331 func (c *Config) Endpoints() []string {437 func (c *Config) Endpoints() []string {
@@ -358,7 +464,8 @@ func (c *Config) RouteByAddress(address string) (Route, bool) {
358 // LocalMuxRoute returns the single provisioned Unix route a remote-side mux464 // LocalMuxRoute returns the single provisioned Unix route a remote-side mux
359 // helper bridges to. A configuration that provisions no Unix route, or more465 // helper bridges to. A configuration that provisions no Unix route, or more
360 // than one, is refused: a helper must never guess which daemon-mux endpoint it466 // than one, is refused: a helper must never guess which daemon-mux endpoint it
361-// serves.467+// serves. The local binding's own carriage is deliberately excluded: the broker
468+// dials it itself, and no remote-side helper ever bridges it.
362 func (c *Config) LocalMuxRoute() (Route, error) {469 func (c *Config) LocalMuxRoute() (Route, error) {
363 if c == nil {470 if c == nil {
364 return Route{}, errors.New("brokerconfig: no configuration")471 return Route{}, errors.New("brokerconfig: no configuration")
@@ -378,13 +485,16 @@ func (c *Config) Resolver() *Resolver {
378 if c == nil {485 if c == nil {
379 return &Resolver{byEndpoint: map[string]Registration{}}486 return &Resolver{byEndpoint: map[string]Registration{}}
380 }487 }
381- return &Resolver{byEndpoint: c.byEndpoint}488+ return &Resolver{byEndpoint: c.byEndpoint, local: c.local}
382 }489 }
383 490
384 // configDocument is the strict on-disk shape. Unknown fields are refused by the491 // configDocument is the strict on-disk shape. Unknown fields are refused by the
385 // decoder, so the file cannot smuggle additional behavior.492 // decoder, so the file cannot smuggle additional behavior.
386 type configDocument struct {493 type configDocument struct {
387 Marker string `json:"marker"`494 Marker string `json:"marker"`
495+ // Local optionally provisions the broker-owned local binding. It is the
496+ // only source of the local identity, policy, and daemonmux route.
497+ Local *localDocument `json:"local,omitempty"`
388 // IdleGrace optionally provisions the sandbox's effective idle grace as a Go498 // IdleGrace optionally provisions the sandbox's effective idle grace as a Go
389 // duration string (for example "90s"). It is the single deterministic source499 // duration string (for example "90s"). It is the single deterministic source
390 // for the idle grace, because a status probe cannot read a running broker's500 // for the idle grace, because a status probe cannot read a running broker's
@@ -393,6 +503,16 @@ type configDocument struct {
393 Registrations []registrationRaw `json:"registrations"`503 Registrations []registrationRaw `json:"registrations"`
394 }504 }
395 505
506+// localDocument is the strict on-disk shape of the broker-owned local binding.
507+// The route must be a local Unix daemonmux carriage, the identity and policy
508+// are required, and the display origin is optional (defaulting to "local").
509+type localDocument struct {
510+ Identity string `json:"identity"`
511+ DisplayOrigin string `json:"displayOrigin,omitempty"`
512+ Route json.RawMessage `json:"route"`
513+ Policy policyRaw `json:"policy"`
514+}
515+
396 // registrationRaw is the raw JSON registration before semantic validation.516 // registrationRaw is the raw JSON registration before semantic validation.
397 type registrationRaw struct {517 type registrationRaw struct {
398 Endpoint string `json:"endpoint"`518 Endpoint string `json:"endpoint"`
A internal/adapters/brokerconfig/local_test.go +332 -0
@@ -0,0 +1,332 @@
1+package brokerconfig
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+const (
15+ localTestIdentity = "offline-local-daemon"
16+ localTestSocket = "/tmp/vev-brokerconfig-test/local-mux.sock"
17+)
18+
19+// localDocument returns the raw local object provisioned by the fixture. The
20+// caller may override or delete fields through the returned map.
21+func localTestDocument() map[string]any {
22+ policy := testPolicy()
23+ return map[string]any{
24+ "identity": localTestIdentity,
25+ "route": localTestSocket,
26+ "policy": map[string]any{
27+ "protocolVersion": policy.ProtocolVersion,
28+ "catalogSchemaVersion": policy.CatalogSchemaVersion,
29+ "environmentPolicy": "client-owned",
30+ "transport": policy.Transport,
31+ "trust": policy.Trust,
32+ "launch": policy.Launch,
33+ "isolation": policy.Isolation,
34+ },
35+ }
36+}
37+
38+// documentWithLocal returns a valid registration document carrying one local
39+// binding, with any field overrides applied.
40+func documentWithLocal(t *testing.T, local map[string]any) map[string]any {
41+ t.Helper()
42+ document := registrationDocument(nil)
43+ document["local"] = local
44+ return document
45+}
46+
47+// loadDocument writes one document into a fresh offline root and loads it.
48+func loadDocument(t *testing.T, document any, reserved []string) (*Config, error) {
49+ t.Helper()
50+ root := t.TempDir()
51+ writeConfig(t, root, document, 0o600)
52+ layout, err := ResolveLayout(root, reserved)
53+ require.NoError(t, err)
54+ return Load(layout)
55+}
56+
57+// localRequest builds one local open-stream request under the fixture policy.
58+func localRequest() ports.BrokerOpenStreamRequest {
59+ return ports.BrokerOpenStreamRequest{
60+ Purpose: ports.BrokerStreamControl,
61+ Local: true,
62+ Policy: testPolicy(),
63+ }
64+}
65+
66+// TestLoadLocalBinding pins the provisioned local binding: the identity,
67+// policy, sanitized display origin, and local Unix route are configuration
68+// authority, and the local route is resolvable by its opaque pool address.
69+func TestLoadLocalBinding(t *testing.T) {
70+ config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil)
71+ require.NoError(t, err)
72+
73+ binding, ok := config.LocalBinding()
74+ require.True(t, ok)
75+ require.Equal(t, ports.BrokerDaemonIdentity(localTestIdentity), binding.Identity)
76+ require.Equal(t, testPolicy(), binding.Policy)
77+ require.Equal(t, LocalDisplayOrigin, binding.DisplayOrigin)
78+ require.True(t, binding.Route.IsLocal())
79+ require.Equal(t, localTestSocket, binding.Route.Path())
80+
81+ resolved, err := config.Resolver().Resolve(context.Background(), localRequest())
82+ require.NoError(t, err)
83+ require.Equal(t, binding.Identity, resolved.Identity)
84+ require.Equal(t, binding.Policy, resolved.Policy)
85+ require.Equal(t, binding.Route.Address(), resolved.Address)
86+
87+ route, ok := config.RouteByAddress(resolved.Address)
88+ require.True(t, ok)
89+ require.Equal(t, localTestSocket, route.Path())
90+}
91+
92+// TestLoadLocalBindingDefaultsDisplayOrigin pins the broker-owned default and
93+// the sanitizer: an absent origin becomes "local", and a hostile origin is
94+// sanitized rather than published raw.
95+func TestLoadLocalBindingDefaultsDisplayOrigin(t *testing.T) {
96+ tests := []struct {
97+ name string
98+ origin any
99+ want string
100+ }{
101+ {name: "absent defaults to the broker-owned origin", origin: nil, want: LocalDisplayOrigin},
102+ {name: "explicit origin is kept", origin: "this-host", want: "this-host"},
103+ {name: "bidi and control runes are stripped", origin: "lo\u202ecal", want: "local"},
104+ {name: "wholly undisplayable defaults", origin: "\u202e\u2028", want: LocalDisplayOrigin},
105+ }
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ local := localTestDocument()
109+ if tt.origin == nil {
110+ delete(local, "displayOrigin")
111+ } else {
112+ local["displayOrigin"] = tt.origin
113+ }
114+ config, err := loadDocument(t, documentWithLocal(t, local), nil)
115+ require.NoError(t, err)
116+ binding, ok := config.LocalBinding()
117+ require.True(t, ok)
118+ require.Equal(t, tt.want, binding.DisplayOrigin)
119+ })
120+ }
121+}
122+
123+// TestLoadRejectsInvalidLocalBinding pins every local-binding refusal at the
124+// load boundary. A binding the broker could not fence is never provisioned.
125+func TestLoadRejectsInvalidLocalBinding(t *testing.T) {
126+ tests := []struct {
127+ name string
128+ mutate func(t *testing.T, local map[string]any)
129+ wantErr string
130+ }{
131+ {
132+ name: "missing identity",
133+ mutate: func(_ *testing.T, local map[string]any) { delete(local, "identity") },
134+ wantErr: "identity",
135+ },
136+ {
137+ name: "missing policy",
138+ mutate: func(_ *testing.T, local map[string]any) { delete(local, "policy") },
139+ wantErr: "policy",
140+ },
141+ {
142+ name: "missing route",
143+ mutate: func(_ *testing.T, local map[string]any) { delete(local, "route") },
144+ wantErr: "route",
145+ },
146+ {
147+ name: "ssh route is refused",
148+ mutate: func(_ *testing.T, local map[string]any) {
149+ local["route"] = map[string]any{"kind": "ssh-stdio", "target": "user@host", "argv": []string{"vev"}}
150+ },
151+ wantErr: "Unix daemonmux",
152+ },
153+ {
154+ name: "relative route is refused",
155+ mutate: func(_ *testing.T, local map[string]any) { local["route"] = "relative/mux.sock" },
156+ wantErr: "not absolute",
157+ },
158+ {
159+ name: "unknown field is refused",
160+ mutate: func(_ *testing.T, local map[string]any) { local["extra"] = 1 },
161+ wantErr: "unknown field",
162+ },
163+ }
164+ for _, tt := range tests {
165+ t.Run(tt.name, func(t *testing.T) {
166+ local := localTestDocument()
167+ tt.mutate(t, local)
168+ _, err := loadDocument(t, documentWithLocal(t, local), nil)
169+ require.Error(t, err)
170+ require.Contains(t, err.Error(), tt.wantErr)
171+ })
172+ }
173+}
174+
175+// TestLoadLocalRouteFencesProduction proves the local route is fenced against
176+// the reserved production runtime and state directories exactly like a remote
177+// Unix route: an isolated sandbox can never reach a production endpoint
178+// through its own local binding.
179+func TestLoadLocalRouteFencesProduction(t *testing.T) {
180+ reservedDir := t.TempDir()
181+ local := localTestDocument()
182+ local["route"] = filepath.Join(reservedDir, "mux.sock")
183+ _, err := loadDocument(t, documentWithLocal(t, local), []string{reservedDir})
184+ require.Error(t, err)
185+ require.Contains(t, err.Error(), "overlaps production")
186+}
187+
188+// TestLoadLocalRouteAliasesRegistrationRoute proves the local binding may
189+// reuse the exact Unix mux route a registration already provisions: the pool
190+// address is identical only when the route is identical.
191+func TestLoadLocalRouteAliasesRegistrationRoute(t *testing.T) {
192+ local := localTestDocument()
193+ local["route"] = "/tmp/vev-brokerconfig-test/mux.sock" // the fixture registration route
194+ config, err := loadDocument(t, documentWithLocal(t, local), nil)
195+ require.NoError(t, err)
196+ binding, ok := config.LocalBinding()
197+ require.True(t, ok)
198+ require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", binding.Route.Path())
199+}
200+
201+// TestLoadRefusesLocalIdentityPoolConflict proves the pool's (identity,
202+// policy) keying is honored across the local and remote provisionings: a local
203+// binding that shares a registration's exact identity and policy but names a
204+// different route is refused rather than silently dialed for one and ignored
205+// for the other.
206+func TestLoadRefusesLocalIdentityPoolConflict(t *testing.T) {
207+ local := localTestDocument()
208+ local["identity"] = testRegistrationIdentity()
209+ local["route"] = "/tmp/vev-brokerconfig-test/other-mux.sock"
210+ _, err := loadDocument(t, documentWithLocal(t, local), nil)
211+ require.Error(t, err)
212+ require.Contains(t, err.Error(), "already provisioned on a different route")
213+}
214+
215+// testRegistrationIdentity reads the identity the fixture registration
216+// provisions, so the collision test is anchored to the real fixture.
217+func testRegistrationIdentity() string { return "offline-daemon" }
218+
219+// TestResolveLocalSuccessAndRefusals pins the local resolution contract:
220+// success returns the configured authority and address, a configuration with
221+// no local binding refuses as unavailable, a conflicting policy is refused as
222+// such, and a cancelled context is reported as cancellation. Every refusal is
223+// typed as a ports.BrokerError so the pool classifies it without text matching.
224+func TestResolveLocalSuccessAndRefusals(t *testing.T) {
225+ config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil)
226+ require.NoError(t, err)
227+ binding, ok := config.LocalBinding()
228+ require.True(t, ok)
229+ resolver := config.Resolver()
230+
231+ noLocalConfig, err := loadDocument(t, registrationDocument(nil), nil)
232+ require.NoError(t, err)
233+
234+ tests := []struct {
235+ name string
236+ resolver *Resolver
237+ request ports.BrokerOpenStreamRequest
238+ wantCode ports.BrokerErrorCode
239+ wantErr error
240+ }{
241+ {
242+ name: "success returns configured authority",
243+ resolver: resolver,
244+ request: localRequest(),
245+ },
246+ {
247+ name: "no local binding is unavailable",
248+ resolver: noLocalConfig.Resolver(),
249+ request: localRequest(),
250+ wantCode: ports.BrokerErrorUnavailable,
251+ wantErr: errNoLocalRoute,
252+ },
253+ {
254+ name: "conflicting policy is refused",
255+ resolver: resolver,
256+ request: func() ports.BrokerOpenStreamRequest {
257+ request := localRequest()
258+ request.Policy.Transport = "other-transport"
259+ return request
260+ }(),
261+ wantCode: ports.BrokerErrorConflictingPolicy,
262+ wantErr: errLocalPolicyConflict,
263+ },
264+ {
265+ name: "cancelled context is cancellation",
266+ resolver: resolver,
267+ request: localRequest(),
268+ wantErr: context.Canceled,
269+ },
270+ }
271+ for _, tt := range tests {
272+ t.Run(tt.name, func(t *testing.T) {
273+ ctx := context.Background()
274+ if errors.Is(tt.wantErr, context.Canceled) {
275+ cancelled, cancel := context.WithCancel(ctx)
276+ cancel()
277+ ctx = cancelled
278+ }
279+ resolved, err := tt.resolver.Resolve(ctx, tt.request)
280+ if tt.wantErr == nil {
281+ require.NoError(t, err)
282+ require.Equal(t, binding.Identity, resolved.Identity)
283+ require.Equal(t, binding.Policy, resolved.Policy)
284+ require.Equal(t, binding.Route.Address(), resolved.Address)
285+ return
286+ }
287+ require.Error(t, err)
288+ if tt.wantCode != 0 {
289+ var typed ports.BrokerError
290+ require.ErrorAs(t, err, &typed)
291+ require.Equal(t, tt.wantCode, typed.Code)
292+ }
293+ require.ErrorIs(t, err, tt.wantErr)
294+ })
295+ }
296+}
297+
298+// TestLocalMuxRouteExcludesLocalBinding proves Config.LocalMuxRoute serves the
299+// remote-side mux helper's route only. The broker-owned local binding's own
300+// carriage is deliberately excluded: no helper ever bridges it, so a
301+// configuration provisioning only the local object has no helper route, and one
302+// provisioning both returns the registration's route, never the local binding's.
303+func TestLocalMuxRouteExcludesLocalBinding(t *testing.T) {
304+ t.Run("local-only configuration provisions no helper route", func(t *testing.T) {
305+ document := map[string]any{
306+ "marker": Marker,
307+ "registrations": []any{},
308+ "local": localTestDocument(),
309+ }
310+ config, err := loadDocument(t, document, nil)
311+ require.NoError(t, err)
312+ _, ok := config.LocalBinding()
313+ require.True(t, ok, "the fixture provisions a broker-owned local binding")
314+ _, err = config.LocalMuxRoute()
315+ require.Error(t, err)
316+ require.Contains(t, err.Error(), "no Unix daemon-mux route is provisioned")
317+ })
318+
319+ t.Run("registration route wins over the local binding route", func(t *testing.T) {
320+ config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil)
321+ require.NoError(t, err)
322+ binding, ok := config.LocalBinding()
323+ require.True(t, ok)
324+
325+ route, err := config.LocalMuxRoute()
326+ require.NoError(t, err)
327+ require.True(t, route.IsLocal())
328+ require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", route.Path(), "the helper bridges the registration route")
329+ require.NotEqual(t, binding.Route.Path(), route.Path(), "the local binding route is not the helper route")
330+ require.NotEqual(t, binding.Route.Address(), route.Address(), "the two routes carry distinct pool addresses")
331+ })
332+}
@@ -0,0 +1,332 @@
1+package brokerconfig
2+
3+import (
4+ "context"
5+ "errors"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/ports"
12+)
13+
14+const (
15+ localTestIdentity = "offline-local-daemon"
16+ localTestSocket = "/tmp/vev-brokerconfig-test/local-mux.sock"
17+)
18+
19+// localDocument returns the raw local object provisioned by the fixture. The
20+// caller may override or delete fields through the returned map.
21+func localTestDocument() map[string]any {
22+ policy := testPolicy()
23+ return map[string]any{
24+ "identity": localTestIdentity,
25+ "route": localTestSocket,
26+ "policy": map[string]any{
27+ "protocolVersion": policy.ProtocolVersion,
28+ "catalogSchemaVersion": policy.CatalogSchemaVersion,
29+ "environmentPolicy": "client-owned",
30+ "transport": policy.Transport,
31+ "trust": policy.Trust,
32+ "launch": policy.Launch,
33+ "isolation": policy.Isolation,
34+ },
35+ }
36+}
37+
38+// documentWithLocal returns a valid registration document carrying one local
39+// binding, with any field overrides applied.
40+func documentWithLocal(t *testing.T, local map[string]any) map[string]any {
41+ t.Helper()
42+ document := registrationDocument(nil)
43+ document["local"] = local
44+ return document
45+}
46+
47+// loadDocument writes one document into a fresh offline root and loads it.
48+func loadDocument(t *testing.T, document any, reserved []string) (*Config, error) {
49+ t.Helper()
50+ root := t.TempDir()
51+ writeConfig(t, root, document, 0o600)
52+ layout, err := ResolveLayout(root, reserved)
53+ require.NoError(t, err)
54+ return Load(layout)
55+}
56+
57+// localRequest builds one local open-stream request under the fixture policy.
58+func localRequest() ports.BrokerOpenStreamRequest {
59+ return ports.BrokerOpenStreamRequest{
60+ Purpose: ports.BrokerStreamControl,
61+ Local: true,
62+ Policy: testPolicy(),
63+ }
64+}
65+
66+// TestLoadLocalBinding pins the provisioned local binding: the identity,
67+// policy, sanitized display origin, and local Unix route are configuration
68+// authority, and the local route is resolvable by its opaque pool address.
69+func TestLoadLocalBinding(t *testing.T) {
70+ config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil)
71+ require.NoError(t, err)
72+
73+ binding, ok := config.LocalBinding()
74+ require.True(t, ok)
75+ require.Equal(t, ports.BrokerDaemonIdentity(localTestIdentity), binding.Identity)
76+ require.Equal(t, testPolicy(), binding.Policy)
77+ require.Equal(t, LocalDisplayOrigin, binding.DisplayOrigin)
78+ require.True(t, binding.Route.IsLocal())
79+ require.Equal(t, localTestSocket, binding.Route.Path())
80+
81+ resolved, err := config.Resolver().Resolve(context.Background(), localRequest())
82+ require.NoError(t, err)
83+ require.Equal(t, binding.Identity, resolved.Identity)
84+ require.Equal(t, binding.Policy, resolved.Policy)
85+ require.Equal(t, binding.Route.Address(), resolved.Address)
86+
87+ route, ok := config.RouteByAddress(resolved.Address)
88+ require.True(t, ok)
89+ require.Equal(t, localTestSocket, route.Path())
90+}
91+
92+// TestLoadLocalBindingDefaultsDisplayOrigin pins the broker-owned default and
93+// the sanitizer: an absent origin becomes "local", and a hostile origin is
94+// sanitized rather than published raw.
95+func TestLoadLocalBindingDefaultsDisplayOrigin(t *testing.T) {
96+ tests := []struct {
97+ name string
98+ origin any
99+ want string
100+ }{
101+ {name: "absent defaults to the broker-owned origin", origin: nil, want: LocalDisplayOrigin},
102+ {name: "explicit origin is kept", origin: "this-host", want: "this-host"},
103+ {name: "bidi and control runes are stripped", origin: "lo\u202ecal", want: "local"},
104+ {name: "wholly undisplayable defaults", origin: "\u202e\u2028", want: LocalDisplayOrigin},
105+ }
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ local := localTestDocument()
109+ if tt.origin == nil {
110+ delete(local, "displayOrigin")
111+ } else {
112+ local["displayOrigin"] = tt.origin
113+ }
114+ config, err := loadDocument(t, documentWithLocal(t, local), nil)
115+ require.NoError(t, err)
116+ binding, ok := config.LocalBinding()
117+ require.True(t, ok)
118+ require.Equal(t, tt.want, binding.DisplayOrigin)
119+ })
120+ }
121+}
122+
123+// TestLoadRejectsInvalidLocalBinding pins every local-binding refusal at the
124+// load boundary. A binding the broker could not fence is never provisioned.
125+func TestLoadRejectsInvalidLocalBinding(t *testing.T) {
126+ tests := []struct {
127+ name string
128+ mutate func(t *testing.T, local map[string]any)
129+ wantErr string
130+ }{
131+ {
132+ name: "missing identity",
133+ mutate: func(_ *testing.T, local map[string]any) { delete(local, "identity") },
134+ wantErr: "identity",
135+ },
136+ {
137+ name: "missing policy",
138+ mutate: func(_ *testing.T, local map[string]any) { delete(local, "policy") },
139+ wantErr: "policy",
140+ },
141+ {
142+ name: "missing route",
143+ mutate: func(_ *testing.T, local map[string]any) { delete(local, "route") },
144+ wantErr: "route",
145+ },
146+ {
147+ name: "ssh route is refused",
148+ mutate: func(_ *testing.T, local map[string]any) {
149+ local["route"] = map[string]any{"kind": "ssh-stdio", "target": "user@host", "argv": []string{"vev"}}
150+ },
151+ wantErr: "Unix daemonmux",
152+ },
153+ {
154+ name: "relative route is refused",
155+ mutate: func(_ *testing.T, local map[string]any) { local["route"] = "relative/mux.sock" },
156+ wantErr: "not absolute",
157+ },
158+ {
159+ name: "unknown field is refused",
160+ mutate: func(_ *testing.T, local map[string]any) { local["extra"] = 1 },
161+ wantErr: "unknown field",
162+ },
163+ }
164+ for _, tt := range tests {
165+ t.Run(tt.name, func(t *testing.T) {
166+ local := localTestDocument()
167+ tt.mutate(t, local)
168+ _, err := loadDocument(t, documentWithLocal(t, local), nil)
169+ require.Error(t, err)
170+ require.Contains(t, err.Error(), tt.wantErr)
171+ })
172+ }
173+}
174+
175+// TestLoadLocalRouteFencesProduction proves the local route is fenced against
176+// the reserved production runtime and state directories exactly like a remote
177+// Unix route: an isolated sandbox can never reach a production endpoint
178+// through its own local binding.
179+func TestLoadLocalRouteFencesProduction(t *testing.T) {
180+ reservedDir := t.TempDir()
181+ local := localTestDocument()
182+ local["route"] = filepath.Join(reservedDir, "mux.sock")
183+ _, err := loadDocument(t, documentWithLocal(t, local), []string{reservedDir})
184+ require.Error(t, err)
185+ require.Contains(t, err.Error(), "overlaps production")
186+}
187+
188+// TestLoadLocalRouteAliasesRegistrationRoute proves the local binding may
189+// reuse the exact Unix mux route a registration already provisions: the pool
190+// address is identical only when the route is identical.
191+func TestLoadLocalRouteAliasesRegistrationRoute(t *testing.T) {
192+ local := localTestDocument()
193+ local["route"] = "/tmp/vev-brokerconfig-test/mux.sock" // the fixture registration route
194+ config, err := loadDocument(t, documentWithLocal(t, local), nil)
195+ require.NoError(t, err)
196+ binding, ok := config.LocalBinding()
197+ require.True(t, ok)
198+ require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", binding.Route.Path())
199+}
200+
201+// TestLoadRefusesLocalIdentityPoolConflict proves the pool's (identity,
202+// policy) keying is honored across the local and remote provisionings: a local
203+// binding that shares a registration's exact identity and policy but names a
204+// different route is refused rather than silently dialed for one and ignored
205+// for the other.
206+func TestLoadRefusesLocalIdentityPoolConflict(t *testing.T) {
207+ local := localTestDocument()
208+ local["identity"] = testRegistrationIdentity()
209+ local["route"] = "/tmp/vev-brokerconfig-test/other-mux.sock"
210+ _, err := loadDocument(t, documentWithLocal(t, local), nil)
211+ require.Error(t, err)
212+ require.Contains(t, err.Error(), "already provisioned on a different route")
213+}
214+
215+// testRegistrationIdentity reads the identity the fixture registration
216+// provisions, so the collision test is anchored to the real fixture.
217+func testRegistrationIdentity() string { return "offline-daemon" }
218+
219+// TestResolveLocalSuccessAndRefusals pins the local resolution contract:
220+// success returns the configured authority and address, a configuration with
221+// no local binding refuses as unavailable, a conflicting policy is refused as
222+// such, and a cancelled context is reported as cancellation. Every refusal is
223+// typed as a ports.BrokerError so the pool classifies it without text matching.
224+func TestResolveLocalSuccessAndRefusals(t *testing.T) {
225+ config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil)
226+ require.NoError(t, err)
227+ binding, ok := config.LocalBinding()
228+ require.True(t, ok)
229+ resolver := config.Resolver()
230+
231+ noLocalConfig, err := loadDocument(t, registrationDocument(nil), nil)
232+ require.NoError(t, err)
233+
234+ tests := []struct {
235+ name string
236+ resolver *Resolver
237+ request ports.BrokerOpenStreamRequest
238+ wantCode ports.BrokerErrorCode
239+ wantErr error
240+ }{
241+ {
242+ name: "success returns configured authority",
243+ resolver: resolver,
244+ request: localRequest(),
245+ },
246+ {
247+ name: "no local binding is unavailable",
248+ resolver: noLocalConfig.Resolver(),
249+ request: localRequest(),
250+ wantCode: ports.BrokerErrorUnavailable,
251+ wantErr: errNoLocalRoute,
252+ },
253+ {
254+ name: "conflicting policy is refused",
255+ resolver: resolver,
256+ request: func() ports.BrokerOpenStreamRequest {
257+ request := localRequest()
258+ request.Policy.Transport = "other-transport"
259+ return request
260+ }(),
261+ wantCode: ports.BrokerErrorConflictingPolicy,
262+ wantErr: errLocalPolicyConflict,
263+ },
264+ {
265+ name: "cancelled context is cancellation",
266+ resolver: resolver,
267+ request: localRequest(),
268+ wantErr: context.Canceled,
269+ },
270+ }
271+ for _, tt := range tests {
272+ t.Run(tt.name, func(t *testing.T) {
273+ ctx := context.Background()
274+ if errors.Is(tt.wantErr, context.Canceled) {
275+ cancelled, cancel := context.WithCancel(ctx)
276+ cancel()
277+ ctx = cancelled
278+ }
279+ resolved, err := tt.resolver.Resolve(ctx, tt.request)
280+ if tt.wantErr == nil {
281+ require.NoError(t, err)
282+ require.Equal(t, binding.Identity, resolved.Identity)
283+ require.Equal(t, binding.Policy, resolved.Policy)
284+ require.Equal(t, binding.Route.Address(), resolved.Address)
285+ return
286+ }
287+ require.Error(t, err)
288+ if tt.wantCode != 0 {
289+ var typed ports.BrokerError
290+ require.ErrorAs(t, err, &typed)
291+ require.Equal(t, tt.wantCode, typed.Code)
292+ }
293+ require.ErrorIs(t, err, tt.wantErr)
294+ })
295+ }
296+}
297+
298+// TestLocalMuxRouteExcludesLocalBinding proves Config.LocalMuxRoute serves the
299+// remote-side mux helper's route only. The broker-owned local binding's own
300+// carriage is deliberately excluded: no helper ever bridges it, so a
301+// configuration provisioning only the local object has no helper route, and one
302+// provisioning both returns the registration's route, never the local binding's.
303+func TestLocalMuxRouteExcludesLocalBinding(t *testing.T) {
304+ t.Run("local-only configuration provisions no helper route", func(t *testing.T) {
305+ document := map[string]any{
306+ "marker": Marker,
307+ "registrations": []any{},
308+ "local": localTestDocument(),
309+ }
310+ config, err := loadDocument(t, document, nil)
311+ require.NoError(t, err)
312+ _, ok := config.LocalBinding()
313+ require.True(t, ok, "the fixture provisions a broker-owned local binding")
314+ _, err = config.LocalMuxRoute()
315+ require.Error(t, err)
316+ require.Contains(t, err.Error(), "no Unix daemon-mux route is provisioned")
317+ })
318+
319+ t.Run("registration route wins over the local binding route", func(t *testing.T) {
320+ config, err := loadDocument(t, documentWithLocal(t, localTestDocument()), nil)
321+ require.NoError(t, err)
322+ binding, ok := config.LocalBinding()
323+ require.True(t, ok)
324+
325+ route, err := config.LocalMuxRoute()
326+ require.NoError(t, err)
327+ require.True(t, route.IsLocal())
328+ require.Equal(t, "/tmp/vev-brokerconfig-test/mux.sock", route.Path(), "the helper bridges the registration route")
329+ require.NotEqual(t, binding.Route.Path(), route.Path(), "the local binding route is not the helper route")
330+ require.NotEqual(t, binding.Route.Address(), route.Address(), "the two routes carry distinct pool addresses")
331+ })
332+}
M internal/adapters/brokerconfig/resolver.go +41 -8
@@ -12,27 +12,40 @@ import (
1212 // Resolve call fences the request against the provisioned registration and
1313 // returns the configured identity, policy, and opaque route address. A request
1414 // can therefore never select an address, identity, or policy of its own.
15+//
16+// A remote request is fenced against its provisioned registration (endpoint,
17+// incarnation, and generation) and policy. A local request carries no endpoint
18+// and no registration, so it is fenced against the broker-owned local binding:
19+// the configured local identity, policy, and Unix daemonmux route. Both paths
20+// return authority chosen by the configuration alone, and neither can widen,
21+// narrow, or replace the provisioned identity or policy.
1522 type Resolver struct {
1623 byEndpoint map[string]Registration
24+ local *LocalBinding
1725 }
1826
1927 var _ ports.BrokerEndpointResolver = (*Resolver)(nil)
2028
2129 // Resolve fences one open-stream request against the immutable configuration.
2230 //
23-// The request must name a configured endpoint, carry that endpoint's exact
24-// registration (endpoint, incarnation, and generation), and request a policy
25-// compatible with the provisioned policy. A local request is refused because
26-// the offline sandbox provisions remote routes only. Unknown, stale,
27-// or conflicting requests are refused with a typed broker error and no dial is
28-// attempted; a cancelled context is reported as such so the pool classifies it
29-// as cancellation rather than unavailability.
31+// A remote request must name a configured endpoint, carry that endpoint's
32+// exact registration (endpoint, incarnation, and generation), and request a
33+// policy compatible with the provisioned policy. A local request must instead
34+// carry an empty endpoint and zero registration (ports enforces that) and is
35+// fenced against the provisioned local binding: without one it is refused, and
36+// with one its requested policy must be exactly compatible with the
37+// provisioned local policy. Either way the resolved identity, policy, and
38+// opaque route address come from the configuration, never from the request.
39+//
40+// Unknown, stale, conflicting, or unprovisioned requests are refused with a
41+// typed broker error and no dial is attempted; a cancelled context is reported
42+// as such so the pool classifies it as cancellation rather than unavailability.
3043 func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
3144 if err := ctx.Err(); err != nil {
3245 return ports.BrokerResolvedEndpoint{}, err
3346 }
3447 if request.Local {
35- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute}
48+ return r.resolveLocal(request)
3649 }
3750 provisioned, ok := r.byEndpoint[request.Endpoint]
3851 if !ok {
@@ -50,3 +63,23 @@ func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRe
5063 }
5164 return resolved, nil
5265 }
66+
67+// resolveLocal fences one local request against the broker-owned local
68+// binding. A configuration that provisions no local route refuses the request
69+// as unavailable; a request whose policy is not exactly compatible with the
70+// provisioned local policy is refused as a conflicting policy. The resolved
71+// identity, policy, and address are the binding's own, so the request supplies
72+// none of them.
73+func (r *Resolver) resolveLocal(request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
74+ if r.local == nil {
75+ return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute}
76+ }
77+ if !r.local.Policy.Compatible(request.Policy) {
78+ return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errLocalPolicyConflict}
79+ }
80+ resolved := ports.BrokerResolvedEndpoint{Identity: r.local.Identity, Policy: r.local.Policy, Address: r.local.Route.Address()}
81+ if err := resolved.Validate(); err != nil {
82+ return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
83+ }
84+ return resolved, nil
85+}
@@ -12,27 +12,40 @@ import (
12 // Resolve call fences the request against the provisioned registration and12 // Resolve call fences the request against the provisioned registration and
13 // returns the configured identity, policy, and opaque route address. A request13 // returns the configured identity, policy, and opaque route address. A request
14 // can therefore never select an address, identity, or policy of its own.14 // can therefore never select an address, identity, or policy of its own.
15+//
16+// A remote request is fenced against its provisioned registration (endpoint,
17+// incarnation, and generation) and policy. A local request carries no endpoint
18+// and no registration, so it is fenced against the broker-owned local binding:
19+// the configured local identity, policy, and Unix daemonmux route. Both paths
20+// return authority chosen by the configuration alone, and neither can widen,
21+// narrow, or replace the provisioned identity or policy.
15 type Resolver struct {22 type Resolver struct {
16 byEndpoint map[string]Registration23 byEndpoint map[string]Registration
24+ local *LocalBinding
17 }25 }
18 26
19 var _ ports.BrokerEndpointResolver = (*Resolver)(nil)27 var _ ports.BrokerEndpointResolver = (*Resolver)(nil)
20 28
21 // Resolve fences one open-stream request against the immutable configuration.29 // Resolve fences one open-stream request against the immutable configuration.
22 //30 //
23-// The request must name a configured endpoint, carry that endpoint's exact31+// A remote request must name a configured endpoint, carry that endpoint's
24-// registration (endpoint, incarnation, and generation), and request a policy32+// exact registration (endpoint, incarnation, and generation), and request a
25-// compatible with the provisioned policy. A local request is refused because33+// policy compatible with the provisioned policy. A local request must instead
26-// the offline sandbox provisions remote routes only. Unknown, stale,34+// carry an empty endpoint and zero registration (ports enforces that) and is
27-// or conflicting requests are refused with a typed broker error and no dial is35+// fenced against the provisioned local binding: without one it is refused, and
28-// attempted; a cancelled context is reported as such so the pool classifies it36+// with one its requested policy must be exactly compatible with the
29-// as cancellation rather than unavailability.37+// provisioned local policy. Either way the resolved identity, policy, and
38+// opaque route address come from the configuration, never from the request.
39+//
40+// Unknown, stale, conflicting, or unprovisioned requests are refused with a
41+// typed broker error and no dial is attempted; a cancelled context is reported
42+// as such so the pool classifies it as cancellation rather than unavailability.
30 func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {43 func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
31 if err := ctx.Err(); err != nil {44 if err := ctx.Err(); err != nil {
32 return ports.BrokerResolvedEndpoint{}, err45 return ports.BrokerResolvedEndpoint{}, err
33 }46 }
34 if request.Local {47 if request.Local {
35- return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute}48+ return r.resolveLocal(request)
36 }49 }
37 provisioned, ok := r.byEndpoint[request.Endpoint]50 provisioned, ok := r.byEndpoint[request.Endpoint]
38 if !ok {51 if !ok {
@@ -50,3 +63,23 @@ func (r *Resolver) Resolve(ctx context.Context, request ports.BrokerOpenStreamRe
50 }63 }
51 return resolved, nil64 return resolved, nil
52 }65 }
66+
67+// resolveLocal fences one local request against the broker-owned local
68+// binding. A configuration that provisions no local route refuses the request
69+// as unavailable; a request whose policy is not exactly compatible with the
70+// provisioned local policy is refused as a conflicting policy. The resolved
71+// identity, policy, and address are the binding's own, so the request supplies
72+// none of them.
73+func (r *Resolver) resolveLocal(request ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
74+ if r.local == nil {
75+ return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: errNoLocalRoute}
76+ }
77+ if !r.local.Policy.Compatible(request.Policy) {
78+ return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errLocalPolicyConflict}
79+ }
80+ resolved := ports.BrokerResolvedEndpoint{Identity: r.local.Identity, Policy: r.local.Policy, Address: r.local.Route.Address()}
81+ if err := resolved.Validate(); err != nil {
82+ return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorIncompatible, Cause: err}
83+ }
84+ return resolved, nil
85+}
M internal/adapters/brokerconfig/route.go +6 -3
@@ -93,8 +93,10 @@ type Route struct {
9393 // is a digest of the route contents, so it leaks no route detail and can
9494 // never collide with a distinct route.
9595 address string
96- // local is true for a Unix route that this process can dial directly, which
97- // is the route a remote-side mux helper bridges to.
96+ // local is true for a Unix route that this process can dial directly. Such a
97+ // route is the carriage a remote-side mux helper bridges to when a
98+ // registration provisions it; a local binding's own carriage is dialled by
99+ // the broker itself and is never a helper route.
98100 local bool
99101 }
100102
@@ -121,7 +123,8 @@ func (r Route) ConnectTimeout() time.Duration { return r.trust.connectTimeout }
121123 func (r Route) Address() string { return r.address }
122124
123125 // IsLocal reports whether the route dials a Unix socket this process can reach
124-// directly, which is the route a remote-side mux helper bridges to.
126+// directly. A local binding's carriage and a registration's Unix carriage are
127+// both local; only the latter is a route a remote-side mux helper bridges to.
125128 func (r Route) IsLocal() bool { return r.local }
126129
127130 // Validate re-checks one already-parsed route. Parsed routes are validated
@@ -93,8 +93,10 @@ type Route struct {
93 // is a digest of the route contents, so it leaks no route detail and can93 // is a digest of the route contents, so it leaks no route detail and can
94 // never collide with a distinct route.94 // never collide with a distinct route.
95 address string95 address string
96- // local is true for a Unix route that this process can dial directly, which96+ // local is true for a Unix route that this process can dial directly. Such a
97- // is the route a remote-side mux helper bridges to.97+ // route is the carriage a remote-side mux helper bridges to when a
98+ // registration provisions it; a local binding's own carriage is dialled by
99+ // the broker itself and is never a helper route.
98 local bool100 local bool
99 }101 }
100 102
@@ -121,7 +123,8 @@ func (r Route) ConnectTimeout() time.Duration { return r.trust.connectTimeout }
121 func (r Route) Address() string { return r.address }123 func (r Route) Address() string { return r.address }
122 124
123 // IsLocal reports whether the route dials a Unix socket this process can reach125 // IsLocal reports whether the route dials a Unix socket this process can reach
124-// directly, which is the route a remote-side mux helper bridges to.126+// directly. A local binding's carriage and a registration's Unix carriage are
127+// both local; only the latter is a route a remote-side mux helper bridges to.
125 func (r Route) IsLocal() bool { return r.local }128 func (r Route) IsLocal() bool { return r.local }
126 129
127 // Validate re-checks one already-parsed route. Parsed routes are validated130 // Validate re-checks one already-parsed route. Parsed routes are validated
A internal/app/broker_offline_local.go +153 -0
@@ -0,0 +1,153 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+
9+ "github.com/bnema/vev/internal/adapters/brokerconfig"
10+ "github.com/bnema/vev/internal/adapters/daemonmux"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/broker"
14+)
15+
16+// Broker-owned local observation composition (Plan 001 P5.3a).
17+//
18+// The offline broker observes its own machine daemon through the same daemonmux
19+// carriage a local logical stream is dialed over. The probe completes only the
20+// physical preamble: it dials the provisioned local Unix route, negotiates
21+// identity, incarnation, and policy, and closes the carriage. It deliberately
22+// opens no logical stream, so observing the local daemon never attaches to a
23+// session, and it only ever dials an existing local Unix socket, so it never
24+// starts a stopped daemon. The composition is isolated from production state:
25+// it derives the probe from the sandbox configuration and never touches the
26+// production runtime or state directories.
27+//
28+// This is a preparation slice: nothing here is activated by production
29+// composition, and the observation-disabled offline authority composition is
30+// unchanged.
31+
32+// localCarrierDialer establishes one raw framed daemonmux carriage for the
33+// opaque route address the broker-owned local binding published. It is the same
34+// seam the pooled mux connector uses, so the probe can never select a transport
35+// or an address of its own.
36+type localCarrierDialer func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error)
37+
38+// localRouteProbe is the broker-owned ports-independent local observation
39+// probe. It holds the resolved local endpoint authority (identity, policy, and
40+// opaque address) and dials it exactly once per observation, then closes the
41+// carriage: no logical stream is ever opened and no daemon process is ever
42+// started.
43+type localRouteProbe struct {
44+ endpoint ports.BrokerResolvedEndpoint
45+ dial localCarrierDialer
46+ ceilings daemonmux.MuxCeilings
47+}
48+
49+var _ broker.LocalProbe = (*localRouteProbe)(nil)
50+
51+// newLocalRouteProbe fences one broker-owned local binding into a probe. The
52+// binding's identity, policy, and route address must validate, the ceilings
53+// must be a valid advertisement, and a nil dial is refused: a probe that cannot
54+// establish a carriage must not exist.
55+func newLocalRouteProbe(binding brokerconfig.LocalBinding, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) {
56+ if dial == nil {
57+ return nil, errors.New("vev: broker local probe requires a dialer")
58+ }
59+ // Probing must never spawn a process, so only a Unix route this process
60+ // dials directly is acceptable. brokerconfig refuses any other kind at load
61+ // time; this re-checks the exported binding a caller may have built itself,
62+ // because the dial seam routes an ssh address to a child process.
63+ if !binding.Route.IsLocal() {
64+ return nil, errors.New("vev: broker local probe requires a local route")
65+ }
66+ if err := ceilings.Validate(); err != nil {
67+ return nil, fmt.Errorf("vev: broker local probe: %w", err)
68+ }
69+ endpoint := ports.BrokerResolvedEndpoint{Identity: binding.Identity, Policy: binding.Policy, Address: binding.Route.Address()}
70+ if err := endpoint.Validate(); err != nil {
71+ return nil, fmt.Errorf("vev: broker local probe: %w", err)
72+ }
73+ return &localRouteProbe{endpoint: endpoint, dial: dial, ceilings: ceilings}, nil
74+}
75+
76+// ProbeLocal observes the local daemon by completing the daemonmux physical
77+// preamble and closing the carriage. A successful preamble reports the only
78+// observed state the carriage exposes: the authenticated identity, the daemon
79+// incarnation, the protocol version the daemon's accepted policy negotiated,
80+// and reachability. Capabilities and the session catalogue are not carried by
81+// the physical preamble, so they stay unknown (zero) rather than being
82+// invented, and every failure reports an explicit availability with zero
83+// observed identity: a probe never manufactures identity, version, or
84+// inventory it did not observe.
85+func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
86+ if p == nil {
87+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil")
88+ }
89+ raw, err := p.dial(ctx, p.endpoint.Address)
90+ if err != nil {
91+ if raw != nil {
92+ _ = raw.Close()
93+ }
94+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
95+ }
96+ if raw == nil {
97+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe: nil carriage")
98+ }
99+ bridge, err := daemonmux.NewPreambleCarrier(raw)
100+ if err != nil {
101+ _ = raw.Close()
102+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
103+ }
104+ // RunClientHandshake closes the carriage on every failure; on success the
105+ // probe closes it here, because it has nothing further to send. The
106+ // handshake never opens a logical stream.
107+ result, err := daemonmux.RunClientHandshake(ctx, bridge, p.endpoint, p.ceilings)
108+ _ = bridge.Close()
109+ if err != nil {
110+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
111+ }
112+ return ports.BrokerDaemonObservation{
113+ Identity: result.Identity,
114+ Incarnation: result.Incarnation,
115+ ProtocolVersion: result.Policy.ProtocolVersion,
116+ Availability: domain.RemoteAvailabilityReachable,
117+ }, nil
118+}
119+
120+// brokerLocalProbe builds the broker-owned local probe over one validated
121+// offline configuration. The dialer looks the opaque address up in the same
122+// immutable configuration the pooled connector uses, so a probe can never reach
123+// an address the configuration did not provision.
124+func brokerLocalProbe(config *brokerconfig.Config, log *slog.Logger) (*localRouteProbe, error) {
125+ binding, ok := config.LocalBinding()
126+ if !ok {
127+ return nil, errors.New("vev: broker local probe requires a provisioned local binding")
128+ }
129+ dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) {
130+ route, ok := config.RouteByAddress(address)
131+ if !ok {
132+ return nil, errors.New("vev: broker local probe: unknown route address")
133+ }
134+ return dialBrokerRoute(ctx, route, log)
135+ }
136+ return newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
137+}
138+
139+// brokerLocalObservation composes the registry's local producer from one
140+// validated offline configuration: the configured display origin and policy
141+// become the local entry's authority, and the probe supplies observed state
142+// only. A configuration without a local binding provisions no local producer.
143+func brokerLocalObservation(config *brokerconfig.Config, log *slog.Logger) (*broker.LocalObservation, error) {
144+ binding, ok := config.LocalBinding()
145+ if !ok {
146+ return nil, errors.New("vev: broker local observation requires a provisioned local binding")
147+ }
148+ probe, err := brokerLocalProbe(config, log)
149+ if err != nil {
150+ return nil, err
151+ }
152+ return &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe}, nil
153+}
@@ -0,0 +1,153 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+
9+ "github.com/bnema/vev/internal/adapters/brokerconfig"
10+ "github.com/bnema/vev/internal/adapters/daemonmux"
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/usecase/broker"
14+)
15+
16+// Broker-owned local observation composition (Plan 001 P5.3a).
17+//
18+// The offline broker observes its own machine daemon through the same daemonmux
19+// carriage a local logical stream is dialed over. The probe completes only the
20+// physical preamble: it dials the provisioned local Unix route, negotiates
21+// identity, incarnation, and policy, and closes the carriage. It deliberately
22+// opens no logical stream, so observing the local daemon never attaches to a
23+// session, and it only ever dials an existing local Unix socket, so it never
24+// starts a stopped daemon. The composition is isolated from production state:
25+// it derives the probe from the sandbox configuration and never touches the
26+// production runtime or state directories.
27+//
28+// This is a preparation slice: nothing here is activated by production
29+// composition, and the observation-disabled offline authority composition is
30+// unchanged.
31+
32+// localCarrierDialer establishes one raw framed daemonmux carriage for the
33+// opaque route address the broker-owned local binding published. It is the same
34+// seam the pooled mux connector uses, so the probe can never select a transport
35+// or an address of its own.
36+type localCarrierDialer func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error)
37+
38+// localRouteProbe is the broker-owned ports-independent local observation
39+// probe. It holds the resolved local endpoint authority (identity, policy, and
40+// opaque address) and dials it exactly once per observation, then closes the
41+// carriage: no logical stream is ever opened and no daemon process is ever
42+// started.
43+type localRouteProbe struct {
44+ endpoint ports.BrokerResolvedEndpoint
45+ dial localCarrierDialer
46+ ceilings daemonmux.MuxCeilings
47+}
48+
49+var _ broker.LocalProbe = (*localRouteProbe)(nil)
50+
51+// newLocalRouteProbe fences one broker-owned local binding into a probe. The
52+// binding's identity, policy, and route address must validate, the ceilings
53+// must be a valid advertisement, and a nil dial is refused: a probe that cannot
54+// establish a carriage must not exist.
55+func newLocalRouteProbe(binding brokerconfig.LocalBinding, dial localCarrierDialer, ceilings daemonmux.MuxCeilings) (*localRouteProbe, error) {
56+ if dial == nil {
57+ return nil, errors.New("vev: broker local probe requires a dialer")
58+ }
59+ // Probing must never spawn a process, so only a Unix route this process
60+ // dials directly is acceptable. brokerconfig refuses any other kind at load
61+ // time; this re-checks the exported binding a caller may have built itself,
62+ // because the dial seam routes an ssh address to a child process.
63+ if !binding.Route.IsLocal() {
64+ return nil, errors.New("vev: broker local probe requires a local route")
65+ }
66+ if err := ceilings.Validate(); err != nil {
67+ return nil, fmt.Errorf("vev: broker local probe: %w", err)
68+ }
69+ endpoint := ports.BrokerResolvedEndpoint{Identity: binding.Identity, Policy: binding.Policy, Address: binding.Route.Address()}
70+ if err := endpoint.Validate(); err != nil {
71+ return nil, fmt.Errorf("vev: broker local probe: %w", err)
72+ }
73+ return &localRouteProbe{endpoint: endpoint, dial: dial, ceilings: ceilings}, nil
74+}
75+
76+// ProbeLocal observes the local daemon by completing the daemonmux physical
77+// preamble and closing the carriage. A successful preamble reports the only
78+// observed state the carriage exposes: the authenticated identity, the daemon
79+// incarnation, the protocol version the daemon's accepted policy negotiated,
80+// and reachability. Capabilities and the session catalogue are not carried by
81+// the physical preamble, so they stay unknown (zero) rather than being
82+// invented, and every failure reports an explicit availability with zero
83+// observed identity: a probe never manufactures identity, version, or
84+// inventory it did not observe.
85+func (p *localRouteProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
86+ if p == nil {
87+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe is nil")
88+ }
89+ raw, err := p.dial(ctx, p.endpoint.Address)
90+ if err != nil {
91+ if raw != nil {
92+ _ = raw.Close()
93+ }
94+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
95+ }
96+ if raw == nil {
97+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, errors.New("vev: broker local probe: nil carriage")
98+ }
99+ bridge, err := daemonmux.NewPreambleCarrier(raw)
100+ if err != nil {
101+ _ = raw.Close()
102+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
103+ }
104+ // RunClientHandshake closes the carriage on every failure; on success the
105+ // probe closes it here, because it has nothing further to send. The
106+ // handshake never opens a logical stream.
107+ result, err := daemonmux.RunClientHandshake(ctx, bridge, p.endpoint, p.ceilings)
108+ _ = bridge.Close()
109+ if err != nil {
110+ return ports.BrokerDaemonObservation{Availability: domain.RemoteAvailabilityUnreachable}, err
111+ }
112+ return ports.BrokerDaemonObservation{
113+ Identity: result.Identity,
114+ Incarnation: result.Incarnation,
115+ ProtocolVersion: result.Policy.ProtocolVersion,
116+ Availability: domain.RemoteAvailabilityReachable,
117+ }, nil
118+}
119+
120+// brokerLocalProbe builds the broker-owned local probe over one validated
121+// offline configuration. The dialer looks the opaque address up in the same
122+// immutable configuration the pooled connector uses, so a probe can never reach
123+// an address the configuration did not provision.
124+func brokerLocalProbe(config *brokerconfig.Config, log *slog.Logger) (*localRouteProbe, error) {
125+ binding, ok := config.LocalBinding()
126+ if !ok {
127+ return nil, errors.New("vev: broker local probe requires a provisioned local binding")
128+ }
129+ dial := func(ctx context.Context, address string) (daemonmux.RawFramedTransport, error) {
130+ route, ok := config.RouteByAddress(address)
131+ if !ok {
132+ return nil, errors.New("vev: broker local probe: unknown route address")
133+ }
134+ return dialBrokerRoute(ctx, route, log)
135+ }
136+ return newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
137+}
138+
139+// brokerLocalObservation composes the registry's local producer from one
140+// validated offline configuration: the configured display origin and policy
141+// become the local entry's authority, and the probe supplies observed state
142+// only. A configuration without a local binding provisions no local producer.
143+func brokerLocalObservation(config *brokerconfig.Config, log *slog.Logger) (*broker.LocalObservation, error) {
144+ binding, ok := config.LocalBinding()
145+ if !ok {
146+ return nil, errors.New("vev: broker local observation requires a provisioned local binding")
147+ }
148+ probe, err := brokerLocalProbe(config, log)
149+ if err != nil {
150+ return nil, err
151+ }
152+ return &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe}, nil
153+}
A internal/app/broker_offline_local_test.go +314 -0
@@ -0,0 +1,314 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/vev/internal/adapters/brokerconfig"
18+ "github.com/bnema/vev/internal/adapters/brokerstore"
19+ "github.com/bnema/vev/internal/adapters/clock"
20+ "github.com/bnema/vev/internal/adapters/daemonmux"
21+ "github.com/bnema/vev/internal/adapters/ipc"
22+ "github.com/bnema/vev/internal/domain"
23+ "github.com/bnema/vev/internal/ports"
24+ "github.com/bnema/vev/internal/usecase/broker"
25+)
26+
27+const brokerLocalTestIdentity = "offline-local-daemon"
28+
29+// testLocalSandboxConfig writes and loads one marker-valid sandbox whose only
30+// provisioned carriage is the broker-owned local binding.
31+func testLocalSandboxConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.LocalBinding) {
32+ t.Helper()
33+ root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
34+ policy := brokerTestPolicy()
35+ require.NoError(t, os.MkdirAll(root, 0o700))
36+ writeSandboxConfig(t, root, map[string]any{
37+ "marker": brokerconfig.Marker,
38+ "registrations": []any{},
39+ "local": map[string]any{
40+ "identity": brokerLocalTestIdentity,
41+ "displayOrigin": "local",
42+ "route": filepath.Join(root, "local-mux.sock"),
43+ "policy": map[string]any{
44+ "protocolVersion": policy.ProtocolVersion,
45+ "catalogSchemaVersion": policy.CatalogSchemaVersion,
46+ "environmentPolicy": "client-owned",
47+ "transport": policy.Transport,
48+ "trust": policy.Trust,
49+ "launch": policy.Launch,
50+ "isolation": policy.Isolation,
51+ },
52+ },
53+ })
54+ layout, err := brokerconfig.ResolveLayout(root, nil)
55+ require.NoError(t, err)
56+ config, err := brokerconfig.Load(layout)
57+ require.NoError(t, err)
58+ binding, ok := config.LocalBinding()
59+ require.True(t, ok)
60+ return config, binding
61+}
62+
63+// rawFramedTransport adapts one in-memory pipe end to the raw framed carriage
64+// the daemonmux bridge consumes. No real socket is opened.
65+func rawFramedTransport(raw net.Conn) daemonmux.RawFramedTransport {
66+ return ipc.NewTransport(raw).(daemonmux.RawFramedTransport)
67+}
68+
69+// testServerBinding builds the daemon-side binding matching one local binding.
70+func testServerBinding(t *testing.T, binding brokerconfig.LocalBinding) daemonmux.ServerBinding {
71+ t.Helper()
72+ serverBinding, err := daemonmux.NewServerBinding(binding.Identity, ports.BrokerDaemonIncarnation{7, 1}, binding.Policy)
73+ require.NoError(t, err)
74+ return serverBinding
75+}
76+
77+// TestBrokerLocalProbeObservesWithoutAttaching proves the local probe completes
78+// only the daemonmux physical preamble: it reports the daemon's authenticated
79+// identity, incarnation, and negotiated protocol version, and then closes the
80+// carriage without ever opening a logical stream (no attach). Capabilities and
81+// the session catalogue stay unknown because the preamble carries neither.
82+func TestBrokerLocalProbeObservesWithoutAttaching(t *testing.T) {
83+ _, binding := testLocalSandboxConfig(t)
84+ serverBinding := testServerBinding(t, binding)
85+
86+ clientConn, serverConn := net.Pipe()
87+ t.Cleanup(func() {
88+ _ = clientConn.Close()
89+ _ = serverConn.Close()
90+ })
91+
92+ var dialed atomic.Value
93+ dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) {
94+ dialed.Store(address)
95+ return rawFramedTransport(clientConn), nil
96+ }
97+ probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
98+ require.NoError(t, err)
99+
100+ // The daemon side completes one preamble and then blocks reading the next
101+ // frame. The probe must never send one: the read ends only when the probe
102+ // closes the carriage, which is the proof that no attachment stream opened.
103+ serverDone := make(chan error, 1)
104+ go func() {
105+ bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn))
106+ if err != nil {
107+ serverDone <- err
108+ return
109+ }
110+ if _, err := daemonmux.RunServerHandshake(context.Background(), bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil {
111+ serverDone <- err
112+ return
113+ }
114+ readCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
115+ defer cancel()
116+ _, recvErr := bridge.Receive(readCtx)
117+ serverDone <- recvErr
118+ }()
119+
120+ observation, err := probe.ProbeLocal(context.Background())
121+ require.NoError(t, err)
122+ require.Equal(t, binding.Identity, observation.Identity)
123+ require.Equal(t, ports.BrokerDaemonIncarnation{7, 1}, observation.Incarnation)
124+ require.Equal(t, binding.Policy.ProtocolVersion, observation.ProtocolVersion)
125+ require.Equal(t, domain.RemoteAvailabilityReachable, observation.Availability)
126+ require.Zero(t, observation.Capabilities, "the preamble carries no capabilities; they must stay unknown")
127+ require.Empty(t, observation.Sessions)
128+ require.False(t, observation.InventoryKnown)
129+ require.False(t, observation.Local, "the probe supplies observed state only, never local authority")
130+ require.Empty(t, observation.Endpoint)
131+ require.Equal(t, binding.Route.Address(), dialed.Load())
132+
133+ serverErr := <-serverDone
134+ require.Error(t, serverErr, "the probe sent no frame after the preamble")
135+ require.True(t, errors.Is(serverErr, io.EOF) || errors.Is(serverErr, io.ErrClosedPipe) || errors.Is(serverErr, context.Canceled),
136+ "expected a closed carriage, got %v", serverErr)
137+}
138+
139+// TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero
140+// observed identity and an explicit availability, never an invented one, and
141+// that the probe only dials the provisioned local address: it has no spawn path
142+// and never starts a stopped daemon.
143+func TestBrokerLocalProbeFailsClosed(t *testing.T) {
144+ _, binding := testLocalSandboxConfig(t)
145+
146+ var addresses []string
147+ dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) {
148+ addresses = append(addresses, address)
149+ return nil, errors.New("no carriage")
150+ }
151+ probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
152+ require.NoError(t, err)
153+
154+ observation, err := probe.ProbeLocal(context.Background())
155+ require.Error(t, err)
156+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
157+ require.Zero(t, observation.Identity)
158+ require.True(t, observation.Incarnation.IsZero())
159+ require.Zero(t, observation.ProtocolVersion)
160+ require.Nil(t, observation.Sessions)
161+ require.Equal(t, []string{binding.Route.Address()}, addresses)
162+
163+ t.Run("a nil carriage is refused", func(t *testing.T) {
164+ probe, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) {
165+ return nil, nil
166+ }, daemonmux.DefaultMuxCeilings())
167+ require.NoError(t, err)
168+ observation, err := probe.ProbeLocal(context.Background())
169+ require.Error(t, err)
170+ require.Zero(t, observation.Identity)
171+ })
172+
173+ t.Run("construction refuses a missing dialer", func(t *testing.T) {
174+ _, err := newLocalRouteProbe(binding, nil, daemonmux.DefaultMuxCeilings())
175+ require.Error(t, err)
176+ })
177+}
178+
179+// TestBrokerLocalObservationComposition proves the composition derives the
180+// local producer's authority from the validated configuration and refuses a
181+// configuration that provisions no local binding.
182+func TestBrokerLocalObservationComposition(t *testing.T) {
183+ config, binding := testLocalSandboxConfig(t)
184+ observation, err := brokerLocalObservation(config, nil)
185+ require.NoError(t, err)
186+ require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin)
187+ require.Equal(t, binding.Policy, observation.Policy)
188+ require.NotNil(t, observation.Probe)
189+
190+ t.Run("no local binding provisions no producer", func(t *testing.T) {
191+ root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
192+ require.NoError(t, os.MkdirAll(root, 0o700))
193+ writeSandboxConfig(t, root, sandboxRegistrationDocument("/tmp/vev-local-test/mux.sock"))
194+ layout, err := brokerconfig.ResolveLayout(root, nil)
195+ require.NoError(t, err)
196+ config, err := brokerconfig.Load(layout)
197+ require.NoError(t, err)
198+ _, err = brokerLocalObservation(config, nil)
199+ require.Error(t, err)
200+ })
201+}
202+
203+// TestBrokerLocalObservationRegistryPublishesLocalFirst proves the composition
204+// end to end: the registry observes the local daemon through the broker-owned
205+// probe and publishes it at index zero, and the durable store persists no local
206+// entry (the local observation is never durable).
207+func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
208+ _, binding := testLocalSandboxConfig(t)
209+ serverBinding := testServerBinding(t, binding)
210+
211+ var dials atomic.Int32
212+ dial := func(ctx context.Context, _ string) (daemonmux.RawFramedTransport, error) {
213+ dials.Add(1)
214+ clientConn, serverConn := net.Pipe()
215+ go func() {
216+ defer serverConn.Close()
217+ bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn))
218+ if err != nil {
219+ return
220+ }
221+ if _, err := daemonmux.RunServerHandshake(ctx, bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil {
222+ return
223+ }
224+ // Hold the carriage until the probe closes it or the attempt is
225+ // retired, so the daemon side owns no logical stream either.
226+ <-ctx.Done()
227+ _ = bridge.Close()
228+ }()
229+ return rawFramedTransport(clientConn), nil
230+ }
231+ probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
232+ require.NoError(t, err)
233+
234+ stateDir := filepath.Join(shortTempDir(t, "vevs"), "state")
235+ require.NoError(t, os.MkdirAll(stateDir, 0o700))
236+ store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: stateDir})
237+ require.NoError(t, err)
238+ t.Cleanup(func() { require.NoError(t, store.Close()) })
239+
240+ registry, err := broker.NewRegistryWithConfig(7, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{
241+ Local: &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe},
242+ })
243+ require.NoError(t, err)
244+
245+ ctx, cancel := context.WithCancel(context.Background())
246+ done := make(chan struct{})
247+ go func() { defer close(done); registry.Run(ctx) }()
248+ t.Cleanup(func() { cancel(); <-done })
249+
250+ require.Eventually(t, func() bool {
251+ snapshot := registry.Snapshot()
252+ return len(snapshot.Daemons) == 1 && snapshot.Daemons[0].Local &&
253+ !snapshot.Daemons[0].Checking && snapshot.Daemons[0].Availability == domain.RemoteAvailabilityReachable
254+ }, 3*time.Second, 2*time.Millisecond)
255+
256+ snapshot := registry.Snapshot()
257+ require.Len(t, snapshot.Daemons, 1)
258+ require.True(t, snapshot.Daemons[0].Local)
259+ require.Equal(t, binding.Identity, snapshot.Daemons[0].Identity)
260+ require.Positive(t, dials.Load())
261+
262+ // The durable store persists remote observations only: the local entry never
263+ // reaches disk.
264+ require.Eventually(t, func() bool {
265+ stored, err := store.Load()
266+ return err == nil && stored.Revision > 0
267+ }, 3*time.Second, 2*time.Millisecond)
268+ stored, err := store.Load()
269+ require.NoError(t, err)
270+ for _, daemon := range stored.Daemons {
271+ require.False(t, daemon.Local, "a local observation must never be durable")
272+ }
273+}
274+
275+// TestBrokerLocalProbeRefusesNonLocalRoute proves the probe refuses a
276+// hand-built binding whose route is an ssh helper route: probing must never
277+// start a child process, so only a Unix route this process dials directly is
278+// acceptable. brokerconfig exposes no exported route constructor and its route
279+// fields are unexported, so the ssh route is obtained the only way this package
280+// can: through a provisioned ssh registration (loadTestRoute, shared with the
281+// mux tests) and used directly as the local binding's route.
282+func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) {
283+ isolateSandboxEnv(t)
284+ sshRoute := loadTestRoute(t, map[string]any{
285+ "kind": "ssh-stdio",
286+ "target": "user@host:2222",
287+ "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"},
288+ })
289+ require.Equal(t, brokerconfig.RouteSSHStdio, sshRoute.Kind())
290+ require.False(t, sshRoute.IsLocal(), "an ssh helper route is never dialed directly")
291+
292+ binding := brokerconfig.LocalBinding{
293+ Identity: brokerLocalTestIdentity,
294+ DisplayOrigin: "local",
295+ Policy: brokerTestPolicy(),
296+ Route: sshRoute,
297+ }
298+ _, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) {
299+ t.Fatal("an ssh route must never be dialed by the local probe")
300+ return nil, nil
301+ }, daemonmux.DefaultMuxCeilings())
302+ require.Error(t, err)
303+ require.Contains(t, err.Error(), "local route")
304+}
305+
306+// TestBrokerLocalProbeNilReceiverFailsClosed proves a nil probe reports an
307+// explicit error and an observation whose availability is never zero.
308+func TestBrokerLocalProbeNilReceiverFailsClosed(t *testing.T) {
309+ var probe *localRouteProbe
310+ observation, err := probe.ProbeLocal(context.Background())
311+ require.Error(t, err)
312+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
313+ require.NotZero(t, observation.Availability, "availability is never zero")
314+}
@@ -0,0 +1,314 @@
1+package app
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "log/slog"
8+ "net"
9+ "os"
10+ "path/filepath"
11+ "sync/atomic"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/require"
16+
17+ "github.com/bnema/vev/internal/adapters/brokerconfig"
18+ "github.com/bnema/vev/internal/adapters/brokerstore"
19+ "github.com/bnema/vev/internal/adapters/clock"
20+ "github.com/bnema/vev/internal/adapters/daemonmux"
21+ "github.com/bnema/vev/internal/adapters/ipc"
22+ "github.com/bnema/vev/internal/domain"
23+ "github.com/bnema/vev/internal/ports"
24+ "github.com/bnema/vev/internal/usecase/broker"
25+)
26+
27+const brokerLocalTestIdentity = "offline-local-daemon"
28+
29+// testLocalSandboxConfig writes and loads one marker-valid sandbox whose only
30+// provisioned carriage is the broker-owned local binding.
31+func testLocalSandboxConfig(t *testing.T) (*brokerconfig.Config, brokerconfig.LocalBinding) {
32+ t.Helper()
33+ root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
34+ policy := brokerTestPolicy()
35+ require.NoError(t, os.MkdirAll(root, 0o700))
36+ writeSandboxConfig(t, root, map[string]any{
37+ "marker": brokerconfig.Marker,
38+ "registrations": []any{},
39+ "local": map[string]any{
40+ "identity": brokerLocalTestIdentity,
41+ "displayOrigin": "local",
42+ "route": filepath.Join(root, "local-mux.sock"),
43+ "policy": map[string]any{
44+ "protocolVersion": policy.ProtocolVersion,
45+ "catalogSchemaVersion": policy.CatalogSchemaVersion,
46+ "environmentPolicy": "client-owned",
47+ "transport": policy.Transport,
48+ "trust": policy.Trust,
49+ "launch": policy.Launch,
50+ "isolation": policy.Isolation,
51+ },
52+ },
53+ })
54+ layout, err := brokerconfig.ResolveLayout(root, nil)
55+ require.NoError(t, err)
56+ config, err := brokerconfig.Load(layout)
57+ require.NoError(t, err)
58+ binding, ok := config.LocalBinding()
59+ require.True(t, ok)
60+ return config, binding
61+}
62+
63+// rawFramedTransport adapts one in-memory pipe end to the raw framed carriage
64+// the daemonmux bridge consumes. No real socket is opened.
65+func rawFramedTransport(raw net.Conn) daemonmux.RawFramedTransport {
66+ return ipc.NewTransport(raw).(daemonmux.RawFramedTransport)
67+}
68+
69+// testServerBinding builds the daemon-side binding matching one local binding.
70+func testServerBinding(t *testing.T, binding brokerconfig.LocalBinding) daemonmux.ServerBinding {
71+ t.Helper()
72+ serverBinding, err := daemonmux.NewServerBinding(binding.Identity, ports.BrokerDaemonIncarnation{7, 1}, binding.Policy)
73+ require.NoError(t, err)
74+ return serverBinding
75+}
76+
77+// TestBrokerLocalProbeObservesWithoutAttaching proves the local probe completes
78+// only the daemonmux physical preamble: it reports the daemon's authenticated
79+// identity, incarnation, and negotiated protocol version, and then closes the
80+// carriage without ever opening a logical stream (no attach). Capabilities and
81+// the session catalogue stay unknown because the preamble carries neither.
82+func TestBrokerLocalProbeObservesWithoutAttaching(t *testing.T) {
83+ _, binding := testLocalSandboxConfig(t)
84+ serverBinding := testServerBinding(t, binding)
85+
86+ clientConn, serverConn := net.Pipe()
87+ t.Cleanup(func() {
88+ _ = clientConn.Close()
89+ _ = serverConn.Close()
90+ })
91+
92+ var dialed atomic.Value
93+ dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) {
94+ dialed.Store(address)
95+ return rawFramedTransport(clientConn), nil
96+ }
97+ probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
98+ require.NoError(t, err)
99+
100+ // The daemon side completes one preamble and then blocks reading the next
101+ // frame. The probe must never send one: the read ends only when the probe
102+ // closes the carriage, which is the proof that no attachment stream opened.
103+ serverDone := make(chan error, 1)
104+ go func() {
105+ bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn))
106+ if err != nil {
107+ serverDone <- err
108+ return
109+ }
110+ if _, err := daemonmux.RunServerHandshake(context.Background(), bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil {
111+ serverDone <- err
112+ return
113+ }
114+ readCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
115+ defer cancel()
116+ _, recvErr := bridge.Receive(readCtx)
117+ serverDone <- recvErr
118+ }()
119+
120+ observation, err := probe.ProbeLocal(context.Background())
121+ require.NoError(t, err)
122+ require.Equal(t, binding.Identity, observation.Identity)
123+ require.Equal(t, ports.BrokerDaemonIncarnation{7, 1}, observation.Incarnation)
124+ require.Equal(t, binding.Policy.ProtocolVersion, observation.ProtocolVersion)
125+ require.Equal(t, domain.RemoteAvailabilityReachable, observation.Availability)
126+ require.Zero(t, observation.Capabilities, "the preamble carries no capabilities; they must stay unknown")
127+ require.Empty(t, observation.Sessions)
128+ require.False(t, observation.InventoryKnown)
129+ require.False(t, observation.Local, "the probe supplies observed state only, never local authority")
130+ require.Empty(t, observation.Endpoint)
131+ require.Equal(t, binding.Route.Address(), dialed.Load())
132+
133+ serverErr := <-serverDone
134+ require.Error(t, serverErr, "the probe sent no frame after the preamble")
135+ require.True(t, errors.Is(serverErr, io.EOF) || errors.Is(serverErr, io.ErrClosedPipe) || errors.Is(serverErr, context.Canceled),
136+ "expected a closed carriage, got %v", serverErr)
137+}
138+
139+// TestBrokerLocalProbeFailsClosed proves an unavailable carriage yields zero
140+// observed identity and an explicit availability, never an invented one, and
141+// that the probe only dials the provisioned local address: it has no spawn path
142+// and never starts a stopped daemon.
143+func TestBrokerLocalProbeFailsClosed(t *testing.T) {
144+ _, binding := testLocalSandboxConfig(t)
145+
146+ var addresses []string
147+ dial := func(_ context.Context, address string) (daemonmux.RawFramedTransport, error) {
148+ addresses = append(addresses, address)
149+ return nil, errors.New("no carriage")
150+ }
151+ probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
152+ require.NoError(t, err)
153+
154+ observation, err := probe.ProbeLocal(context.Background())
155+ require.Error(t, err)
156+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
157+ require.Zero(t, observation.Identity)
158+ require.True(t, observation.Incarnation.IsZero())
159+ require.Zero(t, observation.ProtocolVersion)
160+ require.Nil(t, observation.Sessions)
161+ require.Equal(t, []string{binding.Route.Address()}, addresses)
162+
163+ t.Run("a nil carriage is refused", func(t *testing.T) {
164+ probe, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) {
165+ return nil, nil
166+ }, daemonmux.DefaultMuxCeilings())
167+ require.NoError(t, err)
168+ observation, err := probe.ProbeLocal(context.Background())
169+ require.Error(t, err)
170+ require.Zero(t, observation.Identity)
171+ })
172+
173+ t.Run("construction refuses a missing dialer", func(t *testing.T) {
174+ _, err := newLocalRouteProbe(binding, nil, daemonmux.DefaultMuxCeilings())
175+ require.Error(t, err)
176+ })
177+}
178+
179+// TestBrokerLocalObservationComposition proves the composition derives the
180+// local producer's authority from the validated configuration and refuses a
181+// configuration that provisions no local binding.
182+func TestBrokerLocalObservationComposition(t *testing.T) {
183+ config, binding := testLocalSandboxConfig(t)
184+ observation, err := brokerLocalObservation(config, nil)
185+ require.NoError(t, err)
186+ require.Equal(t, binding.DisplayOrigin, observation.DisplayOrigin)
187+ require.Equal(t, binding.Policy, observation.Policy)
188+ require.NotNil(t, observation.Probe)
189+
190+ t.Run("no local binding provisions no producer", func(t *testing.T) {
191+ root := filepath.Join(shortTempDir(t, "vevl"), "sandbox")
192+ require.NoError(t, os.MkdirAll(root, 0o700))
193+ writeSandboxConfig(t, root, sandboxRegistrationDocument("/tmp/vev-local-test/mux.sock"))
194+ layout, err := brokerconfig.ResolveLayout(root, nil)
195+ require.NoError(t, err)
196+ config, err := brokerconfig.Load(layout)
197+ require.NoError(t, err)
198+ _, err = brokerLocalObservation(config, nil)
199+ require.Error(t, err)
200+ })
201+}
202+
203+// TestBrokerLocalObservationRegistryPublishesLocalFirst proves the composition
204+// end to end: the registry observes the local daemon through the broker-owned
205+// probe and publishes it at index zero, and the durable store persists no local
206+// entry (the local observation is never durable).
207+func TestBrokerLocalObservationRegistryPublishesLocalFirst(t *testing.T) {
208+ _, binding := testLocalSandboxConfig(t)
209+ serverBinding := testServerBinding(t, binding)
210+
211+ var dials atomic.Int32
212+ dial := func(ctx context.Context, _ string) (daemonmux.RawFramedTransport, error) {
213+ dials.Add(1)
214+ clientConn, serverConn := net.Pipe()
215+ go func() {
216+ defer serverConn.Close()
217+ bridge, err := daemonmux.NewPreambleCarrier(rawFramedTransport(serverConn))
218+ if err != nil {
219+ return
220+ }
221+ if _, err := daemonmux.RunServerHandshake(ctx, bridge, serverBinding, daemonmux.DefaultMuxCeilings()); err != nil {
222+ return
223+ }
224+ // Hold the carriage until the probe closes it or the attempt is
225+ // retired, so the daemon side owns no logical stream either.
226+ <-ctx.Done()
227+ _ = bridge.Close()
228+ }()
229+ return rawFramedTransport(clientConn), nil
230+ }
231+ probe, err := newLocalRouteProbe(binding, dial, daemonmux.DefaultMuxCeilings())
232+ require.NoError(t, err)
233+
234+ stateDir := filepath.Join(shortTempDir(t, "vevs"), "state")
235+ require.NoError(t, os.MkdirAll(stateDir, 0o700))
236+ store, err := brokerstore.OpenOffline(brokerstore.Options{Dir: stateDir})
237+ require.NoError(t, err)
238+ t.Cleanup(func() { require.NoError(t, store.Close()) })
239+
240+ registry, err := broker.NewRegistryWithConfig(7, store, nil, clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)), broker.RegistryConfig{
241+ Local: &broker.LocalObservation{DisplayOrigin: binding.DisplayOrigin, Policy: binding.Policy, Probe: probe},
242+ })
243+ require.NoError(t, err)
244+
245+ ctx, cancel := context.WithCancel(context.Background())
246+ done := make(chan struct{})
247+ go func() { defer close(done); registry.Run(ctx) }()
248+ t.Cleanup(func() { cancel(); <-done })
249+
250+ require.Eventually(t, func() bool {
251+ snapshot := registry.Snapshot()
252+ return len(snapshot.Daemons) == 1 && snapshot.Daemons[0].Local &&
253+ !snapshot.Daemons[0].Checking && snapshot.Daemons[0].Availability == domain.RemoteAvailabilityReachable
254+ }, 3*time.Second, 2*time.Millisecond)
255+
256+ snapshot := registry.Snapshot()
257+ require.Len(t, snapshot.Daemons, 1)
258+ require.True(t, snapshot.Daemons[0].Local)
259+ require.Equal(t, binding.Identity, snapshot.Daemons[0].Identity)
260+ require.Positive(t, dials.Load())
261+
262+ // The durable store persists remote observations only: the local entry never
263+ // reaches disk.
264+ require.Eventually(t, func() bool {
265+ stored, err := store.Load()
266+ return err == nil && stored.Revision > 0
267+ }, 3*time.Second, 2*time.Millisecond)
268+ stored, err := store.Load()
269+ require.NoError(t, err)
270+ for _, daemon := range stored.Daemons {
271+ require.False(t, daemon.Local, "a local observation must never be durable")
272+ }
273+}
274+
275+// TestBrokerLocalProbeRefusesNonLocalRoute proves the probe refuses a
276+// hand-built binding whose route is an ssh helper route: probing must never
277+// start a child process, so only a Unix route this process dials directly is
278+// acceptable. brokerconfig exposes no exported route constructor and its route
279+// fields are unexported, so the ssh route is obtained the only way this package
280+// can: through a provisioned ssh registration (loadTestRoute, shared with the
281+// mux tests) and used directly as the local binding's route.
282+func TestBrokerLocalProbeRefusesNonLocalRoute(t *testing.T) {
283+ isolateSandboxEnv(t)
284+ sshRoute := loadTestRoute(t, map[string]any{
285+ "kind": "ssh-stdio",
286+ "target": "user@host:2222",
287+ "argv": []any{"vev", brokerMuxStdioCommand, "--offline-root", "/srv/remote"},
288+ })
289+ require.Equal(t, brokerconfig.RouteSSHStdio, sshRoute.Kind())
290+ require.False(t, sshRoute.IsLocal(), "an ssh helper route is never dialed directly")
291+
292+ binding := brokerconfig.LocalBinding{
293+ Identity: brokerLocalTestIdentity,
294+ DisplayOrigin: "local",
295+ Policy: brokerTestPolicy(),
296+ Route: sshRoute,
297+ }
298+ _, err := newLocalRouteProbe(binding, func(context.Context, string) (daemonmux.RawFramedTransport, error) {
299+ t.Fatal("an ssh route must never be dialed by the local probe")
300+ return nil, nil
301+ }, daemonmux.DefaultMuxCeilings())
302+ require.Error(t, err)
303+ require.Contains(t, err.Error(), "local route")
304+}
305+
306+// TestBrokerLocalProbeNilReceiverFailsClosed proves a nil probe reports an
307+// explicit error and an observation whose availability is never zero.
308+func TestBrokerLocalProbeNilReceiverFailsClosed(t *testing.T) {
309+ var probe *localRouteProbe
310+ observation, err := probe.ProbeLocal(context.Background())
311+ require.Error(t, err)
312+ require.Equal(t, domain.RemoteAvailabilityUnreachable, observation.Availability)
313+ require.NotZero(t, observation.Availability, "availability is never zero")
314+}
M internal/usecase/broker/pool.go +9 -0
@@ -18,6 +18,15 @@ type PoolLimits struct {
1818 Idle time.Duration
1919 }
2020
21+// poolKey is the exact authenticated (identity, policy) pair the broker pools
22+// one physical transport by. The resolved endpoint's opaque address is
23+// deliberately excluded: it selects the transport, not the pooling identity.
24+//
25+// A local stream request carries no endpoint and no registration, so its key is
26+// the broker-owned local identity and policy the resolver returns. Every local
27+// stream therefore shares one pooled physical connection to the local daemon,
28+// exactly as every stream to one remote identity does; the local daemon is not
29+// a configured host, so no registration participates in its key.
2130 type poolKey struct {
2231 identity ports.BrokerDaemonIdentity
2332 policy ports.BrokerPolicy
@@ -18,6 +18,15 @@ type PoolLimits struct {
18 Idle time.Duration18 Idle time.Duration
19 }19 }
20 20
21+// poolKey is the exact authenticated (identity, policy) pair the broker pools
22+// one physical transport by. The resolved endpoint's opaque address is
23+// deliberately excluded: it selects the transport, not the pooling identity.
24+//
25+// A local stream request carries no endpoint and no registration, so its key is
26+// the broker-owned local identity and policy the resolver returns. Every local
27+// stream therefore shares one pooled physical connection to the local daemon,
28+// exactly as every stream to one remote identity does; the local daemon is not
29+// a configured host, so no registration participates in its key.
21 type poolKey struct {30 type poolKey struct {
22 identity ports.BrokerDaemonIdentity31 identity ports.BrokerDaemonIdentity
23 policy ports.BrokerPolicy32 policy ports.BrokerPolicy
M internal/usecase/broker/pool_test.go +27 -0
@@ -87,6 +87,33 @@ func await(t *testing.T, ch <-chan struct{}) {
8787 }
8888 }
8989
90+// TestPoolRefusedResolutionNeverDials proves a refused resolution is terminal:
91+// the pool returns the typed broker error and never asks the connector to open
92+// a physical transport, so an unprovisioned or conflicting request can never
93+// reach an address. The same ordering fences a refused local request.
94+func TestPoolRefusedResolutionNeverDials(t *testing.T) {
95+ var dials atomic.Int32
96+ resolver := poolResolver(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
97+ return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("refused")}
98+ })
99+ connector := poolConnector(func(context.Context, ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) {
100+ dials.Add(1)
101+ return nil, nil
102+ })
103+ pool, err := NewPool(1, resolver, connector, newManualClock(time.Unix(0, 0)), PoolLimits{Physical: 1, Clients: 8, Streams: 8, StreamsPerClient: 8, Idle: time.Minute})
104+ require.NoError(t, err)
105+ t.Cleanup(func() { require.NoError(t, pool.Close()) })
106+
107+ id, err := pool.RegisterClient()
108+ require.NoError(t, err)
109+ _, err = pool.OpenStream(context.Background(), poolRequest(id, 1))
110+ require.Error(t, err)
111+ var typed ports.BrokerError
112+ require.ErrorAs(t, err, &typed)
113+ require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code)
114+ require.Zero(t, dials.Load(), "a refused request must never dial")
115+}
116+
90117 func TestPoolHundredStreamsLossAndIsolation(t *testing.T) {
91118 var calls atomic.Int32
92119 var physical *fakePhysical
@@ -87,6 +87,33 @@ func await(t *testing.T, ch <-chan struct{}) {
87 }87 }
88 }88 }
89 89
90+// TestPoolRefusedResolutionNeverDials proves a refused resolution is terminal:
91+// the pool returns the typed broker error and never asks the connector to open
92+// a physical transport, so an unprovisioned or conflicting request can never
93+// reach an address. The same ordering fences a refused local request.
94+func TestPoolRefusedResolutionNeverDials(t *testing.T) {
95+ var dials atomic.Int32
96+ resolver := poolResolver(func(context.Context, ports.BrokerOpenStreamRequest) (ports.BrokerResolvedEndpoint, error) {
97+ return ports.BrokerResolvedEndpoint{}, ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: errors.New("refused")}
98+ })
99+ connector := poolConnector(func(context.Context, ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) {
100+ dials.Add(1)
101+ return nil, nil
102+ })
103+ pool, err := NewPool(1, resolver, connector, newManualClock(time.Unix(0, 0)), PoolLimits{Physical: 1, Clients: 8, Streams: 8, StreamsPerClient: 8, Idle: time.Minute})
104+ require.NoError(t, err)
105+ t.Cleanup(func() { require.NoError(t, pool.Close()) })
106+
107+ id, err := pool.RegisterClient()
108+ require.NoError(t, err)
109+ _, err = pool.OpenStream(context.Background(), poolRequest(id, 1))
110+ require.Error(t, err)
111+ var typed ports.BrokerError
112+ require.ErrorAs(t, err, &typed)
113+ require.Equal(t, ports.BrokerErrorConflictingPolicy, typed.Code)
114+ require.Zero(t, dials.Load(), "a refused request must never dial")
115+}
116+
90 func TestPoolHundredStreamsLossAndIsolation(t *testing.T) {117 func TestPoolHundredStreamsLossAndIsolation(t *testing.T) {
91 var calls atomic.Int32118 var calls atomic.Int32
92 var physical *fakePhysical119 var physical *fakePhysical
M internal/usecase/broker/registry.go +85 -7
@@ -48,6 +48,11 @@ type RegistryConfig struct {
4848 // expected to be immutable for the run; a caller that needs observation
4949 // keeps the zero-value config.
5050 ObservationDisabled bool
51+ // Local, when non-nil, enables observation of the broker's own machine
52+ // daemon. The registry then publishes the local entry at index zero, ahead
53+ // of every remote, and never persists it. Local is refused together with
54+ // ObservationDisabled: a read-only registry owns no local producer.
55+ Local *LocalObservation
5156 }
5257
5358 // Registry owns configured hosts and their immutable observation projection.
@@ -102,6 +107,17 @@ type Registry struct {
102107 wake chan struct{}
103108 running atomic.Int32
104109
110+ // local selects observation of the broker's own machine daemon. It is nil
111+ // for a remote-only registry. localHost is the configured local authority
112+ // stamped onto every publication (Local, DisplayOrigin, Policy, Rank) plus
113+ // the observed state a probe supplies; localAttempt is the one admitted
114+ // local probe. The local entry is never durable and is never invented: until
115+ // a probe answers, the configured authority is published with unknown
116+ // availability and zero observed identity.
117+ local *LocalObservation
118+ localHost ports.BrokerDaemonObservation
119+ localAttempt *probeAttempt
120+
105121 freshFor time.Duration
106122 retryBase time.Duration
107123 retryMax time.Duration
@@ -123,13 +139,19 @@ func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe por
123139
124140 // NewRegistryWithConfig restores a validated durable snapshot under a fresh
125141 // broker epoch and selects the run mode from cfg. Observation-disabled mode
126-// tolerates a nil probe because such a registry never probes; every other
127-// dependency is required exactly as NewRegistry requires it.
142+// tolerates a nil probe because such a registry never probes, and a registry
143+// configured with a local observation producer (Local) tolerates a nil remote
144+// probe as long as it owns no remote membership. Every other dependency is
145+// required exactly as NewRegistry requires it.
128146 func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger, cfg RegistryConfig) (*Registry, error) {
129147 if epoch == 0 || nilDependency(store) || nilDependency(clock) {
130148 return nil, errors.New("broker: invalid registry dependencies")
131149 }
132- if !cfg.ObservationDisabled && nilDependency(probe) {
150+ // A remote probe is required to observe remotes, but a registry that
151+ // observes only its own machine daemon (Local) may omit it. Such a registry
152+ // never owns a remote host: projectMembership and the mutation paths refuse
153+ // remote membership rather than silently never probing it.
154+ if !cfg.ObservationDisabled && nilDependency(probe) && cfg.Local == nil {
133155 return nil, errors.New("broker: invalid registry dependencies")
134156 }
135157 if log == nil {
@@ -144,6 +166,23 @@ func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore,
144166 freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit,
145167 jitter: jittered,
146168 }
169+ if cfg.ObservationDisabled && cfg.Local != nil {
170+ return nil, errors.New("broker: observation-disabled registry cannot observe the local daemon")
171+ }
172+ if cfg.Local != nil {
173+ if err := cfg.Local.validate(); err != nil {
174+ return nil, err
175+ }
176+ // Copy the configured authority: the registry reads it from the probe
177+ // and publish paths, and a caller that kept the pointer could otherwise
178+ // mutate authority after construction.
179+ local := *cfg.Local
180+ r.local = &local
181+ r.localHost = ports.BrokerDaemonObservation{Local: true, DisplayOrigin: cfg.Local.DisplayOrigin, Policy: cfg.Local.Policy, Availability: domain.RemoteAvailabilityUnknown}
182+ if err := r.localHost.Validate(); err != nil {
183+ return nil, fmt.Errorf("broker: invalid local observation authority: %w", err)
184+ }
185+ }
147186 r.store = newSnapshotWriter(store, log)
148187 snapshot, err := store.Load()
149188 if err != nil {
@@ -193,6 +232,9 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error {
193232 if err := hosts.Validate(); err != nil {
194233 return err
195234 }
235+ if len(hosts.Hosts) > 0 && !r.observationDisabled && nilDependency(r.probe) {
236+ return errors.New("broker: remote hosts require a host probe")
237+ }
196238 next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts))
197239 order := make([]string, 0, len(hosts.Hosts))
198240 for rank, record := range hosts.Hosts {
@@ -413,6 +455,9 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
413455 if r.running.Load() == runStopped {
414456 return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed)
415457 }
458+ if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) {
459+ return errors.New("broker: remote hosts require a host probe")
460+ }
416461 if r.revisionExhaustedLocked() {
417462 return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted)
418463 }
@@ -436,6 +481,9 @@ func (r *Registry) setHosts(records []ports.BrokerHostRecord) error {
436481 if len(records) > ports.BrokerMaxHosts {
437482 return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts)
438483 }
484+ if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) {
485+ return errors.New("broker: remote hosts require a host probe")
486+ }
439487 next := make(map[string]ports.BrokerDaemonObservation, len(records))
440488 order := make([]string, 0, len(records))
441489 for rank, record := range records {
@@ -608,7 +656,15 @@ func (r *Registry) Run(ctx context.Context) {
608656 func (r *Registry) settle() {
609657 r.mu.Lock()
610658 r.cancelAllInflightLocked()
659+ if r.localAttempt != nil {
660+ r.localAttempt.cancel()
661+ r.localAttempt = nil
662+ }
611663 cleared := false
664+ if r.local != nil && r.localHost.Checking {
665+ r.localHost.Checking = false
666+ cleared = true
667+ }
612668 for endpoint, host := range r.hosts {
613669 if !host.Checking {
614670 continue
@@ -645,6 +701,9 @@ type probeResult struct {
645701 snapshot ports.BrokerDaemonObservation
646702 err error
647703 at time.Time
704+ // local marks the result of one local probe attempt, which carries no
705+ // endpoint or registration and is applied to the configured local entry.
706+ local bool
648707 }
649708
650709 func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- probeResult) {
@@ -682,6 +741,9 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
682741 // Only an observation that actually started changes the projection; an
683742 // idle dispatch must not bump the revision or wake subscribers. The
684743 // in-flight projection is never persisted: Checking is transient.
744+ if r.dispatchLocalLocked(ctx, now, results) {
745+ started = true
746+ }
685747 if started {
686748 r.publishLocked(false)
687749 }
@@ -691,6 +753,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
691753 func (r *Registry) apply(result probeResult) {
692754 r.mu.Lock()
693755 defer r.mu.Unlock()
756+ if result.local {
757+ r.applyLocal(result)
758+ return
759+ }
694760 current, ok := r.hosts[result.endpoint]
695761 attempt := r.inflight[result.endpoint]
696762 if !ok || !current.Registration.Equal(result.registration) || attempt == nil || attempt.token != result.attempt {
@@ -834,6 +900,14 @@ func (r *Registry) nextDelay(now time.Time) time.Duration {
834900 r.mu.Lock()
835901 defer r.mu.Unlock()
836902 var earliest time.Time
903+ if r.local != nil && r.localAttempt == nil {
904+ if r.localHost.NextDue.IsZero() {
905+ return 0
906+ }
907+ if earliest.IsZero() || r.localHost.NextDue.Before(earliest) {
908+ earliest = r.localHost.NextDue
909+ }
910+ }
837911 for endpoint, host := range r.hosts {
838912 // An observation already in flight is completed by its result, never
839913 // by the schedule; waiting on it here would spin the run loop.
@@ -958,10 +1032,14 @@ func (r *Registry) publishLocked(persist bool) {
9581032 if r.revisionExhaustedLocked() {
9591033 return
9601034 }
961- // Remote daemons are published in registration order. The local daemon,
962- // when a producer exists (Plan 001 P5.3a), is prepended ahead of these;
963- // the registry produces no local observation yet and never invents one.
964- daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts))
1035+ // Remote daemons are published in registration order. The broker's own
1036+ // machine daemon, when a producer is configured (Plan 001 P5.3a), is the
1037+ // prepended entry at index zero; without a producer the registry produces no
1038+ // local observation and never invents one.
1039+ daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)+1)
1040+ if r.local != nil {
1041+ daemons = append(daemons, r.localHost.Clone())
1042+ }
9651043 for _, endpoint := range r.order {
9661044 host, ok := r.hosts[endpoint]
9671045 if !ok {
@@ -48,6 +48,11 @@ type RegistryConfig struct {
48 // expected to be immutable for the run; a caller that needs observation48 // expected to be immutable for the run; a caller that needs observation
49 // keeps the zero-value config.49 // keeps the zero-value config.
50 ObservationDisabled bool50 ObservationDisabled bool
51+ // Local, when non-nil, enables observation of the broker's own machine
52+ // daemon. The registry then publishes the local entry at index zero, ahead
53+ // of every remote, and never persists it. Local is refused together with
54+ // ObservationDisabled: a read-only registry owns no local producer.
55+ Local *LocalObservation
51 }56 }
52 57
53 // Registry owns configured hosts and their immutable observation projection.58 // Registry owns configured hosts and their immutable observation projection.
@@ -102,6 +107,17 @@ type Registry struct {
102 wake chan struct{}107 wake chan struct{}
103 running atomic.Int32108 running atomic.Int32
104 109
110+ // local selects observation of the broker's own machine daemon. It is nil
111+ // for a remote-only registry. localHost is the configured local authority
112+ // stamped onto every publication (Local, DisplayOrigin, Policy, Rank) plus
113+ // the observed state a probe supplies; localAttempt is the one admitted
114+ // local probe. The local entry is never durable and is never invented: until
115+ // a probe answers, the configured authority is published with unknown
116+ // availability and zero observed identity.
117+ local *LocalObservation
118+ localHost ports.BrokerDaemonObservation
119+ localAttempt *probeAttempt
120+
105 freshFor time.Duration121 freshFor time.Duration
106 retryBase time.Duration122 retryBase time.Duration
107 retryMax time.Duration123 retryMax time.Duration
@@ -123,13 +139,19 @@ func NewRegistry(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe por
123 139
124 // NewRegistryWithConfig restores a validated durable snapshot under a fresh140 // NewRegistryWithConfig restores a validated durable snapshot under a fresh
125 // broker epoch and selects the run mode from cfg. Observation-disabled mode141 // broker epoch and selects the run mode from cfg. Observation-disabled mode
126-// tolerates a nil probe because such a registry never probes; every other142+// tolerates a nil probe because such a registry never probes, and a registry
127-// dependency is required exactly as NewRegistry requires it.143+// configured with a local observation producer (Local) tolerates a nil remote
144+// probe as long as it owns no remote membership. Every other dependency is
145+// required exactly as NewRegistry requires it.
128 func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger, cfg RegistryConfig) (*Registry, error) {146 func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore, probe ports.BrokerHostProbe, clock ports.Clock, log *slog.Logger, cfg RegistryConfig) (*Registry, error) {
129 if epoch == 0 || nilDependency(store) || nilDependency(clock) {147 if epoch == 0 || nilDependency(store) || nilDependency(clock) {
130 return nil, errors.New("broker: invalid registry dependencies")148 return nil, errors.New("broker: invalid registry dependencies")
131 }149 }
132- if !cfg.ObservationDisabled && nilDependency(probe) {150+ // A remote probe is required to observe remotes, but a registry that
151+ // observes only its own machine daemon (Local) may omit it. Such a registry
152+ // never owns a remote host: projectMembership and the mutation paths refuse
153+ // remote membership rather than silently never probing it.
154+ if !cfg.ObservationDisabled && nilDependency(probe) && cfg.Local == nil {
133 return nil, errors.New("broker: invalid registry dependencies")155 return nil, errors.New("broker: invalid registry dependencies")
134 }156 }
135 if log == nil {157 if log == nil {
@@ -144,6 +166,23 @@ func NewRegistryWithConfig(epoch ports.BrokerEpoch, store ports.BrokerHostStore,
144 freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit,166 freshFor: defaultFreshFor, retryBase: defaultRetryBase, retryMax: defaultRetryLimit,
145 jitter: jittered,167 jitter: jittered,
146 }168 }
169+ if cfg.ObservationDisabled && cfg.Local != nil {
170+ return nil, errors.New("broker: observation-disabled registry cannot observe the local daemon")
171+ }
172+ if cfg.Local != nil {
173+ if err := cfg.Local.validate(); err != nil {
174+ return nil, err
175+ }
176+ // Copy the configured authority: the registry reads it from the probe
177+ // and publish paths, and a caller that kept the pointer could otherwise
178+ // mutate authority after construction.
179+ local := *cfg.Local
180+ r.local = &local
181+ r.localHost = ports.BrokerDaemonObservation{Local: true, DisplayOrigin: cfg.Local.DisplayOrigin, Policy: cfg.Local.Policy, Availability: domain.RemoteAvailabilityUnknown}
182+ if err := r.localHost.Validate(); err != nil {
183+ return nil, fmt.Errorf("broker: invalid local observation authority: %w", err)
184+ }
185+ }
147 r.store = newSnapshotWriter(store, log)186 r.store = newSnapshotWriter(store, log)
148 snapshot, err := store.Load()187 snapshot, err := store.Load()
149 if err != nil {188 if err != nil {
@@ -193,6 +232,9 @@ func (r *Registry) projectMembership(hosts ports.BrokerHosts) error {
193 if err := hosts.Validate(); err != nil {232 if err := hosts.Validate(); err != nil {
194 return err233 return err
195 }234 }
235+ if len(hosts.Hosts) > 0 && !r.observationDisabled && nilDependency(r.probe) {
236+ return errors.New("broker: remote hosts require a host probe")
237+ }
196 next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts))238 next := make(map[string]ports.BrokerDaemonObservation, len(hosts.Hosts))
197 order := make([]string, 0, len(hosts.Hosts))239 order := make([]string, 0, len(hosts.Hosts))
198 for rank, record := range hosts.Hosts {240 for rank, record := range hosts.Hosts {
@@ -413,6 +455,9 @@ func (r *Registry) ReplaceHosts(records []ports.BrokerHostRecord) error {
413 if r.running.Load() == runStopped {455 if r.running.Load() == runStopped {
414 return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed)456 return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRegistryClosed)
415 }457 }
458+ if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) {
459+ return errors.New("broker: remote hosts require a host probe")
460+ }
416 if r.revisionExhaustedLocked() {461 if r.revisionExhaustedLocked() {
417 return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted)462 return fmt.Errorf("broker: membership replacement: %w", ports.ErrBrokerRevisionExhausted)
418 }463 }
@@ -436,6 +481,9 @@ func (r *Registry) setHosts(records []ports.BrokerHostRecord) error {
436 if len(records) > ports.BrokerMaxHosts {481 if len(records) > ports.BrokerMaxHosts {
437 return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts)482 return fmt.Errorf("broker: %d host registrations exceed the limit of %d", len(records), ports.BrokerMaxHosts)
438 }483 }
484+ if len(records) > 0 && !r.observationDisabled && nilDependency(r.probe) {
485+ return errors.New("broker: remote hosts require a host probe")
486+ }
439 next := make(map[string]ports.BrokerDaemonObservation, len(records))487 next := make(map[string]ports.BrokerDaemonObservation, len(records))
440 order := make([]string, 0, len(records))488 order := make([]string, 0, len(records))
441 for rank, record := range records {489 for rank, record := range records {
@@ -608,7 +656,15 @@ func (r *Registry) Run(ctx context.Context) {
608 func (r *Registry) settle() {656 func (r *Registry) settle() {
609 r.mu.Lock()657 r.mu.Lock()
610 r.cancelAllInflightLocked()658 r.cancelAllInflightLocked()
659+ if r.localAttempt != nil {
660+ r.localAttempt.cancel()
661+ r.localAttempt = nil
662+ }
611 cleared := false663 cleared := false
664+ if r.local != nil && r.localHost.Checking {
665+ r.localHost.Checking = false
666+ cleared = true
667+ }
612 for endpoint, host := range r.hosts {668 for endpoint, host := range r.hosts {
613 if !host.Checking {669 if !host.Checking {
614 continue670 continue
@@ -645,6 +701,9 @@ type probeResult struct {
645 snapshot ports.BrokerDaemonObservation701 snapshot ports.BrokerDaemonObservation
646 err error702 err error
647 at time.Time703 at time.Time
704+ // local marks the result of one local probe attempt, which carries no
705+ // endpoint or registration and is applied to the configured local entry.
706+ local bool
648 }707 }
649 708
650 func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- probeResult) {709 func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- probeResult) {
@@ -682,6 +741,9 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
682 // Only an observation that actually started changes the projection; an741 // Only an observation that actually started changes the projection; an
683 // idle dispatch must not bump the revision or wake subscribers. The742 // idle dispatch must not bump the revision or wake subscribers. The
684 // in-flight projection is never persisted: Checking is transient.743 // in-flight projection is never persisted: Checking is transient.
744+ if r.dispatchLocalLocked(ctx, now, results) {
745+ started = true
746+ }
685 if started {747 if started {
686 r.publishLocked(false)748 r.publishLocked(false)
687 }749 }
@@ -691,6 +753,10 @@ func (r *Registry) dispatch(ctx context.Context, now time.Time, results chan<- p
691 func (r *Registry) apply(result probeResult) {753 func (r *Registry) apply(result probeResult) {
692 r.mu.Lock()754 r.mu.Lock()
693 defer r.mu.Unlock()755 defer r.mu.Unlock()
756+ if result.local {
757+ r.applyLocal(result)
758+ return
759+ }
694 current, ok := r.hosts[result.endpoint]760 current, ok := r.hosts[result.endpoint]
695 attempt := r.inflight[result.endpoint]761 attempt := r.inflight[result.endpoint]
696 if !ok || !current.Registration.Equal(result.registration) || attempt == nil || attempt.token != result.attempt {762 if !ok || !current.Registration.Equal(result.registration) || attempt == nil || attempt.token != result.attempt {
@@ -834,6 +900,14 @@ func (r *Registry) nextDelay(now time.Time) time.Duration {
834 r.mu.Lock()900 r.mu.Lock()
835 defer r.mu.Unlock()901 defer r.mu.Unlock()
836 var earliest time.Time902 var earliest time.Time
903+ if r.local != nil && r.localAttempt == nil {
904+ if r.localHost.NextDue.IsZero() {
905+ return 0
906+ }
907+ if earliest.IsZero() || r.localHost.NextDue.Before(earliest) {
908+ earliest = r.localHost.NextDue
909+ }
910+ }
837 for endpoint, host := range r.hosts {911 for endpoint, host := range r.hosts {
838 // An observation already in flight is completed by its result, never912 // An observation already in flight is completed by its result, never
839 // by the schedule; waiting on it here would spin the run loop.913 // by the schedule; waiting on it here would spin the run loop.
@@ -958,10 +1032,14 @@ func (r *Registry) publishLocked(persist bool) {
958 if r.revisionExhaustedLocked() {1032 if r.revisionExhaustedLocked() {
959 return1033 return
960 }1034 }
961- // Remote daemons are published in registration order. The local daemon,1035+ // Remote daemons are published in registration order. The broker's own
962- // when a producer exists (Plan 001 P5.3a), is prepended ahead of these;1036+ // machine daemon, when a producer is configured (Plan 001 P5.3a), is the
963- // the registry produces no local observation yet and never invents one.1037+ // prepended entry at index zero; without a producer the registry produces no
964- daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts))1038+ // local observation and never invents one.
1039+ daemons := make([]ports.BrokerDaemonObservation, 0, len(r.hosts)+1)
1040+ if r.local != nil {
1041+ daemons = append(daemons, r.localHost.Clone())
1042+ }
965 for _, endpoint := range r.order {1043 for _, endpoint := range r.order {
966 host, ok := r.hosts[endpoint]1044 host, ok := r.hosts[endpoint]
967 if !ok {1045 if !ok {
A internal/usecase/broker/registry_local.go +199 -0
@@ -0,0 +1,199 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
13+)
14+
15+// Broker-owned local observation (Plan 001 P5.3a).
16+//
17+// The broker's own machine daemon is not a configured host: it has no
18+// endpoint, no registration, and no durable membership. It is observed through
19+// the same probe lifecycle as a remote host, but its configured authority
20+// (display origin and policy) is supplied by the composition and its entry is
21+// always published first, at index zero, and never persisted. Observing the
22+// local daemon only reads: the probe dials the broker-owned local route and
23+// completes the daemonmux physical preamble, so it never opens a logical
24+// session stream (never attaches) and never starts a stopped daemon.
25+
26+// localProbeKey seeds the deterministic jitter for the local probe schedule.
27+// It is a fixed token, so local retry and refresh jitter is reproducible and
28+// independent of the configured remotes. A remote endpoint spelled "local"
29+// shares the seed, which affects only the jitter spread and never identity.
30+const localProbeKey = "local"
31+
32+// LocalProbe observes the broker's own machine daemon without attaching to it
33+// or starting a stopped daemon. It reports only observed state: the observed
34+// daemon identity, incarnation, protocol version, capabilities, availability,
35+// failure, freshness, and session inventory. Configured authority (the local
36+// flag, display origin, policy, and rank) is stamped by the registry and never
37+// trusted from the probe; unknown identity, version, or inventory is zero and
38+// is never invented. The returned observation must satisfy
39+// ports.BrokerDaemonObservation.Validate once the registry stamps authority.
40+//
41+// ProbeLocal must honor cancellation: the registry cancels the attempt context
42+// when the run settles, and a probe that ignores cancellation delays shutdown.
43+type LocalProbe interface {
44+ ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error)
45+}
46+
47+// LocalObservation configures observation of the broker's own machine daemon.
48+// The display origin and policy are configured authority: the registry stamps
49+// them onto every local publication and never reads them from a probe. The
50+// probe is the sole observed-state source.
51+type LocalObservation struct {
52+ // DisplayOrigin is the presentation hint published for the local entry. It
53+ // is a display value only and never routing authority.
54+ DisplayOrigin string
55+ // Policy is the exact connection policy of the local binding.
56+ Policy ports.BrokerPolicy
57+ // Probe observes the local daemon. It is required.
58+ Probe LocalProbe
59+}
60+
61+// validate refuses a local observation the registry could not publish or fence.
62+func (l *LocalObservation) validate() error {
63+ if l == nil {
64+ return nil
65+ }
66+ if nilDependency(l.Probe) {
67+ return errors.New("broker: local observation has no probe")
68+ }
69+ if err := l.Policy.Validate(); err != nil {
70+ return fmt.Errorf("broker: local observation authority: %w", err)
71+ }
72+ if l.DisplayOrigin == "" || ports.SanitizeBrokerDisplayText(l.DisplayOrigin) != l.DisplayOrigin {
73+ return errors.New("broker: local observation has an invalid display origin")
74+ }
75+ return nil
76+}
77+
78+// dispatchLocalLocked admits at most one local probe attempt when its schedule
79+// says it is due. Callers must hold r.mu. It reports whether an attempt
80+// started, so dispatch publishes the transient Checking state exactly once.
81+func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, results chan<- probeResult) bool {
82+ if r.local == nil || r.localAttempt != nil {
83+ return false
84+ }
85+ if !r.localHost.NextDue.IsZero() && now.Before(r.localHost.NextDue) {
86+ return false
87+ }
88+ r.attempts++
89+ attempt := r.attempts
90+ probeCtx, cancel := context.WithCancel(ctx)
91+ r.localAttempt = &probeAttempt{token: attempt, cancel: cancel}
92+ r.localHost.Checking = true
93+ r.localHost.LastAttempt = now
94+ go func(attempt uint64, probeCtx context.Context) {
95+ snapshot, err := r.local.Probe.ProbeLocal(probeCtx)
96+ result := probeResult{local: true, attempt: attempt, snapshot: snapshot, err: err, at: r.clock.Now()}
97+ select {
98+ case results <- result:
99+ case <-probeCtx.Done():
100+ // The attempt was retired (run settled), so its completion is
101+ // already fenced: dropping it keeps a settled registry from
102+ // queueing a result the run loop would only discard.
103+ }
104+ }(attempt, probeCtx)
105+ return true
106+}
107+
108+// applyLocal adopts one completed local probe. Callers must hold r.mu, exactly
109+// as Registry.apply requires. A stale attempt touches nothing. A confirmed
110+// reachable result publishes the observed state with configured authority
111+// stamped on top; every other outcome publishes a typed failure and keeps the
112+// last-known identity and inventory, so an unreachable local daemon never
113+// reports an invented identity and its availability is never zero.
114+func (r *Registry) applyLocal(result probeResult) {
115+ if r.local == nil {
116+ return
117+ }
118+ attempt := r.localAttempt
119+ if attempt == nil || attempt.token != result.attempt {
120+ return
121+ }
122+ r.localAttempt = nil
123+ // The probe has returned, so releasing its derived context here only frees
124+ // the attempt's resources; the record is gone before any new attempt can
125+ // start.
126+ attempt.cancel()
127+
128+ current := r.localHost
129+ current.Checking = false
130+ current.LastAttempt = result.at
131+
132+ kind, availability, failed := observationOutcome(result)
133+ if !failed {
134+ observed := stampLocalAuthority(result.snapshot.Clone(), current)
135+ observed.Checking = false
136+ observed.LastAttempt = result.at
137+ observed.LastSuccess = result.at
138+ observed.NextDue = result.at.Add(r.jitter(r.freshFor, localProbeKey, result.attempt))
139+ observed.ConsecutiveFailures = 0
140+ observed.LastFailure = domain.RemoteFailure{}
141+ observed.FailureEpisode = current.FailureEpisode
142+ // The published local observation must be fully valid once authority is
143+ // stamped. A probe that invents partial identity or an invalid session
144+ // catalogue is an invalid response, never a published observation.
145+ if err := observed.Validate(); err != nil {
146+ r.log.Debug("broker: local observation is invalid", "err", err)
147+ kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
148+ } else if err := validateLocalObservation(observed); err != nil {
149+ r.log.Debug("broker: local observation is not publishable", "err", err)
150+ kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
151+ } else {
152+ r.localHost = observed
153+ r.publishLocked(true)
154+ return
155+ }
156+ }
157+ // Every non-reachable outcome is a typed failure on the capped retry
158+ // cadence: the observed identity, last-known inventory, and failure episode
159+ // counter stay authoritative.
160+ current.Availability = availability
161+ if current.ConsecutiveFailures == 0 {
162+ current.FailureEpisode++
163+ }
164+ current.ConsecutiveFailures++
165+ current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}
166+ current.NextDue = result.at.Add(r.jitter(r.retryDelay(current.ConsecutiveFailures), localProbeKey, result.attempt))
167+ r.localHost = current
168+ r.publishLocked(true)
169+}
170+
171+// stampLocalAuthority copies the configured local authority onto a fresh probe
172+// result. Probe results supply observed state only, so their local flag,
173+// endpoint, registration, display origin, policy, and rank fields are discarded
174+// wholesale: a hostile probe can never promote itself to local authority or
175+// borrow a remote endpoint.
176+func stampLocalAuthority(observation, authority ports.BrokerDaemonObservation) ports.BrokerDaemonObservation {
177+ observation.Local = true
178+ observation.Endpoint = ""
179+ observation.Registration = domain.RemoteRegistration{}
180+ observation.DisplayOrigin = authority.DisplayOrigin
181+ observation.Policy = authority.Policy
182+ observation.Rank = authority.Rank
183+ return observation
184+}
185+
186+// validateLocalObservation re-checks the catalogue shape of a local session
187+// inventory. The durable projection rule cannot apply to a local observation
188+// (the local daemon is never durable and has no registration incarnation), so
189+// the catalogue itself is validated directly against the current schema.
190+func validateLocalObservation(observation ports.BrokerDaemonObservation) error {
191+ if len(observation.Sessions) == 0 {
192+ return nil
193+ }
194+ return catalogue.ValidateRemoteCatalog(catalogue.RemoteCatalog{
195+ ProtocolVersion: protocol.Version,
196+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
197+ Sessions: observation.Sessions,
198+ })
199+}
@@ -0,0 +1,199 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
11+ "github.com/bnema/vev/internal/protocol"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
13+)
14+
15+// Broker-owned local observation (Plan 001 P5.3a).
16+//
17+// The broker's own machine daemon is not a configured host: it has no
18+// endpoint, no registration, and no durable membership. It is observed through
19+// the same probe lifecycle as a remote host, but its configured authority
20+// (display origin and policy) is supplied by the composition and its entry is
21+// always published first, at index zero, and never persisted. Observing the
22+// local daemon only reads: the probe dials the broker-owned local route and
23+// completes the daemonmux physical preamble, so it never opens a logical
24+// session stream (never attaches) and never starts a stopped daemon.
25+
26+// localProbeKey seeds the deterministic jitter for the local probe schedule.
27+// It is a fixed token, so local retry and refresh jitter is reproducible and
28+// independent of the configured remotes. A remote endpoint spelled "local"
29+// shares the seed, which affects only the jitter spread and never identity.
30+const localProbeKey = "local"
31+
32+// LocalProbe observes the broker's own machine daemon without attaching to it
33+// or starting a stopped daemon. It reports only observed state: the observed
34+// daemon identity, incarnation, protocol version, capabilities, availability,
35+// failure, freshness, and session inventory. Configured authority (the local
36+// flag, display origin, policy, and rank) is stamped by the registry and never
37+// trusted from the probe; unknown identity, version, or inventory is zero and
38+// is never invented. The returned observation must satisfy
39+// ports.BrokerDaemonObservation.Validate once the registry stamps authority.
40+//
41+// ProbeLocal must honor cancellation: the registry cancels the attempt context
42+// when the run settles, and a probe that ignores cancellation delays shutdown.
43+type LocalProbe interface {
44+ ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error)
45+}
46+
47+// LocalObservation configures observation of the broker's own machine daemon.
48+// The display origin and policy are configured authority: the registry stamps
49+// them onto every local publication and never reads them from a probe. The
50+// probe is the sole observed-state source.
51+type LocalObservation struct {
52+ // DisplayOrigin is the presentation hint published for the local entry. It
53+ // is a display value only and never routing authority.
54+ DisplayOrigin string
55+ // Policy is the exact connection policy of the local binding.
56+ Policy ports.BrokerPolicy
57+ // Probe observes the local daemon. It is required.
58+ Probe LocalProbe
59+}
60+
61+// validate refuses a local observation the registry could not publish or fence.
62+func (l *LocalObservation) validate() error {
63+ if l == nil {
64+ return nil
65+ }
66+ if nilDependency(l.Probe) {
67+ return errors.New("broker: local observation has no probe")
68+ }
69+ if err := l.Policy.Validate(); err != nil {
70+ return fmt.Errorf("broker: local observation authority: %w", err)
71+ }
72+ if l.DisplayOrigin == "" || ports.SanitizeBrokerDisplayText(l.DisplayOrigin) != l.DisplayOrigin {
73+ return errors.New("broker: local observation has an invalid display origin")
74+ }
75+ return nil
76+}
77+
78+// dispatchLocalLocked admits at most one local probe attempt when its schedule
79+// says it is due. Callers must hold r.mu. It reports whether an attempt
80+// started, so dispatch publishes the transient Checking state exactly once.
81+func (r *Registry) dispatchLocalLocked(ctx context.Context, now time.Time, results chan<- probeResult) bool {
82+ if r.local == nil || r.localAttempt != nil {
83+ return false
84+ }
85+ if !r.localHost.NextDue.IsZero() && now.Before(r.localHost.NextDue) {
86+ return false
87+ }
88+ r.attempts++
89+ attempt := r.attempts
90+ probeCtx, cancel := context.WithCancel(ctx)
91+ r.localAttempt = &probeAttempt{token: attempt, cancel: cancel}
92+ r.localHost.Checking = true
93+ r.localHost.LastAttempt = now
94+ go func(attempt uint64, probeCtx context.Context) {
95+ snapshot, err := r.local.Probe.ProbeLocal(probeCtx)
96+ result := probeResult{local: true, attempt: attempt, snapshot: snapshot, err: err, at: r.clock.Now()}
97+ select {
98+ case results <- result:
99+ case <-probeCtx.Done():
100+ // The attempt was retired (run settled), so its completion is
101+ // already fenced: dropping it keeps a settled registry from
102+ // queueing a result the run loop would only discard.
103+ }
104+ }(attempt, probeCtx)
105+ return true
106+}
107+
108+// applyLocal adopts one completed local probe. Callers must hold r.mu, exactly
109+// as Registry.apply requires. A stale attempt touches nothing. A confirmed
110+// reachable result publishes the observed state with configured authority
111+// stamped on top; every other outcome publishes a typed failure and keeps the
112+// last-known identity and inventory, so an unreachable local daemon never
113+// reports an invented identity and its availability is never zero.
114+func (r *Registry) applyLocal(result probeResult) {
115+ if r.local == nil {
116+ return
117+ }
118+ attempt := r.localAttempt
119+ if attempt == nil || attempt.token != result.attempt {
120+ return
121+ }
122+ r.localAttempt = nil
123+ // The probe has returned, so releasing its derived context here only frees
124+ // the attempt's resources; the record is gone before any new attempt can
125+ // start.
126+ attempt.cancel()
127+
128+ current := r.localHost
129+ current.Checking = false
130+ current.LastAttempt = result.at
131+
132+ kind, availability, failed := observationOutcome(result)
133+ if !failed {
134+ observed := stampLocalAuthority(result.snapshot.Clone(), current)
135+ observed.Checking = false
136+ observed.LastAttempt = result.at
137+ observed.LastSuccess = result.at
138+ observed.NextDue = result.at.Add(r.jitter(r.freshFor, localProbeKey, result.attempt))
139+ observed.ConsecutiveFailures = 0
140+ observed.LastFailure = domain.RemoteFailure{}
141+ observed.FailureEpisode = current.FailureEpisode
142+ // The published local observation must be fully valid once authority is
143+ // stamped. A probe that invents partial identity or an invalid session
144+ // catalogue is an invalid response, never a published observation.
145+ if err := observed.Validate(); err != nil {
146+ r.log.Debug("broker: local observation is invalid", "err", err)
147+ kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
148+ } else if err := validateLocalObservation(observed); err != nil {
149+ r.log.Debug("broker: local observation is not publishable", "err", err)
150+ kind, availability, failed = domain.RemoteFailureInvalidResponse, domain.RemoteAvailabilityInvalidResponse, true
151+ } else {
152+ r.localHost = observed
153+ r.publishLocked(true)
154+ return
155+ }
156+ }
157+ // Every non-reachable outcome is a typed failure on the capped retry
158+ // cadence: the observed identity, last-known inventory, and failure episode
159+ // counter stay authoritative.
160+ current.Availability = availability
161+ if current.ConsecutiveFailures == 0 {
162+ current.FailureEpisode++
163+ }
164+ current.ConsecutiveFailures++
165+ current.LastFailure = domain.RemoteFailure{Kind: kind, Err: result.err}
166+ current.NextDue = result.at.Add(r.jitter(r.retryDelay(current.ConsecutiveFailures), localProbeKey, result.attempt))
167+ r.localHost = current
168+ r.publishLocked(true)
169+}
170+
171+// stampLocalAuthority copies the configured local authority onto a fresh probe
172+// result. Probe results supply observed state only, so their local flag,
173+// endpoint, registration, display origin, policy, and rank fields are discarded
174+// wholesale: a hostile probe can never promote itself to local authority or
175+// borrow a remote endpoint.
176+func stampLocalAuthority(observation, authority ports.BrokerDaemonObservation) ports.BrokerDaemonObservation {
177+ observation.Local = true
178+ observation.Endpoint = ""
179+ observation.Registration = domain.RemoteRegistration{}
180+ observation.DisplayOrigin = authority.DisplayOrigin
181+ observation.Policy = authority.Policy
182+ observation.Rank = authority.Rank
183+ return observation
184+}
185+
186+// validateLocalObservation re-checks the catalogue shape of a local session
187+// inventory. The durable projection rule cannot apply to a local observation
188+// (the local daemon is never durable and has no registration incarnation), so
189+// the catalogue itself is validated directly against the current schema.
190+func validateLocalObservation(observation ports.BrokerDaemonObservation) error {
191+ if len(observation.Sessions) == 0 {
192+ return nil
193+ }
194+ return catalogue.ValidateRemoteCatalog(catalogue.RemoteCatalog{
195+ ProtocolVersion: protocol.Version,
196+ SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
197+ Sessions: observation.Sessions,
198+ })
199+}
A internal/usecase/broker/registry_local_test.go +460 -0
@@ -0,0 +1,460 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+// scriptedLocalProbe scripts one local observation per received call: the test
18+// owns the completion, so the local probe never depends on wall time.
19+type scriptedLocalProbe struct {
20+ calls chan localProbeCall
21+ canceled chan struct{}
22+}
23+
24+type localProbeCall struct {
25+ result chan localProbeAnswer
26+}
27+
28+type localProbeAnswer struct {
29+ snapshot ports.BrokerDaemonObservation
30+ err error
31+}
32+
33+func newScriptedLocalProbe(capacity int) *scriptedLocalProbe {
34+ return &scriptedLocalProbe{calls: make(chan localProbeCall, capacity), canceled: make(chan struct{}, 1)}
35+}
36+
37+func (p *scriptedLocalProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
38+ call := localProbeCall{result: make(chan localProbeAnswer, 1)}
39+ select {
40+ case p.calls <- call:
41+ case <-ctx.Done():
42+ p.noteCanceled()
43+ return ports.BrokerDaemonObservation{}, ctx.Err()
44+ }
45+ select {
46+ case answer := <-call.result:
47+ return answer.snapshot, answer.err
48+ case <-ctx.Done():
49+ p.noteCanceled()
50+ return ports.BrokerDaemonObservation{}, ctx.Err()
51+ }
52+}
53+
54+func (p *scriptedLocalProbe) noteCanceled() {
55+ select {
56+ case p.canceled <- struct{}{}:
57+ default:
58+ }
59+}
60+
61+func receiveLocalCall(t *testing.T, probe *scriptedLocalProbe) localProbeCall {
62+ t.Helper()
63+ select {
64+ case call := <-probe.calls:
65+ return call
66+ case <-time.After(time.Second):
67+ t.Fatal("timed out waiting for local probe")
68+ return localProbeCall{}
69+ }
70+}
71+
72+// newLocalTestRegistry builds a registry that observes the broker's own machine
73+// daemon through localProbe, beside an optional remote probe.
74+func newLocalTestRegistry(t *testing.T, store ports.BrokerHostStore, remoteProbe ports.BrokerHostProbe, localProbe LocalProbe, clock *manualClock) *Registry {
75+ t.Helper()
76+ registry, err := NewRegistryWithConfig(1, store, remoteProbe, clock, nil, RegistryConfig{
77+ Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: localProbe},
78+ })
79+ require.NoError(t, err)
80+ registry.jitter = identityJitter
81+ return registry
82+}
83+
84+// waitLocal waits for the published local entry (always index zero) to satisfy
85+// predicate and returns it.
86+func waitLocal(t *testing.T, r *Registry, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation {
87+ t.Helper()
88+ snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool {
89+ return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local && predicate(snapshot.Daemons[0])
90+ })
91+ return snapshot.Daemons[0]
92+}
93+
94+// TestRegistryPublishesObservedLocalDaemonFirst proves the local observation is
95+// published at index zero ahead of every remote, carries the configured
96+// authority plus the observed identity/incarnation/version, and is never
97+// durable: the store persists the remote observations only.
98+func TestRegistryPublishesObservedLocalDaemonFirst(t *testing.T) {
99+ clock := newManualClock(time.Unix(100, 0))
100+ store := newTestStore()
101+ local := newScriptedLocalProbe(1)
102+ registry := newLocalTestRegistry(t, store, newTestProbe(1), local, clock)
103+ reg := registration(t, "user@host:22", 1)
104+ require.NoError(t, registry.setHosts(hostRecords(reg)))
105+ startRegistry(t, registry)
106+
107+ call := receiveLocalCall(t, local)
108+ call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
109+ Identity: "local-daemon",
110+ Incarnation: ports.BrokerDaemonIncarnation{9},
111+ ProtocolVersion: protocol.Version,
112+ Availability: domain.RemoteAvailabilityReachable,
113+ }}
114+
115+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
116+ return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable
117+ })
118+ require.True(t, host.Local)
119+ require.Zero(t, host.Endpoint)
120+ require.Equal(t, domain.RemoteRegistration{}, host.Registration)
121+ require.Equal(t, "local", host.DisplayOrigin)
122+ require.Equal(t, poolPolicy(), host.Policy)
123+ require.Zero(t, host.Rank)
124+ require.Equal(t, ports.BrokerDaemonIdentity("local-daemon"), host.Identity)
125+ require.Equal(t, ports.BrokerDaemonIncarnation{9}, host.Incarnation)
126+ require.Equal(t, protocol.Version, host.ProtocolVersion)
127+
128+ snapshot := registry.Snapshot()
129+ require.True(t, snapshot.Daemons[0].Local)
130+ require.Equal(t, reg.Endpoint, snapshot.Daemons[1].Endpoint)
131+
132+ // Never durable: the store receives remote observations only, and the local
133+ // entry is dropped before persistence.
134+ stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
135+ return len(snapshot.Daemons) == 1 && !snapshot.Daemons[0].Local
136+ })
137+ require.Equal(t, reg.Endpoint, stored.Daemons[0].Endpoint)
138+}
139+
140+// TestRegistryLocalObservationNeverInventsState proves an unreachable local
141+// daemon is published with the explicit unknown availability and zero observed
142+// identity, and a hostile probe result cannot supply local authority,
143+// endpoint, registration, policy, or display origin.
144+func TestRegistryLocalObservationNeverInventsState(t *testing.T) {
145+ t.Run("failure keeps zero identity and typed availability", func(t *testing.T) {
146+ clock := newManualClock(time.Unix(100, 0))
147+ local := newScriptedLocalProbe(1)
148+ registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock)
149+ startRegistry(t, registry)
150+
151+ call := receiveLocalCall(t, local)
152+ call.result <- localProbeAnswer{err: errors.New("local carriage refused")}
153+
154+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
155+ return !o.Checking && o.Availability == domain.RemoteAvailabilityUnreachable
156+ })
157+ require.True(t, host.Local)
158+ require.Zero(t, host.Endpoint)
159+ require.Equal(t, ports.BrokerDaemonIdentity(""), host.Identity)
160+ require.True(t, host.Incarnation.IsZero())
161+ require.Zero(t, host.ProtocolVersion)
162+ require.Equal(t, domain.RemoteFailureTransport, host.LastFailure.Kind)
163+ require.Equal(t, 1, len(registry.Snapshot().Daemons))
164+ })
165+
166+ t.Run("hostile probe cannot supply local authority", func(t *testing.T) {
167+ clock := newManualClock(time.Unix(100, 0))
168+ local := newScriptedLocalProbe(1)
169+ registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock)
170+ startRegistry(t, registry)
171+
172+ hostilePolicy := poolPolicy()
173+ hostilePolicy.Trust = "attacker"
174+ call := receiveLocalCall(t, local)
175+ call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
176+ Local: false,
177+ Endpoint: "attacker@evil",
178+ DisplayOrigin: "evil",
179+ Rank: 42,
180+ Registration: registration(t, "attacker@evil", 9),
181+ Policy: hostilePolicy,
182+ Identity: "local-daemon",
183+ Incarnation: ports.BrokerDaemonIncarnation{9},
184+ ProtocolVersion: protocol.Version,
185+ Availability: domain.RemoteAvailabilityReachable,
186+ }}
187+
188+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
189+ return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable
190+ })
191+ require.True(t, host.Local)
192+ require.Zero(t, host.Endpoint)
193+ require.Equal(t, domain.RemoteRegistration{}, host.Registration)
194+ require.Equal(t, "local", host.DisplayOrigin)
195+ require.Equal(t, poolPolicy(), host.Policy)
196+ require.NotEqual(t, hostilePolicy, host.Policy)
197+ require.Zero(t, host.Rank)
198+ })
199+}
200+
201+// TestRegistryLocalObservationValidatesProbeResults proves an invalid observed
202+// projection is classified as an invalid response, never published as an
203+// observation the wire refuses: a partial identity (identity without a version)
204+// is rejected without inventing the missing half.
205+func TestRegistryLocalObservationValidatesProbeResults(t *testing.T) {
206+ clock := newManualClock(time.Unix(100, 0))
207+ local := newScriptedLocalProbe(1)
208+ registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock)
209+ startRegistry(t, registry)
210+
211+ call := receiveLocalCall(t, local)
212+ call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
213+ Identity: "local-daemon", // no protocol version: partial identity
214+ Incarnation: ports.BrokerDaemonIncarnation{9},
215+ Availability: domain.RemoteAvailabilityReachable,
216+ }}
217+
218+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
219+ return !o.Checking && o.Availability == domain.RemoteAvailabilityInvalidResponse
220+ })
221+ require.True(t, host.Local)
222+ require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind)
223+}
224+
225+// TestRegistryLocalObservationConstruction pins the composition refusals: a
226+// read-only registry owns no local producer, a local producer requires a probe,
227+// and its policy and display origin are validated before anything is published.
228+func TestRegistryLocalObservationConstruction(t *testing.T) {
229+ clock := newManualClock(time.Unix(100, 0))
230+ valid := &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}
231+
232+ invalidPolicy := poolPolicy()
233+ invalidPolicy.ProtocolVersion = 0
234+
235+ tests := []struct {
236+ name string
237+ cfg RegistryConfig
238+ remoteProbe ports.BrokerHostProbe
239+ }{
240+ {
241+ name: "observation-disabled registry cannot observe locally",
242+ cfg: RegistryConfig{ObservationDisabled: true, Local: valid},
243+ remoteProbe: newTestProbe(1),
244+ },
245+ {
246+ name: "local producer requires a probe",
247+ cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy()}},
248+ remoteProbe: newTestProbe(1),
249+ },
250+ {
251+ name: "local authority policy is validated",
252+ cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "local", Policy: invalidPolicy, Probe: newScriptedLocalProbe(1)}},
253+ remoteProbe: newTestProbe(1),
254+ },
255+ {
256+ name: "local display origin is required",
257+ cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}},
258+ remoteProbe: newTestProbe(1),
259+ },
260+ {
261+ name: "local display origin must be normalized",
262+ cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "ev\u202eil", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}},
263+ remoteProbe: newTestProbe(1),
264+ },
265+ {
266+ name: "no producer at all is still refused",
267+ cfg: RegistryConfig{},
268+ remoteProbe: nil,
269+ },
270+ }
271+ for _, tt := range tests {
272+ t.Run(tt.name, func(t *testing.T) {
273+ _, err := NewRegistryWithConfig(1, newTestStore(), tt.remoteProbe, clock, nil, tt.cfg)
274+ require.Error(t, err)
275+ })
276+ }
277+}
278+
279+// TestRegistryLocalOnlyRequiresNoRemoteProbe proves a composition that observes
280+// only the broker's own machine daemon needs no remote probe, and that such a
281+// registry refuses remote membership rather than silently never probing it.
282+func TestRegistryLocalOnlyRequiresNoRemoteProbe(t *testing.T) {
283+ clock := newManualClock(time.Unix(100, 0))
284+ local := newScriptedLocalProbe(1)
285+ registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{
286+ Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: local},
287+ })
288+ require.NoError(t, err)
289+ registry.jitter = identityJitter
290+
291+ reg := registration(t, "user@host:22", 1)
292+ require.Error(t, registry.setHosts(hostRecords(reg)))
293+ require.Error(t, registry.ReplaceHosts(hostRecords(reg)))
294+
295+ startRegistry(t, registry)
296+ call := receiveLocalCall(t, local)
297+ call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
298+ Identity: "local-daemon",
299+ Incarnation: ports.BrokerDaemonIncarnation{9},
300+ ProtocolVersion: protocol.Version,
301+ Availability: domain.RemoteAvailabilityReachable,
302+ }}
303+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
304+ return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable
305+ })
306+ require.True(t, host.Local)
307+ require.Len(t, registry.Snapshot().Daemons, 1)
308+}
309+
310+// localEntryCount counts the published local entries. A valid snapshot carries
311+// at most one, so this is also the assertion that no second local entry is
312+// ever invented.
313+func localEntryCount(snapshot ports.BrokerSnapshot) int {
314+ count := 0
315+ for _, daemon := range snapshot.Daemons {
316+ if daemon.Local {
317+ count++
318+ }
319+ }
320+ return count
321+}
322+
323+// remoteEndpoints lists the published remote endpoints in publication order,
324+// excluding the local entry.
325+func remoteEndpoints(snapshot ports.BrokerSnapshot) []string {
326+ var endpoints []string
327+ for _, daemon := range snapshot.Daemons {
328+ if daemon.Local {
329+ continue
330+ }
331+ endpoints = append(endpoints, daemon.Endpoint)
332+ }
333+ return endpoints
334+}
335+
336+// TestRegistryLocalEntryStaysFirstAcrossMembershipChanges proves the published
337+// snapshot always carries exactly one local entry at index zero while the
338+// remote membership churns through add, reorder, removal, re-add, and clear,
339+// with the remotes in registration order behind it. The local entry stays first
340+// because the registry owns it separately from durable membership: it is never
341+// reordered by a membership change and never replaced by a remote.
342+func TestRegistryLocalEntryStaysFirstAcrossMembershipChanges(t *testing.T) {
343+ first := registration(t, "user@first:22", 1)
344+ second := registration(t, "user@second:22", 2)
345+ third := registration(t, "user@third:22", 3)
346+
347+ steps := []struct {
348+ name string
349+ apply func(t *testing.T, r *Registry)
350+ remotes []string
351+ }{
352+ {
353+ name: "no remotes publishes the local entry alone",
354+ apply: func(*testing.T, *Registry) {},
355+ },
356+ {
357+ name: "adding remotes keeps the local entry first",
358+ apply: func(t *testing.T, r *Registry) { require.NoError(t, r.setHosts(hostRecords(first, second))) },
359+ remotes: []string{first.Endpoint, second.Endpoint},
360+ },
361+ {
362+ name: "reordering remotes keeps the local entry first",
363+ apply: func(t *testing.T, r *Registry) { require.NoError(t, r.setHosts(hostRecords(second, first))) },
364+ remotes: []string{second.Endpoint, first.Endpoint},
365+ },
366+ {
367+ name: "durable replacement keeps the local entry first",
368+ apply: func(t *testing.T, r *Registry) { require.NoError(t, r.ReplaceHosts(hostRecords(first))) },
369+ remotes: []string{first.Endpoint},
370+ },
371+ {
372+ name: "removing then re-adding a remote keeps the local entry first",
373+ apply: func(t *testing.T, r *Registry) {
374+ require.NoError(t, r.ReplaceHosts(hostRecords()))
375+ require.NoError(t, r.ReplaceHosts(hostRecords(first, third)))
376+ },
377+ remotes: []string{first.Endpoint, third.Endpoint},
378+ },
379+ {
380+ name: "clearing durable membership leaves the local entry alone",
381+ apply: func(t *testing.T, r *Registry) { require.NoError(t, r.ReplaceHosts(hostRecords())) },
382+ remotes: nil,
383+ },
384+ }
385+ registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), newScriptedLocalProbe(1), newManualClock(time.Unix(100, 0)))
386+ for _, tt := range steps {
387+ t.Run(tt.name, func(t *testing.T) {
388+ tt.apply(t, registry)
389+ snapshot := registry.Snapshot()
390+ require.NotEmpty(t, snapshot.Daemons)
391+ require.True(t, snapshot.Daemons[0].Local, "the local entry is always at index zero")
392+ require.Equal(t, 1, localEntryCount(snapshot), "exactly one local entry is published")
393+ require.Equal(t, tt.remotes, remoteEndpoints(snapshot), "remotes keep registration order behind the local entry")
394+ })
395+ }
396+}
397+
398+// TestRegistryLocalOnlyRefusesRestoredRemoteMembership proves a local-only
399+// registry (a configured local observation and no remote probe) refuses restored
400+// durable remote membership at the projectMembership boundary, so it never owns
401+// a remote host it could never probe. The setHosts/ReplaceHosts refusals are
402+// covered beside this test.
403+func TestRegistryLocalOnlyRefusesRestoredRemoteMembership(t *testing.T) {
404+ clock := newManualClock(time.Unix(100, 0))
405+ local := func() *LocalObservation {
406+ return &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}
407+ }
408+ reg := registration(t, "user@host:22", 1)
409+
410+ t.Run("construction refuses a store with restored remote membership", func(t *testing.T) {
411+ store := newTestStore()
412+ store.hosts = ports.BrokerHosts{Revision: 1, Hosts: hostRecords(reg)}
413+ _, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{Local: local()})
414+ require.Error(t, err)
415+ require.Contains(t, err.Error(), "remote hosts require a host probe")
416+ })
417+
418+ t.Run("projectMembership refuses restored remote membership directly", func(t *testing.T) {
419+ registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: local()})
420+ require.NoError(t, err)
421+ // Empty restored membership is accepted: the local-only registry owns no
422+ // remote host and needs no remote probe.
423+ require.NoError(t, registry.projectMembership(ports.BrokerHosts{Revision: 1}))
424+ err = registry.projectMembership(ports.BrokerHosts{Revision: 1, Hosts: hostRecords(reg)})
425+ require.Error(t, err)
426+ require.Contains(t, err.Error(), "remote hosts require a host probe")
427+ require.Empty(t, remoteEndpoints(registry.Snapshot()), "the refused membership is never published")
428+ })
429+}
430+
431+// TestPoolLocalStreamsShareOnePhysicalEntry proves two local open-stream
432+// requests under one broker-owned local binding resolve to the same pooled
433+// (identity, policy) physical entry: the pool dials one daemonmux carriage,
434+// both logical streams ride it, and closing one never closes its sibling. It
435+// lives here because pool_test.go is outside this change's write scope; the
436+// fake connector/resolver harness it reuses is defined there.
437+func TestPoolLocalStreamsShareOnePhysicalEntry(t *testing.T) {
438+ var connects atomic.Int32
439+ var physical *fakePhysical
440+ pool, _ := setupPool(t, func(_ context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) {
441+ connects.Add(1)
442+ physical = &fakePhysical{endpoint: endpoint, done: make(chan struct{})}
443+ return physical, nil
444+ })
445+ id, err := pool.RegisterClient()
446+ require.NoError(t, err)
447+ first, err := pool.OpenStream(context.Background(), poolRequest(id, 1))
448+ require.NoError(t, err)
449+ second, err := pool.OpenStream(context.Background(), poolRequest(id, 2))
450+ require.NoError(t, err)
451+ require.EqualValues(t, 1, connects.Load(), "both local streams share one physical carriage")
452+ require.NoError(t, first.Close())
453+ select {
454+ case <-second.Done():
455+ t.Fatal("closing one local stream closed its sibling")
456+ default:
457+ }
458+ require.NoError(t, second.Close())
459+ require.NoError(t, physical.Close())
460+}
@@ -0,0 +1,460 @@
1+package broker
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol"
15+)
16+
17+// scriptedLocalProbe scripts one local observation per received call: the test
18+// owns the completion, so the local probe never depends on wall time.
19+type scriptedLocalProbe struct {
20+ calls chan localProbeCall
21+ canceled chan struct{}
22+}
23+
24+type localProbeCall struct {
25+ result chan localProbeAnswer
26+}
27+
28+type localProbeAnswer struct {
29+ snapshot ports.BrokerDaemonObservation
30+ err error
31+}
32+
33+func newScriptedLocalProbe(capacity int) *scriptedLocalProbe {
34+ return &scriptedLocalProbe{calls: make(chan localProbeCall, capacity), canceled: make(chan struct{}, 1)}
35+}
36+
37+func (p *scriptedLocalProbe) ProbeLocal(ctx context.Context) (ports.BrokerDaemonObservation, error) {
38+ call := localProbeCall{result: make(chan localProbeAnswer, 1)}
39+ select {
40+ case p.calls <- call:
41+ case <-ctx.Done():
42+ p.noteCanceled()
43+ return ports.BrokerDaemonObservation{}, ctx.Err()
44+ }
45+ select {
46+ case answer := <-call.result:
47+ return answer.snapshot, answer.err
48+ case <-ctx.Done():
49+ p.noteCanceled()
50+ return ports.BrokerDaemonObservation{}, ctx.Err()
51+ }
52+}
53+
54+func (p *scriptedLocalProbe) noteCanceled() {
55+ select {
56+ case p.canceled <- struct{}{}:
57+ default:
58+ }
59+}
60+
61+func receiveLocalCall(t *testing.T, probe *scriptedLocalProbe) localProbeCall {
62+ t.Helper()
63+ select {
64+ case call := <-probe.calls:
65+ return call
66+ case <-time.After(time.Second):
67+ t.Fatal("timed out waiting for local probe")
68+ return localProbeCall{}
69+ }
70+}
71+
72+// newLocalTestRegistry builds a registry that observes the broker's own machine
73+// daemon through localProbe, beside an optional remote probe.
74+func newLocalTestRegistry(t *testing.T, store ports.BrokerHostStore, remoteProbe ports.BrokerHostProbe, localProbe LocalProbe, clock *manualClock) *Registry {
75+ t.Helper()
76+ registry, err := NewRegistryWithConfig(1, store, remoteProbe, clock, nil, RegistryConfig{
77+ Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: localProbe},
78+ })
79+ require.NoError(t, err)
80+ registry.jitter = identityJitter
81+ return registry
82+}
83+
84+// waitLocal waits for the published local entry (always index zero) to satisfy
85+// predicate and returns it.
86+func waitLocal(t *testing.T, r *Registry, predicate func(ports.BrokerDaemonObservation) bool) ports.BrokerDaemonObservation {
87+ t.Helper()
88+ snapshot := waitSnapshot(t, r, func(snapshot ports.BrokerSnapshot) bool {
89+ return len(snapshot.Daemons) > 0 && snapshot.Daemons[0].Local && predicate(snapshot.Daemons[0])
90+ })
91+ return snapshot.Daemons[0]
92+}
93+
94+// TestRegistryPublishesObservedLocalDaemonFirst proves the local observation is
95+// published at index zero ahead of every remote, carries the configured
96+// authority plus the observed identity/incarnation/version, and is never
97+// durable: the store persists the remote observations only.
98+func TestRegistryPublishesObservedLocalDaemonFirst(t *testing.T) {
99+ clock := newManualClock(time.Unix(100, 0))
100+ store := newTestStore()
101+ local := newScriptedLocalProbe(1)
102+ registry := newLocalTestRegistry(t, store, newTestProbe(1), local, clock)
103+ reg := registration(t, "user@host:22", 1)
104+ require.NoError(t, registry.setHosts(hostRecords(reg)))
105+ startRegistry(t, registry)
106+
107+ call := receiveLocalCall(t, local)
108+ call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
109+ Identity: "local-daemon",
110+ Incarnation: ports.BrokerDaemonIncarnation{9},
111+ ProtocolVersion: protocol.Version,
112+ Availability: domain.RemoteAvailabilityReachable,
113+ }}
114+
115+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
116+ return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable
117+ })
118+ require.True(t, host.Local)
119+ require.Zero(t, host.Endpoint)
120+ require.Equal(t, domain.RemoteRegistration{}, host.Registration)
121+ require.Equal(t, "local", host.DisplayOrigin)
122+ require.Equal(t, poolPolicy(), host.Policy)
123+ require.Zero(t, host.Rank)
124+ require.Equal(t, ports.BrokerDaemonIdentity("local-daemon"), host.Identity)
125+ require.Equal(t, ports.BrokerDaemonIncarnation{9}, host.Incarnation)
126+ require.Equal(t, protocol.Version, host.ProtocolVersion)
127+
128+ snapshot := registry.Snapshot()
129+ require.True(t, snapshot.Daemons[0].Local)
130+ require.Equal(t, reg.Endpoint, snapshot.Daemons[1].Endpoint)
131+
132+ // Never durable: the store receives remote observations only, and the local
133+ // entry is dropped before persistence.
134+ stored := waitStored(t, store, func(snapshot ports.BrokerSnapshot) bool {
135+ return len(snapshot.Daemons) == 1 && !snapshot.Daemons[0].Local
136+ })
137+ require.Equal(t, reg.Endpoint, stored.Daemons[0].Endpoint)
138+}
139+
140+// TestRegistryLocalObservationNeverInventsState proves an unreachable local
141+// daemon is published with the explicit unknown availability and zero observed
142+// identity, and a hostile probe result cannot supply local authority,
143+// endpoint, registration, policy, or display origin.
144+func TestRegistryLocalObservationNeverInventsState(t *testing.T) {
145+ t.Run("failure keeps zero identity and typed availability", func(t *testing.T) {
146+ clock := newManualClock(time.Unix(100, 0))
147+ local := newScriptedLocalProbe(1)
148+ registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock)
149+ startRegistry(t, registry)
150+
151+ call := receiveLocalCall(t, local)
152+ call.result <- localProbeAnswer{err: errors.New("local carriage refused")}
153+
154+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
155+ return !o.Checking && o.Availability == domain.RemoteAvailabilityUnreachable
156+ })
157+ require.True(t, host.Local)
158+ require.Zero(t, host.Endpoint)
159+ require.Equal(t, ports.BrokerDaemonIdentity(""), host.Identity)
160+ require.True(t, host.Incarnation.IsZero())
161+ require.Zero(t, host.ProtocolVersion)
162+ require.Equal(t, domain.RemoteFailureTransport, host.LastFailure.Kind)
163+ require.Equal(t, 1, len(registry.Snapshot().Daemons))
164+ })
165+
166+ t.Run("hostile probe cannot supply local authority", func(t *testing.T) {
167+ clock := newManualClock(time.Unix(100, 0))
168+ local := newScriptedLocalProbe(1)
169+ registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock)
170+ startRegistry(t, registry)
171+
172+ hostilePolicy := poolPolicy()
173+ hostilePolicy.Trust = "attacker"
174+ call := receiveLocalCall(t, local)
175+ call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
176+ Local: false,
177+ Endpoint: "attacker@evil",
178+ DisplayOrigin: "evil",
179+ Rank: 42,
180+ Registration: registration(t, "attacker@evil", 9),
181+ Policy: hostilePolicy,
182+ Identity: "local-daemon",
183+ Incarnation: ports.BrokerDaemonIncarnation{9},
184+ ProtocolVersion: protocol.Version,
185+ Availability: domain.RemoteAvailabilityReachable,
186+ }}
187+
188+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
189+ return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable
190+ })
191+ require.True(t, host.Local)
192+ require.Zero(t, host.Endpoint)
193+ require.Equal(t, domain.RemoteRegistration{}, host.Registration)
194+ require.Equal(t, "local", host.DisplayOrigin)
195+ require.Equal(t, poolPolicy(), host.Policy)
196+ require.NotEqual(t, hostilePolicy, host.Policy)
197+ require.Zero(t, host.Rank)
198+ })
199+}
200+
201+// TestRegistryLocalObservationValidatesProbeResults proves an invalid observed
202+// projection is classified as an invalid response, never published as an
203+// observation the wire refuses: a partial identity (identity without a version)
204+// is rejected without inventing the missing half.
205+func TestRegistryLocalObservationValidatesProbeResults(t *testing.T) {
206+ clock := newManualClock(time.Unix(100, 0))
207+ local := newScriptedLocalProbe(1)
208+ registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), local, clock)
209+ startRegistry(t, registry)
210+
211+ call := receiveLocalCall(t, local)
212+ call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
213+ Identity: "local-daemon", // no protocol version: partial identity
214+ Incarnation: ports.BrokerDaemonIncarnation{9},
215+ Availability: domain.RemoteAvailabilityReachable,
216+ }}
217+
218+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
219+ return !o.Checking && o.Availability == domain.RemoteAvailabilityInvalidResponse
220+ })
221+ require.True(t, host.Local)
222+ require.Equal(t, domain.RemoteFailureInvalidResponse, host.LastFailure.Kind)
223+}
224+
225+// TestRegistryLocalObservationConstruction pins the composition refusals: a
226+// read-only registry owns no local producer, a local producer requires a probe,
227+// and its policy and display origin are validated before anything is published.
228+func TestRegistryLocalObservationConstruction(t *testing.T) {
229+ clock := newManualClock(time.Unix(100, 0))
230+ valid := &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}
231+
232+ invalidPolicy := poolPolicy()
233+ invalidPolicy.ProtocolVersion = 0
234+
235+ tests := []struct {
236+ name string
237+ cfg RegistryConfig
238+ remoteProbe ports.BrokerHostProbe
239+ }{
240+ {
241+ name: "observation-disabled registry cannot observe locally",
242+ cfg: RegistryConfig{ObservationDisabled: true, Local: valid},
243+ remoteProbe: newTestProbe(1),
244+ },
245+ {
246+ name: "local producer requires a probe",
247+ cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy()}},
248+ remoteProbe: newTestProbe(1),
249+ },
250+ {
251+ name: "local authority policy is validated",
252+ cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "local", Policy: invalidPolicy, Probe: newScriptedLocalProbe(1)}},
253+ remoteProbe: newTestProbe(1),
254+ },
255+ {
256+ name: "local display origin is required",
257+ cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}},
258+ remoteProbe: newTestProbe(1),
259+ },
260+ {
261+ name: "local display origin must be normalized",
262+ cfg: RegistryConfig{Local: &LocalObservation{DisplayOrigin: "ev\u202eil", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}},
263+ remoteProbe: newTestProbe(1),
264+ },
265+ {
266+ name: "no producer at all is still refused",
267+ cfg: RegistryConfig{},
268+ remoteProbe: nil,
269+ },
270+ }
271+ for _, tt := range tests {
272+ t.Run(tt.name, func(t *testing.T) {
273+ _, err := NewRegistryWithConfig(1, newTestStore(), tt.remoteProbe, clock, nil, tt.cfg)
274+ require.Error(t, err)
275+ })
276+ }
277+}
278+
279+// TestRegistryLocalOnlyRequiresNoRemoteProbe proves a composition that observes
280+// only the broker's own machine daemon needs no remote probe, and that such a
281+// registry refuses remote membership rather than silently never probing it.
282+func TestRegistryLocalOnlyRequiresNoRemoteProbe(t *testing.T) {
283+ clock := newManualClock(time.Unix(100, 0))
284+ local := newScriptedLocalProbe(1)
285+ registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{
286+ Local: &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: local},
287+ })
288+ require.NoError(t, err)
289+ registry.jitter = identityJitter
290+
291+ reg := registration(t, "user@host:22", 1)
292+ require.Error(t, registry.setHosts(hostRecords(reg)))
293+ require.Error(t, registry.ReplaceHosts(hostRecords(reg)))
294+
295+ startRegistry(t, registry)
296+ call := receiveLocalCall(t, local)
297+ call.result <- localProbeAnswer{snapshot: ports.BrokerDaemonObservation{
298+ Identity: "local-daemon",
299+ Incarnation: ports.BrokerDaemonIncarnation{9},
300+ ProtocolVersion: protocol.Version,
301+ Availability: domain.RemoteAvailabilityReachable,
302+ }}
303+ host := waitLocal(t, registry, func(o ports.BrokerDaemonObservation) bool {
304+ return !o.Checking && o.Availability == domain.RemoteAvailabilityReachable
305+ })
306+ require.True(t, host.Local)
307+ require.Len(t, registry.Snapshot().Daemons, 1)
308+}
309+
310+// localEntryCount counts the published local entries. A valid snapshot carries
311+// at most one, so this is also the assertion that no second local entry is
312+// ever invented.
313+func localEntryCount(snapshot ports.BrokerSnapshot) int {
314+ count := 0
315+ for _, daemon := range snapshot.Daemons {
316+ if daemon.Local {
317+ count++
318+ }
319+ }
320+ return count
321+}
322+
323+// remoteEndpoints lists the published remote endpoints in publication order,
324+// excluding the local entry.
325+func remoteEndpoints(snapshot ports.BrokerSnapshot) []string {
326+ var endpoints []string
327+ for _, daemon := range snapshot.Daemons {
328+ if daemon.Local {
329+ continue
330+ }
331+ endpoints = append(endpoints, daemon.Endpoint)
332+ }
333+ return endpoints
334+}
335+
336+// TestRegistryLocalEntryStaysFirstAcrossMembershipChanges proves the published
337+// snapshot always carries exactly one local entry at index zero while the
338+// remote membership churns through add, reorder, removal, re-add, and clear,
339+// with the remotes in registration order behind it. The local entry stays first
340+// because the registry owns it separately from durable membership: it is never
341+// reordered by a membership change and never replaced by a remote.
342+func TestRegistryLocalEntryStaysFirstAcrossMembershipChanges(t *testing.T) {
343+ first := registration(t, "user@first:22", 1)
344+ second := registration(t, "user@second:22", 2)
345+ third := registration(t, "user@third:22", 3)
346+
347+ steps := []struct {
348+ name string
349+ apply func(t *testing.T, r *Registry)
350+ remotes []string
351+ }{
352+ {
353+ name: "no remotes publishes the local entry alone",
354+ apply: func(*testing.T, *Registry) {},
355+ },
356+ {
357+ name: "adding remotes keeps the local entry first",
358+ apply: func(t *testing.T, r *Registry) { require.NoError(t, r.setHosts(hostRecords(first, second))) },
359+ remotes: []string{first.Endpoint, second.Endpoint},
360+ },
361+ {
362+ name: "reordering remotes keeps the local entry first",
363+ apply: func(t *testing.T, r *Registry) { require.NoError(t, r.setHosts(hostRecords(second, first))) },
364+ remotes: []string{second.Endpoint, first.Endpoint},
365+ },
366+ {
367+ name: "durable replacement keeps the local entry first",
368+ apply: func(t *testing.T, r *Registry) { require.NoError(t, r.ReplaceHosts(hostRecords(first))) },
369+ remotes: []string{first.Endpoint},
370+ },
371+ {
372+ name: "removing then re-adding a remote keeps the local entry first",
373+ apply: func(t *testing.T, r *Registry) {
374+ require.NoError(t, r.ReplaceHosts(hostRecords()))
375+ require.NoError(t, r.ReplaceHosts(hostRecords(first, third)))
376+ },
377+ remotes: []string{first.Endpoint, third.Endpoint},
378+ },
379+ {
380+ name: "clearing durable membership leaves the local entry alone",
381+ apply: func(t *testing.T, r *Registry) { require.NoError(t, r.ReplaceHosts(hostRecords())) },
382+ remotes: nil,
383+ },
384+ }
385+ registry := newLocalTestRegistry(t, newTestStore(), newTestProbe(1), newScriptedLocalProbe(1), newManualClock(time.Unix(100, 0)))
386+ for _, tt := range steps {
387+ t.Run(tt.name, func(t *testing.T) {
388+ tt.apply(t, registry)
389+ snapshot := registry.Snapshot()
390+ require.NotEmpty(t, snapshot.Daemons)
391+ require.True(t, snapshot.Daemons[0].Local, "the local entry is always at index zero")
392+ require.Equal(t, 1, localEntryCount(snapshot), "exactly one local entry is published")
393+ require.Equal(t, tt.remotes, remoteEndpoints(snapshot), "remotes keep registration order behind the local entry")
394+ })
395+ }
396+}
397+
398+// TestRegistryLocalOnlyRefusesRestoredRemoteMembership proves a local-only
399+// registry (a configured local observation and no remote probe) refuses restored
400+// durable remote membership at the projectMembership boundary, so it never owns
401+// a remote host it could never probe. The setHosts/ReplaceHosts refusals are
402+// covered beside this test.
403+func TestRegistryLocalOnlyRefusesRestoredRemoteMembership(t *testing.T) {
404+ clock := newManualClock(time.Unix(100, 0))
405+ local := func() *LocalObservation {
406+ return &LocalObservation{DisplayOrigin: "local", Policy: poolPolicy(), Probe: newScriptedLocalProbe(1)}
407+ }
408+ reg := registration(t, "user@host:22", 1)
409+
410+ t.Run("construction refuses a store with restored remote membership", func(t *testing.T) {
411+ store := newTestStore()
412+ store.hosts = ports.BrokerHosts{Revision: 1, Hosts: hostRecords(reg)}
413+ _, err := NewRegistryWithConfig(1, store, nil, clock, nil, RegistryConfig{Local: local()})
414+ require.Error(t, err)
415+ require.Contains(t, err.Error(), "remote hosts require a host probe")
416+ })
417+
418+ t.Run("projectMembership refuses restored remote membership directly", func(t *testing.T) {
419+ registry, err := NewRegistryWithConfig(1, newTestStore(), nil, clock, nil, RegistryConfig{Local: local()})
420+ require.NoError(t, err)
421+ // Empty restored membership is accepted: the local-only registry owns no
422+ // remote host and needs no remote probe.
423+ require.NoError(t, registry.projectMembership(ports.BrokerHosts{Revision: 1}))
424+ err = registry.projectMembership(ports.BrokerHosts{Revision: 1, Hosts: hostRecords(reg)})
425+ require.Error(t, err)
426+ require.Contains(t, err.Error(), "remote hosts require a host probe")
427+ require.Empty(t, remoteEndpoints(registry.Snapshot()), "the refused membership is never published")
428+ })
429+}
430+
431+// TestPoolLocalStreamsShareOnePhysicalEntry proves two local open-stream
432+// requests under one broker-owned local binding resolve to the same pooled
433+// (identity, policy) physical entry: the pool dials one daemonmux carriage,
434+// both logical streams ride it, and closing one never closes its sibling. It
435+// lives here because pool_test.go is outside this change's write scope; the
436+// fake connector/resolver harness it reuses is defined there.
437+func TestPoolLocalStreamsShareOnePhysicalEntry(t *testing.T) {
438+ var connects atomic.Int32
439+ var physical *fakePhysical
440+ pool, _ := setupPool(t, func(_ context.Context, endpoint ports.BrokerResolvedEndpoint) (ports.BrokerPhysicalConnection, error) {
441+ connects.Add(1)
442+ physical = &fakePhysical{endpoint: endpoint, done: make(chan struct{})}
443+ return physical, nil
444+ })
445+ id, err := pool.RegisterClient()
446+ require.NoError(t, err)
447+ first, err := pool.OpenStream(context.Background(), poolRequest(id, 1))
448+ require.NoError(t, err)
449+ second, err := pool.OpenStream(context.Background(), poolRequest(id, 2))
450+ require.NoError(t, err)
451+ require.EqualValues(t, 1, connects.Load(), "both local streams share one physical carriage")
452+ require.NoError(t, first.Close())
453+ select {
454+ case <-second.Done():
455+ t.Fatal("closing one local stream closed its sibling")
456+ default:
457+ }
458+ require.NoError(t, second.Close())
459+ require.NoError(t, physical.Close())
460+}

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