1package client
2
3import (
4 "errors"
5 "fmt"
6
7 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/internal/protocol"
10)
11
12// Closed, one-shot initial navigation.
13//
14// InitialNavigation is the comparable, closed description of the single broker
15// navigation a client performs after its first committed catalogue publication.
16// It replaces the former single-variant enum outright: there is no alias, and
17// the zero value is a safe no-navigation "picker" intent so a configuration
18// that omits the intent opens nothing.
19//
20// The union carries no live authority of its own. Validate is total and
21// performs no I/O; Resolve revalidates the intent against one broker snapshot
22// and returns the exact ports.BrokerOpenStreamRequest gabarit, whose connection
23// and stream identity the supervisor fills from its own connection before it
24// opens the stream.
25//
26// Rules the closed union enforces:
27//
28// - Picker carries nothing at all: a zero destination, a zero epoch, no name,
29// and a zero target.
30// - CreateEphemeral and CreateNamed carry a valid endpoint fence, a name
31// only for the named variant, and no target.
32// - AttachExact carries a valid endpoint fence, a nonzero epoch, no name, and
33// a validated protocol.ExactSessionTarget.
34// - AttachNamed carries a valid endpoint fence, a name, and no target; the
35// daemon resolves the name and refuses an unknown one.
36// - A local creation may state epoch zero, meaning "bind to the first adopted
37// publication". Every remote intent, and every exact attach even locally,
38// requires the epoch of the snapshot that resolved it, and a nonzero epoch
39// must equal the current snapshot's epoch. Epochs never compare numerically
40// across broker processes.
41type InitialNavigationKind uint8
42
43const (
44 // InitialNavigationPicker is the zero kind: present the picker and open
45 // nothing. A configuration that omits the intent is safe.
46 InitialNavigationPicker InitialNavigationKind = iota
47 // InitialNavigationCreateEphemeral creates one ephemeral session.
48 InitialNavigationCreateEphemeral
49 // InitialNavigationCreateNamed creates one named session.
50 InitialNavigationCreateNamed
51 // InitialNavigationAttachExact attaches to one exact session lifecycle.
52 InitialNavigationAttachExact
53 // InitialNavigationAttachNamed attaches to the session the daemon knows by
54 // name; existence is the daemon's decision, not the broker catalogue's.
55 InitialNavigationAttachNamed
56)
57
58func (k InitialNavigationKind) String() string {
59 switch k {
60 case InitialNavigationPicker:
61 return "picker"
62 case InitialNavigationCreateEphemeral:
63 return "create_ephemeral"
64 case InitialNavigationCreateNamed:
65 return "create_named"
66 case InitialNavigationAttachExact:
67 return "attach_exact"
68 case InitialNavigationAttachNamed:
69 return "attach_named"
70 default:
71 return fmt.Sprintf("invalid(%d)", uint8(k))
72 }
73}
74
75// InitialNavigation is one closed, one-shot navigation intent. Destination is
76// the existing ports.BrokerEndpointFence: a local destination is exactly
77// Local=true with no registration, and a remote destination carries the
78// complete registration observed in a broker snapshot, never a bare hostname
79// and never an invented daemon identity.
80type InitialNavigation struct {
81 Kind InitialNavigationKind
82 Epoch ports.BrokerEpoch
83 Destination ports.BrokerEndpointFence
84 Name string
85 Target protocol.ExactSessionTarget
86}
87
88// InitialNavigationResolver translates one composition-owned CLI target (an
89// attach name or a remote endpoint) into an exact navigation identity against
90// the first committed broker snapshot. It is a translation of a target into
91// identity, not an owner of navigation: it performs no I/O, it is consulted at
92// most once, and the supervisor remains the only component that consumes the
93// intent and opens the stream.
94type InitialNavigationResolver func(ports.BrokerSnapshot) (InitialNavigation, error)
95
96// Validate checks the closed union. It is total, pure, and never consults a
97// snapshot: a caller can always reject a contradictory intent offline.
98func (n InitialNavigation) Validate() error {
99 switch n.Kind {
100 case InitialNavigationPicker:
101 if n.Destination != (ports.BrokerEndpointFence{}) {
102 return errors.New("vev: picker navigation carries a destination")
103 }
104 if n.Epoch != 0 {
105 return errors.New("vev: picker navigation carries an epoch")
106 }
107 if n.Name != "" {
108 return errors.New("vev: picker navigation carries a session name")
109 }
110 if n.Target != (protocol.ExactSessionTarget{}) {
111 return errors.New("vev: picker navigation carries an exact target")
112 }
113 return nil
114 case InitialNavigationCreateEphemeral, InitialNavigationCreateNamed, InitialNavigationAttachExact, InitialNavigationAttachNamed:
115 default:
116 return fmt.Errorf("vev: unknown initial navigation kind %d", uint8(n.Kind))
117 }
118 if err := n.Destination.Validate(); err != nil {
119 return fmt.Errorf("vev: initial navigation destination: %w", err)
120 }
121 if !n.Destination.Local && n.Epoch == 0 {
122 // A remote destination cannot be decided before the connection: it
123 // requires the epoch of the snapshot that resolved its registration.
124 return errors.New("vev: remote initial navigation carries no epoch")
125 }
126 switch n.Kind {
127 case InitialNavigationCreateEphemeral:
128 if n.Name != "" {
129 return errors.New("vev: ephemeral creation navigation carries a session name")
130 }
131 if n.Target != (protocol.ExactSessionTarget{}) {
132 return errors.New("vev: ephemeral creation navigation carries an exact target")
133 }
134 case InitialNavigationCreateNamed, InitialNavigationAttachNamed:
135 if err := domain.ValidateSessionName(n.Name); err != nil {
136 return fmt.Errorf("vev: initial navigation session name: %w", err)
137 }
138 if n.Target != (protocol.ExactSessionTarget{}) {
139 return errors.New("vev: named navigation carries an exact target")
140 }
141 case InitialNavigationAttachExact:
142 if n.Epoch == 0 {
143 return errors.New("vev: exact attach navigation carries no epoch")
144 }
145 if n.Name != "" {
146 return errors.New("vev: exact attach navigation carries a session name")
147 }
148 if err := n.Target.Validate(); err != nil {
149 return fmt.Errorf("vev: initial navigation target: %w", err)
150 }
151 }
152 return nil
153}
154
155// Resolve revalidates the intent against one broker snapshot and returns the
156// exact stream-request gabarit it names. The gabarit deliberately leaves
157// Connection and Stream zero: the supervisor sets them from its adopted
158// connection before it opens the stream. Policy is copied from the selected
159// authority and the environment is never read from the broker process.
160//
161// Picker is a success without an operation for the supervisor, but resolving it
162// directly is refused: a caller never receives a nil-but-usable request for a
163// navigation that opens nothing.
164func (n InitialNavigation) Resolve(snapshot ports.BrokerSnapshot) (ports.BrokerOpenStreamRequest, error) {
165 if n.Kind == InitialNavigationPicker {
166 return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker navigation opens no stream"}
167 }
168 if err := n.Validate(); err != nil {
169 return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: initialNavigationRefusal(n), Text: "initial navigation is not valid"}
170 }
171 if err := snapshot.Validate(); err != nil {
172 return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"}
173 }
174 // The gabarit is validated end to end against a placeholder connection
175 // identity, then stripped of it: the supervisor is the only component that
176 // assigns the live connection and stream before it opens the stream.
177 request, err := resolveCatalogueRequest(snapshot, pickerSelectionFromNavigation(n), initialNavigationGabaritBase)
178 if err != nil {
179 return ports.BrokerOpenStreamRequest{}, err
180 }
181 request.Connection = ports.BrokerConnectionID{}
182 request.Stream = 0
183 return request, nil
184}
185
186// initialNavigationGabaritBase is the placeholder connection identity used to
187// validate a resolved initial-navigation request end to end before the
188// supervisor assigns its own live connection and stream identity. The gabarit
189// returned to the caller always carries a zero connection and stream.
190var initialNavigationGabaritBase = pickerResolveBase{Connection: ports.BrokerConnectionID{1}, Stream: ports.BrokerStreamID(1)}
191
192// pickerSelectionFromNavigation projects one validated intent into the exact
193// selection identity the shared resolver already understands, so the union and
194// the interactive picker cannot drift into two resolution semantics.
195func pickerSelectionFromNavigation(n InitialNavigation) pickerSelectionRef {
196 ref := pickerSelectionRef{
197 epoch: n.Epoch,
198 local: n.Destination.Local,
199 endpoint: n.Destination.Registration.Endpoint,
200 registration: n.Destination.Registration,
201 }
202 switch n.Kind {
203 case InitialNavigationCreateNamed:
204 ref.kind = pickerSelectionCreateNamed
205 ref.createName = n.Name
206 case InitialNavigationCreateEphemeral:
207 ref.kind = pickerSelectionCreateEphemeral
208 case InitialNavigationAttachExact:
209 ref.kind = pickerSelectionExact
210 ref.lifecycle = n.Target.LifecycleID
211 ref.name = n.Target.SessionName
212 case InitialNavigationAttachNamed:
213 ref.kind = pickerSelectionAttachNamed
214 ref.name = n.Name
215 }
216 return ref
217}
218
219// initialNavigationRefusal classifies a failed intent validation into the
220// bounded picker refusal taxonomy without exposing free text: an invalid
221// creation name keeps its own code, and every other contradiction is an
222// unknown-kind refusal.
223func initialNavigationRefusal(n InitialNavigation) pickerCatalogueErrorCode {
224 if n.Kind == InitialNavigationCreateNamed || n.Kind == InitialNavigationAttachNamed {
225 if err := domain.ValidateSessionName(n.Name); err != nil {
226 return pickerCatalogueInvalidName
227 }
228 }
229 return pickerCatalogueUnknown
230}