diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f8fa3c1f..4bf4a07e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -129,7 +129,11 @@ jobs:
# and `scikit-learn` (framework/requirements.txt) — mirror that here. Without these the
# suite errors out during collection (5 collection errors), a false red the moment the
# scripts change. They resolve against the already-installed torch, so torch is untouched.
- pip install -r backend/fl-platform-api/requirements.txt pytest "peft>=0.11" scikit-learn
+ # PyJWT: the FL-server import chain (security/token_verify.py -> `import jwt`, pulled in by
+ # the connection-token interceptor) needs it; framework/requirements.txt declares it, but
+ # this job installs the backend lockfile + uses the framework via sys.path, so add it here
+ # (mirrors framework/requirements.txt's `PyJWT>=2.8,<3`), like peft/scikit-learn above.
+ pip install -r backend/fl-platform-api/requirements.txt pytest "peft>=0.11" scikit-learn "PyJWT>=2.8,<3"
# TE-10: the skip-integrity guard in tests/conftest.py fails this job if any test is
# SKIPPED (this suite allowlists no skip reasons; `-m "not slow"` deselection is not a skip).
- name: pytest (FL trainer scripts; pytest.ini deselects -m slow)
diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml
index 0a841314..cc0dc438 100644
--- a/.github/workflows/mobile.yml
+++ b/.github/workflows/mobile.yml
@@ -68,10 +68,18 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install --index-url https://download.pytorch.org/whl/cpu "torch==${TORCH_VERSION}"
- pip install numpy pytest
+ # test_perturbation.py imports `fedlearn`, whose package __init__ eagerly pulls in the gRPC
+ # server (grpc/protobuf) and token-verify (PyJWT) — so the parity job needs the framework's
+ # full dependency set, not just numpy. requirements.txt pins the matching torch==2.12.0
+ # (already installed above) and no torchvision, so this resolves cleanly.
+ # pytest-cov: framework/pytest.ini addopts include --cov=fedlearn (TE-11), so pytest errors
+ # with "unrecognized arguments: --cov" unless it is installed — even for this subset run.
+ pip install -r framework/requirements.txt pytest pytest-cov
- name: Run perturbation parity tests
working-directory: framework
- run: PYTHONPATH=src pytest tests/test_perturbation.py -v
+ # --no-cov: this runs a single test file, not the full suite, so coverage would trip the
+ # --cov-fail-under floor by design (see the pytest.ini note). Disable it for the subset.
+ run: PYTHONPATH=src pytest tests/test_perturbation.py -v --no-cov
# ---- C++ parity gate: the gtests compare the C++ core to the Python golden fixture ----
# The core links the ExecuTorch runtime (no libtorch/ATen). The ET pip package ships headers but
diff --git a/.gitleaks.toml b/.gitleaks.toml
index 8b831bb2..8577ba92 100644
--- a/.gitleaks.toml
+++ b/.gitleaks.toml
@@ -15,4 +15,8 @@ description = "Public test/dev-only fixture credentials (never used outside test
paths = [
'''.*/src/test/.*''',
'''.*/application-(test|dev)\.properties$''',
+ # Framework test fixtures — e.g. framework/tests/fixtures/golden_connection_token.json, a
+ # golden token signed with the dummy secret base64("fedlearn-golden-token-secret-32b") used
+ # by the cross-language connection-token parity tests. Test-only, never a real credential.
+ '''.*/tests/fixtures/.*''',
]
diff --git a/README.md b/README.md
index 3c481940..414055dc 100644
--- a/README.md
+++ b/README.md
@@ -299,7 +299,7 @@ FedLearn-Platform/
│ │ ├── repository/ # JPA repositories
│ │ ├── model/ # Entities
│ │ ├── security/ # JWT provider
-│ │ └── flower/ # FlowerServerManager
+│ │ └── orchestration/ # FlServerManager (renamed from flower/, DA-12)
│ ├── src/main/resources/
│ │ └── scripts/ # Python FL server scripts
│ └── README.md # Backend documentation
@@ -308,7 +308,7 @@ FedLearn-Platform/
│ ├── src/ # main / preload / renderer
│ └── README.md # Desktop documentation
│
-├── mobile_client/ # React Native client + native C++ (libtorch) core
+├── mobile_client/ # React Native client + native C++ (ExecuTorch) core
│ ├── proto/ # Byte-mirror of /proto (checked in CI)
│ └── README.md # Mobile documentation
│
@@ -516,7 +516,7 @@ Live at **https://fedlearn.duckdns.org**. Deploy procedure: [`docs/guides/aws_de
- Let's Encrypt certbot for auto-renewing TLS
- PostgreSQL 16 (local Docker Compose or host package) on the EC2 host, data dir EBS-backed across reboots
- Spring Boot as a systemd service (`fedlearn.service`)
-- Python FL servers spawned by `FlowerServerManager`
+- Python FL servers spawned by `FlServerManager`
Required env vars (set in `/etc/systemd/system/fedlearn.service`):
diff --git a/backend/fl-platform-api/requirements.txt b/backend/fl-platform-api/requirements.txt
index 7d092e9e..76fe0add 100644
--- a/backend/fl-platform-api/requirements.txt
+++ b/backend/fl-platform-api/requirements.txt
@@ -1,6 +1,6 @@
accelerate==1.10.0
aiohappyeyeballs==2.6.1
-aiohttp==3.12.15
+aiohttp>=3.14.0,<4.0 # SE-22: CVE-2026-34993/47265 (RCE) + DoS/smuggling fixes (was 3.12.15)
aiohttp-cors==0.8.1
aiosignal==1.4.0
annotated-types==0.7.0
@@ -13,7 +13,7 @@ click==8.1.8
colorama==0.4.6
colorful==0.5.7
contourpy==1.3.3
-cryptography==44.0.3
+cryptography==44.0.3 # SE-22: the framework floor is >=46.0.6, but this lockfile uses flwr-datasets (FederatedDataset in fl_server.py/client.py) -> flwr 1.20.0, which pins cryptography<45.0.0. The 46.x floor is therefore UNREACHABLE here until flwr-datasets is dropped/updated; 44.0.3 is the newest flwr-compatible pin. Tracked as the SE-22 residual.
cycler==0.12.1
datasets==3.1.0
dill==0.3.8
@@ -58,7 +58,7 @@ opentelemetry-semantic-conventions==0.57b0
packaging==25.0
pandas==2.3.1
pathspec==0.12.1
-pillow==11.0.0
+pillow>=12.2.0,<13.0 # SE-22: PYSEC-2026-165 + CVE-2026-40192/42309/42310/42311 (was 11.0.0)
platformdirs==4.3.8
prometheus_client==0.22.1
propcache==0.3.2
@@ -82,7 +82,7 @@ PyYAML==6.0.2
ray==2.48.0
referencing==0.36.2
regex==2025.7.34
-requests==2.32.4
+requests>=2.33.0,<3.0 # SE-22: CVE-2026-25645 (was 2.32.4)
rich==13.9.4
rpds-py==0.27.0
rsa==4.9.1
diff --git a/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/bootstrap/StartupReconciler.java b/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/bootstrap/StartupReconciler.java
index a91e70d3..97233195 100644
--- a/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/bootstrap/StartupReconciler.java
+++ b/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/bootstrap/StartupReconciler.java
@@ -1,6 +1,6 @@
package com.federated.fl_platform_api.bootstrap;
-import com.federated.fl_platform_api.flower.FlowerServerManager;
+import com.federated.fl_platform_api.orchestration.FlServerManager;
import com.federated.fl_platform_api.model.Run;
import com.federated.fl_platform_api.model.RunStatus;
import com.federated.fl_platform_api.repository.ProjectRepository;
@@ -23,11 +23,11 @@
/**
* BA-3: reconciles FL-server processes against persisted run state on backend startup.
*
- *
FL servers are spawned as child OS processes and tracked only in {@link FlowerServerManager}'s
+ *
FL servers are spawned as child OS processes and tracked only in {@link FlServerManager}'s
* in-memory map, so a backend crash orphans them: children keep running (holding gRPC ports) while
* their runs sit forever in a non-terminal state with no handle to stop them. On boot this loads every
* still-in-flight run and, using the PID + OS start-instant recorded at spawn (see
- * {@link FlowerServerManager#recordProcessIdentity}):
+ * {@link FlServerManager#recordProcessIdentity}):
*
* - re-adopts a run whose recorded PID is still live and whose start-instant matches — the
* server survived the restart, so it is tracked again and a later stop can terminate it;
@@ -61,7 +61,7 @@ public class StartupReconciler implements HealthIndicator {
private final RunRepository runRepository;
private final ProjectRepository projectRepository;
private final RunService runService;
- private final FlowerServerManager serverManager;
+ private final FlServerManager serverManager;
private final ProcessProbe processProbe;
private final RunTokenRegistry runTokenRegistry;
@@ -73,7 +73,7 @@ public class StartupReconciler implements HealthIndicator {
private volatile ReconciliationResult lastResult;
public StartupReconciler(RunRepository runRepository, ProjectRepository projectRepository,
- RunService runService, FlowerServerManager serverManager,
+ RunService runService, FlServerManager serverManager,
ProcessProbe processProbe, RunTokenRegistry runTokenRegistry) {
this.runRepository = runRepository;
this.projectRepository = projectRepository;
diff --git a/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/config/FlSecretDistinctnessValidator.java b/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/config/FlSecretDistinctnessValidator.java
new file mode 100644
index 00000000..edd8e321
--- /dev/null
+++ b/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/config/FlSecretDistinctnessValidator.java
@@ -0,0 +1,66 @@
+package com.federated.fl_platform_api.config;
+
+import jakarta.annotation.PostConstruct;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Set;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+
+/**
+ * SE-20: fail the boot closed when the FL connection-token signing secret is the SAME as the web-auth
+ * JWT secret on a DEPLOYED profile.
+ *
+ * {@code app.fl.token-secret} defaults to {@code app.jwt.secret} ({@code
+ * ${APP_FL_TOKEN_SECRET:${app.jwt.secret}}}) for local backward-compat. But the FL server is
+ * network-facing and holds the FL secret (SE-1/SE-7); if that secret equals the web-auth key, a
+ * compromise of the FL server can mint valid web/admin sessions — defeating the SE-7/SE-17
+ * trust-domain isolation (which deliberately keeps {@code APP_JWT_SECRET} out of the FL child). On
+ * {@code ec2demo}/{@code production} we therefore require a DISTINCT {@code APP_FL_TOKEN_SECRET} and
+ * refuse to start otherwise. Off those profiles (dev/test/base) the fallback stays allowed so a local
+ * run needs no extra secret.
+ *
+ *
Note: this is the "boot check they differ" half of finding #7. The complementary web-JWT
+ * audience/type binding (so the two token classes can't be cross-presented even under a shared dev
+ * secret) is a deliberate, separate change to the auth hot path and is tracked as remaining.
+ */
+@Component
+public class FlSecretDistinctnessValidator {
+
+ /** Profiles that must not share the web + FL signing secret. */
+ static final Set DEPLOYED_PROFILES = Set.of("ec2demo", "production");
+
+ private final String jwtSecret;
+ private final String flTokenSecret;
+ private final Environment environment;
+
+ public FlSecretDistinctnessValidator(
+ @Value("${app.jwt.secret}") String jwtSecret,
+ @Value("${app.fl.token-secret}") String flTokenSecret,
+ Environment environment) {
+ this.jwtSecret = jwtSecret;
+ this.flTokenSecret = flTokenSecret;
+ this.environment = environment;
+ }
+
+ @PostConstruct
+ void validateOnBoot() {
+ check(jwtSecret, flTokenSecret, environment.getActiveProfiles());
+ }
+
+ /**
+ * Throw when a deployed profile is active and the two secrets resolve equal. Package-private +
+ * static so the policy is unit-testable without a Spring context.
+ */
+ static void check(String jwtSecret, String flTokenSecret, String[] activeProfiles) {
+ boolean deployed = Arrays.stream(activeProfiles).anyMatch(DEPLOYED_PROFILES::contains);
+ if (deployed && Objects.equals(jwtSecret, flTokenSecret)) {
+ throw new IllegalStateException(
+ "SE-20: app.fl.token-secret must be DISTINCT from app.jwt.secret on a deployed "
+ + "profile (ec2demo/production). Set a dedicated APP_FL_TOKEN_SECRET so a "
+ + "compromise of the network-facing FL server cannot forge web/admin "
+ + "sessions. It currently falls back to the web-auth secret.");
+ }
+ }
+}
diff --git a/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactController.java b/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactController.java
index 3ba9cbb5..23667448 100644
--- a/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactController.java
+++ b/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactController.java
@@ -3,9 +3,12 @@
import com.federated.fl_platform_api.dto.ArtifactDto;
import com.federated.fl_platform_api.model.ArtifactKind;
import com.federated.fl_platform_api.model.ModelArtifact;
+import com.federated.fl_platform_api.model.Project;
import com.federated.fl_platform_api.repository.ModelArtifactRepository;
+import com.federated.fl_platform_api.repository.ProjectRepository;
import com.federated.fl_platform_api.security.OrgScope;
import com.federated.fl_platform_api.service.ArtifactBlobStore;
+import com.federated.fl_platform_api.service.AuthorizationService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
@@ -41,11 +44,16 @@ public class ArtifactController {
private final ModelArtifactRepository artifacts;
private final ArtifactBlobStore blobStore;
private final OrgScope orgScope;
+ private final AuthorizationService authz;
+ private final ProjectRepository projects;
- public ArtifactController(ModelArtifactRepository artifacts, ArtifactBlobStore blobStore, OrgScope orgScope) {
+ public ArtifactController(ModelArtifactRepository artifacts, ArtifactBlobStore blobStore, OrgScope orgScope,
+ AuthorizationService authz, ProjectRepository projects) {
this.artifacts = artifacts;
this.blobStore = blobStore;
this.orgScope = orgScope;
+ this.authz = authz;
+ this.projects = projects;
}
/**
@@ -56,7 +64,9 @@ public ArtifactController(ModelArtifactRepository artifacts, ArtifactBlobStore b
@GetMapping
public java.util.List list(@RequestParam UUID projectId) {
return artifacts.findByProjectId(projectId).stream()
- .filter(a -> orgScope.allows(a.getOrgId()))
+ // SE-16: org-visible AND (published OR participant). A non-participant sees only the
+ // project's PUBLISHED rows (the marketplace items), never its private weights.
+ .filter(this::mayRead)
.sorted(java.util.Comparator.comparing(ModelArtifact::getCreatedAt,
java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder())).reversed())
.map(ArtifactDto::from)
@@ -93,7 +103,7 @@ public ResponseEntity latest(
@RequestParam UUID projectId,
@RequestParam(defaultValue = "FULL_CHECKPOINT") ArtifactKind kind) {
ModelArtifact a = artifacts.findFirstByProjectIdAndKindOrderByCreatedAtDesc(projectId, kind).orElse(null);
- if (a == null || !orgScope.allows(a.getOrgId())) {
+ if (a == null || !mayRead(a)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(ArtifactDto.from(a));
@@ -101,6 +111,26 @@ public ResponseEntity latest(
private ModelArtifact visibleOr404(UUID id) {
ModelArtifact a = artifacts.findById(id).orElse(null);
- return (a != null && orgScope.allows(a.getOrgId())) ? a : null;
+ return (a != null && mayRead(a)) ? a : null;
+ }
+
+ /**
+ * SE-16 read gate. An artifact is readable iff it is in the caller's org scope AND one of:
+ * it is org-shared with no owning project ({@code projectId == null}, e.g. a {@code BASE_REF});
+ * it has been explicitly PUBLISHED to the org marketplace (FE-12); or the caller is a participant
+ * (owner/member/client/admin) of its project. Otherwise a non-participant reads it as absent, so a
+ * project's private model weights — and the mere existence of the project — never leak, while the
+ * intentional publish-to-share flow keeps working. Mirrors the gate the rest of the project read
+ * surface applies (results, logs, STOMP).
+ */
+ private boolean mayRead(ModelArtifact a) {
+ if (!orgScope.allows(a.getOrgId())) {
+ return false; // tenant isolation
+ }
+ if (a.getProjectId() == null || a.isPublished()) {
+ return true; // org-shared base, or explicitly published to the marketplace
+ }
+ Project p = projects.findById(a.getProjectId()).orElse(null);
+ return p != null && authz.isParticipant(p);
}
}
diff --git a/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactLineageController.java b/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactLineageController.java
index ba91899d..2859a20e 100644
--- a/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactLineageController.java
+++ b/backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactLineageController.java
@@ -1,9 +1,12 @@
package com.federated.fl_platform_api.controller;
import com.federated.fl_platform_api.model.ModelArtifact;
+import com.federated.fl_platform_api.model.Project;
import com.federated.fl_platform_api.repository.ModelArtifactRepository;
+import com.federated.fl_platform_api.repository.ProjectRepository;
import com.federated.fl_platform_api.security.OrgScope;
import com.federated.fl_platform_api.service.ArtifactRegistryService;
+import com.federated.fl_platform_api.service.AuthorizationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -27,20 +30,26 @@ public class ArtifactLineageController {
private final ArtifactRegistryService registry;
private final ModelArtifactRepository artifacts;
private final OrgScope orgScope;
+ private final AuthorizationService authz;
+ private final ProjectRepository projects;
public ArtifactLineageController(ArtifactRegistryService registry,
ModelArtifactRepository artifacts,
- OrgScope orgScope) {
+ OrgScope orgScope,
+ AuthorizationService authz,
+ ProjectRepository projects) {
this.registry = registry;
this.artifacts = artifacts;
this.orgScope = orgScope;
+ this.authz = authz;
+ this.projects = projects;
}
@GetMapping("/{id}/lineage")
public ResponseEntity>> lineage(@PathVariable UUID id) {
ModelArtifact target = artifacts.findById(id).orElse(null);
- if (target == null || !orgScope.allows(target.getOrgId())) {
- return ResponseEntity.notFound().build(); // 404 for both — no cross-org existence leak
+ if (target == null || !mayRead(target)) {
+ return ResponseEntity.notFound().build(); // 404 for all — no cross-org/cross-project leak (SE-16)
}
List
*/
@ExtendWith(MockitoExtension.class)
-class FlowerServerManagerProcessIdentityTest {
+class FlServerManagerProcessIdentityTest {
private static final Instant STARTED = Instant.parse("2026-07-03T12:00:00Z");
- private FlowerServerManager managerWith(RunRepository repo) {
- FlowerServerManager m = new FlowerServerManager();
+ private FlServerManager managerWith(RunRepository repo) {
+ FlServerManager m = new FlServerManager();
ReflectionTestUtils.setField(m, "runRepository", repo);
return m;
}
diff --git a/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/flower/FlowerServerManagerRunnerSeamTest.java b/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/orchestration/FlowerServerManagerRunnerSeamTest.java
similarity index 97%
rename from backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/flower/FlowerServerManagerRunnerSeamTest.java
rename to backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/orchestration/FlowerServerManagerRunnerSeamTest.java
index 398fd462..a0c955ff 100644
--- a/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/flower/FlowerServerManagerRunnerSeamTest.java
+++ b/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/orchestration/FlowerServerManagerRunnerSeamTest.java
@@ -1,4 +1,4 @@
-package com.federated.fl_platform_api.flower;
+package com.federated.fl_platform_api.orchestration;
import com.federated.fl_platform_api.dto.ModelRecipeDto;
import com.federated.fl_platform_api.exception.ServerProcessException;
@@ -42,12 +42,12 @@
* DA-8: the FL-server orchestration seam ({@link FlServerProcessRunner}) makes the spawn path
* unit-testable WITHOUT launching a real process — the whole orchestration (build command → apply the
* SE-1/SE-7 env contract → track handle → broadcast stdout → startup probe → surface an early exit)
- * runs against a fake runner. This complements {@link FlowerServerManagerIntegrationTest} (which still
+ * runs against a fake runner. This complements {@link FlServerManagerIntegrationTest} (which still
* exercises the real {@link LocalProcessFlServerRunner} end to end for behaviour preservation).
*/
-class FlowerServerManagerRunnerSeamTest {
+class FlServerManagerRunnerSeamTest {
- private FlowerServerManager manager;
+ private FlServerManager manager;
private WebSocketService ws;
@BeforeEach
@@ -63,7 +63,7 @@ void setUp() {
new ModelRecipeDto("CNN", "CNN", "image",
List.of(), List.of(), List.of(), null)));
- manager = new FlowerServerManager();
+ manager = new FlServerManager();
ReflectionTestUtils.setField(manager, "logBroadcaster", ws);
ReflectionTestUtils.setField(manager, "runTokenRegistry", runTokenRegistry);
ReflectionTestUtils.setField(manager, "runRepository", mock(RunRepository.class));
@@ -77,7 +77,7 @@ void setUp() {
ReflectionTestUtils.setField(manager, "internalApiKey", "the-api-key");
// The fake runner ignores the command's script path, but the manager still builds an absolute
// File() from it — a null would NPE before the runner is reached (@Value default isn't applied
- // under `new FlowerServerManager()`).
+ // under `new FlServerManager()`).
ReflectionTestUtils.setField(manager, "flServerWrapperPath", "run_fl_server.sh");
ReflectionTestUtils.setField(manager, "portRangeStart", 50000);
ReflectionTestUtils.setField(manager, "portRangeEnd", 50010);
diff --git a/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/registry/ArtifactControllerTest.java b/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/registry/ArtifactControllerTest.java
index a349002f..d88dc8c2 100644
--- a/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/registry/ArtifactControllerTest.java
+++ b/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/registry/ArtifactControllerTest.java
@@ -3,9 +3,13 @@
import com.federated.fl_platform_api.controller.ArtifactController;
import com.federated.fl_platform_api.model.ArtifactKind;
import com.federated.fl_platform_api.model.ModelArtifact;
+import com.federated.fl_platform_api.model.Project;
import com.federated.fl_platform_api.repository.ModelArtifactRepository;
+import com.federated.fl_platform_api.repository.ProjectRepository;
import com.federated.fl_platform_api.security.OrgScope;
import com.federated.fl_platform_api.service.ArtifactBlobStore;
+import com.federated.fl_platform_api.service.AuthorizationService;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@@ -16,6 +20,7 @@
import java.util.Set;
import java.util.UUID;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -30,17 +35,36 @@
/**
* BA-11: the registry READ endpoints — get-by-id, content-addressed blob download, and project head —
* are org-scoped (404 for a foreign/missing id, never 403: no cross-org existence leak) and the download
- * returns the immutable bytes with the content hash as the ETag. Standalone MockMvc; mocked repo + store.
+ * returns the immutable bytes with the content hash as the ETag.
+ *
+ * SE-16: they are ALSO participant-scoped. Org scope alone is too coarse for the most sensitive object
+ * in the platform (the trained model bytes) — and collapses to nothing in the single-org fallback — so a
+ * non-participant of the artifact's project is refused (404, no existence leak) exactly like the rest of
+ * the project read surface. BASE_REF-style rows with no project stay org-scoped.
+ *
+ * Standalone MockMvc; mocked repo + store + authz + projects.
*/
class ArtifactControllerTest {
private final ModelArtifactRepository artifacts = mock(ModelArtifactRepository.class);
private final ArtifactBlobStore blobStore = mock(ArtifactBlobStore.class);
+ private final AuthorizationService authz = mock(AuthorizationService.class);
+ private final ProjectRepository projects = mock(ProjectRepository.class);
+
+ @BeforeEach
+ void participantByDefault() {
+ // Default: the project loads and the caller IS a participant, so the pre-existing tests exercise
+ // exactly the org boundary. The SE-16 tests override isParticipant to false to hit the new gate.
+ when(projects.findById(any())).thenReturn(Optional.of(mock(Project.class)));
+ when(authz.isParticipant(any())).thenReturn(true);
+ }
private MockMvc mvc(OrgScope scope) {
- return MockMvcBuilders.standaloneSetup(new ArtifactController(artifacts, blobStore, scope)).build();
+ return MockMvcBuilders.standaloneSetup(
+ new ArtifactController(artifacts, blobStore, scope, authz, projects)).build();
}
+ /** A BASE_REF-style artifact: org-shared, no owning project (projectId == null). */
private ModelArtifact artifact(UUID id, UUID org) {
ModelArtifact a = new ModelArtifact();
a.setId(id);
@@ -51,6 +75,13 @@ private ModelArtifact artifact(UUID id, UUID org) {
return a;
}
+ /** A project-owned artifact (the sensitive case the SE-16 participant gate protects). */
+ private ModelArtifact projectArtifact(UUID id, UUID org, UUID projectId) {
+ ModelArtifact a = artifact(id, org);
+ a.setProjectId(projectId);
+ return a;
+ }
+
private OrgScope scopeOf(UUID... orgs) {
OrgScope s = new OrgScope();
s.set(Set.of(orgs), false);
@@ -114,8 +145,7 @@ void blob_download_is_404_for_a_foreign_org_and_never_touches_the_store() throws
@Test
void latest_returns_the_project_head_and_404_cross_org() throws Exception {
UUID pid = UUID.randomUUID(), org = UUID.randomUUID(), id = UUID.randomUUID();
- ModelArtifact head = artifact(id, org);
- head.setProjectId(pid);
+ ModelArtifact head = projectArtifact(id, org, pid);
when(artifacts.findFirstByProjectIdAndKindOrderByCreatedAtDesc(pid, ArtifactKind.FULL_CHECKPOINT))
.thenReturn(Optional.of(head));
@@ -130,11 +160,9 @@ void latest_returns_the_project_head_and_404_cross_org() throws Exception {
@Test
void list_returns_the_projects_visible_artifacts_newest_first_with_provenance() throws Exception {
UUID pid = UUID.randomUUID(), org = UUID.randomUUID();
- ModelArtifact older = artifact(UUID.randomUUID(), org);
- older.setProjectId(pid);
+ ModelArtifact older = projectArtifact(UUID.randomUUID(), org, pid);
older.setCreatedAt(Instant.parse("2026-01-01T00:00:00Z"));
- ModelArtifact newer = artifact(UUID.randomUUID(), org);
- newer.setProjectId(pid);
+ ModelArtifact newer = projectArtifact(UUID.randomUUID(), org, pid);
newer.setCreatedAt(Instant.parse("2026-06-01T00:00:00Z"));
newer.setBaseModelRef("bert-base");
newer.setLicenseTag("apache-2.0");
@@ -155,10 +183,8 @@ void list_returns_the_projects_visible_artifacts_newest_first_with_provenance()
@Test
void list_filters_out_cross_org_rows_and_never_leaks() throws Exception {
UUID pid = UUID.randomUUID(), myOrg = UUID.randomUUID(), foreignOrg = UUID.randomUUID();
- ModelArtifact mine = artifact(UUID.randomUUID(), myOrg);
- mine.setProjectId(pid);
- ModelArtifact foreign = artifact(UUID.randomUUID(), foreignOrg);
- foreign.setProjectId(pid);
+ ModelArtifact mine = projectArtifact(UUID.randomUUID(), myOrg, pid);
+ ModelArtifact foreign = projectArtifact(UUID.randomUUID(), foreignOrg, pid);
when(artifacts.findByProjectId(pid)).thenReturn(java.util.List.of(mine, foreign));
mvc(scopeOf(myOrg)).perform(get("/api/artifacts").param("projectId", pid.toString()))
@@ -171,4 +197,108 @@ void list_filters_out_cross_org_rows_and_never_leaks() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(0));
}
+
+ // ---- SE-16: participant gate (the fix for the artifact-BOLA finding) ----
+
+ @Test
+ void blob_is_404_for_a_same_org_non_participant_and_never_touches_the_store() throws Exception {
+ UUID id = UUID.randomUUID(), org = UUID.randomUUID(), pid = UUID.randomUUID();
+ when(artifacts.findById(id)).thenReturn(Optional.of(projectArtifact(id, org, pid)));
+ when(authz.isParticipant(any())).thenReturn(false); // in-org, but NOT a participant of the project
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}/blob", id))
+ .andExpect(status().isNotFound());
+ verify(blobStore, never()).get(anyString()); // the weights are never read for a non-participant
+ }
+
+ @Test
+ void get_is_404_for_a_same_org_non_participant() throws Exception {
+ UUID id = UUID.randomUUID(), org = UUID.randomUUID(), pid = UUID.randomUUID();
+ when(artifacts.findById(id)).thenReturn(Optional.of(projectArtifact(id, org, pid)));
+ when(authz.isParticipant(any())).thenReturn(false);
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}", id))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void blob_is_ok_for_a_participant() throws Exception {
+ UUID id = UUID.randomUUID(), org = UUID.randomUUID(), pid = UUID.randomUUID();
+ byte[] bytes = "weights".getBytes(StandardCharsets.UTF_8);
+ when(artifacts.findById(id)).thenReturn(Optional.of(projectArtifact(id, org, pid)));
+ when(blobStore.get("a".repeat(64))).thenReturn(bytes);
+ when(authz.isParticipant(any())).thenReturn(true);
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}/blob", id))
+ .andExpect(status().isOk())
+ .andExpect(content().bytes(bytes));
+ }
+
+ @Test
+ void latest_is_404_for_a_same_org_non_participant() throws Exception {
+ UUID pid = UUID.randomUUID(), org = UUID.randomUUID(), id = UUID.randomUUID();
+ when(artifacts.findFirstByProjectIdAndKindOrderByCreatedAtDesc(pid, ArtifactKind.FULL_CHECKPOINT))
+ .thenReturn(Optional.of(projectArtifact(id, org, pid)));
+ when(authz.isParticipant(any())).thenReturn(false);
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/latest").param("projectId", pid.toString()))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void list_is_empty_for_a_same_org_non_participant() throws Exception {
+ UUID pid = UUID.randomUUID(), org = UUID.randomUUID();
+ when(artifacts.findByProjectId(pid))
+ .thenReturn(java.util.List.of(projectArtifact(UUID.randomUUID(), org, pid)));
+ when(authz.isParticipant(any())).thenReturn(false);
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts").param("projectId", pid.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(0));
+ }
+
+ @Test
+ void base_ref_with_null_project_stays_org_scoped_readable() throws Exception {
+ // A BASE_REF-style artifact has no project to participate in; the org gate is the whole gate,
+ // so a non-participant flag is irrelevant and the org-visible caller still reads it.
+ UUID id = UUID.randomUUID(), org = UUID.randomUUID();
+ when(artifacts.findById(id)).thenReturn(Optional.of(artifact(id, org))); // projectId == null
+ when(authz.isParticipant(any())).thenReturn(false);
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}", id))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(id.toString()));
+ }
+
+ @Test
+ void published_artifact_is_downloadable_by_a_non_participant_marketplace_flow() throws Exception {
+ // FE-12: an owner PUBLISHES an adapter to the org marketplace; a non-participant org member
+ // must still be able to download it. The SE-16 gate must not break this intentional sharing.
+ UUID id = UUID.randomUUID(), org = UUID.randomUUID(), pid = UUID.randomUUID();
+ byte[] bytes = "published adapter".getBytes(StandardCharsets.UTF_8);
+ ModelArtifact pub = projectArtifact(id, org, pid);
+ pub.setPublished(true);
+ when(artifacts.findById(id)).thenReturn(Optional.of(pub));
+ when(blobStore.get("a".repeat(64))).thenReturn(bytes);
+ when(authz.isParticipant(any())).thenReturn(false); // NOT a participant — but it's published
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}/blob", id))
+ .andExpect(status().isOk())
+ .andExpect(content().bytes(bytes));
+ }
+
+ @Test
+ void list_shows_published_rows_but_hides_private_ones_from_a_non_participant() throws Exception {
+ UUID pid = UUID.randomUUID(), org = UUID.randomUUID();
+ ModelArtifact publicRow = projectArtifact(UUID.randomUUID(), org, pid);
+ publicRow.setPublished(true);
+ ModelArtifact privateRow = projectArtifact(UUID.randomUUID(), org, pid); // unpublished
+ when(artifacts.findByProjectId(pid)).thenReturn(java.util.List.of(publicRow, privateRow));
+ when(authz.isParticipant(any())).thenReturn(false);
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts").param("projectId", pid.toString()))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(1)) // only the published row
+ .andExpect(jsonPath("$[0].id").value(publicRow.getId().toString()));
+ }
}
diff --git a/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/registry/ArtifactLineageControllerTest.java b/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/registry/ArtifactLineageControllerTest.java
index a3aad25d..1458a5a3 100644
--- a/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/registry/ArtifactLineageControllerTest.java
+++ b/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/registry/ArtifactLineageControllerTest.java
@@ -3,9 +3,13 @@
import com.federated.fl_platform_api.controller.ArtifactLineageController;
import com.federated.fl_platform_api.model.ArtifactKind;
import com.federated.fl_platform_api.model.ModelArtifact;
+import com.federated.fl_platform_api.model.Project;
import com.federated.fl_platform_api.repository.ModelArtifactRepository;
+import com.federated.fl_platform_api.repository.ProjectRepository;
import com.federated.fl_platform_api.security.OrgScope;
import com.federated.fl_platform_api.service.ArtifactRegistryService;
+import com.federated.fl_platform_api.service.AuthorizationService;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
@@ -16,6 +20,7 @@
import java.util.Set;
import java.util.UUID;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
@@ -24,15 +29,26 @@
/**
* Standalone MockMvc (no Spring context / DB): the lineage read returns the chain for an artifact in
- * a visible org and 404 for a foreign or missing one (no cross-org existence leak).
+ * a visible org and 404 for a foreign or missing one (no cross-org existence leak). SE-16: it is also
+ * participant-scoped — a non-participant of the artifact's project gets 404, mirroring the metadata/blob
+ * read path.
*/
class ArtifactLineageControllerTest {
private final ArtifactRegistryService registry = mock(ArtifactRegistryService.class);
private final ModelArtifactRepository artifacts = mock(ModelArtifactRepository.class);
+ private final AuthorizationService authz = mock(AuthorizationService.class);
+ private final ProjectRepository projects = mock(ProjectRepository.class);
+
+ @BeforeEach
+ void participantByDefault() {
+ when(projects.findById(any())).thenReturn(Optional.of(mock(Project.class)));
+ when(authz.isParticipant(any())).thenReturn(true);
+ }
private MockMvc mvc(OrgScope scope) {
- return MockMvcBuilders.standaloneSetup(new ArtifactLineageController(registry, artifacts, scope)).build();
+ return MockMvcBuilders.standaloneSetup(
+ new ArtifactLineageController(registry, artifacts, scope, authz, projects)).build();
}
private ModelArtifact artifact(UUID id, UUID org, ArtifactKind kind) {
@@ -45,6 +61,12 @@ private ModelArtifact artifact(UUID id, UUID org, ArtifactKind kind) {
return a;
}
+ private ModelArtifact projectArtifact(UUID id, UUID org, ArtifactKind kind, UUID projectId) {
+ ModelArtifact a = artifact(id, org, kind);
+ a.setProjectId(projectId);
+ return a;
+ }
+
@Test
void returns_the_chain_for_an_artifact_in_a_visible_org() throws Exception {
UUID id = UUID.randomUUID(), org = UUID.randomUUID();
@@ -53,10 +75,7 @@ void returns_the_chain_for_an_artifact_in_a_visible_org() throws Exception {
when(registry.getLineageChain(id)).thenReturn(List.of(
artifact(UUID.randomUUID(), org, ArtifactKind.BASE_REF), target));
- OrgScope scope = new OrgScope();
- scope.set(Set.of(org), false);
-
- mvc(scope).perform(get("/api/artifacts/{id}/lineage", id))
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}/lineage", id))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].kind").value("BASE_REF")) // root is the base
.andExpect(jsonPath("$[1].id").value(id.toString())); // ...then the leaf
@@ -67,10 +86,7 @@ void returns_404_for_an_artifact_in_a_foreign_org() throws Exception {
UUID id = UUID.randomUUID(), org = UUID.randomUUID();
when(artifacts.findById(id)).thenReturn(Optional.of(artifact(id, org, ArtifactKind.LORA_ADAPTER)));
- OrgScope scope = new OrgScope();
- scope.set(Set.of(UUID.randomUUID()), false); // caller sees a DIFFERENT org
-
- mvc(scope).perform(get("/api/artifacts/{id}/lineage", id))
+ mvc(scopeOf(UUID.randomUUID())).perform(get("/api/artifacts/{id}/lineage", id)) // caller sees a DIFFERENT org
.andExpect(status().isNotFound());
}
@@ -82,4 +98,51 @@ void returns_404_for_a_missing_artifact() throws Exception {
mvc(new OrgScope()).perform(get("/api/artifacts/{id}/lineage", id))
.andExpect(status().isNotFound());
}
+
+ // ---- SE-16: participant gate ----
+
+ @Test
+ void lineage_is_404_for_a_same_org_non_participant() throws Exception {
+ UUID id = UUID.randomUUID(), org = UUID.randomUUID(), pid = UUID.randomUUID();
+ when(artifacts.findById(id)).thenReturn(Optional.of(
+ projectArtifact(id, org, ArtifactKind.LORA_ADAPTER, pid)));
+ when(authz.isParticipant(any())).thenReturn(false); // in-org, but not a project participant
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}/lineage", id))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void lineage_ok_for_a_participant() throws Exception {
+ UUID id = UUID.randomUUID(), org = UUID.randomUUID(), pid = UUID.randomUUID();
+ ModelArtifact target = projectArtifact(id, org, ArtifactKind.LORA_ADAPTER, pid);
+ when(artifacts.findById(id)).thenReturn(Optional.of(target));
+ when(registry.getLineageChain(id)).thenReturn(List.of(target));
+ when(authz.isParticipant(any())).thenReturn(true);
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}/lineage", id))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].id").value(id.toString()));
+ }
+
+ @Test
+ void lineage_ok_for_a_published_artifact_to_a_non_participant() throws Exception {
+ // A published marketplace adapter's provenance is readable despite non-participation (FE-12).
+ UUID id = UUID.randomUUID(), org = UUID.randomUUID(), pid = UUID.randomUUID();
+ ModelArtifact target = projectArtifact(id, org, ArtifactKind.LORA_ADAPTER, pid);
+ target.setPublished(true);
+ when(artifacts.findById(id)).thenReturn(Optional.of(target));
+ when(registry.getLineageChain(id)).thenReturn(List.of(target));
+ when(authz.isParticipant(any())).thenReturn(false);
+
+ mvc(scopeOf(org)).perform(get("/api/artifacts/{id}/lineage", id))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$[0].id").value(id.toString()));
+ }
+
+ private OrgScope scopeOf(UUID... orgs) {
+ OrgScope s = new OrgScope();
+ s.set(Set.of(orgs), false);
+ return s;
+ }
}
diff --git a/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/service/ModelBundleAutostageIntegrationTest.java b/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/service/ModelBundleAutostageIntegrationTest.java
index 2246ccb3..83cf7b07 100644
--- a/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/service/ModelBundleAutostageIntegrationTest.java
+++ b/backend/fl-platform-api/src/test/java/com/federated/fl_platform_api/service/ModelBundleAutostageIntegrationTest.java
@@ -1,7 +1,7 @@
package com.federated.fl_platform_api.service;
import com.federated.fl_platform_api.dto.ModelBundleDto;
-import com.federated.fl_platform_api.flower.FlowerServerManager;
+import com.federated.fl_platform_api.orchestration.FlServerManager;
import com.federated.fl_platform_api.model.PlatformRole;
import com.federated.fl_platform_api.model.Project;
import com.federated.fl_platform_api.model.ProjectVisibility;
@@ -50,7 +50,7 @@
* {@code POST /api/projects/{id}/start} → {@code GET /api/runs/{runId}/model-bundle}) and asserts a 200
* with the staged manifest, plus all five bundle files on disk.
*
- * The FL-server spawn is the only thing stubbed ({@link FlowerServerManager} is a {@code @MockBean}, so
+ *
The FL-server spawn is the only thing stubbed ({@link FlServerManager} is a {@code @MockBean}, so
* no real {@code python fl_server.py} runs); the auto-stage itself uses the real
* {@link ScriptModelBundleStager} bean and the real, stdlib-only {@code scripts/stage_model_bundle.py} —
* exactly the code path a live host takes. Staging is made synchronous via the package-private executor
@@ -71,7 +71,7 @@ class ModelBundleAutostageIntegrationTest {
@Autowired PasswordEncoder passwordEncoder;
@Autowired ScriptModelBundleStager stager;
- @MockitoBean FlowerServerManager flowerServerManager; // never spawn a real python FL server under test
+ @MockitoBean FlServerManager flServerManager; // never spawn a real python FL server under test
private static Path bundleDir;
@@ -92,8 +92,8 @@ static void props(DynamicPropertyRegistry registry) throws IOException {
@BeforeEach
void setUp() {
- when(flowerServerManager.isServerRunning(any())).thenReturn(false);
- when(flowerServerManager.startServerForProject(any(), any(), anyInt(), anyInt()))
+ when(flServerManager.isServerRunning(any())).thenReturn(false);
+ when(flServerManager.startServerForProject(any(), any(), anyInt(), anyInt()))
.thenReturn(Optional.of(50000));
// Stage on the calling thread so the served bundle is ready the moment /start returns (the same
// executor seam the ScriptModelBundleStager unit test uses).
diff --git a/framework/requirements.txt b/framework/requirements.txt
index ae1ab30d..cd068428 100644
--- a/framework/requirements.txt
+++ b/framework/requirements.txt
@@ -1,7 +1,12 @@
# Core ML packages (PyTorch installed separately with CUDA support)
-torch
-torchvision
-torchaudio
+# torch is PINNED to the golden-fixture / executorch-toolchain version (2.12.0): the DeComFL golden
+# fixtures + executorch==1.3.1 native extension were built against it, and test_torch_version_matches_manifest
+# gates on it. torchvision/torchaudio are intentionally NOT listed here: the framework does not import
+# them (setup.py already filters torch*/torchvision* out of install_requires), and pulling torchvision
+# from PyPI against this pytorch-index torch build produces an ABI mismatch (`operator torchvision::nms
+# does not exist`) that breaks transformers-importing tests. Install them from the matched pytorch CPU
+# index alongside torch only where a consumer actually needs them.
+torch==2.12.0
# Transformers and NLP
transformers==4.55.2
diff --git a/framework/src/fedlearn/backbone/__init__.py b/framework/src/fedlearn/backbone/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/framework/src/fedlearn/backbone/distribution.py b/framework/src/fedlearn/backbone/distribution.py
new file mode 100644
index 00000000..9e70386b
--- /dev/null
+++ b/framework/src/fedlearn/backbone/distribution.py
@@ -0,0 +1,114 @@
+"""DA-11 §4.5b: client-side frozen-backbone distribution.
+
+A frozen backbone (the DA-11 ``BASE_REF`` artifact) is serialized to deterministic, content-addressed
+bytes, fetched by a client through an injected seam, sha256-verified, content-addressed-cached, and
+reconstructed onto a model whose head is trained locally. The fetch source is a ``Callable[[], bytes]``
+so this framework contract is independent of HOW the bytes arrive (Phase 2B wires it to the Java
+``BASE_REF`` endpoint). Fail-loud throughout: a hash mismatch or a key mismatch is rejected, never
+silently loaded (the TINYNET_GOLDEN ``model_dim`` class of bug).
+"""
+from __future__ import annotations
+
+import hashlib
+import os
+import tempfile
+from collections import OrderedDict
+from pathlib import Path
+from typing import Callable
+
+import torch
+import torch.nn as nn
+
+from fedlearn.communication.safetensors_codec import load_safetensors, save_safetensors
+from fedlearn.estimators.params import frozen_state
+
+
+def serialize_backbone(model: nn.Module) -> bytes:
+ """Deterministic safetensors blob of the model's :func:`frozen_state` (F32, named order)."""
+ # .cpu() so a backbone trained/held on MPS/CUDA serializes without a device-transfer crash
+ # (no-op for a CPU tensor); the wire is CPU float32 (safetensors_codec).
+ tensors = [(name, t.cpu().numpy()) for name, t in frozen_state(model).items()]
+ return save_safetensors(tensors)
+
+
+def backbone_sha256(blob: bytes) -> str:
+ """The content address of a backbone blob: lowercase-hex sha256."""
+ return hashlib.sha256(blob).hexdigest()
+
+
+class BackboneIntegrityError(ValueError):
+ """Fetched backbone bytes' sha256 does not match the requested content address."""
+
+
+class BackboneCache:
+ """Content-addressed on-disk cache for frozen-backbone blobs. A blob is fetched once (via an
+ injected ``fetch`` callable), sha256-verified against the requested key, and stored at
+ ``cache_dir/``. Subsequent requests for the same key are served from disk without
+ fetching. A cache file whose bytes no longer hash to its name is treated as a miss and re-fetched
+ (self-healing). Writes are atomic (temp file + ``os.replace``) so a crash mid-write never leaves a
+ half-written blob under its final content-addressed name.
+ """
+
+ def __init__(self, cache_dir: "os.PathLike[str] | str") -> None:
+ self._dir = Path(cache_dir)
+ self._dir.mkdir(parents=True, exist_ok=True)
+
+ def path_for(self, sha256: str) -> Path:
+ return self._dir / sha256
+
+ def get_or_fetch(self, sha256: str, fetch: Callable[[], bytes]) -> Path:
+ target = self.path_for(sha256)
+ if target.exists() and backbone_sha256(target.read_bytes()) == sha256:
+ return target # cache hit
+ blob = fetch()
+ actual = backbone_sha256(blob)
+ if actual != sha256:
+ raise BackboneIntegrityError(
+ f"backbone integrity check failed: requested {sha256} but fetched bytes hash to "
+ f"{actual} — refusing to cache (possible corruption or wrong artifact)."
+ )
+ self._atomic_write(target, blob)
+ return target
+
+ def _atomic_write(self, target: Path, blob: bytes) -> None:
+ fd, tmp = tempfile.mkstemp(dir=str(self._dir), suffix=".tmp")
+ try:
+ with os.fdopen(fd, "wb") as f:
+ f.write(blob)
+ os.replace(tmp, target)
+ except BaseException:
+ try:
+ os.unlink(tmp)
+ except FileNotFoundError:
+ pass
+ raise
+
+
+class BackboneKeyMismatch(ValueError):
+ """A fetched backbone blob's key set does not match the model's declared frozen layout."""
+
+
+def reconstruct_frozen_backbone(model: nn.Module, backbone_bytes: bytes) -> nn.Module:
+ """Load a fetched frozen-backbone blob onto ``model`` (non-strict), re-freeze the loaded
+ parameters, and return the same model. The head is never touched — after this call the model's
+ only trainable (federated) subset is its head.
+
+ Fail-loud: the blob's key set MUST equal ``frozen_state(model)``'s keys. An unexpected key (a blob
+ that carries something the model does not declare frozen) or a missing key (a truncated blob)
+ raises :class:`BackboneKeyMismatch` rather than silently loading a partial/misaligned backbone.
+ """
+ tensors, _meta = load_safetensors(backbone_bytes)
+ blob_keys = [name for name, _ in tensors]
+ expected_keys = list(frozen_state(model).keys())
+ if set(blob_keys) != set(expected_keys):
+ raise BackboneKeyMismatch(
+ f"backbone key mismatch: blob has {sorted(blob_keys)} but the model declares frozen "
+ f"layout {sorted(expected_keys)}. Send serialize_backbone(model) for the same recipe."
+ )
+ state = OrderedDict((name, torch.from_numpy(arr)) for name, arr in tensors)
+ model.load_state_dict(state, strict=False) # head keys are 'missing' (trained locally) — expected
+ param_names = {name for name, _ in model.named_parameters()}
+ for name in blob_keys:
+ if name in param_names:
+ model.get_parameter(name).requires_grad_(False)
+ return model
diff --git a/framework/src/fedlearn/bundle/BUNDLE_FORMAT.md b/framework/src/fedlearn/bundle/BUNDLE_FORMAT.md
index e724c1ce..05fd4231 100644
--- a/framework/src/fedlearn/bundle/BUNDLE_FORMAT.md
+++ b/framework/src/fedlearn/bundle/BUNDLE_FORMAT.md
@@ -39,14 +39,25 @@ are the same value by construction.
## Fixture-MVP boundary (what is NOT yet wired)
-The **format is defined and tested here**, but the mobile bundle-provisioning path
-(`scripts/stage_model_bundle.py`) still stages a hardcoded 43-parameter TinyNet golden fixture
-rather than a project's real recipe, and `fl_server.py` currently registers the legacy `.npz` bytes.
-Two follow-ons complete DA-9 end-to-end:
-
-1. Wire the export path (`init_model.py` / `stage_model_bundle.py`) to emit **this** manifest for
- real recipes, serializing adapters as safetensors.
-2. Register the safetensors artifact bytes (so `artifact_sha256` matches the served bundle exactly).
+The **format is defined and tested here**. Of the two follow-ons that originally completed DA-9
+end-to-end, one has since landed and one has not:
+
+1. **Still open.** The mobile bundle-*provisioning* path (`scripts/stage_model_bundle.py`) still
+ stages a hardcoded 43-parameter TinyNet golden fixture rather than a project's real recipe. Wiring
+ the export path (`init_model.py` / `stage_model_bundle.py`) to emit this manifest for real recipes
+ remains outstanding.
+2. **Done (DA-9 bullet 3).** For `LLM_LORA` runs, `fl_server.py`'s `_emit_and_register_lora_bundle`
+ now serializes the adapter to safetensors and registers *those* bytes — not the `.npz` — so
+ `artifact_sha256` matches the served bundle exactly (see `fl_server.py`'s `_emit_and_register_lora_bundle`
+ and `_register_model_artifact`). It falls back to registering the legacy `.npz` bytes only if
+ building the real bundle fails. `FULL_CHECKPOINT` runs (non-LoRA recipes) still register `.npz`
+ bytes directly — that is correct by design, not a gap: a full checkpoint's wire format *is* the
+ imaging air-gap `.npz` (see "Serialization" above), not a placeholder awaiting a safetensors
+ conversion.
Until (1) lands, a fixture bundle **must** set `"provenance": {"source": "golden-fixture-mvp"}` so
telemetry never mistakes a fixture run for real project progress.
+
+See also: `wikis/backend/07_artifact_registry.md` for the registry side of this (the
+`model_artifacts`/`artifact_blobs`/`artifact_lineage` tables a registered bundle's `artifact_sha256`
+resolves to).
diff --git a/framework/src/fedlearn/client/decomfl_client.py b/framework/src/fedlearn/client/decomfl_client.py
index bb0e69ac..b503c434 100644
--- a/framework/src/fedlearn/client/decomfl_client.py
+++ b/framework/src/fedlearn/client/decomfl_client.py
@@ -64,6 +64,28 @@ def set_grpc_client(self, grpc_client):
"""Set gRPC client for heartbeat updates."""
self.grpc_client = grpc_client
+ def assert_dim_matches(self, server_model_dim: int) -> None:
+ """MO-19/FR-14: fail loud if the server's trainable flat dimension differs from this client's.
+
+ The server advertises ``model_dim`` in the DeComFL config; the shared-seed perturbation ``z``
+ it generates has that length. If this client's trainable parameter vector is a different
+ length, ``z`` misaligns and the model diverges silently — so we reject the run up front rather
+ than train garbage. Almost always the server was built from a full ``state_dict()`` (buffers +
+ frozen params) instead of the ``requires_grad``-filtered trainable layout
+ (:func:`estimators.params.trainable_state`). The server-side complement is
+ :meth:`fedlearn.server.decomfl_strategy.DeComFL.validate_participant_dim`.
+ """
+ client_dim = self.zo_estimator.get_num_params(self.model)
+ if client_dim != server_model_dim:
+ raise ValueError(
+ f"DeComFL trainable-dimension mismatch: this client has {client_dim} trainable "
+ f"params but the server's model_dim is {server_model_dim}. The shared-seed "
+ f"perturbation would misalign and the model would diverge. Ensure the server's "
+ f"initial_parameters are the requires_grad-filtered trainable layout "
+ f"(estimators.params.trainable_state), NOT a full state_dict() (buffers + frozen "
+ f"params inflate the server's flat vector)."
+ )
+
def load_global_model(self, parameters: OrderedDict[str, torch.Tensor]) -> None:
"""Adopt the server's global model (DeComFL requires every party to share x_0).
diff --git a/framework/src/fedlearn/client/decomfl_start.py b/framework/src/fedlearn/client/decomfl_start.py
index ee444a1c..d4b6eddc 100644
--- a/framework/src/fedlearn/client/decomfl_start.py
+++ b/framework/src/fedlearn/client/decomfl_start.py
@@ -96,6 +96,19 @@ def start_decomfl_client(server_address: str, client: DeComFLClient, client_id:
# A successful poll clears the transient-failure counter.
consecutive_failures = 0
+ # MO-19/FR-14: the server advertises its trainable flat dimension in the config. If
+ # ours differs, the shared-seed perturbation would misalign and the model would
+ # diverge silently — a FATAL setup error (not transient), so fail loud and exit
+ # rather than train garbage or retry a condition that can never clear.
+ server_dim = config.get('model_dim')
+ if server_dim:
+ try:
+ client.assert_dim_matches(int(server_dim))
+ except ValueError as dim_err:
+ log.error("[%s] %s", client_id, dim_err)
+ outcome = OUTCOME_ERROR
+ break
+
if server_round == -1:
log.info("[%s] Server signalled run complete; shutting down cleanly", client_id)
outcome = OUTCOME_COMPLETED
diff --git a/framework/src/fedlearn/estimators/params.py b/framework/src/fedlearn/estimators/params.py
index 903fb8e3..9a3580af 100644
--- a/framework/src/fedlearn/estimators/params.py
+++ b/framework/src/fedlearn/estimators/params.py
@@ -60,3 +60,23 @@ def trainable_state(model: nn.Module) -> "OrderedDict[str, torch.Tensor]":
return OrderedDict(
(name, p.detach().clone()) for name, p in model.named_parameters() if p.requires_grad
)
+
+
+def frozen_state(model: nn.Module) -> "OrderedDict[str, torch.Tensor]":
+ """The FROZEN, F32-only complement of :func:`trainable_state` — the bytes a DA-11 ``BASE_REF``
+ backbone blob carries. Frozen (``requires_grad is False``) parameters in ``named_parameters()``
+ order, then all *float* buffers in ``named_buffers()`` order, as detached clones.
+
+ Integer buffers (e.g. ``BatchNorm.num_batches_tracked``) are excluded on purpose: they are not
+ used in eval-mode forward and are not part of the platform's float32-only wire
+ (``safetensors_codec``). Excluding them keeps this manifest F32-consistent and byte-deterministic
+ for content addressing while remaining forward-correct for a real BatchNorm backbone (Phase 2C).
+ """
+ out: "OrderedDict[str, torch.Tensor]" = OrderedDict()
+ for name, p in model.named_parameters():
+ if not p.requires_grad:
+ out[name] = p.detach().clone()
+ for name, b in model.named_buffers():
+ if b is not None and b.is_floating_point():
+ out[name] = b.detach().clone()
+ return out
diff --git a/framework/src/fedlearn/security/identity.py b/framework/src/fedlearn/security/identity.py
new file mode 100644
index 00000000..25a66e99
--- /dev/null
+++ b/framework/src/fedlearn/security/identity.py
@@ -0,0 +1,85 @@
+"""SE-15: bind the FL connection token to a single client identity.
+
+The backend-minted connection token carries a server-assigned, unforgeable ``partitionId`` — one per
+run+user enrollment (``RunEnrollment``). The wire ``client_id`` on the FL RPCs is, by contrast, a
+self-chosen handle the proto itself marks "NOT trusted for authz". Without binding the two, a single
+valid token can be replayed with many different ``client_id`` values, so one enrolled participant can
+impersonate the whole cohort and dominate FedAvg/DeComFL aggregation (each fake ``client_id`` becomes
+its own averaged update).
+
+This module extracts the verified ``partitionId`` from an RPC's metadata so the servicer can pin one
+token (one partition) to one ``client_id`` (see ``FLCoordinator.bind_or_check_identity``). The
+``x-connection-token`` interceptor (``interceptor.py``) already proves the token and binds it to the
+server's run; it just discards the claims, so we re-read + re-verify the token here where the request's
+``client_id`` and the RPC context are both in hand. Verification is a cheap HMAC check.
+
+Enforcement is gated on the same ``FEDLEARN_REQUIRE_CLIENT_AUTH`` switch as the interceptor: when
+client-auth is off (local/dev fail-open) there is no token and the extractor is absent, so binding is
+skipped and behaviour is unchanged.
+"""
+from __future__ import annotations
+
+import os
+from typing import Callable, Optional
+
+from fedlearn.security.interceptor import (
+ ENABLE_ENV,
+ METADATA_KEY,
+ SECRET_ENV,
+ SECRET_FALLBACK_ENV,
+)
+from fedlearn.security.token_verify import (
+ DEFAULT_AUDIENCE,
+ TokenVerificationError,
+ verify_connection_token,
+)
+
+# A callable that, given a gRPC ServicerContext, returns the verified token partition (or None).
+PartitionExtractor = Callable[[object], Optional[int]]
+
+
+def partition_from_metadata(metadata, secret_base64: str, audience: str = DEFAULT_AUDIENCE) -> Optional[int]:
+ """The verified ``partitionId`` claim from an RPC's ``x-connection-token``, or ``None``.
+
+ Returns ``None`` when there is no token, the token fails verification, or it carries no numeric
+ ``partitionId``. It never raises: the ``ConnectionTokenInterceptor`` is the auth gate that rejects
+ a missing/invalid token before the servicer runs; this is only the *identity* read, so an absent
+ identity simply means "don't bind" rather than an error.
+ """
+ token = dict(metadata or ()).get(METADATA_KEY)
+ if not token:
+ return None
+ try:
+ claims = verify_connection_token(token, secret_base64, audience)
+ except TokenVerificationError:
+ return None
+ partition = claims.get("partitionId")
+ if partition is None:
+ return None
+ try:
+ return int(partition)
+ except (TypeError, ValueError):
+ return None
+
+
+def partition_extractor_from_env(env=None) -> Optional[PartitionExtractor]:
+ """A ``(context) -> Optional[int]`` yielding the verified token partition, or ``None`` when
+ client-auth enforcement is off — mirroring ``interceptor_from_env``'s gate so the two are wired
+ together (auth on => both the interceptor and identity binding; auth off => neither).
+
+ Returns ``None`` (binding disabled) when ``FEDLEARN_REQUIRE_CLIENT_AUTH != 1`` or no secret is
+ configured. The interceptor already fails the server closed on the enforce-on-but-no-secret
+ misconfiguration, so we do not re-raise here.
+ """
+ env = os.environ if env is None else env
+ if env.get(ENABLE_ENV) != "1":
+ return None
+ secret = env.get(SECRET_ENV) or env.get(SECRET_FALLBACK_ENV)
+ if not secret:
+ return None
+ audience = DEFAULT_AUDIENCE
+
+ def _extract(context) -> Optional[int]:
+ return partition_from_metadata(context.invocation_metadata(), secret, audience)
+
+ return _extract
diff --git a/framework/src/fedlearn/server/coordinator.py b/framework/src/fedlearn/server/coordinator.py
index ce17575d..5acabf1d 100644
--- a/framework/src/fedlearn/server/coordinator.py
+++ b/framework/src/fedlearn/server/coordinator.py
@@ -93,6 +93,11 @@ def __init__(self, strategy: Strategy, min_clients_for_aggregation: int, clients
self._global_model_params: Optional[OrderedDict[str, torch.Tensor]] = None
self._client_updates_received: List[Tuple[OrderedDict[str, torch.Tensor], int]] = []
self._registered_clients: set[str] = set()
+ # SE-15: pin one connection-token partition to one wire client_id (a 1:1 bijection), so a
+ # single valid token cannot be replayed under many client_ids to Sybil the cohort / dominate
+ # aggregation. Populated lazily on first use per identity (trust-on-first-use).
+ self._partition_to_client: dict[int, str] = {}
+ self._client_to_partition: dict[str, int] = {}
self.current_round = 1 # Start at round 1
self.stop_requested = False
# True only after all configured rounds finished successfully (distinct
@@ -401,6 +406,25 @@ def register_client(self, client_id: str) -> bool:
self._registered_clients.add(client_id)
return True
+ def bind_or_check_identity(self, partition_id: int, client_id: str) -> bool:
+ """SE-15: enforce a 1:1 binding between a connection token's server-assigned ``partition_id``
+ and the wire ``client_id``. The first ``(partition, client_id)`` pair pins the binding
+ (trust-on-first-use); thereafter this partition MUST always present that same client_id, and
+ that client_id MUST NOT be claimed by any other partition. Returns ``False`` on any conflict —
+ a token replayed under a second client_id (the Sybil), or a client_id already owned by a
+ different token. The whole check-then-bind runs under the coordinator lock, so two concurrent
+ first-use calls for the same partition cannot both win.
+ """
+ with self._lock:
+ bound_client = self._partition_to_client.get(partition_id)
+ if bound_client is not None:
+ return bound_client == client_id
+ if self._client_to_partition.get(client_id, partition_id) != partition_id:
+ return False # this client_id already belongs to a different partition (token)
+ self._partition_to_client[partition_id] = client_id
+ self._client_to_partition[client_id] = partition_id
+ return True
+
def get_global_model_params(self) -> Optional[OrderedDict[str, torch.Tensor]]:
"""Safely returns the final global model parameters."""
with self._lock:
diff --git a/framework/src/fedlearn/server/grpc_servicer.py b/framework/src/fedlearn/server/grpc_servicer.py
index 6db218b2..b9b22333 100644
--- a/framework/src/fedlearn/server/grpc_servicer.py
+++ b/framework/src/fedlearn/server/grpc_servicer.py
@@ -1,11 +1,13 @@
import hashlib
import logging
+import os
import time
from typing import List, Dict
import grpc
from concurrent import futures
import io
+import itertools
import torch
# Import the generated stubs
from fedlearn.communication.generated import fedlearn_pb2
@@ -22,17 +24,67 @@
)
from fedlearn.server.decomfl_strategy import DeComFL
+# SE-18: default caps on a single streamed model upload. Generous enough for LLM-scale adapters, but
+# bounded so one client cannot grow the reassembly buffer without limit (bytes/chunks) or hold a
+# server thread indefinitely (seconds). Override via env; a non-positive seconds value disables the
+# wall-clock cap.
+_DEFAULT_MAX_UPLOAD_BYTES = 2 * 1024 ** 3 # 2 GiB
+_DEFAULT_MAX_UPLOAD_CHUNKS = 100_000
+_DEFAULT_MAX_UPLOAD_SECONDS = 600.0 # 10 min of active streaming
+
+
+class _StreamLimitExceeded(Exception):
+ """SE-18: a streamed upload exceeded a resource cap (bytes/chunks -> RESOURCE_EXHAUSTED, or the
+ wall-clock deadline -> DEADLINE_EXCEEDED). Carries the gRPC status code to abort with.
+
+ A dedicated type (not ValueError/Exception) so the servicer's broad handlers below do not remap
+ the abort to INVALID_ARGUMENT/INTERNAL — it is caught by its own clause first.
+ """
+
+ def __init__(self, message, code=grpc.StatusCode.RESOURCE_EXHAUSTED):
+ super().__init__(message)
+ self.code = code
+
class FederatedLearningServiceServicer(fedlearn_pb2_grpc.FederatedLearningServiceServicer):
"""
The gRPC servicer class. Acts as a dispatcher, forwarding calls to the FLCoordinator.
"""
- def __init__(self, coordinator: FLCoordinator):
+ def __init__(self, coordinator: FLCoordinator, partition_extractor=None):
self.coordinator = coordinator
+ # SE-15: (context) -> Optional[int] returning the verified connection-token partition; None
+ # disables identity binding (client-auth off / dev fail-open), preserving existing behavior.
+ self._partition_extractor = partition_extractor
+ # SE-18: bound the streamed-upload reassembly buffer (memory-exhaustion DoS defense) and the
+ # wall-clock time a single upload may spend actively streaming (slow-drip DoS defense).
+ self._max_upload_bytes = int(os.environ.get("FEDLEARN_MAX_UPLOAD_BYTES", _DEFAULT_MAX_UPLOAD_BYTES))
+ self._max_upload_chunks = int(os.environ.get("FEDLEARN_MAX_UPLOAD_CHUNKS", _DEFAULT_MAX_UPLOAD_CHUNKS))
+ self._max_upload_seconds = float(os.environ.get("FEDLEARN_MAX_UPLOAD_SECONDS", _DEFAULT_MAX_UPLOAD_SECONDS))
+
+ def _enforce_client_identity(self, client_id, context):
+ """SE-15: pin one connection-token partition to one wire client_id. No-op when identity
+ binding is disabled (auth off) or the call carries no verifiable token; otherwise aborts
+ PERMISSION_DENIED when this client_id doesn't match the identity already bound to the token —
+ stopping one valid token from being replayed under many client_ids to Sybil the cohort.
+
+ MUST be called BEFORE any broad ``try/except`` in the RPC: ``context.abort`` raises, and that
+ must reach gRPC rather than be swallowed as an INVALID_ARGUMENT/INTERNAL response.
+ """
+ if self._partition_extractor is None:
+ return
+ partition = self._partition_extractor(context)
+ if partition is None:
+ return
+ if not self.coordinator.bind_or_check_identity(partition, client_id):
+ context.abort(
+ grpc.StatusCode.PERMISSION_DENIED,
+ "client_id does not match the identity bound to this connection token",
+ )
def RegisterClient(self, request: fedlearn_pb2.RegisterClientRequest, context):
client_id = request.client_id
+ self._enforce_client_identity(client_id, context) # SE-15: bind partition <-> client_id
run_id = request.run_id
client_pv = request.protocol_version
# enrollment_token: minted by the Spring backend at enroll (P2). MVP validates permissively
@@ -166,11 +218,11 @@ def GetGlobalModelStream(self, request: fedlearn_pb2.GetGlobalModelRequest, cont
def SubmitModelUpdate(self, request: fedlearn_pb2.SubmitModelUpdateRequest, context):
"""Handle standard unary model update (for small models)."""
- client_id = "UNKNOWN"
+ client_id = request.client_id
+ self._enforce_client_identity(client_id, context) # SE-15 (before the try: abort must reach gRPC)
trained_on_round = -1
try:
- client_id = request.client_id
trained_on_round = request.trained_on_round
logging.info(f"=" * 60)
@@ -217,6 +269,16 @@ def SubmitModelUpdateStream(self, request_iterator, context):
Returns:
SubmitModelUpdateResponse
"""
+ # SE-15: the client_id lives in the chunk stream, so resolve + enforce the identity BEFORE the
+ # broad try/except below (whose `except Exception` would otherwise swallow the identity abort
+ # as INTERNAL). Pull the first chunk, bind partition<->client_id, then feed it back into the
+ # loop unchanged via itertools.chain.
+ stream = iter(request_iterator)
+ try:
+ first_chunk = next(stream)
+ except StopIteration:
+ context.abort(grpc.StatusCode.INVALID_ARGUMENT, "empty model update stream")
+ self._enforce_client_identity(first_chunk.client_id, context)
try:
buffer = io.BytesIO()
client_id = None
@@ -224,18 +286,49 @@ def SubmitModelUpdateStream(self, request_iterator, context):
num_examples = 0
total_chunks = 0
chunks_received = 0
+ total_bytes = 0 # SE-18: cumulative payload size, bounds-checked against the cap below
+ # SE-18: wall-clock deadline for the active streaming loop. Checked on each chunk arrival,
+ # so it bounds a slow-drip upload; a client that connects then goes fully silent blocks on
+ # the next read and is bounded instead by gRPC's max_connection_age_ms / keepalive (set in
+ # server.py). A non-positive cap disables this guard.
+ deadline = time.monotonic() + self._max_upload_seconds
logging.info(f"[Server] Receiving streamed model update...")
- # Stream chunks directly into buffer
- for chunk in request_iterator:
+ # Stream chunks directly into buffer (the already-pulled first chunk is chained back in so
+ # the header extraction below is unchanged).
+ for chunk in itertools.chain([first_chunk], stream):
if client_id is None:
client_id = chunk.client_id
round_num = chunk.trained_on_round
total_chunks = chunk.total_chunks
logging.info(f"[Server] Receiving {total_chunks} chunk(s) from {client_id} for round {round_num}")
+ # SE-18: reject an honestly-declared oversize upload up front, before buffering.
+ if chunk.total_bytes > self._max_upload_bytes:
+ raise _StreamLimitExceeded(
+ f"declared upload size {chunk.total_bytes} bytes exceeds the "
+ f"{self._max_upload_bytes}-byte cap (FEDLEARN_MAX_UPLOAD_BYTES)")
+
+ # SE-18: bound the wall-clock time spent streaming this upload.
+ if self._max_upload_seconds > 0 and time.monotonic() > deadline:
+ raise _StreamLimitExceeded(
+ f"streamed upload exceeded the {self._max_upload_seconds:.0f}s deadline "
+ f"(FEDLEARN_MAX_UPLOAD_SECONDS)", code=grpc.StatusCode.DEADLINE_EXCEEDED)
+
+ # SE-18: enforce the caps BEFORE writing, so the buffer never exceeds the limit even if
+ # the client lies about (or omits) total_bytes / total_chunks or never sends is_final.
+ chunk_len = len(chunk.chunk_data)
+ if total_bytes + chunk_len > self._max_upload_bytes:
+ raise _StreamLimitExceeded(
+ f"streamed upload exceeded the {self._max_upload_bytes}-byte cap "
+ f"(FEDLEARN_MAX_UPLOAD_BYTES)")
+ if chunks_received + 1 > self._max_upload_chunks:
+ raise _StreamLimitExceeded(
+ f"streamed upload exceeded the {self._max_upload_chunks}-chunk cap "
+ f"(FEDLEARN_MAX_UPLOAD_CHUNKS)")
buffer.write(chunk.chunk_data)
+ total_bytes += chunk_len
chunks_received += 1
# Progress update
@@ -267,6 +360,12 @@ def SubmitModelUpdateStream(self, request_iterator, context):
return fedlearn_pb2.SubmitModelUpdateResponse(received=True)
+ except _StreamLimitExceeded as e:
+ # SE-18: a size cap (RESOURCE_EXHAUSTED) or the wall-clock deadline (DEADLINE_EXCEEDED)
+ # was hit. Caught BEFORE the broad handlers so the abort isn't remapped to
+ # INVALID_ARGUMENT/INTERNAL; the exact code rides on the exception.
+ logging.warning(f"[Server] Rejecting streamed upload from {client_id}: {e}")
+ context.abort(e.code, str(e))
except ValueError as e:
# Malformed / non-finite streamed payload -> client error -> INVALID_ARGUMENT.
logging.info(f"[Server] Rejecting invalid streamed model update from {client_id}: {e}")
@@ -305,8 +404,9 @@ def Heartbeat(self, request: fedlearn_pb2.HeartbeatRequest, context):
Handle heartbeat from client.
This is a FAST call that doesn't block.
"""
+ client_id = request.client_id
+ self._enforce_client_identity(client_id, context) # SE-15 (before the try: abort must reach gRPC)
try:
- client_id = request.client_id
run_id = request.run_id # v2 field 2 — the run this heartbeat belongs to
status = request.status
current_step = request.current_step
@@ -373,7 +473,11 @@ def GetDeComFLConfig(self, request: fedlearn_pb2.GetDeComFLConfigRequest, contex
'learning_rate': str(strategy.eta),
'smoothing_param': str(strategy.mu),
'num_local_steps': str(strategy.K),
- 'num_perturbations': str(strategy.P)
+ 'num_perturbations': str(strategy.P),
+ # MO-19/FR-14: advertise the server's trainable flat dimension so every client (python
+ # or mobile) can fail loud at the handshake if its own trainable dim differs — instead
+ # of training on a misaligned shared-seed perturbation and diverging silently.
+ 'model_dim': str(strategy.model_dim),
}
logging.info(f"[Server] Sending {len(seeds)} local steps, {len(rebuild_history)} missed rounds")
@@ -402,8 +506,9 @@ def SubmitGradientScalars(self, request: fedlearn_pb2.SubmitGradientScalarsReque
"""
Handle submission of gradient scalars from DeComFL client.
"""
+ client_id = request.client_id
+ self._enforce_client_identity(client_id, context) # SE-15 (before the try: abort must reach gRPC)
try:
- client_id = request.client_id
trained_on_round = request.trained_on_round
num_examples = request.num_examples
diff --git a/framework/src/fedlearn/server/server.py b/framework/src/fedlearn/server/server.py
index f75dbc3e..62519c57 100644
--- a/framework/src/fedlearn/server/server.py
+++ b/framework/src/fedlearn/server/server.py
@@ -8,6 +8,7 @@
from .grpc_servicer import FederatedLearningServiceServicer
from ..communication.generated import fedlearn_pb2_grpc
from ..security.interceptor import interceptor_from_env
+from ..security.identity import partition_extractor_from_env
from ..security.tls import check_server_tls_policy
import logging
import sys
@@ -109,7 +110,13 @@ def start_server(
# Add servicer
fedlearn_pb2_grpc.add_FederatedLearningServiceServicer_to_server(
- FederatedLearningServiceServicer(coordinator),
+ FederatedLearningServiceServicer(
+ coordinator,
+ # SE-15: bind each connection token's server-assigned partition to a single client_id.
+ # Wired to the same FEDLEARN_REQUIRE_CLIENT_AUTH gate as the auth interceptor; returns
+ # None (binding disabled) in local/dev fail-open.
+ partition_extractor=partition_extractor_from_env(),
+ ),
grpc_server
)
diff --git a/framework/src/fedlearn/server/subset_federation.py b/framework/src/fedlearn/server/subset_federation.py
new file mode 100644
index 00000000..d85c9001
--- /dev/null
+++ b/framework/src/fedlearn/server/subset_federation.py
@@ -0,0 +1,83 @@
+"""DA-11: federate only a model's requires_grad (trainable) subset under FedAvg.
+
+The wire payload is estimators.params.trainable_state (FR-14 layout). This module adds the pieces
+FedAvg lacks for a partially-trainable (frozen-backbone) model: a fail-loud, shape-aware guard
+(``validate_subset_update``) so a client whose trainable keys OR shapes differ from the server's is
+rejected (never silently averaged), a per-client pre-aggregation sweep (``guard_client_updates``),
+and a non-strict reconstruction that writes the aggregated subset back onto a net whose frozen
+params stay intact. The averaging itself is unchanged — FedAvgAggregator already averages over the
+keys it is handed.
+
+The guard MUST run per-client, BEFORE aggregation: FedAvgAggregator.aggregate() derives its output
+key-set from the FIRST client's update and silently skips (`if key in params`) any key a LATER
+client is missing (see ``strategy.FedAvgAggregator.aggregate``) — so validating the AGGREGATED
+output can never catch a non-first client's bad payload; it always reflects the first client's key
+set. ``guard_client_updates`` is the fix: call it on the raw per-client payload list before handing
+them to the aggregator.
+"""
+from __future__ import annotations
+
+from collections import OrderedDict
+
+import torch
+import torch.nn as nn
+
+from fedlearn.estimators.params import trainable_state
+
+
+class SubsetDimMismatch(ValueError):
+ """A client's trainable subset (keys/order) does not match the server's expected trainable layout."""
+
+
+def expected_trainable_keys(model: nn.Module) -> list[str]:
+ """The ordered trainable (requires_grad) parameter names — the server's expected wire keys."""
+ return list(trainable_state(model).keys())
+
+
+def validate_subset_update(update: "OrderedDict[str, torch.Tensor]", model: nn.Module) -> None:
+ """Raise SubsetDimMismatch unless `update` matches model's expected trainable layout on BOTH
+ axes: the key list (order-sensitive) AND each tensor's shape. A same-key/wrong-shape update
+ (e.g. a misconfigured client's head) is exactly as much a contract violation as a wrong key
+ set, so it raises this same typed error instead of falling through to load_state_dict's raw,
+ untyped RuntimeError.
+ """
+ expected = trainable_state(model)
+ update_keys = list(update.keys())
+ expected_keys = list(expected.keys())
+ if update_keys != expected_keys:
+ raise SubsetDimMismatch(
+ f"trainable-subset mismatch: client sent {update_keys} but the server expects "
+ f"{expected_keys} (requires_grad params in named_parameters order). Send "
+ f"estimators.params.trainable_state(model), NOT a full state_dict()."
+ )
+ for name, expected_tensor in expected.items():
+ actual_shape = tuple(update[name].shape)
+ expected_shape = tuple(expected_tensor.shape)
+ if actual_shape != expected_shape:
+ raise SubsetDimMismatch(
+ f"trainable-subset shape mismatch for {name!r}: client sent shape {actual_shape} "
+ f"but the server expects {expected_shape} (model parameter shape)."
+ )
+
+
+def guard_client_updates(
+ client_payloads: "list[OrderedDict[str, torch.Tensor]]", model: nn.Module
+) -> None:
+ """Validate EVERY client's raw payload against model's expected trainable layout BEFORE
+ aggregation runs (FINDING 1). This is where the fail-loud guarantee actually lives:
+ FedAvgAggregator.aggregate() derives its output key-set from the FIRST client's update and
+ silently skips (`if key in params`) any key a LATER client is missing, so a non-first client
+ with a bad payload would otherwise be averaged with no error. Raises on the first bad client
+ (SubsetDimMismatch), so a malformed client is rejected and the round never proceeds to
+ aggregation."""
+ for payload in client_payloads:
+ validate_subset_update(payload, model)
+
+
+def apply_trainable_subset(model: nn.Module, subset: "OrderedDict[str, torch.Tensor]") -> None:
+ """Write an aggregated trainable subset back onto model (non-strict, so the frozen backbone is
+ preserved). Validates keys+shapes against the model's expected trainable layout first
+ (fail-loud); with that guard in place, `unexpected` from load_state_dict can never be
+ non-empty at this call site (see subset_federation module docstring / DA-11 review)."""
+ validate_subset_update(subset, model)
+ model.load_state_dict(subset, strict=False)
diff --git a/framework/tests/__init__.py b/framework/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/framework/tests/fixtures/__init__.py b/framework/tests/fixtures/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/framework/tests/fixtures/tiny_frozen_model.py b/framework/tests/fixtures/tiny_frozen_model.py
new file mode 100644
index 00000000..c5a8d7f1
--- /dev/null
+++ b/framework/tests/fixtures/tiny_frozen_model.py
@@ -0,0 +1,26 @@
+"""Throwaway tiny frozen-backbone + linear-head net for the DA-11 Phase-1 vertical slice."""
+import torch
+import torch.nn as nn
+
+
+class TinyFrozenNet(nn.Module):
+ def __init__(self):
+ super().__init__()
+ # A trivial "backbone": 1x8x8 grayscale -> 4 features. Frozen after build.
+ self.backbone = nn.Sequential(
+ nn.Conv2d(1, 2, kernel_size=3, padding=1), nn.ReLU(),
+ nn.AdaptiveAvgPool2d(1), nn.Flatten(), # -> [B, 2]
+ )
+ self.head = nn.Linear(2, 3) # 3 classes; the only trainable part
+
+ def forward(self, x):
+ return self.head(self.backbone(x))
+
+
+def build_tiny_frozen_net(seed: int = 0) -> nn.Module:
+ with torch.random.fork_rng():
+ torch.manual_seed(seed)
+ net = TinyFrozenNet()
+ for p in net.backbone.parameters():
+ p.requires_grad_(False)
+ return net
diff --git a/framework/tests/test_backbone_distribution.py b/framework/tests/test_backbone_distribution.py
new file mode 100644
index 00000000..3d4833e2
--- /dev/null
+++ b/framework/tests/test_backbone_distribution.py
@@ -0,0 +1,236 @@
+from collections import OrderedDict
+
+import torch
+import torch.nn as nn
+
+from fedlearn.estimators.params import frozen_state, trainable_state
+from tests.fixtures.tiny_frozen_model import build_tiny_frozen_net
+
+
+def test_frozen_state_is_backbone_only_and_disjoint_from_trainable():
+ net = build_tiny_frozen_net(seed=0)
+ frozen = frozen_state(net)
+ trainable = trainable_state(net)
+ # Backbone conv params are frozen; the head is trainable.
+ assert set(frozen.keys()) == {"backbone.0.weight", "backbone.0.bias"}
+ assert set(trainable.keys()) == {"head.weight", "head.bias"}
+ # Disjoint, and together they cover every parameter.
+ assert set(frozen) & set(trainable) == set()
+ all_params = {name for name, _ in net.named_parameters()}
+ assert set(frozen) | set(trainable) == all_params
+ # Values match the live tensors and are detached clones (mutation-safe).
+ for name, p in net.named_parameters():
+ if name in frozen:
+ assert torch.equal(frozen[name], p.detach())
+ assert frozen["backbone.0.weight"].requires_grad is False
+
+
+def test_frozen_state_includes_float_buffers_excludes_integer_buffers():
+ class BNBackbone(nn.Module):
+ def __init__(self):
+ super().__init__()
+ self.bn = nn.BatchNorm2d(2) # running_mean/var (float) + num_batches_tracked (int64)
+ self.head = nn.Linear(2, 3)
+ def forward(self, x):
+ return self.head(self.bn(x).mean(dim=(2, 3)))
+
+ net = BNBackbone()
+ for p in net.bn.parameters():
+ p.requires_grad_(False)
+ frozen = frozen_state(net)
+ # Float buffers are in; the int64 num_batches_tracked is out (not F32-wire, unused in eval).
+ assert "bn.running_mean" in frozen
+ assert "bn.running_var" in frozen
+ assert "bn.num_batches_tracked" not in frozen
+ # Frozen bn weight/bias present; trainable head absent.
+ assert "bn.weight" in frozen and "bn.bias" in frozen
+ assert "head.weight" not in frozen
+ # Every emitted tensor is float dtype.
+ assert all(t.is_floating_point() for t in frozen.values())
+
+
+def test_frozen_state_preserves_named_order():
+ net = build_tiny_frozen_net(seed=0)
+ frozen = frozen_state(net)
+ # Params emitted in named_parameters() order (weight before bias for the conv).
+ assert list(frozen.keys()) == ["backbone.0.weight", "backbone.0.bias"]
+ assert isinstance(frozen, OrderedDict)
+
+
+import hashlib
+
+from fedlearn.backbone.distribution import serialize_backbone, backbone_sha256
+from fedlearn.communication.safetensors_codec import load_safetensors
+
+
+def test_serialize_backbone_is_byte_deterministic_and_content_addressed():
+ net_a = build_tiny_frozen_net(seed=0)
+ net_b = build_tiny_frozen_net(seed=0) # same seed -> identical frozen backbone
+ blob_a = serialize_backbone(net_a)
+ blob_b = serialize_backbone(net_b)
+ assert blob_a == blob_b # byte-identical
+ assert backbone_sha256(blob_a) == backbone_sha256(blob_b)
+ assert backbone_sha256(blob_a) == hashlib.sha256(blob_a).hexdigest()
+
+
+def test_serialize_backbone_differs_when_frozen_weights_differ():
+ net0 = build_tiny_frozen_net(seed=0)
+ net1 = build_tiny_frozen_net(seed=1) # different frozen backbone
+ assert backbone_sha256(serialize_backbone(net0)) != backbone_sha256(serialize_backbone(net1))
+
+
+def test_serialize_backbone_roundtrips_to_frozen_tensors():
+ net = build_tiny_frozen_net(seed=0)
+ tensors, meta = load_safetensors(serialize_backbone(net))
+ names = [n for n, _ in tensors]
+ assert names == ["backbone.0.weight", "backbone.0.bias"] # frozen only, in order
+
+
+import pytest
+
+from fedlearn.backbone.distribution import BackboneCache, BackboneIntegrityError
+
+
+def test_cache_fetches_once_then_serves_from_disk(tmp_path):
+ net = build_tiny_frozen_net(seed=0)
+ blob = serialize_backbone(net)
+ sha = backbone_sha256(blob)
+ cache = BackboneCache(tmp_path)
+ calls = {"n": 0}
+
+ def fetch():
+ calls["n"] += 1
+ return blob
+
+ p1 = cache.get_or_fetch(sha, fetch)
+ assert p1.exists() and p1.read_bytes() == blob
+ assert calls["n"] == 1
+ p2 = cache.get_or_fetch(sha, fetch) # hit -> fetch NOT called again
+ assert p2 == p1
+ assert calls["n"] == 1
+
+
+def test_cache_rejects_hash_mismatch_and_writes_nothing(tmp_path):
+ net = build_tiny_frozen_net(seed=0)
+ blob = serialize_backbone(net)
+ wrong_sha = backbone_sha256(serialize_backbone(build_tiny_frozen_net(seed=1)))
+ cache = BackboneCache(tmp_path)
+ with pytest.raises(BackboneIntegrityError):
+ cache.get_or_fetch(wrong_sha, lambda: blob) # bytes hash != requested key
+ assert not (tmp_path / wrong_sha).exists() # nothing partial left behind
+ assert list(tmp_path.iterdir()) == []
+
+
+def test_cache_self_heals_a_corrupted_cache_file(tmp_path):
+ net = build_tiny_frozen_net(seed=0)
+ blob = serialize_backbone(net)
+ sha = backbone_sha256(blob)
+ cache = BackboneCache(tmp_path)
+ (tmp_path / sha).write_bytes(b"corrupted-not-the-backbone") # wrong bytes on disk
+ calls = {"n": 0}
+
+ def fetch():
+ calls["n"] += 1
+ return blob
+
+ p = cache.get_or_fetch(sha, fetch) # detects bad on-disk bytes, re-fetches, overwrites
+ assert p.read_bytes() == blob
+ assert calls["n"] == 1
+
+
+from fedlearn.backbone.distribution import reconstruct_frozen_backbone, BackboneKeyMismatch
+
+
+def test_reconstruct_loads_backbone_freezes_it_and_leaves_head_trainable():
+ source = build_tiny_frozen_net(seed=7) # the registered backbone
+ blob = serialize_backbone(source)
+ target = build_tiny_frozen_net(seed=99) # a fresh client net, different backbone weights
+ # Precondition: the two nets' backbones differ before reconstruction.
+ assert not torch.equal(
+ dict(target.named_parameters())["backbone.0.weight"].detach(),
+ dict(source.named_parameters())["backbone.0.weight"].detach(),
+ )
+ reconstruct_frozen_backbone(target, blob)
+ # Backbone now byte-identical to the source; frozen.
+ for name, p in target.named_parameters():
+ if name.startswith("backbone."):
+ assert torch.equal(p.detach(), dict(source.named_parameters())[name].detach())
+ assert p.requires_grad is False
+ # Head untouched + still trainable; only the head is the federated (trainable) subset.
+ assert dict(target.named_parameters())["head.weight"].requires_grad is True
+ assert set(trainable_state(target).keys()) == {"head.weight", "head.bias"}
+
+
+def test_reconstruct_rejects_unexpected_key():
+ target = build_tiny_frozen_net(seed=0)
+ # A blob carrying a key the model's frozen layout does not declare.
+ from fedlearn.communication.safetensors_codec import save_safetensors
+ import numpy as np
+ bad = save_safetensors([("backbone.0.weight", np.zeros((2, 1, 3, 3), dtype="client_id bijection.
+ * ``fedlearn.security.identity`` — extracting the verified partition from an RPC's token metadata.
+ * ``FederatedLearningServiceServicer._enforce_client_identity`` — the servicer gate that aborts
+ PERMISSION_DENIED when a token is replayed under a second client_id.
+"""
+import base64
+import time
+
+import grpc
+import jwt as pyjwt
+import pytest
+from unittest.mock import MagicMock
+
+from fedlearn.security.identity import partition_from_metadata, partition_extractor_from_env
+from fedlearn.security.interceptor import ENABLE_ENV, METADATA_KEY, SECRET_ENV
+from fedlearn.security.token_verify import DEFAULT_AUDIENCE
+from fedlearn.server.coordinator import FLCoordinator
+from fedlearn.server.grpc_servicer import FederatedLearningServiceServicer
+from fedlearn.server.strategy import Strategy
+
+_SECRET = b"fedlearn-test-connection-token-secret-key!" # 41 bytes -> HS256-capable
+_SECRET_B64 = base64.b64encode(_SECRET).decode()
+
+
+def _coord():
+ return FLCoordinator(MagicMock(spec=Strategy), 1, 1)
+
+
+def _mint(**overrides):
+ payload = {"aud": DEFAULT_AUDIENCE, "exp": int(time.time()) + 300}
+ payload.update(overrides)
+ return pyjwt.encode(payload, _SECRET, algorithm="HS256")
+
+
+def _md(token=None):
+ return [(METADATA_KEY, token)] if token is not None else []
+
+
+class _Abort(Exception):
+ pass
+
+
+class _Ctx:
+ """Fake gRPC ServicerContext: invocation_metadata() + an abort() that raises like the real one."""
+
+ def __init__(self, md=None):
+ self._md = md or []
+ self.aborted = None
+
+ def invocation_metadata(self):
+ return self._md
+
+ def abort(self, code, details):
+ self.aborted = (code, details)
+ raise _Abort(details)
+
+
+# ---- coordinator: 1:1 partition <-> client_id bijection ----
+
+def test_first_use_binds_and_repeats_ok():
+ c = _coord()
+ assert c.bind_or_check_identity(5, "clientA") is True # first use pins 5 <-> clientA
+ assert c.bind_or_check_identity(5, "clientA") is True # idempotent (re-register / heartbeat / resubmit)
+
+
+def test_same_token_second_client_id_is_rejected():
+ c = _coord()
+ assert c.bind_or_check_identity(5, "clientA") is True
+ assert c.bind_or_check_identity(5, "clientB") is False # THE Sybil: one token replayed as a 2nd client
+
+
+def test_client_id_cannot_be_claimed_by_a_second_partition():
+ c = _coord()
+ assert c.bind_or_check_identity(5, "shared") is True
+ assert c.bind_or_check_identity(6, "shared") is False # a different token stealing an in-use client_id
+
+
+def test_distinct_partitions_and_client_ids_coexist():
+ c = _coord()
+ assert c.bind_or_check_identity(5, "a") is True
+ assert c.bind_or_check_identity(6, "b") is True
+
+
+# ---- identity extractor (token -> partition) ----
+
+def test_partition_from_valid_token():
+ assert partition_from_metadata(_md(_mint(partitionId=7, runId="r1")), _SECRET_B64) == 7
+
+
+def test_partition_none_without_or_invalid_token():
+ assert partition_from_metadata(_md(), _SECRET_B64) is None # no token
+ assert partition_from_metadata(_md("garbage.not.jwt"), _SECRET_B64) is None # unverifiable
+ assert partition_from_metadata(_md(_mint(runId="r1")), _SECRET_B64) is None # no partitionId claim
+ other = base64.b64encode(b"a-different-secret-of-sufficient-length!!").decode()
+ assert partition_from_metadata(_md(_mint(partitionId=7)), other) is None # wrong signing secret
+
+
+def test_extractor_gated_on_require_client_auth():
+ assert partition_extractor_from_env({}) is None # auth off (default) -> disabled
+ assert partition_extractor_from_env({ENABLE_ENV: "0"}) is None
+ ext = partition_extractor_from_env({ENABLE_ENV: "1", SECRET_ENV: _SECRET_B64})
+ assert ext is not None
+ assert ext(_Ctx(_md(_mint(partitionId=9)))) == 9
+ assert ext(_Ctx(_md())) is None # no token on the call
+
+
+# ---- servicer: the enforcement gate ----
+
+def test_servicer_binds_first_client_then_rejects_a_second_on_same_token():
+ servicer = FederatedLearningServiceServicer(_coord(), partition_extractor=lambda ctx: 5)
+ servicer._enforce_client_identity("a", _Ctx()) # first client for partition 5 -> binds, no abort
+ ctx = _Ctx()
+ with pytest.raises(_Abort): # same token (partition 5), a different client_id
+ servicer._enforce_client_identity("b", ctx)
+ assert ctx.aborted[0] == grpc.StatusCode.PERMISSION_DENIED
+ servicer._enforce_client_identity("a", _Ctx()) # the originally-bound client_id still passes
+
+
+def test_servicer_no_binding_when_extractor_disabled():
+ servicer = FederatedLearningServiceServicer(_coord(), partition_extractor=None)
+ servicer._enforce_client_identity("anything", _Ctx()) # auth off: any client_id passes (dev fail-open)
+ servicer._enforce_client_identity("anything-else", _Ctx())
+
+
+def test_servicer_no_binding_when_call_has_no_token():
+ servicer = FederatedLearningServiceServicer(_coord(), partition_extractor=lambda ctx: None)
+ servicer._enforce_client_identity("a", _Ctx()) # extractor yields None -> no enforcement
+ servicer._enforce_client_identity("b", _Ctx())
diff --git a/framework/tests/test_decomfl_client_lifecycle.py b/framework/tests/test_decomfl_client_lifecycle.py
index 749a85c3..f53803fd 100644
--- a/framework/tests/test_decomfl_client_lifecycle.py
+++ b/framework/tests/test_decomfl_client_lifecycle.py
@@ -69,6 +69,9 @@ def fit(self, parameters, config): # not reached in terminal/error scenarios
def load_global_model(self, params): # FR-1 initial sync; no-op for the lifecycle doubles
pass
+ def assert_dim_matches(self, server_model_dim): # MO-19: no-op unless a test overrides to reject
+ pass
+
class _FakeComm:
"""
@@ -137,14 +140,36 @@ def _fast_and_isolated(monkeypatch):
def _run(comm):
+ return _run_with(comm, _FakeClient())
+
+
+def _run_with(comm, client):
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(ds, "GrpcClient", lambda client_id, server_address: comm)
try:
- return start_decomfl_client("addr:1", _FakeClient(), "c0")
+ return start_decomfl_client("addr:1", client, "c0")
finally:
monkeypatch.undo()
+class TestDimGuard:
+ """MO-19: a server/client trainable-dimension mismatch (advertised via config['model_dim']) is a
+ FATAL setup error — the client must fail loud and exit, not train on a misaligned perturbation or
+ retry-loop on a condition that can never clear."""
+
+ def test_dim_mismatch_exits_error_without_retry(self):
+ class _RejectingDimClient(_FakeClient):
+ def assert_dim_matches(self, server_model_dim):
+ raise ValueError(
+ f"DeComFL trainable-dimension mismatch: 25 vs {server_model_dim}")
+
+ # A config that advertises a model_dim the client rejects.
+ comm = _FakeComm(config_action=lambda: (0, [], [], {"learning_rate": "0.01", "model_dim": "99"}))
+ outcome = _run_with(comm, _RejectingDimClient())
+ assert outcome == ERROR
+ assert comm.config_calls == 1 # fatal -> did NOT retry-loop
+
+
# ---------------------------------------------------------------------------
# Client round-loop: run completion -> clean exit
# ---------------------------------------------------------------------------
diff --git a/framework/tests/test_decomfl_layout_contract.py b/framework/tests/test_decomfl_layout_contract.py
index 57e917d1..dca0d1ff 100644
--- a/framework/tests/test_decomfl_layout_contract.py
+++ b/framework/tests/test_decomfl_layout_contract.py
@@ -15,6 +15,10 @@
from fedlearn.estimators import params
from fedlearn.server.decomfl_strategy import DeComFL
+from fedlearn.client.decomfl_client import DeComFLClient
+from fedlearn.server.coordinator import FLCoordinator
+from fedlearn.server.grpc_servicer import FederatedLearningServiceServicer
+from fedlearn.communication.generated import fedlearn_pb2
class FrozenNet(nn.Module):
@@ -63,3 +67,36 @@ def test_server_built_from_full_state_dict_is_detected_not_silent():
assert server.model_dim > params.num_trainable(m) # would silently misalign z
with pytest.raises(ValueError, match="dimension mismatch"): # now FAIL-LOUD
server.validate_participant_dim(params.num_trainable(m), client_id="phone-1")
+
+
+# ---- MO-19: the CLIENT-side half of the same guard (server advertises model_dim; client checks) ----
+
+class _CfgCtx:
+ """Minimal unary context for GetDeComFLConfig (no identity gate on this read RPC)."""
+
+ def invocation_metadata(self):
+ return []
+
+ def set_code(self, code):
+ pass
+
+ def set_details(self, details):
+ pass
+
+
+def test_client_asserts_its_trainable_dim_matches_the_server_model_dim():
+ m = FrozenNet() # 25 trainable (fc1); fc2 frozen
+ client = DeComFLClient(model=m, train_loader=None, device="cpu")
+ client.assert_dim_matches(25) # d_server == d_client -> no raise
+ with pytest.raises(ValueError, match="dimension mismatch"):
+ client.assert_dim_matches(43) # the full-state_dict d_server=43 vs d_client=25 bug -> fail loud
+
+
+def test_server_advertises_its_model_dim_in_the_decomfl_config():
+ # The server publishes model_dim so ANY client (python or mobile) can self-check at the handshake.
+ m = FrozenNet()
+ strategy = _decomfl(params.trainable_state(m)) # model_dim == 25
+ coordinator = FLCoordinator(strategy, 1, 1)
+ servicer = FederatedLearningServiceServicer(coordinator)
+ resp = servicer.GetDeComFLConfig(fedlearn_pb2.GetDeComFLConfigRequest(client_id="c0"), _CfgCtx())
+ assert resp.config["model_dim"] == "25"
diff --git a/framework/tests/test_stream_upload_limits.py b/framework/tests/test_stream_upload_limits.py
new file mode 100644
index 00000000..700f1aa1
--- /dev/null
+++ b/framework/tests/test_stream_upload_limits.py
@@ -0,0 +1,127 @@
+"""SE-18: bound the streamed model-upload buffer so a client can't exhaust server memory.
+
+``SubmitModelUpdateStream`` reassembles a client's ``ModelUpdateChunk`` stream into a single in-memory
+buffer. With no cap, a malicious or buggy client that never sends ``is_final_chunk`` (or sends huge /
+endless chunks) grows that buffer without bound -> OOM DoS. The server must abort
+``RESOURCE_EXHAUSTED`` as soon as the cumulative bytes OR the chunk count exceeds a configurable cap,
+BEFORE the buffer grows past it — while leaving legitimately-sized uploads untouched.
+
+The abort must sit outside the servicer's broad ``except Exception`` (which would otherwise remap it to
+INTERNAL) — the same ordering constraint as the SE-15 identity gate.
+"""
+import grpc
+import pytest
+from unittest.mock import MagicMock
+
+from fedlearn.communication.generated import fedlearn_pb2
+from fedlearn.server.grpc_servicer import FederatedLearningServiceServicer
+
+
+class _Abort(Exception):
+ pass
+
+
+class _Ctx:
+ """Fake ServicerContext whose abort() records the code and raises like the real one."""
+
+ def __init__(self):
+ self.aborted = None
+
+ def invocation_metadata(self):
+ return []
+
+ def abort(self, code, details):
+ self.aborted = (code, details)
+ raise _Abort(details)
+
+
+def _servicer(max_bytes=None, max_chunks=None):
+ # partition_extractor=None -> identity binding disabled, so the stream reaches the cap logic.
+ s = FederatedLearningServiceServicer(MagicMock(), partition_extractor=None)
+ if max_bytes is not None:
+ s._max_upload_bytes = max_bytes
+ if max_chunks is not None:
+ s._max_upload_chunks = max_chunks
+ return s
+
+
+def _chunk(data=b"", is_final=False, client_id="c0", total_chunks=1, total_bytes=0, num_examples=1):
+ return fedlearn_pb2.ModelUpdateChunk(
+ client_id=client_id, trained_on_round=1, total_chunks=total_chunks,
+ chunk_data=data, is_final_chunk=is_final, num_examples=num_examples, total_bytes=total_bytes,
+ )
+
+
+def test_aborts_resource_exhausted_when_cumulative_bytes_exceed_cap():
+ s = _servicer(max_bytes=10, max_chunks=10_000)
+ chunks = [_chunk(b"AAAAA"), _chunk(b"BBBBB"), _chunk(b"CCCCC", is_final=True)] # 15 > 10
+ ctx = _Ctx()
+ with pytest.raises(_Abort):
+ s.SubmitModelUpdateStream(iter(chunks), ctx)
+ assert ctx.aborted[0] == grpc.StatusCode.RESOURCE_EXHAUSTED
+
+
+def test_aborts_resource_exhausted_when_chunk_count_exceeds_cap():
+ s = _servicer(max_bytes=10**9, max_chunks=2)
+ chunks = [_chunk(b"a"), _chunk(b"b"), _chunk(b"c"), _chunk(b"d", is_final=True)] # 4 > 2
+ ctx = _Ctx()
+ with pytest.raises(_Abort):
+ s.SubmitModelUpdateStream(iter(chunks), ctx)
+ assert ctx.aborted[0] == grpc.StatusCode.RESOURCE_EXHAUSTED
+
+
+def test_aborts_early_on_declared_oversize_before_buffering():
+ # An honest client that DECLARES a huge total_bytes is rejected on the first chunk.
+ s = _servicer(max_bytes=100, max_chunks=10_000)
+ ctx = _Ctx()
+ with pytest.raises(_Abort):
+ s.SubmitModelUpdateStream(iter([_chunk(b"x", total_bytes=10_000)]), ctx)
+ assert ctx.aborted[0] == grpc.StatusCode.RESOURCE_EXHAUSTED
+
+
+class _Clock:
+ """Deterministic monotonic-clock stand-in: yields each time, then sticks on the last."""
+
+ def __init__(self, times):
+ self._times = list(times)
+
+ def __call__(self):
+ return self._times.pop(0) if len(self._times) > 1 else self._times[0]
+
+
+def test_aborts_deadline_exceeded_when_upload_runs_past_the_time_cap(monkeypatch):
+ # A slow-but-under-cap upload must be bounded in wall-clock time, distinct from the size caps.
+ s = _servicer(max_bytes=10 ** 9, max_chunks=10_000)
+ s._max_upload_seconds = 5.0
+ # deadline computed at t=0 (-> 5.0); the loop's first check reads t=100 -> past the deadline.
+ monkeypatch.setattr("fedlearn.server.grpc_servicer.time.monotonic", _Clock([0.0, 100.0]))
+ ctx = _Ctx()
+ with pytest.raises(_Abort):
+ s.SubmitModelUpdateStream(iter([_chunk(b"a"), _chunk(b"b", is_final=True)]), ctx)
+ assert ctx.aborted[0] == grpc.StatusCode.DEADLINE_EXCEEDED
+
+
+def test_zero_deadline_disables_the_time_cap(monkeypatch):
+ # max_seconds <= 0 disables the wall-clock guard (byte/chunk caps still apply).
+ s = _servicer(max_bytes=10 ** 9, max_chunks=10_000)
+ s._max_upload_seconds = 0
+ monkeypatch.setattr("fedlearn.server.grpc_servicer.time.monotonic", _Clock([0.0, 10_000.0]))
+ monkeypatch.setattr("fedlearn.server.grpc_servicer.chunks_to_parameters",
+ lambda data, compressed=False: ({"w": object()}, 1))
+ ctx = _Ctx()
+ resp = s.SubmitModelUpdateStream(iter([_chunk(b"a", is_final=True)]), ctx)
+ assert resp.received is True
+ assert ctx.aborted is None
+
+
+def test_within_limits_upload_is_not_rejected(monkeypatch):
+ # A legitimately-sized upload must pass the guard and reach decode+submit unharmed.
+ s = _servicer(max_bytes=10**9, max_chunks=10_000)
+ monkeypatch.setattr("fedlearn.server.grpc_servicer.chunks_to_parameters",
+ lambda data, compressed=False: ({"w": object()}, 42))
+ chunks = [_chunk(b"AAAAA"), _chunk(b"BBBBB", is_final=True, num_examples=42)]
+ ctx = _Ctx()
+ resp = s.SubmitModelUpdateStream(iter(chunks), ctx)
+ assert resp.received is True
+ assert ctx.aborted is None
+ s.coordinator.submit_client_update.assert_called_once()
diff --git a/framework/tests/test_subset_federation.py b/framework/tests/test_subset_federation.py
new file mode 100644
index 00000000..a85b5b72
--- /dev/null
+++ b/framework/tests/test_subset_federation.py
@@ -0,0 +1,152 @@
+from collections import OrderedDict
+
+import torch
+from tests.fixtures.tiny_frozen_model import build_tiny_frozen_net
+
+
+def test_tiny_frozen_net_has_frozen_backbone_and_trainable_head():
+ net = build_tiny_frozen_net(seed=0)
+ trainable = {n for n, p in net.named_parameters() if p.requires_grad}
+ frozen = {n for n, p in net.named_parameters() if not p.requires_grad}
+ assert trainable == {"head.weight", "head.bias"}, trainable
+ assert all(n.startswith("backbone.") for n in frozen), frozen
+ assert frozen, "backbone must contribute frozen params"
+
+
+import pytest
+from fedlearn.server.subset_federation import (
+ expected_trainable_keys, validate_subset_update, SubsetDimMismatch,
+)
+
+
+def test_expected_keys_are_trainable_only_in_order():
+ net = build_tiny_frozen_net(seed=0)
+ assert expected_trainable_keys(net) == ["head.weight", "head.bias"]
+
+
+def test_guard_accepts_matching_keys_and_rejects_mismatch():
+ net = build_tiny_frozen_net(seed=0)
+ good = OrderedDict([
+ ("head.weight", torch.zeros_like(net.head.weight)),
+ ("head.bias", torch.zeros_like(net.head.bias)),
+ ])
+ validate_subset_update(good, net) # no raise
+ with pytest.raises(SubsetDimMismatch):
+ validate_subset_update(OrderedDict([("head.weight", torch.zeros_like(net.head.weight))]), net) # missing key
+ with pytest.raises(SubsetDimMismatch):
+ validate_subset_update(OrderedDict([
+ ("backbone.0.weight", torch.zeros_like(net.backbone[0].weight)),
+ ("head.weight", torch.zeros_like(net.head.weight)),
+ ("head.bias", torch.zeros_like(net.head.bias)),
+ ]), net) # extra/frozen key
+
+
+def test_guard_rejects_same_keys_different_order():
+ """Minor: cardinality alone isn't enough -- a REORDERED same-key-set update must also be
+ rejected, since the wire layout is order-sensitive (estimators.params.trainable_state order)."""
+ net = build_tiny_frozen_net(seed=0)
+ reordered = OrderedDict([
+ ("head.bias", torch.zeros_like(net.head.bias)),
+ ("head.weight", torch.zeros_like(net.head.weight)),
+ ])
+ with pytest.raises(SubsetDimMismatch):
+ validate_subset_update(reordered, net)
+
+
+def test_guard_rejects_same_keys_wrong_shape_with_typed_error():
+ """FINDING 2: a same-key update with a WRONG SHAPE (e.g. a differently-sized head from a
+ misconfigured client) must raise the typed SubsetDimMismatch -- not fall through to a raw
+ untyped RuntimeError out of load_state_dict."""
+ net = build_tiny_frozen_net(seed=0) # head.weight is [3, 2], head.bias is [3]
+ wrong_shape = OrderedDict([
+ ("head.weight", torch.zeros(5, 2)), # server expects [3, 2]
+ ("head.bias", torch.zeros(3)),
+ ])
+ with pytest.raises(SubsetDimMismatch):
+ validate_subset_update(wrong_shape, net)
+
+
+from fedlearn.server.subset_federation import apply_trainable_subset
+
+
+def test_apply_subset_updates_head_keeps_backbone_and_rejects_mismatch():
+ net = build_tiny_frozen_net(seed=0)
+ backbone_before = {n: p.clone() for n, p in net.named_parameters() if not p.requires_grad}
+ new_head = OrderedDict([
+ ("head.weight", torch.ones_like(net.head.weight)),
+ ("head.bias", torch.full_like(net.head.bias, 2.0)),
+ ])
+ apply_trainable_subset(net, new_head)
+ assert torch.equal(net.head.weight, torch.ones_like(net.head.weight))
+ assert torch.equal(net.head.bias, torch.full_like(net.head.bias, 2.0))
+ for n, p in net.named_parameters():
+ if not p.requires_grad:
+ assert torch.equal(p, backbone_before[n]), f"frozen {n} changed"
+ with pytest.raises(SubsetDimMismatch):
+ apply_trainable_subset(net, OrderedDict([("head.weight", net.head.weight.clone())]))
+
+
+from fedlearn.estimators.params import trainable_state, num_trainable
+from fedlearn.server.strategy import FedAvgAggregator
+from fedlearn.server.subset_federation import guard_client_updates
+
+
+def test_guard_client_updates_rejects_second_client_missing_a_key():
+ """FINDING 1: FedAvgAggregator.aggregate() builds its key-set from the FIRST client and
+ silently skips (`if key in params`) any key a LATER client lacks -- no error. The guard must
+ therefore run on each client's RAW payload before aggregation, so a non-first client missing a
+ trainable key is REJECTED, never silently averaged."""
+ server = build_tiny_frozen_net(seed=0)
+ good = trainable_state(build_tiny_frozen_net(seed=0))
+ bad_missing_bias = OrderedDict([("head.weight", good["head.weight"].clone())]) # no head.bias
+ with pytest.raises(SubsetDimMismatch):
+ guard_client_updates([good, bad_missing_bias], server)
+
+
+def test_guard_client_updates_rejects_second_client_extra_key():
+ """FINDING 1, extra-key variant: a later client sending a key the server doesn't expect (e.g.
+ a frozen backbone param sneaking onto the wire) must also be rejected pre-aggregation."""
+ server = build_tiny_frozen_net(seed=0)
+ good = trainable_state(build_tiny_frozen_net(seed=0))
+ bad_extra_key = OrderedDict(good)
+ bad_extra_key["backbone.0.weight"] = torch.zeros_like(server.backbone[0].weight)
+ with pytest.raises(SubsetDimMismatch):
+ guard_client_updates([good, bad_extra_key], server)
+
+
+def test_guard_client_updates_accepts_all_matching_clients():
+ server = build_tiny_frozen_net(seed=0)
+ a = trainable_state(build_tiny_frozen_net(seed=0))
+ b = trainable_state(build_tiny_frozen_net(seed=0))
+ guard_client_updates([a, b], server) # no raise
+
+
+def test_vertical_slice_payload_is_head_only_and_round_averages():
+ server = build_tiny_frozen_net(seed=0)
+ expected = expected_trainable_keys(server)
+
+ # Two clients start from the server's frozen backbone; each has a different head.
+ def client_update(cid, head_fill, num_examples):
+ net = build_tiny_frozen_net(seed=0)
+ with torch.no_grad():
+ net.head.weight.fill_(head_fill); net.head.bias.fill_(head_fill)
+ payload = trainable_state(net) # the wire payload
+ # (a) payload is HEAD-ONLY — the frozen backbone is NOT on the wire
+ assert list(payload.keys()) == expected
+ assert all(not k.startswith("backbone.") for k in payload)
+ assert sum(t.numel() for t in payload.values()) == num_trainable(net) # << full model
+ return (cid, payload, num_examples)
+
+ updates = [client_update("client-a", 1.0, 10), client_update("client-b", 3.0, 10)]
+ # Per-client guard runs BEFORE aggregation (FINDING 1) -- this is the real fail-loud check;
+ # validating the AGGREGATED output afterward (the old assertion here) is redundant with what
+ # apply_trainable_subset already does internally, and can never catch a non-first client's
+ # bad payload since FedAvgAggregator's key-set always mirrors the FIRST client.
+ guard_client_updates([payload for _, payload, _ in updates], server)
+ aggregated = FedAvgAggregator().aggregate(updates) # averages the subset only
+ apply_trainable_subset(server, aggregated) # reconstruct on the frozen backbone
+
+ # equal weights -> head averages to 2.0; backbone untouched (still frozen, non-trainable)
+ assert torch.allclose(server.head.weight, torch.full_like(server.head.weight, 2.0))
+ assert torch.allclose(server.head.bias, torch.full_like(server.head.bias, 2.0))
+ assert not any(p.requires_grad for n, p in server.named_parameters() if n.startswith("backbone."))
diff --git a/mobile_client/bridge/common/FedLearnCoreModule.cpp b/mobile_client/bridge/common/FedLearnCoreModule.cpp
index 6db30aad..c21d1acc 100644
--- a/mobile_client/bridge/common/FedLearnCoreModule.cpp
+++ b/mobile_client/bridge/common/FedLearnCoreModule.cpp
@@ -466,28 +466,37 @@ std::string FedLearnCoreModule::doStageBundleFile(const std::string& filename,
// resolve/reject the JS Promise via the CallInvoker. createPromiseAsJSIValue + Promise are RN
// helpers; reconcile signatures against the generated CxxSpec.
// ============================================================================
-// Run `work` (blocking, on a worker) then `build` the jsi result on the JS thread. The worker thread is
-// TRACKED (not detached) so ~FedLearnCoreModule / doStop can join it before members are destroyed — a
-// detached worker capturing `this` would otherwise resume against a freed module (use-after-free). The
-// deferred invokeAsync callback captures only value/Runtime state (never `this`), so it is safe to run
-// after the module is gone. rt2 is the app-lifetime jsi::Runtime, which outlives every worker.
+// Run `work` (blocking, on a worker) then `build` the jsi result — the jsi::Runtime is touched ONLY on
+// the JS thread, never on the worker and never through a reference captured across the async boundary.
+// The worker thread is TRACKED (not detached) so ~FedLearnCoreModule / doStop can join it before members
+// are destroyed — a detached worker (which still reaches module state through `work`) would otherwise
+// resume against a freed module (use-after-free). The worker captures ONLY value-copyable, runtime-
+// independent state (no jsi::Runtime, no `this`). It hands the plain-C++ result back to the JS thread via
+// the CallInvoker; the runtime used to build/resolve is the one the invoker passes to the callback AT
+// EXECUTION TIME (RN 0.80: react::CallFunc == std::function), which the invoker runs
+// on the JS thread while that runtime is live. So no captured Runtime& is ever dereferenced off-thread or
+// after teardown. (The previous shape captured the executor's rt2 across the worker; by reference-collapse
+// that bound to the real runtime, but left it exposed to a use-after-free if the JS runtime was torn down
+// mid-round — e.g. an RN instance reload while a long round is in flight; MO-9.)
template
jsi::Value FedLearnCoreModule::runOnWorker(jsi::Runtime& rt, Work work, Build build) {
auto invoker = jsInvoker_;
return react::createPromiseAsJSIValue(
- rt, [this, invoker, work, build](jsi::Runtime& rt2, std::shared_ptr promise) {
+ rt, [this, invoker, work, build](jsi::Runtime& /*rt2*/, std::shared_ptr promise) {
auto done = std::make_shared>(false);
- // The worker thread body touches no module members directly (work/build are self-contained
- // callables; workers_/workersMutex_ are handled in the outer lambda below), so it must NOT
- // capture `this` — RN's -Werror=unused-lambda-capture would reject it, and an unused `this`
- // on a worker only invites accidental use-after-free later.
- std::thread t([&rt2, invoker, promise, work, build, done]() {
+ // The worker captures no jsi::Runtime and no `this`: work/build are self-contained callables and
+ // the result crosses back as a plain C++ value. (An unused `this`/Runtime& on a worker would trip
+ // RN's -Werror=unused-lambda-capture and only invites accidental use-after-free later.)
+ std::thread t([invoker, promise, work, build, done]() {
try {
- auto result = work(); // blocking C++ (touches module state; joined before teardown)
- invoker->invokeAsync([&rt2, promise, build, result]() { promise->resolve(build(rt2, result)); });
+ auto result = work(); // blocking C++ (touches module state via `work`; joined before teardown)
+ // Marshal back onto the JS thread. `jsRt` is supplied by the CallInvoker at execution time on
+ // the JS thread — the runtime is NOT captured across the async boundary.
+ invoker->invokeAsync(
+ [promise, build, result](jsi::Runtime& jsRt) { promise->resolve(build(jsRt, result)); });
} catch (const std::exception& e) {
std::string msg = e.what();
- invoker->invokeAsync([promise, msg]() { promise->reject(msg); });
+ invoker->invokeAsync([promise, msg](jsi::Runtime&) { promise->reject(msg); });
}
done->store(true);
});
diff --git a/wikis/README.md b/wikis/README.md
index 697a1609..d9fe8fe0 100644
--- a/wikis/README.md
+++ b/wikis/README.md
@@ -38,7 +38,7 @@ FedLearn is built around four core components — backend, frontend, framework,
| [**Frontend**](#frontend) | TypeScript (React 19 + Vite) | Web dashboard, real-time monitoring, project management, auth flows |
| [**Framework**](#framework) | Python (PyTorch + gRPC) | Core FL engine — FedAvg, DeComFL, data partitioning, gRPC client/server |
| [**Desktop**](#desktop) | TypeScript (Electron 42) | Local training orchestrator, hardware detection, Docker/PyInstaller execution |
-| [**Mobile**](#mobile) | React Native 0.80 + native C++ (libtorch) | On-device FL client; runs the DeComFL zeroth-order path natively in C++ via a TurboModule bridge (iOS + Android) |
+| [**Mobile**](#mobile) | React Native 0.80 + native C++ (ExecuTorch) | On-device FL client; runs the DeComFL zeroth-order path natively in C++ via a TurboModule bridge (iOS + Android) |
| [**Client (Docker)**](#client-docker) | Docker (multi-arch) | Containerised FL client; thin wrapper around the framework for Jetson / CUDA / CPU deployments |
---
@@ -98,7 +98,7 @@ Backend → validates credentials → issues JWT (HttpOnly cookie or JSON)
### FL Server Provisioning — Local vs. Cloud
```
-Backend (FlowerServerManager)
+Backend (FlServerManager)
│
├── LOCAL MODE
│ ProcessBuilder.start() → python run_server.py --port
@@ -125,15 +125,16 @@ The backend is the central control plane. It owns the REST API, user authenticat
| [Architecture & Core Concepts](./backend/01_architecture_overview.md) | Directory structure, domain models (Projects, Results, Logs), technology stack |
| [Security & Authentication](./backend/02_security_and_auth.md) | Stateless JWT filter chain, WebSocket handshake security, internal API key mechanism |
| [Project Management Lifecycle](./backend/03_project_management.md) | `ProjectService`, `ProjectController`, round configuration, model initialization |
-| [Federated Orchestration](./backend/04_federated_orchestration.md) | `FlowerServerManager` — local `ProcessBuilder` vs. AWS ECS Fargate provisioning |
+| [Federated Orchestration](./backend/04_federated_orchestration.md) | `FlServerManager` — local `ProcessBuilder` vs. AWS ECS Fargate provisioning |
| [WebSocket Log Streaming](./backend/05_websocket_logs_streaming.md) | Stdout capture → STOMP topics → frontend real-time observability |
-| [Identity, Multi-Tenancy & Audit](./backend/06_identity_multitenancy_and_audit.md) | ⚠️ **Designed on a separate identity-foundations branch — not present here.** Organizations + org/project memberships, platform/org/project role model (`PlatformRole` enum), org-scoped data isolation, `@Auditable` audit trail. This branch ships only the coarse `users.role IN (USER, ADMIN)` model. |
+| [Identity, Multi-Tenancy & Audit](./backend/06_identity_multitenancy_and_audit.md) | **Present on this branch** (`V4`–`V7` migrations): organizations + org/project memberships, platform/org/project role model (`PlatformRole` enum), org-scoped data isolation (`OrgScopeFilter`), `@Auditable` audit trail. Supersedes the original coarse `users.role IN (USER, ADMIN)` model. |
+| [Content-Addressed Model Artifact Registry](./backend/07_artifact_registry.md) | The versioned, content-addressed registry (`artifact_blobs` / `model_artifacts` / `artifact_lineage`) that superseded the single overwritable `.npz`; write path, registry-first inference/warm-start read path, HTTP surface, `V12`/`V18` migrations |
**Key cross-component interfaces:**
- Exposes `POST /api/projects/{id}/start` → triggers Framework server spawn.
- Streams logs to Frontend via STOMP topic `/topic/logs/{projectId}`.
- Desktop authenticates against `POST /api/auth/login` before initiating training.
-- Authorization on this branch is a single coarse column — `users.role IN (USER, ADMIN)` (highest committed migration is `V3`). The multi-tenant identity model (organizations, org/project memberships, platform/org/project roles, the `@Auditable` audit trail) is **designed on a separate identity-foundations branch and is not present here** — see the banner on [Identity, Multi-Tenancy & Audit](./backend/06_identity_multitenancy_and_audit.md).
+- Authorization is the layered identity model — `PlatformRole` (platform), `OrgRole` (organization), `MembershipRole` (project) — committed in the `V4`–`V7` migrations (highest committed migration is `V19`). Organizations, org/project memberships, org-scoped isolation, and the `@Auditable` audit trail are all present; see [Identity, Multi-Tenancy & Audit](./backend/06_identity_multitenancy_and_audit.md). The original coarse `users.role IN (USER, ADMIN)` column (`V2`) has been superseded.
---
@@ -163,7 +164,7 @@ The frontend is a single-page application providing the primary web-based contro
> **Path:** [`wikis/framework/`](./framework/README.md)
> **Stack:** Python 3.10+, PyTorch, gRPC / Protocol Buffers — **custom FL engine (no Flower / `flwr`)**
-The framework is the heart of the platform — a standalone Python library (`fedlearn`) that implements the full federated learning lifecycle using gRPC for communication and PyTorch for model training. The `flower` package name on the Java side (`FlowerServerManager`) is historical — there is no Flower/`flwr` dependency anywhere.
+The framework is the heart of the platform — a standalone Python library (`fedlearn`) that implements the full federated learning lifecycle using gRPC for communication and PyTorch for model training. The Java-side orchestration package (`orchestration/`, class `FlServerManager`) was renamed from the legacy `flower` / `FlowerServerManager` name (DA-12) — there is no Flower/`flwr` dependency anywhere.
| Document | Description |
|---|---|
@@ -212,9 +213,9 @@ The desktop application is the local training orchestrator for FL participants.
### Mobile
> **Path:** [`wikis/mobile/`](./mobile/README.md)
-> **Stack:** React Native 0.80, TypeScript, native C++ (libtorch) via a TurboModule bridge, Android + iOS
+> **Stack:** React Native 0.80, TypeScript, native C++ (ExecuTorch) via a TurboModule bridge, Android + iOS
-The mobile client is an on-device FL participant for phones and tablets. The JS/TS layer handles UI, auth, and orchestration; the heavy lifting — the **DeComFL zeroth-order training path** — runs natively in C++ on libtorch through a TurboModule (JSI) bridge, keeping training data on-device. It adopted the **Ember** design system and brand fonts in `2.1.0`.
+The mobile client is an on-device FL participant for phones and tablets. The JS/TS layer handles UI, auth, and orchestration; the heavy lifting — the **DeComFL zeroth-order training path** — runs natively in C++ on ExecuTorch through a TurboModule (JSI) bridge, keeping training data on-device. It adopted the **Ember** design system and brand fonts in `2.1.0`.
| Document | Description |
|---|---|
@@ -301,7 +302,8 @@ wikis/ ← repo-root docs (promoted out of docs/)
│ ├── 03_project_management.md
│ ├── 04_federated_orchestration.md
│ ├── 05_websocket_logs_streaming.md
-│ └── 06_identity_multitenancy_and_audit.md ← designed; not on this branch
+│ ├── 06_identity_multitenancy_and_audit.md ← present on this branch (V4–V7 migrations)
+│ └── 07_artifact_registry.md
│
├── frontend/ ← React 19 SPA
│ ├── README.md
diff --git a/wikis/backend/01_architecture_overview.md b/wikis/backend/01_architecture_overview.md
index 2a7d9c43..7a8e6a65 100644
--- a/wikis/backend/01_architecture_overview.md
+++ b/wikis/backend/01_architecture_overview.md
@@ -8,11 +8,7 @@ The FedLearn backend is designed as an **Orchestration API**. It does not perfor
The backend acts as the central control plane connecting the React web interface to the distributed Python FL ecosystem.
-> ⚠️ **Branch reality.** Two parts of this overview describe designed-but-not-yet-committed work:
-> 1. **Database** — the backend runs on **PostgreSQL** for every profile (H2 has been retired): `dev`/`ec2demo` against a local Postgres (`backend/fl-platform-api/docker-compose.yml` → `docker compose up -d`), `test` against Testcontainers Postgres (`jdbc:tc:postgresql:16.6-alpine`), and deployed envs override `SPRING_DATASOURCE_*`. The highest committed Flyway migration is **`V19`**.
-> 2. **Identity / multi-tenancy / audit** — the `audit/`, `bootstrap/`, and `email/` packages, the `Organization` / `OrganizationMembership` / `ProjectMembership` / `ProjectAccessRequest` / `AuditEvent` entities, `PlatformRole` / `OrgScope` / `AuthorizationService`, and the `V4`–`V6` migrations live on a **separate identity-foundations branch and are _not present_ on this branch.** This branch ships only `users.role IN (USER, ADMIN)` (migration `V2`). See [06 - Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md).
->
-> The orchestration (`flower/`), project, results, logging, security-filter, config, controller, and DTO machinery described below is current.
+> ✅ **Branch reality.** The backend runs on **PostgreSQL** for every profile (H2 has been retired): `dev`/`ec2demo` against a local Postgres (`backend/fl-platform-api/docker-compose.yml` → `docker compose up -d`), `test` against Testcontainers Postgres (`jdbc:tc:postgresql:16.6-alpine`), and deployed envs override `SPRING_DATASOURCE_*`. The highest committed Flyway migration is **`V19`**. The full **identity / multi-tenancy / audit subsystem IS present**: the `audit/`, `bootstrap/`, and `email/` packages, the `Organization` / `OrganizationMembership` / `ProjectMembership` / `ProjectAccessRequest` / `AuditEvent` entities, `PlatformRole` / `OrgScopeFilter` / `AuthorizationService`, and the `V4`–`V7` identity migrations (the original `users.role IN (USER, ADMIN)` from `V2` has been superseded by the layered `PlatformRole`). See [06 - Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md). The orchestration package (`orchestration/`, renamed from the legacy `flower/` — DA-12), project, results, logging, security-filter, config, controller, and DTO machinery described below is current.
### Tech Stack
* **Language:** Java 21
@@ -37,8 +33,8 @@ The source code is located at `backend/fl-platform-api/src/main/java/com/federat
| `dto/` | Data Transfer Objects. POJOs used to decouple the external JSON payloads from the internal JPA entities. |
| `email/` | Pluggable email layer. The `EmailService` interface with a `LoggingEmailService` (dev) and `SmtpEmailService` (prod) adapter, selected by `EmailConfig` on `app.email.provider`. |
| `exception/` | Custom runtime exceptions and the `@ControllerAdvice` global exception handler that translates them into standardized HTTP responses. |
-| `flower/` | The core orchestration layer. Contains `FlowerServerManager` which interfaces with AWS or local processes to spawn the ML servers. |
-| `model/` | JPA Entities defining the database schema. On this branch: `Project.java`, `User.java`, `RoundResult.java`, `ServerLog.java`. The identity entities (`Organization.java`, `AuditEvent.java`, …) belong to the designed identity-foundations branch — see the banner above. |
+| `orchestration/` | The core orchestration layer (renamed from the legacy `flower/` — DA-12). Contains `FlServerManager` which interfaces with AWS or local processes to spawn the ML servers. |
+| `model/` | JPA Entities defining the database schema: `Project.java`, `User.java`, `RoundResult.java`, `ServerLog.java`, plus the present identity entities (`Organization.java`, `OrganizationMembership.java`, `ProjectMembership.java`, `ProjectAccessRequest.java`, `AuditEvent.java`). |
| `repository/` | Spring Data JPA interfaces extending `JpaRepository` for database access. |
| `security/` | JWT generation, API Key filters, WebSocket handshake interceptors, the request-scoped `OrgScope`/`OrgScopeFilter` multi-tenant gate, and the login auditing success/failure handlers. |
| `service/` | Business logic layer. Controllers delegate to services (like `ProjectService`) to handle complex operations and transactional boundaries. `AuthorizationService` centralises the role/org-scope checks. |
@@ -47,7 +43,7 @@ The source code is located at `backend/fl-platform-api/src/main/java/com/federat
## 3. Core Domain Models (JPA Entities)
-The database is organized around the following core entities (the identity/multi-tenancy entities below belong to the designed identity-foundations branch — see the banner above):
+The database is organized around the following core entities (the identity/multi-tenancy entities below are present on this branch — see the banner above):
### `User`
Represents an authenticated platform user.
@@ -88,6 +84,11 @@ Persistent storage for stdout logs generated by the Python FL Server.
1. **User Action:** A user clicks "Start Project" on the React dashboard.
2. **REST Request:** The React app sends an HTTP POST to the Spring Boot `ProjectController` with a valid JWT.
3. **Service Logic:** `ProjectService` validates ownership and asks the `ModelInitializer` to build a local `.npz` weights file.
-4. **Orchestration:** `FlowerServerManager` provisions a Python FL Server (either locally via `ProcessBuilder` or on AWS Fargate).
+4. **Orchestration:** `FlServerManager` provisions a Python FL Server (either locally via `ProcessBuilder` or on AWS Fargate).
5. **Real-time Observability:** The Python FL Server streams its logs back to Spring Boot. `WebSocketService` intercepts these logs, saves them to the `server_logs` table, and broadcasts them via STOMP to the React dashboard.
6. **Results Storage:** As the Python FL Server completes training rounds, it sends POST requests to Spring Boot's internal endpoints (secured by API Key) to save `RoundResult` data.
+
+> This `.npz` file (step 3) is the project's *initial*, pre-training architecture — that mechanic is
+> unchanged and current. What is **not** current is treating this file as the only place a *trained*
+> model ever lives: on run completion the final model is additionally registered as a versioned,
+> content-addressed artifact. See [07 - Content-Addressed Model Artifact Registry](07_artifact_registry.md).
diff --git a/wikis/backend/02_security_and_auth.md b/wikis/backend/02_security_and_auth.md
index 8dc1f7a4..3587398d 100644
--- a/wikis/backend/02_security_and_auth.md
+++ b/wikis/backend/02_security_and_auth.md
@@ -2,7 +2,7 @@
The FedLearn backend implements a robust, multi-layered security architecture designed to handle both standard REST API clients (React) and internal Machine Learning servers (Python).
-> ⚠️ **Branch reality.** The JWT / cookie / WebSocket-handshake / internal-API-key mechanics on this page are **current**. The **role and org-scope material** — `PlatformRole` / `PLATFORM_ADMIN`, the three role layers, `OrgScope` / `OrgScopeFilter`, `organization_memberships`, and the `@Auditable` audit trail — is **designed on a separate identity-foundations branch and is _not present_ here.** This branch ships a single coarse `users.role IN (USER, ADMIN)` column (migration `V2`; highest committed migration `V3`). See the banner on [06 - Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md).
+> ✅ **Branch reality.** The JWT / cookie / WebSocket-handshake / internal-API-key mechanics on this page are **current**. The **role and org-scope material** — `PlatformRole` / `PLATFORM_ADMIN`, the three role layers, `OrgScope` / `OrgScopeFilter`, `organization_memberships`, and the `@Auditable` audit trail — **is also present on this branch** (the `V4`–`V7` identity migrations; the coarse `users.role IN (USER, ADMIN)` column from `V2` has been superseded). See [06 - Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md).
## 1. REST API Security (JWT)
@@ -103,7 +103,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
}
```
-The Spring Boot backend securely passes this `FEDLEARN_INTERNAL_API_KEY` to the Python server via environment variables during the orchestration phase (see `FlowerServerManager`), ensuring that only backend-spawned ML processes can hit the internal endpoints.
+The Spring Boot backend securely passes this `FEDLEARN_INTERNAL_API_KEY` to the Python server via environment variables during the orchestration phase (see `FlServerManager`), ensuring that only backend-spawned ML processes can hit the internal endpoints.
---
diff --git a/wikis/backend/03_project_management.md b/wikis/backend/03_project_management.md
index e73f0399..f5e884ad 100644
--- a/wikis/backend/03_project_management.md
+++ b/wikis/backend/03_project_management.md
@@ -2,7 +2,7 @@
This document explains how a Federated Learning Project is created, initialized, and managed within the Spring Boot backend.
-> ⚠️ **Branch reality.** The project CRUD, model initialization, and FL-server start/stop flows on this page are **current**. The **org-scoping and audit layers** described here — `projects.org_id` (the V5 migration), `authz.requireOrgScope(...)`, `OrgScope`, `isPlatformAdmin()`, and the `@Auditable` annotations — are **designed on a separate identity-foundations branch and are _not present_ here.** On this branch a project is owned by a `User` with no org pinning, and authorization is the coarse `users.role IN (USER, ADMIN)` model. See [06 - Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md).
+> ✅ **Branch reality.** The project CRUD, model initialization, and FL-server start/stop flows on this page are **current**. The **org-scoping and audit layers** described here — `projects.org_id` (the `V5` migration), `AuthorizationService`, `OrgScopeFilter`, `isPlatformAdmin()`, and the `@Auditable` annotations — **are also present on this branch** (the `V4`–`V7` identity migrations). A project is owned by a `User` and pinned to an org via `projects.org_id`. See [06 - Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md).
## 1. Project Creation Flow
@@ -25,6 +25,13 @@ This service method is annotated with `@Transactional`. If any step fails, the e
4. **Model Initialization:** `ModelInitializer.initializeModelFile()` is invoked. It executes a local Python script (`run_init_model.sh`) that constructs the initial model architecture (PyTorch) based on the `modelType` and saves it to the `.npz` file.
5. **Finalize DB Entry:** The `Project` entity is updated with the absolute path to the `.npz` file and its status is set to `CREATED`.
+> Steps 3–5 describe the project's *initial* model file only, written once at creation. They predate,
+> and are unrelated to, the content-addressed model artifact registry that now also records every run's
+> *trained* output as a versioned, provenance-tracked row (`model_artifacts`) rather than treating this
+> `.npz` as the sole, overwritable record. See [07 - Content-Addressed Model Artifact Registry](07_artifact_registry.md)
+> for the write path (registration on run completion) and the read path (inference/warm-start now prefer
+> the registry over this file when an artifact exists).
+
`createProject` is also annotated `@Auditable(action = PROJECT_CREATED)`, so a successful creation writes an `audit_events` row in the same transaction (see [06 - Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md)).
## 2. Project Ownership, Membership, and Org Isolation
@@ -86,7 +93,7 @@ The `POST /api/projects/{projectId}/start` endpoint kicks off the machine learni
1. The user specifies the training `strategy` (e.g., `FedAvg`), the `minClients` required, and the `numRounds`.
2. The `ProjectService` enforces org-scope then ownership (`requireOrgScope` → `requireOwnerOrAdmin`) and ensures the server isn't already running. `startServerForProject` is `@Auditable(action = RUN_STARTED)`; `stopServerForProject` is `@Auditable(action = RUN_STOPPED)`.
-3. It calls `FlowerServerManager.startServerForProject(...)`. (See [04 - Federated Orchestration](04_federated_orchestration.md) for full details on this component).
+3. It calls `FlServerManager.startServerForProject(...)`. (See [04 - Federated Orchestration](04_federated_orchestration.md) for full details on this component).
4. The backend updates the project's status to `RUNNING` and saves the network `serverPort` where the FL Server is listening.
5. A real-time `ProjectStatusUpdateDto` is fired over WebSockets to instantly update the React dashboard UI.
@@ -99,5 +106,5 @@ When the FL Server successfully finishes all its federated rounds, it sends a fi
### Deletion
When a user deletes a project (`DELETE /api/projects/{projectId}`):
1. The service enforces org-scope (`requireOrgScope`) then ownership (`requireOwnerOrAdmin`). `deleteProject` is `@Auditable(action = PROJECT_DELETED)`.
-2. It makes a best-effort attempt to terminate any actively running FL Server processes via `FlowerServerManager.stopServerForProject()`. This prevents ghost processes or orphaned AWS Fargate tasks from lingering and consuming resources.
+2. It makes a best-effort attempt to terminate any actively running FL Server processes via `FlServerManager.stopServerForProject()`. This prevents ghost processes or orphaned AWS Fargate tasks from lingering and consuming resources.
3. The database row is deleted. Cascade rules automatically delete the associated `ServerLog`, `RoundResult`, `ProjectMembership`, and `ProjectAccessRequest` entries.
diff --git a/wikis/backend/04_federated_orchestration.md b/wikis/backend/04_federated_orchestration.md
index 655e8fb0..7a68d607 100644
--- a/wikis/backend/04_federated_orchestration.md
+++ b/wikis/backend/04_federated_orchestration.md
@@ -1,12 +1,12 @@
-# 04 - Federated Orchestration (FlowerServerManager)
+# 04 - Federated Orchestration (FlServerManager)
-The `FlowerServerManager` is the most operationally complex class in the Spring Boot backend. It is responsible for bridging the gap between the stateless Java REST API and the stateful, heavily computational Python Federated Learning servers.
+The `FlServerManager` is the most operationally complex class in the Spring Boot backend. It is responsible for bridging the gap between the stateless Java REST API and the stateful, heavily computational Python Federated Learning servers.
The application supports two distinct execution paths, determined entirely by the presence of AWS configuration properties (`ecs.cluster-name`, `ecs.task-definition`, etc.) in the `application.properties`.
## Path A: Local Execution (ProcessBuilder)
-When AWS configurations are not present, the `FlowerServerManager` falls back to local execution. This is primarily used for development, testing on a Macbook, or bare-metal deployments (like the RIT lab environment).
+When AWS configurations are not present, the `FlServerManager` falls back to local execution. This is primarily used for development, testing on a Macbook, or bare-metal deployments (like the RIT lab environment).
### Process Lifecycle
1. **Port Allocation:** The manager asks the host OS kernel for an ephemeral port via `new ServerSocket(0)`.
@@ -28,7 +28,7 @@ Every line read is passed to the `WebSocketService` to be broadcast to the UI. I
When the backend is deployed to a production environment (like AWS), local process spawning is disabled. A Spring Boot container running behind an Application Load Balancer cannot spawn heavily computational Python tasks inside itself—it would cause OOM kills, and the dynamically allocated ports would be inaccessible from the public internet.
-Instead, the `FlowerServerManager` utilizes the AWS SDK (`EcsClient`) to orchestrate infrastructure-level task provisioning.
+Instead, the `FlServerManager` utilizes the AWS SDK (`EcsClient`) to orchestrate infrastructure-level task provisioning.
### ECS `RunTaskRequest`
The manager dynamically builds a `RunTaskRequest`. It targets a serverless AWS Fargate cluster.
diff --git a/wikis/backend/06_identity_multitenancy_and_audit.md b/wikis/backend/06_identity_multitenancy_and_audit.md
index 28af1490..0bb5212e 100644
--- a/wikis/backend/06_identity_multitenancy_and_audit.md
+++ b/wikis/backend/06_identity_multitenancy_and_audit.md
@@ -7,24 +7,26 @@ plumbing that seeds the first administrator. It is the deepest part of the
backend's authorization story; the JWT/cookie/WebSocket mechanics that establish
*who* the caller is live in [02 - Security and Authentication](02_security_and_auth.md).
-> ⚠️ **Branch reality (read this first).** This entire subsystem is **designed on a
-> separate identity-foundations branch and is _not present_ on the current branch
-> (`feat/ember-rebrand`).** On this branch, authorization is a single coarse column —
-> `users.role IN (USER, ADMIN)` (added by migration `V2`); `users.id` is `BIGINT`,
-> `projects.id` is `UUID`, and the **highest committed Flyway migration is `V3`**.
-> There are **no `V4`/`V5`/`V6` migrations and no corresponding Java** here: the
-> three-layer platform/org/project role model, `organization_memberships` /
-> `project_memberships`, `users.platform_role` / `PLATFORM_ADMIN`, `projects.org_id`,
-> the `audit_events` table + `@Auditable` / `AuditAction` aspect, the `EmailService`
-> stack, and the `APP_BOOTSTRAP_ADMIN_*` bootstrap **do not exist on this branch**.
-> Everything below documents that **designed** system for reference — it is not what
-> is currently committed here.
+> ✅ **Branch reality (read this first).** This entire subsystem **IS present and
+> committed on this branch.** Authorization layers the original single-user model:
+> `users.id` is `BIGINT`, `projects.id` is `UUID`, and the **highest committed Flyway
+> migration is `V19`**. The identity foundations landed in **`V4`–`V7`**
+> (`V5__identity_foundations.sql`, `V6__identity_hardening.sql`,
+> `V7__owner_role_and_approval_workflows.sql`): the three-layer platform/org/project
+> role model, `organization_memberships` / `project_memberships`,
+> `users.platform_role` / `PLATFORM_ADMIN`, `projects.org_id`, the `audit_events`
+> table + `@Auditable` / `AuditAction` aspect, the `EmailService` stack, and the
+> `APP_BOOTSTRAP_ADMIN_*` bootstrap **all exist here** (`PlatformRole`, `OrgRole`,
+> `OrgScopeFilter`, `AuthorizationService`, and the membership/audit repositories are
+> all under `com.federated.fl_platform_api`). The single coarse `users.role IN (USER,
+> ADMIN)` column from `V2` was the original model and has since been superseded.
>
-> On the identity-foundations branch the subsystem is **backend-first**: the
-> membership/admin/access-request/user-search/client endpoints are enforced on the
-> server, but the web/desktop RBAC UI is deferred (those clients ship the **Ember**
-> design system unchanged and do not yet surface organizations, memberships, or the
-> admin console).
+> The subsystem is enforced on the **backend** (the membership / admin /
+> access-request / user-search / client endpoints) AND surfaced in the **frontend**:
+> role-gated routes (`RoleRoute allow={['PLATFORM_ADMIN']}` / `['PROJECT_OWNER', …]`),
+> the `AdminDashboard` / `OwnerDashboard` / `ClientDashboard`, and the
+> owner-promotion / deletion-request approval flows. The clients ship the **Ember**
+> design system.
---
diff --git a/wikis/backend/07_artifact_registry.md b/wikis/backend/07_artifact_registry.md
new file mode 100644
index 00000000..9b1066ce
--- /dev/null
+++ b/wikis/backend/07_artifact_registry.md
@@ -0,0 +1,290 @@
+# 07 - Content-Addressed Model Artifact Registry
+
+This page documents the model artifact registry — the subsystem that replaced the platform's original
+"one overwritable `.npz` at `projects.model_path`" design with a versioned, content-addressed,
+provenance-tracked store. It shipped in slices tagged `DA-1` through `DA-9`, `BA-11`, `SE-11`, and
+`FE-12` in the code; this page describes the shipped result, not the roadmap.
+
+> **What changed, in one line.** Before this subsystem, `Project.modelPath` (a single mutable file
+> path, `backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/model/Project.java:34`)
+> was the only record of a project's model — each completed run overwrote it, with no history, no
+> dedup, and no way to say "this LoRA adapter was trained over that base." The registry adds a second,
+> parallel source of truth: an immutable, sha256-addressed blob store plus an append-only provenance
+> table with a lineage DAG. `projects.model_path` is **not removed** — it is still written every round
+> as the training-loop's working file — but reads (inference, warm-start) now prefer the registry when
+> an artifact exists, and a run's *final* model is additionally registered as a durable, listable row.
+> See "Retiring the `.npz`-overwrite gap" below for exactly what is superseded and what still legitimately
+> uses `.npz`.
+
+## 1. The data model
+
+Four tables/entities, split so identical bytes dedup independently of who produced them:
+
+| Concept | Type | Table | Source |
+|---|---|---|---|
+| Immutable blob | `ArtifactBlob` | `artifact_blobs` (sha256 PK) | `backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/model/ArtifactBlob.java:13` |
+| Provenance record | `ModelArtifact` | `model_artifacts` (UUID PK) | `backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/model/ModelArtifact.java:18` |
+| Lineage edge | `ArtifactLineage` | `artifact_lineage` (UUID PK) | `backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/model/ArtifactLineage.java:15` |
+| What an artifact *is* | `ArtifactKind` enum | — | `backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/model/ArtifactKind.java:7` |
+
+**`ArtifactBlob`** (`artifact_blobs`) is keyed by the lowercase-hex sha256 of its bytes
+(`ArtifactBlob.java:16-18`) and carries no tenant or provenance semantics — `sizeBytes` and a `backend`
+discriminator (`'LOCAL_FS' | 'S3'`, `ArtifactBlob.java:23-25`) only. Identical bytes from any org or run
+collapse to one row (`backend/fl-platform-api/src/main/resources/db/migration/V12__model_artifact_registry.sql:25-31`).
+
+**`ModelArtifact`** (`model_artifacts`) is the per-org, per-run provenance row that points at a blob by
+`blobSha256` — deliberately **not unique** (`ModelArtifact.java:29-30`), so two orgs or two runs can
+record the same bytes as distinct provenance rows over one deduplicated blob
+(`model/ModelArtifact.java:10-11`; schema comment at `V12__model_artifact_registry.sql:10-14`). Key
+columns: `orgId` (NOT NULL, tenant pin), `kind` (`ArtifactKind`), nullable `projectId`/`runId` (FK
+`ON DELETE SET NULL` — an artifact outlives its producer, `ModelArtifact.java:36-42`,
+`V12__model_artifact_registry.sql:38-39`), `recipeKey`, `baseModelRef`, `licenseTag`, `evalCardJson`
+(freeform eval-card JSON as TEXT), and `published`/`publishedAt` (added by `V18`, marketplace-only —
+see §4). Rows are never updated in place: "a new model is a new row" (`ModelArtifact.java:14`).
+
+**`ArtifactLineage`** (`artifact_lineage`) is a directed `child → parent` edge under a
+`relationship` (`LineageRelationship` — `ADAPTER_OF`, `DERIVED_FROM`, `CONTINUED_FROM`;
+`backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/model/LineageRelationship.java`).
+`UNIQUE(child_id, parent_id, relationship)` plus a `CHECK (child_id <> parent_id)` forbid duplicate
+edges and self-loops (`V12__model_artifact_registry.sql:57-65`); FKs to `model_artifacts` are
+`ON DELETE RESTRICT` so an edge never dangles (lineage rows are as append-only as the artifacts they
+connect).
+
+**`ArtifactKind`** (`ArtifactKind.java:7-14`) has exactly three values:
+
+| Kind | Meaning | Lineage wired on register |
+|---|---|---|
+| `FULL_CHECKPOINT` | A complete model checkpoint (imaging CNN, etc.) — the air-gap/export unit | `CONTINUED_FROM` the project's prior `FULL_CHECKPOINT` head, if one exists |
+| `LORA_ADAPTER` | A federated LoRA/PEFT adapter over a frozen base — the tradable marketplace unit | `ADAPTER_OF` a deduped `BASE_REF`, plus `CONTINUED_FROM` the prior `LORA_ADAPTER` head, if any |
+| `BASE_REF` | A reference to a frozen base model an org hosts/uses — many orgs may share one blob | none (it *is* a lineage root) |
+
+A `BASE_REF`'s "content" is a small JSON reference manifest (`{"base_model_ref": ..., "license": ...}`),
+**not** the base model's weights, which live upstream — it exists purely so an adapter has something
+content-addressed to point `ADAPTER_OF` at, and so the same base dedups across orgs at the blob layer
+(`backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/service/ArtifactRegistryService.java:142-160`).
+`findOrCreateBaseRef` is a **private** helper of `ArtifactRegistryService`, not a standalone public API —
+it is invoked only from inside `register()` when a `LORA_ADAPTER` is registered
+(`ArtifactRegistryService.java:97-98`).
+
+## 2. Storage: `ArtifactBlobStore`
+
+The blob store is a small interface (`put`/`get`/`exists`/`backendId`,
+`backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/service/ArtifactBlobStore.java:11-24`)
+with one implementation, `LocalFsArtifactBlobStore`
+(`backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/service/LocalFsArtifactBlobStore.java:22`):
+
+- **Content IS the key.** `put(byte[])` computes the sha256 of the bytes itself — a caller can never
+ choose or spoof the key (`LocalFsArtifactBlobStore.java:37-38`).
+- **Write-once, idempotent.** If the target path already exists, `put` is a no-op success — writing
+ identical bytes twice is not an error (`LocalFsArtifactBlobStore.java:40-42`).
+- **Two-level fan-out.** Blobs live at `root///<64-hex-sha256>`
+ (`LocalFsArtifactBlobStore.java:86-89`), avoiding one giant flat directory.
+- **Atomic write.** Content goes to a temp file in the same directory, then `Files.move(..., ATOMIC_MOVE)`
+ — a crash mid-write can never leave a partial blob at the content-addressed path
+ (`LocalFsArtifactBlobStore.java:43-54`). A losing writer in a write race just discards its temp file,
+ since the winner already wrote identical bytes (`LocalFsArtifactBlobStore.java:49-50`).
+- **Integrity-checked on every read.** `get` recomputes the sha256 of the bytes it read and throws
+ `IllegalStateException` if it doesn't match the requested key — bit-rot or a swapped file fails loud
+ rather than silently serving the wrong weights under the right id
+ (`LocalFsArtifactBlobStore.java:70-78`).
+
+Configured root: `app.artifact-store.root` (default `artifact-store`,
+`backend/fl-platform-api/src/main/resources/application.properties:190`).
+
+## 3. Write path — registering a run's final model
+
+```
+fl_server.py (run completes)
+ │ _register_model_artifact() / _emit_and_register_lora_bundle()
+ │ POST /api/internal/projects/{projectId}/artifacts
+ │ multipart: model bytes + kind + recipeKey + baseModelRef? + licenseTag? + evalCard?
+ ▼
+InternalArtifactController (X-Internal-Key gated, backend/.../controller/InternalArtifactController.java:34)
+ │ registry.registerForProject(projectId, bytes, kind, ...)
+ ▼
+ArtifactRegistryService.registerForProject (resolves project -> orgId, activeRunId)
+ │
+ ├─ ArtifactRegistryService.register()
+ │ ├─ SE-11 gate: requireAccountantTraceForDpClaim(evalCardJson) [may throw 400]
+ │ ├─ look up the project's PRIOR head of this kind (pre-insert)
+ │ ├─ blobStore.put(bytes) -> sha256 ──▶ artifact_blobs (ArtifactBlobStore)
+ │ ├─ INSERT model_artifacts row ──▶ model_artifacts
+ │ ├─ if LORA_ADAPTER: findOrCreateBaseRef(...) ──▶ artifact_lineage (ADAPTER_OF -> BASE_REF)
+ │ └─ if a prior head existed ──▶ artifact_lineage (CONTINUED_FROM -> prior head)
+ ▼
+201 { "id": , "sha256": }
+```
+
+(`ArtifactRegistryService.java:65-105`; the internal endpoint itself is
+`backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/InternalArtifactController.java:34-50`.)
+
+Two Python-side call sites feed this endpoint (`backend/fl-platform-api/src/main/resources/scripts/fl_server.py`):
+
+- **Non-LoRA recipes** (`FULL_CHECKPOINT`): `_register_model_artifact` posts the run's `--model-path`
+ `.npz` bytes directly (`fl_server.py:85-119`, called from the final-save block at `fl_server.py:1007-1012`)
+ — a full checkpoint's wire format *is* the imaging air-gap `.npz` by design (see
+ `framework/src/fedlearn/bundle/BUNDLE_FORMAT.md`, "Serialization"), so registering those exact bytes
+ is correct, not a gap.
+- **`LLM_LORA` recipes**: `_emit_and_register_lora_bundle` serializes the adapter as safetensors
+ (`adapter_to_safetensors`), builds a versioned bundle manifest whose `artifact_sha256` is the hash of
+ those exact bytes, and registers **the safetensors bytes** — not the `.npz`
+ (`fl_server.py:122-147`; DA-9 bullet 3, see `framework/src/fedlearn/bundle/BUNDLE_FORMAT.md`). On any
+ failure building the bundle it falls back to registering the `.npz` so the run is still recorded
+ (`fl_server.py:143-146`).
+
+Registration is **additive and non-fatal**: the legacy `projects.model_path` `.npz` write happens
+first and is unconditional; the registry POST is wrapped in a broad `try/except` that only logs on
+failure (`fl_server.py:105-119`) — a registry outage can never abort a real federated run.
+
+An eval card is attached at registration time, built from the run's own history/strategy
+(`build_eval_card`, `fl_server.py:150-185`). If the strategy ran differential privacy, `SE-11` requires
+the card's `dp.accounted_epsilon`/`dp.delta` to be present and numeric before the registry will accept a
+`dp.enabled: true` claim — an unaccounted DP claim is rejected with `IllegalArgumentException` → 400
+(`ArtifactRegistryService.java:162-198`).
+
+## 4. Read path — inference and FL-server warm-start (BA-11)
+
+`RegistryModelResolver` (`backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/service/RegistryModelResolver.java:32`)
+is the shared bean two otherwise-unrelated callers both depend on — it exists as a separate bean
+specifically because `FlServerManager` cannot depend on `ProjectService` (Spring rejects the
+resulting circular reference; `RegistryModelResolver.java:20-24`):
+
+```
+ProjectService.resolveInferenceTarget(projectId) FlServerManager.startLocalServer(project, ...)
+ │ │
+ └───────────────────────┬──────────────────────────────────┘
+ ▼
+ RegistryModelResolver.resolveModelPath(project)
+ │
+ 1. headArtifact(project) — skip entirely for "LLM_LORA"
+ (safetensors head; .npz reader can't parse it) -> Optional.empty()
+ 2. else: ModelArtifactRepository
+ .findFirstByProjectIdAndKindOrderByCreatedAtDesc(
+ projectId, FULL_CHECKPOINT) -> the project's current head, or empty
+ 3. if a head exists: materializeBlob(head.blobSha256)
+ - cache hit? /.npz already present -> reuse, done
+ - else: blobStore.get(sha256) [integrity-checked; THROWS on mismatch/IO error]
+ write temp file -> atomic rename to .npz
+ │
+ ▼
+ Optional localFilesystemPath
+```
+
+(`RegistryModelResolver.java:47-105`; call sites at
+`backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/service/ProjectService.java:733`
+and `backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/orchestration/FlServerManager.java:165`.)
+
+Both callers fall back to the legacy `.npz` path (`project.getModelPath()`) when the resolver returns
+`Optional.empty()` — a project with no registry artifact yet (pre-registry data, or a project that
+never finished a run) or a LoRA project. Critically, **a fallback only fires on "no artifact"**, never
+on "artifact unreadable": if a registry blob exists but fails to read or fails its integrity check,
+`ArtifactBlobStore.get` throws unchecked and that exception is allowed to propagate out of
+`resolveModelPath`'s caller in `ProjectService` (inference), while `FlServerManager`'s warm-start
+call site catches only the narrower `IOException` from the local cache write and logs+falls back for
+that specific failure (`RegistryModelResolver.java:65-71`). The intent, stated directly in the code, is
+fail-loud: a corrupt or unreadable registry head must never be silently masked by the `.npz` fallback,
+because that fallback is supposed to mean "no artifact", not "artifact unreadable"
+(`RegistryModelResolver.java:56-58`).
+
+Cache config: `app.model-blob-cache.dir` (default `models/blob-cache`,
+`application.properties:194`). The materialized file is always named `.npz` regardless of the
+artifact's original bytes' internal format — accurate for `FULL_CHECKPOINT` (whose registered bytes
+already are a `.npz`), and moot for `LLM_LORA` (which this resolver never returns a path for at all,
+per step 1 above).
+
+## 5. HTTP surface
+
+| Method & path | Controller | Auth | Purpose |
+|---|---|---|---|
+| `GET /api/artifacts?projectId=` | `ArtifactController.list` | Session, org-scoped (filtered, never leaks) | The project's artifacts the caller may see, newest first |
+| `GET /api/artifacts/{id}` | `ArtifactController.get` | Session, org-scoped (404 on cross-org) | Artifact metadata (`ArtifactDto`, incl. `blobSha256`) |
+| `GET /api/artifacts/{id}/blob` | `ArtifactController.blob` | Session, org-scoped (404 on cross-org) | The immutable bytes; integrity-checked on read; sha256 echoed as a strong ETag |
+| `GET /api/artifacts/latest?projectId=&kind=` | `ArtifactController.latest` | Session, org-scoped | The project's current head artifact of `kind` (default `FULL_CHECKPOINT`) |
+| `GET /api/artifacts/{id}/lineage` | `ArtifactLineageController.lineage` | Session, org-scoped (404 on cross-org) | The provenance chain, base → … → the artifact |
+| `POST /api/internal/projects/{id}/artifacts` | `InternalArtifactController.registerArtifact` | `X-Internal-Key` (service-to-service only) | `fl_server.py`'s registration callback |
+| `GET /api/marketplace/adapters` | `MarketplaceController.browse` | Session, org-scoped | Published `LORA_ADAPTER`s the caller's orgs can see, newest-published first |
+| `POST /api/marketplace/adapters/{id}/publish` | `MarketplaceController.publish` | Session, owner-or-admin | Publish a `LORA_ADAPTER` to the org marketplace (`FE-12`) |
+| `DELETE /api/marketplace/adapters/{id}/publish` | `MarketplaceController.unpublish` | Session, owner-or-admin | Withdraw |
+
+Sources: `backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/controller/ArtifactController.java:37-106`,
+`.../controller/ArtifactLineageController.java:24-61`,
+`.../controller/InternalArtifactController.java:24-51`,
+`.../controller/MarketplaceController.java:28-52`.
+
+The read-side controllers share one rule: a cross-org id is always a **404**, never a 403, so existence
+never leaks (`ArtifactController.java:33-35`, `ArtifactLineageController.java:42-43`). The internal
+endpoint sits behind `InternalApiKeyFilter`, which gates all of `/api/internal/**` on the shared
+`X-Internal-Key` header and rejects with 401 if it's absent, mismatched, or unconfigured
+(`backend/fl-platform-api/src/main/java/com/federated/fl_platform_api/security/InternalApiKeyFilter.java:20-29`).
+
+## 6. Lineage traversal
+
+`ArtifactRegistryService.getLineageChain(artifactId)` walks `ArtifactLineage.findByChildId` recursively
+toward parents, then returns the visited set in post-order (parents before children) using a
+`LinkedHashMap` for stable ordering and a `seen` set for cycle-safety
+(`ArtifactRegistryService.java:125-140`). `ArtifactLineageController` exposes this at
+`GET /api/artifacts/{id}/lineage` as a flat list of `{id, kind, sha256, baseModelRef, licenseTag,
+createdAt}` (`ArtifactLineageController.java:39-60`) — e.g. for a continued LoRA run, the chain would
+read `BASE_REF → LORA_ADAPTER(round 1) → LORA_ADAPTER(round 2)`.
+
+## 7. Flyway migrations
+
+| Migration | Adds |
+|---|---|
+| `V12__model_artifact_registry.sql` | `artifact_blobs`, `model_artifacts`, `artifact_lineage` — the keystone (`DA-1`) |
+| `V18__artifact_marketplace_publish.sql` | `model_artifacts.published` / `published_at` + a `(org_id, kind, published)` index for the marketplace feed (`FE-12`) |
+
+`V12`'s own header comment states the intent this page documents: it "replaces the 'one overwritable
+`.npz` at `projects.model_path`' model with a versioned, content-addressed, lineage-tracked registry"
+while leaving `projects.model_path` "intentionally... untouched (legacy writers still use it)"
+(`backend/fl-platform-api/src/main/resources/db/migration/V12__model_artifact_registry.sql:1-20`).
+
+## 8. Related: the adapter bundle format (DA-9)
+
+A `LORA_ADAPTER`'s registered bytes are also packaged as a versioned "bundle" for mobile/marketplace
+delivery — safetensors payload + a JSON manifest whose `artifact_sha256` is, by construction, the exact
+same content hash the registry stores (`framework/src/fedlearn/bundle/BUNDLE_FORMAT.md`). See that file
+for the manifest schema; this page only tracks where its "Fixture-MVP boundary" section needed a
+correction (below).
+
+## 9. Retiring the `.npz`-overwrite gap
+
+Older material (design comments, wiki prose, and one bundle-format doc) described or assumed a single
+mutable `.npz`, overwritten every round, as the platform's *only* model store — no history, no dedup,
+no way to express "this adapter came from that base." That gap is closed by the registry described
+above. Concretely, in this pass:
+
+- **`framework/src/fedlearn/bundle/BUNDLE_FORMAT.md`** ("Fixture-MVP boundary" section) stated, as a
+ present-tense fact, that "`fl_server.py` currently registers the legacy `.npz` bytes" and listed
+ "register the safetensors artifact bytes" as an open follow-on. That is now **stale for the LoRA
+ path**: `_emit_and_register_lora_bundle` (landed after that doc was written; see `fl_server.py:122-147`)
+ already serializes the adapter to safetensors and registers *those* bytes, not the `.npz`. It remains
+ **accurate** that the mobile bundle-*provisioning* path (`scripts/stage_model_bundle.py`) still stages
+ a hardcoded `TINYNET_GOLDEN` fixture rather than a project's real recipe — that half of the boundary is
+ still open. The doc has been corrected in place (see the diff on this branch) rather than rewritten,
+ per the "don't delete history-relevant context" rule — the two follow-ons are now marked done/open
+ individually instead of both open.
+- **`wikis/backend/01_architecture_overview.md`** ("`ProjectService` ... asks the `ModelInitializer` to
+ build a local `.npz` weights file") and **`wikis/backend/03_project_management.md`** ("Determine File
+ Path" / "Model Initialization" / "Finalize DB Entry" steps, all `.npz`-based) describe **project
+ creation**, not the registry: `ModelInitializer` still writes the project's *initial*, pre-training
+ architecture to a `.npz` at creation time, and that description is accurate and unrelated to what the
+ registry replaces. Both pages now carry a pointer to this page so a reader isn't left assuming the
+ `.npz` is the only place a trained model ever lives — see the one-line addition on each.
+- **Everywhere else `.npz`/"overwrite" appears in code** (`RegistryModelResolver`, `ArtifactController`,
+ `ArtifactBlobStore`, `ModelArtifact` javadoc, `fl_server.py` comments) is **already correct as
+ written** — those comments explicitly call the `.npz` "legacy" or "overwritable" in describing the
+ registry that supersedes it. Nothing needed retiring there.
+
+What genuinely still relies on `.npz`, by design, not by gap:
+
+1. **`FULL_CHECKPOINT` byte format.** A full checkpoint's registered bytes are the `.npz` bytes
+ themselves — that is the imaging air-gap export format, not a placeholder (`BUNDLE_FORMAT.md`,
+ "Serialization").
+2. **`RegistryModelResolver`'s local cache filename.** Materialized registry blobs are cached as
+ `.npz` (`RegistryModelResolver.java:89`) because, for the only kind this resolver ever
+ materializes (`FULL_CHECKPOINT`), the bytes really are an `.npz` archive — this is a real, current
+ mechanic, not the retired vision.
+3. **`projects.model_path` itself.** Still written every round by `fl_server.py` as the training loop's
+ working file (`fl_server.py:982-996`) and still the fallback read path when no registry artifact
+ exists. The registry is additive, not a replacement of the column.
diff --git a/wikis/backend/README.md b/wikis/backend/README.md
index 632e9831..5a7612be 100644
--- a/wikis/backend/README.md
+++ b/wikis/backend/README.md
@@ -4,7 +4,7 @@ Welcome to the internal documentation for the **FedLearn-Platform Backend**.
This section of the wiki covers the Spring Boot 3 API, the database interactions, security, WebSockets, and how the federated learning python processes are orchestrated natively or scaled on AWS ECS Fargate.
-> ⚠️ **Branch reality.** The backend runs on **PostgreSQL** for every profile (H2 has been retired) — `dev`/`ec2demo` against a local Postgres (`backend/fl-platform-api/docker-compose.yml` → `docker compose up -d`) and `test` against Testcontainers Postgres. The highest committed Flyway migration is **`V19`**. Authorization on this branch is the single coarse `users.role IN (USER, ADMIN)` column (migration `V2`). The identity / multi-tenancy / audit subsystem documented in page 06 is **designed on a separate identity-foundations branch and is not present here.**
+> ✅ **Branch reality.** The backend runs on **PostgreSQL** for every profile (H2 has been retired) — `dev`/`ec2demo` against a local Postgres (`backend/fl-platform-api/docker-compose.yml` → `docker compose up -d`) and `test` against Testcontainers Postgres. The highest committed Flyway migration is **`V19`**. The full **identity / multi-tenancy / audit subsystem documented in page 06 IS present on this branch**: the three-layer role model (`PlatformRole` platform role, `OrgRole` org role, `MembershipRole` project membership), organization-scoped isolation (`OrgScopeFilter`), `AuthorizationService`, the `@Auditable`/`AuditEvent` trail, and the `email/` + `bootstrap/` plumbing — backed by the `V4`–`V7` identity migrations. (The single coarse `users.role IN (USER, ADMIN)` column from `V2` was the *original* model; it has since been superseded by the layered `PlatformRole`.)
## Documentation Index
@@ -17,11 +17,14 @@ This section of the wiki covers the Spring Boot 3 API, the database interactions
3. **[Project Management Lifecycle](03_project_management.md)**
Details the `ProjectService` and `ProjectController` logic. Explains how training rounds are configured, how projects are persisted to the database, and how models are initialized.
-4. **[Federated Orchestration (FlowerServerManager)](04_federated_orchestration.md)**
+4. **[Federated Orchestration (FlServerManager)](04_federated_orchestration.md)**
The most complex component. Documents how the Java API dynamically provisions the Python FL aggregation servers, differentiating between local machine `ProcessBuilder` execution and cloud-native AWS ECS Fargate orchestration.
5. **[WebSocket Log Streaming](05_websocket_logs_streaming.md)**
Explains the real-time observability pipeline. Shows how the backend captures standard output from the Python FL servers, routes it via STOMP topics to the React frontend, and persists it for export.
-6. **[Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md)** — ⚠️ **designed on a separate identity-foundations branch; not present on this branch.**
- Documents the identity subsystem: the three-layer role model (platform / organization / project), organization-scoped multi-tenant isolation (`OrgScope`), the `@Auditable` audit trail, the email + first-run bootstrap plumbing, the V4–V6 migrations, and the membership/admin/access-request REST endpoints. None of this is committed on the current branch — it is included for reference.
+6. **[Identity, Multi-Tenancy & Audit](06_identity_multitenancy_and_audit.md)**
+ Documents the identity subsystem — **present on this branch**: the three-layer role model (platform / organization / project), organization-scoped multi-tenant isolation (`OrgScopeFilter`), the `@Auditable` audit trail, the email + first-run bootstrap plumbing, the `V4`–`V7` identity migrations, and the membership/admin/access-request REST endpoints.
+
+7. **[Content-Addressed Model Artifact Registry](07_artifact_registry.md)**
+ Documents the registry that replaced the single overwritable `.npz` at `projects.model_path`: the `artifact_blobs` / `model_artifacts` / `artifact_lineage` data model, the `ArtifactBlobStore` write-once content store, `RegistryModelResolver`'s registry-first read path for inference and FL-server warm-start, the artifact/lineage/marketplace REST surface, and the `V12`/`V18` migrations.
diff --git a/wikis/desktop/07-hardware-profiles.md b/wikis/desktop/07-hardware-profiles.md
index e071a690..9702ae24 100644
--- a/wikis/desktop/07-hardware-profiles.md
+++ b/wikis/desktop/07-hardware-profiles.md
@@ -324,7 +324,7 @@ hostname:port → fedlearn-server.local:8080
domain:port → api.fedlearn.company.com:8080
```
-Note: This is **not** the same as the backend HTTP URL configured in the auth settings. That URL is for the Spring Boot REST API. This address is for the federated learning training coordinator (Flower/gRPC).
+Note: This is **not** the same as the backend HTTP URL configured in the auth settings. That URL is for the Spring Boot REST API. This address is for the federated learning training coordinator (gRPC).
### `partitionId` and Data Partitioning
diff --git a/wikis/framework/01_architecture_overview.md b/wikis/framework/01_architecture_overview.md
index 40abc327..56d11b16 100644
--- a/wikis/framework/01_architecture_overview.md
+++ b/wikis/framework/01_architecture_overview.md
@@ -34,7 +34,7 @@ The library is intentionally decoupled from the platform orchestration layer. Yo
The library has **zero dependency on the Spring Boot backend**. Its only external communication is gRPC between the Python server and Python clients.
-> **No Flower / `flwr` dependency.** Despite the historical `flower` package name on the Java side (`FlowerServerManager`), the FL framework is entirely custom — its own protobuf contract (`fedlearn.v1`) and its own FedAvg / DeComFL strategies. `framework/requirements.txt` has **no `flwr` / `flwr-datasets`** entry (they carried zero imports and were removed). `transformers` *is* still pinned — it backs the FoT (federated optimisation / LLM) path and the HuggingFace model loaders.
+> **No Flower / `flwr` dependency.** Despite the legacy `flower` package name on the Java side (renamed to `orchestration` / `FlServerManager` — DA-12), the FL framework is entirely custom — its own protobuf contract (`fedlearn.v1`) and its own FedAvg / DeComFL strategies. `framework/requirements.txt` has **no `flwr` / `flwr-datasets`** entry (they carried zero imports and were removed). `transformers` *is* still pinned — it backs the FoT (federated optimisation / LLM) path and the HuggingFace model loaders.
---
@@ -212,7 +212,7 @@ The Python framework is invoked as a **child process** by two parts of the platf
### 1. Spring Boot Backend
-The `FlowerServerManager` Java service calls `ProcessBuilder` to launch a Python `run_server.py` script. The process's stdout is streamed as JSON log lines to the frontend via STOMP WebSocket. On AWS Fargate, it instead launches an ECS task running a Docker image that packages the framework.
+The `FlServerManager` Java service calls `ProcessBuilder` to launch a Python `run_server.py` script. The process's stdout is streamed as JSON log lines to the frontend via STOMP WebSocket. On AWS Fargate, it instead launches an ECS task running a Docker image that packages the framework.
```
Spring Boot
diff --git a/wikis/framework/06_decomfl.md b/wikis/framework/06_decomfl.md
index 014d029e..79d816b2 100644
--- a/wikis/framework/06_decomfl.md
+++ b/wikis/framework/06_decomfl.md
@@ -179,7 +179,7 @@ def generate_perturbation(self, seed: int, num_params: int) -> torch.Tensor:
return torch.randn(num_params, generator=generator, device=self.device)
```
-The canonical, device-independent source of truth for this `z` is `canonical_perturbation(seed, num_params)` in `framework/src/fedlearn/estimators/perturbation.py` — it generates on the **CPU** with a *local* `torch.Generator` (never the process-global RNG), so the result is bit-stable across CPU/CUDA/MPS for a given seed; callers move the result to their compute device afterwards. Both the server (`decomfl_strategy.py`) and client (`estimators/zeroth_order.py`) delegate here, and the native C++ (libtorch) mobile core reproduces it in `mobile_client/shared/src/Perturbation.cpp`.
+The canonical, device-independent source of truth for this `z` is `canonical_perturbation(seed, num_params)` in `framework/src/fedlearn/estimators/perturbation.py` — it generates on the **CPU** with a *local* `torch.Generator` (never the process-global RNG), so the result is bit-stable across CPU/CUDA/MPS for a given seed; callers move the result to their compute device afterwards. Both the server (`decomfl_strategy.py`) and client (`estimators/zeroth_order.py`) delegate here, and the native C++ (ExecuTorch) mobile core reproduces it in `mobile_client/shared/src/Perturbation.cpp`.
#### Cross-Architecture Determinism (golden-vector parity)
diff --git a/wikis/frontend/Routing_and_Auth.md b/wikis/frontend/Routing_and_Auth.md
index 724366b1..da92297f 100644
--- a/wikis/frontend/Routing_and_Auth.md
+++ b/wikis/frontend/Routing_and_Auth.md
@@ -51,9 +51,9 @@ useEffect(() => {
### Roles & Backend RBAC
-Authentication is **cookie-only** — the frontend never reads or sends a token; the single role carried on the session is `users.role` (`USER` / `ADMIN`).
+Authentication is **cookie-only** — the frontend never reads or sends a token; the role carried on the session is the layered `platform_role` (`USER` / `PROJECT_OWNER` / `PLATFORM_ADMIN`).
-> ⚠️ **Branch reality.** The identity/RBAC endpoints (membership, admin, access-request, discover) and the three-layer `platform_role` model are part of the identity-foundations work that is **designed on a separate branch and is _not present_ here** (see the backend [Identity, Multi-Tenancy & Audit](../backend/06_identity_multitenancy_and_audit.md) banner). On the current branch there is only the coarse `USER` / `ADMIN` role and no membership/admin/access-request/discover screens. The web client ships the **Ember** design system.
+> ✅ **Branch reality.** The identity/RBAC endpoints (membership, admin, access-request, discover) and the three-layer `platform_role` model **are present on this branch** (backend `V4`–`V7` migrations; see the backend [Identity, Multi-Tenancy & Audit](../backend/06_identity_multitenancy_and_audit.md) page). The web client renders them through role-gated routes (`RoleRoute allow={['PLATFORM_ADMIN']}` / `['PROJECT_OWNER', …]`) and dashboards (`AdminDashboard`, `OwnerDashboard`, `ClientDashboard`) plus the owner-promotion / deletion-request approval flows — superseding the original coarse `USER` / `ADMIN` role. The web client ships the **Ember** design system.
## Routing Configuration