Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions nvflare/client/cell/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
105 changes: 105 additions & 0 deletions nvflare/client/cell/defs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Protocol vocabulary for the Client API control protocol over Cell.

This module is interface freeze #1 of the Client API Execution Modes design
(docs/design/client_api_execution_modes.md, "Control Protocol"). It is consumed by the
trainer-side Cell engine, the external_process backend, and the attach backend. It is a
pure vocabulary module: constants only, no Cell/cellnet imports, no I/O.
"""

# Cell channel used for all Client API control protocol messages.
# Clearly namespaced so it cannot collide with the legacy FlareAgent channel
# ("flare_agent" in nvflare/client/ipc/defs.py) or other Cell channels.
# This is a frozen wire constant; its exact value is part of the cross-track contract.
CHANNEL = "client_api"

# The Client API control protocol version carried in HELLO.
# V1 supports exactly one protocol version; the field exists so later versions
# can define a compatibility window.
PROTOCOL_VERSION = 1


class Topic:
"""Topics of the Client API control protocol messages over the Cell CHANNEL.

Reply-type messages (HELLO_CHALLENGE, HELLO_ACCEPTED, TASK_ACCEPTED, RESULT_ACCEPTED,
RESULT_REJECTED) may be modeled as Cell request replies at runtime rather than separate
sends; the constants still name them so state machines, logs, and tests share one
vocabulary.

Values are prefixed with "client_api." so they never collide with legacy topic values
(e.g. "hello"/"heartbeat"/"abort"/"bye" in nvflare/client/ipc/defs.py).
"""

# Session setup (all out-of-process modes)
HELLO = "client_api.hello"
HELLO_CHALLENGE = "client_api.hello_challenge"
HELLO_PROOF = "client_api.hello_proof"
HELLO_ACCEPTED = "client_api.hello_accepted"
Comment thread
YuanTingHsieh marked this conversation as resolved.
# Distinct from ERROR (a protocol/transport error): HELLO_REJECTED is a clean, semantic
# auth/handshake refusal (bad proof, wrong scope, consumed/expired nonce, single-session),
# so TE-3/AT-2 state machines can tell a recoverable auth failure from a protocol fault.
HELLO_REJECTED = "client_api.hello_rejected"

# Per task (every round)
TASK_READY = "client_api.task_ready"
TASK_ACCEPTED = "client_api.task_accepted"
TASK_FAILED = "client_api.task_failed"
RESULT_READY = "client_api.result_ready"
RESULT_ACCEPTED = "client_api.result_accepted"
RESULT_REJECTED = "client_api.result_rejected"

# Throughout the session
LOG = "client_api.log"
HEARTBEAT = "client_api.heartbeat"

# Teardown / failure
ABORT = "client_api.abort"
SHUTDOWN = "client_api.shutdown"
BYE = "client_api.bye"
ERROR = "client_api.error"


class MsgKey:
"""Keys for Client API control protocol message payloads.

These string values are the frozen wire contract shared across tracks; renaming a value is
a protocol break, not a refactor.
"""

SESSION_ID = "session_id"
ATTACH_ID = "attach_id"
JOB_ID = "job_id"
SITE_NAME = "site_name"
TRAINER_FQCN = "trainer_fqcn"
TARGET_FQCN = "target_fqcn"
RANK = "rank"
# Rank policy the session is scoped to (a TokenScope / HELLO_PROOF-covered field);
# distinct from RANK, which is the concrete rank a trainer reports in HELLO.
RANK_POLICY = "rank_policy"
PROTOCOL_VERSION = "protocol_version"
NONCE = "nonce"
PROOF = "proof"
REASON = "reason"
TASK_ID = "task_id"
# TASK_READY carries the task name alongside the task id.
TASK_NAME = "task_name"
# TASK_READY carries the FLModel reference and its params (lazy refs, not materialized bytes).
MODEL = "model"
PARAMS = "params"
RESULT_ID = "result_id"
TRANSFER_ID = "transfer_id"
# RESULT_READY carries the payload manifest describing the result envelope.
MANIFEST = "manifest"
13 changes: 13 additions & 0 deletions tests/unit_test/client/cell/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
123 changes: 123 additions & 0 deletions tests/unit_test/client/cell/defs_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from nvflare.client.cell.defs import CHANNEL, PROTOCOL_VERSION, MsgKey, Topic
from nvflare.client.ipc import defs as ipc_defs


def _public_str_values(clazz) -> dict:
return {name: value for name, value in vars(clazz).items() if not name.startswith("_") and isinstance(value, str)}


class TestDefs:
def test_protocol_version(self):
assert PROTOCOL_VERSION == 1

def test_expected_topics_present(self):
expected = {
"HELLO",
"HELLO_CHALLENGE",
"HELLO_PROOF",
"HELLO_ACCEPTED",
"HELLO_REJECTED",
"TASK_READY",
"TASK_ACCEPTED",
"TASK_FAILED",
"RESULT_READY",
"RESULT_ACCEPTED",
"RESULT_REJECTED",
"LOG",
"HEARTBEAT",
"ABORT",
"SHUTDOWN",
"BYE",
"ERROR",
}
assert expected == set(_public_str_values(Topic).keys())

def test_topic_values_unique(self):
values = list(_public_str_values(Topic).values())
assert len(values) == len(set(values))

def test_msg_key_values_unique(self):
values = list(_public_str_values(MsgKey).values())
assert len(values) == len(set(values))

def test_no_collision_with_legacy_ipc_defs(self):
# the Client API control protocol must not collide with the legacy FlareAgent
# channel/topic values in nvflare/client/ipc/defs.py
assert CHANNEL != ipc_defs.CHANNEL
legacy_topics = {
value for name, value in vars(ipc_defs).items() if name.startswith("TOPIC_") and isinstance(value, str)
}
assert not legacy_topics.intersection(_public_str_values(Topic).values())

def test_channel_wire_value(self):
# frozen cross-track wire constant: renaming this value is a protocol break
assert CHANNEL == "client_api"

def test_topic_wire_values(self):
# exact frozen wire strings for every topic; a value rename must fail CI
expected = {
"HELLO": "client_api.hello",
"HELLO_CHALLENGE": "client_api.hello_challenge",
"HELLO_PROOF": "client_api.hello_proof",
"HELLO_ACCEPTED": "client_api.hello_accepted",
"HELLO_REJECTED": "client_api.hello_rejected",
"TASK_READY": "client_api.task_ready",
"TASK_ACCEPTED": "client_api.task_accepted",
"TASK_FAILED": "client_api.task_failed",
"RESULT_READY": "client_api.result_ready",
"RESULT_ACCEPTED": "client_api.result_accepted",
"RESULT_REJECTED": "client_api.result_rejected",
"LOG": "client_api.log",
"HEARTBEAT": "client_api.heartbeat",
"ABORT": "client_api.abort",
"SHUTDOWN": "client_api.shutdown",
"BYE": "client_api.bye",
"ERROR": "client_api.error",
}
assert _public_str_values(Topic) == expected
# every topic is namespaced under the channel prefix
assert all(value.startswith("client_api.") for value in expected.values())

def test_msg_key_wire_values(self):
# exact frozen wire strings for every message key; a value rename must fail CI
expected = {
"SESSION_ID": "session_id",
"ATTACH_ID": "attach_id",
"JOB_ID": "job_id",
"SITE_NAME": "site_name",
"TRAINER_FQCN": "trainer_fqcn",
"TARGET_FQCN": "target_fqcn",
"RANK": "rank",
"RANK_POLICY": "rank_policy",
"PROTOCOL_VERSION": "protocol_version",
"NONCE": "nonce",
"PROOF": "proof",
"REASON": "reason",
"TASK_ID": "task_id",
"TASK_NAME": "task_name",
"MODEL": "model",
"PARAMS": "params",
"RESULT_ID": "result_id",
"TRANSFER_ID": "transfer_id",
"MANIFEST": "manifest",
}
assert _public_str_values(MsgKey) == expected

def test_msg_key_names_present(self):
# the protocol/Appendix B wire keys must exist by name
for name in ("TASK_NAME", "MANIFEST", "RANK_POLICY", "MODEL", "PARAMS", "NONCE", "PROOF"):
assert hasattr(MsgKey, name)
Loading