fleetd #131: enforce package boundaries with an ArchUnit cycle test #270

Closed
agent wants to merge 0 commits from worker/fleetd-131-archunit-18b834-7 into main
Member

What

Adds PackageCyclesTest (fleetd/src/test/java/dev/ltms/fleet/PackageCyclesTest.java): an ArchUnit test that fails the build the moment a NEW cycle appears between the top-level dev.ltms.fleet.* packages. Today's real cycles are recorded as explicit, narrow exceptions -- each one ignores dependencies between exactly two named packages, in both directions, nothing else.

No package moves in this PR -- ticket #131 is explicit that removing a cycle is its own, later PR.

This replaces the stalled first attempt (bare SlicesRuleDefinition...beFreeOfCycles() with no exception list, and a working-directory-relative importPath(Path.of("target/classes"))). Both problems are fixed here: the exception list is the actual work of this ticket, and importPackages("dev.ltms.fleet") resolves off the classpath, not a relative path.

Dependency (needs CVE clearance)

  • com.tngtech.archunit:archunit-junit5:1.5.0, test scope.
  • 1.5.0 is current stable on Maven Central as of today (checked maven-metadata.xml: <latest>1.5.0</latest>). The earlier draft had pinned 1.4.1 -- I moved it to 1.5.0.
  • mvn dependency:tree -Dincludes=com.tngtech.archunit (full tree, verbose, grepped for archunit/guava/asm/slf4j):
    com.tngtech.archunit:archunit-junit5:jar:1.5.0:test
     +- com.tngtech.archunit:archunit-junit5-api:jar:1.5.0:test
     |   \- com.tngtech.archunit:archunit:jar:1.5.0:test
     |        \- (org.slf4j:slf4j-api:jar:2.0.18:test - omitted for conflict with 2.0.16)
     \- com.tngtech.archunit:archunit-junit5-engine:jar:1.5.0:test
          \- com.tngtech.archunit:archunit-junit5-engine-api:jar:1.5.0:test
    
    Only transitive addition of note is slf4j-api:2.0.18, resolved down to the project's pinned 2.0.16 (no conflict, no version bump needed). No guava, no separate ASM jar -- archunit 1.5.0 shades its own bytecode-parsing internals. Java level: builds and runs clean under this project's maven.compiler.release=25; archunit itself targets an older baseline so it imposes no floor above 25.
  • I could not run the IDE-side Mend.io CVE check (jetbrains get_file_problems on pom.xml) -- that tool is primary-only per this repo's CLAUDE.md; I have no IDE MCP mount as a worker. The lead needs to run that themselves.

The exception list -- and why it's 5 cycles, not the ticket's 3

The ticket (fetched fresh via mcp__gitea__issue_read, since it predates the bridge->fleet rename) names three cycles: auth<->mcp, msg<->mcp, and core<->herdr (its evidence for the third: "20 files ... import herdr, while member imports config, guard, herdr, peer, placement"). I re-derived the graph from the code as it stands today rather than trusting those identifiers, per the brief. Running the bare rule (main code only, no exceptions) reports 5 real pairwise cycles, not 3:

cycle ticket step evidence
auth <-> mcp step 1 auth/CallerResolver.java:3 imports mcp.ConnectionIdentity; mcp/FleetMcp.java:3-7 imports auth.AuditLog/Authz/CallerResolver/Principal/Role
mcp <-> msg step 2 msg/ReplyPushLoop.java:5, msg/LeadHeartbeatLoop.java:5 import mcp.PrimaryRegistry; mcp/FleetMcp.java:15-18 imports msg.LeadChannel/LeadMessage/MessageService/Rendezvous
inject <-> msg new, not one of the ticket's three inject/CompletionResolver.java:4-5, inject/Injector.java:6, inject/TurnListener.java:3 import msg.Rendezvous/msg.TurnToken; msg/MessageService.java:6 imports inject.Injector
metrics <-> msg new metrics/FleetMetrics.java:3 imports msg.ReplyInbox; msg/MessageService.java:7-8, msg/LeadHeartbeatLoop.java:6-7, msg/ReplyPushLoop.java:6-7 import metrics.FleetMetrics/metrics.Metrics
msg <-> session new session/SessionManager.java:7 imports msg.TurnToken; msg/LeadHeartbeatLoop.java:8 imports session.MemberSession

core <-> herdr does not exist as a main-code cycle today -- reporting only, not fixing. I checked every file in herdr/: it has zero cross-package imports of any kind (grep -H "^import dev\.ltms\.fleet\." src/main/java/dev/ltms/fleet/herdr/*.java | grep -v herdr\. returns nothing). herdr is a pure leaf package in main code; it cannot be part of any cycle. It only reappears if test classes are included in the scan (confirmed: with tests included, herdr/member/peer/config/guard/placement all show up in cycles) -- that's test wiring, not shipped architecture, which is exactly why this test scans main code only (ImportOption.Predefined.DO_NOT_INCLUDE_TESTS).

So: msg is a hub, bidirectionally coupled to four other packages (mcp, inject, metrics, session). Each of the 3 new pairs is commented in the test as "found while implementing this test, not one of the ticket's original three; needs its own follow-up step" -- I did not invent new ticket steps or try to map them onto steps 1-3, since that would be scope creep into design decisions that belong to the lead.

Mutation proof 1 -- a new cycle is caught

Added two throwaway classes (config.MutationCycleProbeA importing health.HealthState, health.MutationCycleProbeB importing config.FleetConfig) -- a pair not on the exception list. Result: RED.

Architecture Violation [Priority: MEDIUM] - Rule 'slices matching 'dev.ltms.fleet.(*)..' should be free of cycles' was violated (2 times):
Cycle detected: Slice auth ->
                Slice config ->
                Slice health ->
                Slice session ->
                Slice auth
  ...
Cycle detected: Slice config ->
                Slice health ->
                Slice config
  1. Dependencies of Slice config
    - Field <dev.ltms.fleet.config.MutationCycleProbeA.probe> has type <dev.ltms.fleet.health.HealthState> in (MutationCycleProbeA.java:0)
  2. Dependencies of Slice health

(A second, larger cycle also appeared -- auth -> config -> health -> session -> auth -- because the new config<->health edge happened to stitch together pre-existing one-way relationships into a bigger loop. That's real and expected; it doesn't change the exception list since none of those 4 packages are exception pairs.)

Reverted both files (rm, untracked so nothing to git diff). Re-ran: green again (Tests run: 1, Failures: 0). git status --porcelain after revert showed only the two intended files (pom.xml, PackageCyclesTest.java).

Mutation proof 2 -- the exceptions are narrow

Deleted the single ignoreCycle(rule, "metrics", "msg") line. Result: RED, for that cycle only --

Architecture Violation [Priority: MEDIUM] - Rule 'slices matching 'dev.ltms.fleet.(*)..' should be free of cycles' was violated (1 times):
Cycle detected: Slice metrics ->
                Slice msg ->
                Slice metrics
  1. Dependencies of Slice metrics
    - Method <dev.ltms.fleet.metrics.FleetMetrics.create(...)> has parameter of type <dev.ltms.fleet.msg.ReplyInbox> in (FleetMetrics.java:0)
  2. Dependencies of Slice msg
    - Constructor <dev.ltms.fleet.msg.LeadHeartbeatLoop.<init>(...)> has parameter of type <dev.ltms.fleet.metrics.Metrics> in (LeadHeartbeatLoop.java:0)
    ...

"was violated (1 times)" -- the other 4 accepted pairs stayed suppressed; only the deleted one surfaced. Restored the line; re-ran: green again (Tests run: 1, Failures: 0).

Build result

cd fleetd && mvn clean install (unpiped, full output read):

[INFO] Tests run: 1265, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
[INFO] Total time:  42.998 s

PackageCyclesTest itself: Tests run: 1, Failures: 0, Errors: 0, Skipped: 0.

Caveats for review

  • 5 accepted exceptions, not the ticket's 3 -- see table above. core<->herdr does not need an exception (it doesn't currently exist in main code); the lead should decide whether to update ticket #131's text/steps, since 3 of the 5 exceptions currently reference "needs its own follow-up step" rather than a specific numbered step.
  • CVE clearance on archunit-junit5:1.5.0 is the lead's to run (jetbrains get_file_problems on pom.xml) -- not available to a worker.
  • I did not attempt to fix any of the 5 cycles or move Agent/AgentStatus/AgentControl out of herdr -- out of scope per the ticket and the brief.
## What Adds `PackageCyclesTest` (`fleetd/src/test/java/dev/ltms/fleet/PackageCyclesTest.java`): an ArchUnit test that fails the build the moment a NEW cycle appears between the top-level `dev.ltms.fleet.*` packages. Today's real cycles are recorded as explicit, narrow exceptions -- each one ignores dependencies between exactly two named packages, in both directions, nothing else. **No package moves in this PR** -- ticket #131 is explicit that removing a cycle is its own, later PR. This replaces the stalled first attempt (bare `SlicesRuleDefinition...beFreeOfCycles()` with no exception list, and a working-directory-relative `importPath(Path.of("target/classes"))`). Both problems are fixed here: the exception list is the actual work of this ticket, and `importPackages("dev.ltms.fleet")` resolves off the classpath, not a relative path. ## Dependency (needs CVE clearance) - `com.tngtech.archunit:archunit-junit5:1.5.0`, test scope. - **1.5.0 is current stable** on Maven Central as of today (checked `maven-metadata.xml`: `<latest>1.5.0</latest>`). The earlier draft had pinned 1.4.1 -- I moved it to 1.5.0. - `mvn dependency:tree -Dincludes=com.tngtech.archunit` (full tree, verbose, grepped for archunit/guava/asm/slf4j): ``` com.tngtech.archunit:archunit-junit5:jar:1.5.0:test +- com.tngtech.archunit:archunit-junit5-api:jar:1.5.0:test | \- com.tngtech.archunit:archunit:jar:1.5.0:test | \- (org.slf4j:slf4j-api:jar:2.0.18:test - omitted for conflict with 2.0.16) \- com.tngtech.archunit:archunit-junit5-engine:jar:1.5.0:test \- com.tngtech.archunit:archunit-junit5-engine-api:jar:1.5.0:test ``` Only transitive addition of note is `slf4j-api:2.0.18`, resolved down to the project's pinned `2.0.16` (no conflict, no version bump needed). No guava, no separate ASM jar -- archunit 1.5.0 shades its own bytecode-parsing internals. Java level: builds and runs clean under this project's `maven.compiler.release=25`; archunit itself targets an older baseline so it imposes no floor above 25. - I could not run the IDE-side Mend.io CVE check (`jetbrains get_file_problems` on `pom.xml`) -- that tool is primary-only per this repo's `CLAUDE.md`; I have no IDE MCP mount as a worker. The lead needs to run that themselves. ## The exception list -- and why it's 5 cycles, not the ticket's 3 The ticket (fetched fresh via `mcp__gitea__issue_read`, since it predates the bridge->fleet rename) names three cycles: `auth<->mcp`, `msg<->mcp`, and `core<->herdr` (its evidence for the third: "20 files ... import herdr, while member imports config, guard, herdr, peer, placement"). I re-derived the graph from the code as it stands today rather than trusting those identifiers, per the brief. Running the bare rule (main code only, no exceptions) reports **5 real pairwise cycles**, not 3: | cycle | ticket step | evidence | |---|---|---| | `auth <-> mcp` | step 1 | `auth/CallerResolver.java:3` imports `mcp.ConnectionIdentity`; `mcp/FleetMcp.java:3-7` imports `auth.AuditLog/Authz/CallerResolver/Principal/Role` | | `mcp <-> msg` | step 2 | `msg/ReplyPushLoop.java:5`, `msg/LeadHeartbeatLoop.java:5` import `mcp.PrimaryRegistry`; `mcp/FleetMcp.java:15-18` imports `msg.LeadChannel/LeadMessage/MessageService/Rendezvous` | | `inject <-> msg` | **new, not one of the ticket's three** | `inject/CompletionResolver.java:4-5`, `inject/Injector.java:6`, `inject/TurnListener.java:3` import `msg.Rendezvous`/`msg.TurnToken`; `msg/MessageService.java:6` imports `inject.Injector` | | `metrics <-> msg` | **new** | `metrics/FleetMetrics.java:3` imports `msg.ReplyInbox`; `msg/MessageService.java:7-8`, `msg/LeadHeartbeatLoop.java:6-7`, `msg/ReplyPushLoop.java:6-7` import `metrics.FleetMetrics`/`metrics.Metrics` | | `msg <-> session` | **new** | `session/SessionManager.java:7` imports `msg.TurnToken`; `msg/LeadHeartbeatLoop.java:8` imports `session.MemberSession` | **`core <-> herdr` does not exist as a main-code cycle today** -- reporting only, not fixing. I checked every file in `herdr/`: it has zero cross-package imports of any kind (`grep -H "^import dev\.ltms\.fleet\." src/main/java/dev/ltms/fleet/herdr/*.java | grep -v herdr\.` returns nothing). `herdr` is a pure leaf package in main code; it cannot be part of any cycle. It only reappears if test classes are included in the scan (confirmed: with tests included, `herdr`/`member`/`peer`/`config`/`guard`/`placement` all show up in cycles) -- that's test wiring, not shipped architecture, which is exactly why this test scans main code only (`ImportOption.Predefined.DO_NOT_INCLUDE_TESTS`). So: `msg` is a hub, bidirectionally coupled to four other packages (`mcp`, `inject`, `metrics`, `session`). Each of the 3 new pairs is commented in the test as "found while implementing this test, not one of the ticket's original three; needs its own follow-up step" -- I did not invent new ticket steps or try to map them onto steps 1-3, since that would be scope creep into design decisions that belong to the lead. ## Mutation proof 1 -- a new cycle is caught Added two throwaway classes (`config.MutationCycleProbeA` importing `health.HealthState`, `health.MutationCycleProbeB` importing `config.FleetConfig`) -- a pair not on the exception list. Result: RED. ``` Architecture Violation [Priority: MEDIUM] - Rule 'slices matching 'dev.ltms.fleet.(*)..' should be free of cycles' was violated (2 times): Cycle detected: Slice auth -> Slice config -> Slice health -> Slice session -> Slice auth ... Cycle detected: Slice config -> Slice health -> Slice config 1. Dependencies of Slice config - Field <dev.ltms.fleet.config.MutationCycleProbeA.probe> has type <dev.ltms.fleet.health.HealthState> in (MutationCycleProbeA.java:0) 2. Dependencies of Slice health ``` (A second, larger cycle also appeared -- `auth -> config -> health -> session -> auth` -- because the new `config<->health` edge happened to stitch together pre-existing one-way relationships into a bigger loop. That's real and expected; it doesn't change the exception list since none of those 4 packages are exception pairs.) Reverted both files (`rm`, untracked so nothing to `git diff`). Re-ran: green again (`Tests run: 1, Failures: 0`). `git status --porcelain` after revert showed only the two intended files (`pom.xml`, `PackageCyclesTest.java`). ## Mutation proof 2 -- the exceptions are narrow Deleted the single `ignoreCycle(rule, "metrics", "msg")` line. Result: RED, for that cycle only -- ``` Architecture Violation [Priority: MEDIUM] - Rule 'slices matching 'dev.ltms.fleet.(*)..' should be free of cycles' was violated (1 times): Cycle detected: Slice metrics -> Slice msg -> Slice metrics 1. Dependencies of Slice metrics - Method <dev.ltms.fleet.metrics.FleetMetrics.create(...)> has parameter of type <dev.ltms.fleet.msg.ReplyInbox> in (FleetMetrics.java:0) 2. Dependencies of Slice msg - Constructor <dev.ltms.fleet.msg.LeadHeartbeatLoop.<init>(...)> has parameter of type <dev.ltms.fleet.metrics.Metrics> in (LeadHeartbeatLoop.java:0) ... ``` "was violated (1 times)" -- the other 4 accepted pairs stayed suppressed; only the deleted one surfaced. Restored the line; re-ran: green again (`Tests run: 1, Failures: 0`). ## Build result `cd fleetd && mvn clean install` (unpiped, full output read): ``` [INFO] Tests run: 1265, Failures: 0, Errors: 0, Skipped: 0 [INFO] BUILD SUCCESS [INFO] Total time: 42.998 s ``` `PackageCyclesTest` itself: `Tests run: 1, Failures: 0, Errors: 0, Skipped: 0`. ## Caveats for review - 5 accepted exceptions, not the ticket's 3 -- see table above. `core<->herdr` does not need an exception (it doesn't currently exist in main code); the lead should decide whether to update ticket #131's text/steps, since 3 of the 5 exceptions currently reference "needs its own follow-up step" rather than a specific numbered step. - CVE clearance on `archunit-junit5:1.5.0` is the lead's to run (`jetbrains get_file_problems` on `pom.xml`) -- not available to a worker. - I did not attempt to fix any of the 5 cycles or move `Agent`/`AgentStatus`/`AgentControl` out of `herdr` -- out of scope per the ticket and the brief.
agent added 1 commit 2026-09-03 15:24:19 +02:00
fleetd #131: enforce package boundaries with an ArchUnit cycle test
CI / contract (pull_request) Successful in 1m10s
CI / build (pull_request) Successful in 2m1s
1006805027
Adds PackageCyclesTest, which fails the build on any new cycle between
the top-level dev.ltms.fleet.* packages. Today's five real cycles are
recorded as narrow, explicit exceptions (ignoreDependency per named
pair, both directions), each commented with the ticket step (or a note
that it needs its own) that removes it. No package moves in this PR.

archunit-junit5 1.5.0 (current stable, newer than an earlier 1.4.1
draft). Main code only (DO_NOT_INCLUDE_TESTS) and importPackages(...)
instead of a working-directory-relative target/classes path.
ltms closed this pull request 2026-09-04 03:26:41 +02:00
Some checks are pending
CI / contract (pull_request) Successful in 1m10s
CI / build (pull_request) Successful in 2m1s

Pull request closed

Sign in to join this conversation.