diff --git a/nvflare/client/cell/__init__.py b/nvflare/client/cell/__init__.py new file mode 100644 index 0000000000..4fc25d0d3c --- /dev/null +++ b/nvflare/client/cell/__init__.py @@ -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. diff --git a/nvflare/client/cell/defs.py b/nvflare/client/cell/defs.py new file mode 100644 index 0000000000..57ae47d64f --- /dev/null +++ b/nvflare/client/cell/defs.py @@ -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" + # 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" diff --git a/tests/unit_test/client/cell/__init__.py b/tests/unit_test/client/cell/__init__.py new file mode 100644 index 0000000000..4fc25d0d3c --- /dev/null +++ b/tests/unit_test/client/cell/__init__.py @@ -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. diff --git a/tests/unit_test/client/cell/defs_test.py b/tests/unit_test/client/cell/defs_test.py new file mode 100644 index 0000000000..b5644a09f2 --- /dev/null +++ b/tests/unit_test/client/cell/defs_test.py @@ -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)