@@ -122,10 +122,9 @@ intermesh adhoc init --mesh <mesh> --name <name>
1221221 . ** Check existing membership** : The daemon checks ` DaemonState::adhoc_membership() `
123123 for persisted membership.
124124
125- - If already root of the * same* mesh with the same name,
126- treat ` --init ` as idempotent token regeneration (ok + token).
127- - If already in a * different* mesh than the requested ` --mesh ` ,
128- return error + error_message.
125+ - If already root of the * same* mesh, treat ` --init ` as idempotent
126+ token regeneration (ok + token).
127+ - If already in a * different* mesh: return error.
129128
1301292 . ** Create and persist Membership** : A ` Membership ` struct is created
131130 containing the mesh pattern, root IMID, root IP, and node name. This is
@@ -166,35 +165,36 @@ the mesh (via SSH, configuration management, or other secure channels).
166165
167166## Joining a Mesh
168167
169- A client joins an existing mesh with ` intermesh adhoc join ` :
168+ A client joins an existing mesh via token or hard-coded root identity :
170169
171170```
172171intermesh adhoc join <token> --name <name>
172+ intermesh adhoc join --mesh <domain> --root-imid <imid> --root-ip <ip>
173173```
174174
175- ### Join Process
175+ Token mode requires a bootstrap token from root. Hard-coded mode is for
176+ orchestrated environments where root identity is provisioned out of band —
177+ no token exchange or gRPC to root occurs.
178+
179+ ### Join Process (Token Mode)
176180
1771811 . ** Check existing membership** : The daemon checks
178182 ` DaemonState::adhoc_membership() ` for persisted membership.
179183
180- For ` --join ` , the daemon enforces a single-mesh constraint, but idempotent
181- re-joins are allowed:
184+ Idempotency is based on root identity, not name:
182185 - If not in a mesh: proceed with join.
183- - If already in the same mesh with the same name : return ok (no-op) .
184- - If already in a different mesh or with a different name : return error.
186+ - If already joined to the same root ( ` root_imid ` matches) : return ok.
187+ - If already joined to a different root : return error.
185188
1861892 . ** Client validates token** : The client decodes the token, verifies the
187190 signature, and checks expiration. The client prompts for confirmation
188191 showing the mesh domain and root IMID before proceeding.
189192
1901933 . ** Client sends bootstrap request** : The client connects to the root node's
191- IP address (from token) and sends a ` JoinRequest ` containing:
194+ IP address (from token) and sends a ` JoinTokenRequest ` containing:
192195 - The original token (for root to validate)
193- - The requested node name (short name only, e.g., "db" not "db.test.mesh")
194-
195- Note: The daemon-to-daemon ` JoinRequest.name ` field currently uses the short
196- name (first label). This should be changed to use the full node name for
197- consistency with the admin API.
196+ - The requested name (` requested_name ` ) — this is what the joiner wants
197+ to be called, but root's endorsement is what makes it authoritative
198198
1991994 . ** Root processes request** : The root validates the token signature and
200200 expiration, then adds the joiner to its persisted ` Membership ` . The
@@ -218,7 +218,8 @@ intermesh adhoc join <token> --name <name>
218218 If the root knows the joiner's IP, it also endorses it.
219219
2202205 . ** Client creates local Membership** : After receiving confirmation, the
221- client creates a ` Membership ` struct and persists it to the state file.
221+ client creates a ` Membership ` struct (with ` root_name: None ` — the client
222+ learns its name from root's endorsement) and persists it to the state file.
222223 ` Membership::to_bases() ` generates the client's intended endorsements,
223224 pushed to the endorsement manager:
224225
@@ -262,6 +263,34 @@ its own subdomain.
262263The client is now fully integrated into the mesh and can discover other nodes
263264through gossip.
264265
266+ ### Join Process (Hard-Coded Mode)
267+
268+ Hard-coded join (`intermesh adhoc join --mesh <domain > --root-imid <imid >
269+ --root-ip <ip >` ) writes local ` Membership` directly from explicit args. No
270+ token exchange, no gRPC to root. The orchestrator is responsible for running
271+ ` adhoc add ` on root first, then ` adhoc join ` on each joiner.
272+
273+ The joiner does not specify a name — it learns its name from root's
274+ endorsement after gossip convergence. Idempotency is based on ` root_imid ` :
275+ re-joining the same root is a no-op, joining a different root is an error.
276+
277+ ## Adding Members (Root-Only)
278+
279+ Root can add members explicitly without token exchange:
280+
281+ ```
282+ intermesh adhoc add --name <name> --imid <imid> --ip <ip>
283+ ```
284+
285+ This is a root-only operation that adds a joiner entry to ` Membership.joiners `
286+ and recomputes intent. The endorsement manager's reconcile loop signs the
287+ new member's name, delegation, and IP endorsements. IP is required — there
288+ is no discovery mechanism in adhoc mode.
289+
290+ Validation: mesh must be initialized (` FailedPrecondition ` ), caller must be
291+ root (` PermissionDenied ` ), and name must be under the mesh pattern
292+ (` InvalidArgument ` ).
293+
265294## Mesh Membership
266295
267296### Explicit persistence
@@ -277,11 +306,15 @@ struct Membership {
277306 mesh : NamePattern , // e.g. "**.test.mesh"
278307 root_imid : Imid ,
279308 root_ip : IpAddr ,
280- my_name : Name , // e.g. "db .test.mesh"
281- joiners : BTreeMap <Imid , Joiner >, // root-only: accepted joiners
309+ root_name : Option < Name > , // only set on root ( e.g. "root .test.mesh")
310+ joiners : BTreeMap <Imid , Joiner >, // root-only: accepted joiners
282311}
283312```
284313
314+ ` root_name ` is only set on the root node (used for self-endorsement in
315+ ` root_bases() ` ). Non-root nodes set it to ` None ` — they learn their name
316+ from root's endorsement, not from local state.
317+
285318Membership is accessed via ` DaemonState::adhoc_membership() ` and set via
286319` adhoc::Handle::set_membership() ` , which persists to disk and pushes intent
287320to the endorsement manager atomically under a mutex.
@@ -293,19 +326,26 @@ can diff intent vs observed and re-sign any missing endorsements.
293326
294327### CLI UX
295328
296- The CLI commands ` intermesh adhoc init ` and ` intermesh adhoc join ` are designed
297- to be idempotent where possible:
329+ The CLI commands are designed to be idempotent where possible:
298330
299331- ` intermesh adhoc init --mesh <mesh> --name <name> `
300332 - if not in a mesh: initialize and generate token
301- - if already root of the same mesh with same name : regenerate token
302- - if already in a different mesh or with different name : error
333+ - if already root of the same mesh: regenerate token (idempotent)
334+ - if already in a different mesh: error
303335
304- - ` intermesh adhoc join <token> --name <name> `
336+ - ` intermesh adhoc join <token> --name <name> ` (token mode)
305337 - if not in a mesh: join using the token
306- - if already in the same mesh with same name: success (no-op)
307- - if already in the same mesh with different name: error (no rename)
308- - if already in a different mesh: error
338+ - if already joined to the same root: success (no-op)
339+ - if already joined to a different root: error
340+
341+ - ` intermesh adhoc join --mesh <domain> --root-imid <imid> --root-ip <ip> `
342+ (hard-coded mode)
343+ - if not in a mesh: join with explicit root identity
344+ - if already joined to the same root: success (no-op)
345+ - if already joined to a different root: error
346+
347+ - ` intermesh adhoc add --name <name> --imid <imid> --ip <ip> ` (root-only)
348+ - adds a member by explicit identity, no token exchange
309349
310350### Leaving a Mesh (future)
311351
@@ -323,22 +363,18 @@ The adhoc protocol is defined in `proto/adhoc.proto`.
323363
324364 The admin API uses standard gRPC status codes for errors:
325365
326- - ` Init(InitRequest) -> InitResponse `
327- - On success: returns response with ` token ` field
328- - On error: returns gRPC ` Status ` (e.g., ` ALREADY_EXISTS ` , ` INVALID_ARGUMENT ` )
366+ - ` Init(InitRequest) -> InitResponse ` — initialize mesh, returns token
367+ - ` Join(JoinTokenRequest) -> JoinResponse ` — token-based join
368+ - ` JoinDirect(JoinDirectRequest) -> JoinResponse ` — hard-coded join
369+ - ` Add(AddRequest) -> AddResponse ` — add member by IMID (root-only)
329370
330- - ` Join(AdminJoinRequest) -> AdminJoinResponse `
331- - On success: returns empty response
332- - On error: returns gRPC ` Status ` with error message
371+ All RPCs return gRPC ` Status ` on error (e.g., ` ALREADY_EXISTS ` ,
372+ ` INVALID_ARGUMENT ` , ` PERMISSION_DENIED ` ).
333373
334374 Mesh membership status is exposed via the main state dump (` StateDump ` ) rather
335375 than a dedicated RPC. The state dump includes ` adhoc_membership ` containing
336376 ` Membership ` data (if in a mesh).
337377
338- This design:
339- - Uses idiomatic gRPC error handling
340- - Reuses existing state introspection infrastructure
341- - Allows CLI to query mesh status * before* init/join calls
342-
343378- ** ` JoinService ` ** : Network interface for daemon-to-daemon bootstrap
344- - ` Accept(JoinRequest) -> JoinResponse ` : Process join request from a peer
379+ - ` Accept(JoinTokenRequest) -> JoinResponse ` : Process join request from a
380+ peer
0 commit comments