diff --git a/modules/scan/cmd/scan/escl.go b/modules/scan/cmd/scan/escl.go
new file mode 100644
index 00000000..0f7c2a9d
--- /dev/null
+++ b/modules/scan/cmd/scan/escl.go
@@ -0,0 +1,201 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+)
+
+// eSCL (the AirScan protocol) over HTTP: POST /ScanJobs answers 201 and the job's Location, GET
+// /NextDocument answers the image, and a second NextDocument answers 404 once the job is done.
+
+// maxImage bounds what a scanner may send: a 600 dpi A4 JPEG is a few tens of megabytes.
+const maxImage = 256 << 20
+
+// Client speaks eSCL to one scanner.
+type Client struct {
+ Base string
+ HTTP *http.Client
+}
+
+// NewClient is a client for the eSCL base URL, with a timeout that outlasts a slow scan.
+func NewClient(base string) *Client {
+ return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}}
+}
+
+// Capabilities are the platen's largest scan region, in the 300ths of an inch the region is asked in.
+type Capabilities struct {
+ MaxWidth, MaxHeight int
+}
+
+type capabilitiesXML struct {
+ MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"`
+ MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"`
+}
+
+// Status is what /ScannerStatus says.
+type Status struct {
+ State string `xml:"State" json:"state"`
+ AdfState string `xml:"AdfState" json:"adf-state"`
+}
+
+func (c *Client) get(ctx context.Context, rawURL string) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ return c.HTTP.Do(req)
+}
+
+func readBody(resp *http.Response, limit int64) ([]byte, error) {
+ defer resp.Body.Close()
+ return io.ReadAll(io.LimitReader(resp.Body, limit))
+}
+
+// Capabilities asks the scanner how large its glass is.
+func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) {
+ resp, err := c.get(ctx, c.Base+"/ScannerCapabilities")
+ if err != nil {
+ return Capabilities{}, fmt.Errorf("the scanner does not answer: %w", err)
+ }
+ body, err := readBody(resp, 1<<20)
+ if err != nil {
+ return Capabilities{}, fmt.Errorf("the scanner's capabilities cannot be read: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status)
+ }
+ var x capabilitiesXML
+ if err := xml.Unmarshal(body, &x); err != nil {
+ return Capabilities{}, fmt.Errorf("the scanner's capabilities are not XML: %w", err)
+ }
+ if x.MaxWidth <= 0 || x.MaxHeight <= 0 {
+ return Capabilities{}, fmt.Errorf("the scanner's capabilities name no platen size")
+ }
+ return Capabilities{MaxWidth: x.MaxWidth, MaxHeight: x.MaxHeight}, nil
+}
+
+// Status asks what the scanner is doing.
+func (c *Client) Status(ctx context.Context) (Status, error) {
+ resp, err := c.get(ctx, c.Base+"/ScannerStatus")
+ if err != nil {
+ return Status{}, fmt.Errorf("the scanner does not answer: %w", err)
+ }
+ body, err := readBody(resp, 1<<20)
+ if err != nil {
+ return Status{}, fmt.Errorf("the scanner's status cannot be read: %w", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ return Status{}, fmt.Errorf("the scanner refused its status: %s", resp.Status)
+ }
+ var s Status
+ if err := xml.Unmarshal(body, &s); err != nil {
+ return Status{}, fmt.Errorf("the scanner's status is not XML: %w", err)
+ }
+ return s, nil
+}
+
+// colorModes maps the config's words to eSCL's.
+var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"}
+
+// ScanSettings is the request body: the Platen, the whole glass, the colour mode and resolution, as a JPEG.
+func ScanSettings(c Capabilities, resolution int, colorMode string) (string, error) {
+ escl, ok := colorModes[colorMode]
+ if !ok {
+ return "", fmt.Errorf("color-mode %q is not color or gray", colorMode)
+ }
+ return fmt.Sprintf(`
+
+2.63
+escl:ThreeHundredthsOfInches00%d%d
+Platen
+%s
+%d%d
+image/jpeg
+
+`, c.MaxWidth, c.MaxHeight, escl, resolution, resolution), nil
+}
+
+// Scan puts one job to the scanner and returns the JPEG it sends. The resolution asked is only a request: the
+// real one is the image's own density (Density), for the real scanner answered 300 with 200.
+func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]byte, error) {
+ caps, err := c.Capabilities(ctx)
+ if err != nil {
+ return nil, err
+ }
+ settings, err := ScanSettings(caps, resolution, colorMode)
+ if err != nil {
+ return nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/ScanJobs", bytes.NewReader([]byte(settings)))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "text/xml")
+ resp, err := c.HTTP.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("the scanner does not answer: %w", err)
+ }
+ reply, _ := readBody(resp, 1<<20)
+ if resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("the scanner refused the scan job: %s %s", resp.Status, strings.TrimSpace(string(reply)))
+ }
+ loc := resp.Header.Get("Location")
+ if loc == "" {
+ return nil, fmt.Errorf("the scanner took the scan job but named no Location")
+ }
+ base, _ := url.Parse(c.Base + "/")
+ ref, err := url.Parse(loc)
+ if err != nil {
+ return nil, fmt.Errorf("the scan job's Location %q is not a URL: %w", loc, err)
+ }
+ doc := strings.TrimRight(base.ResolveReference(ref).String(), "/") + "/NextDocument"
+ got, err := c.get(ctx, doc)
+ if err != nil {
+ return nil, fmt.Errorf("the scanned page cannot be fetched: %w", err)
+ }
+ image, err := readBody(got, maxImage)
+ if err != nil {
+ return nil, fmt.Errorf("the scanned page cannot be read: %w", err)
+ }
+ if got.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("the scanner gave no page: %s", got.Status)
+ }
+ if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
+ return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct)
+ }
+ return image, nil
+}
+
+// Density is the image's own resolution in dpi, from its JFIF header (units dpi, or dots per cm).
+func Density(jpegBytes []byte) (int, error) {
+ b := jpegBytes
+ if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
+ return 0, fmt.Errorf("the image is not a JPEG")
+ }
+ for i := 2; i+4 <= len(b) && b[i] == 0xFF; {
+ marker, n := b[i+1], int(b[i+2])<<8|int(b[i+3])
+ if marker == 0xE0 && n >= 16 && i+4+12 <= len(b) && string(b[i+4:i+9]) == "JFIF\x00" {
+ units := b[i+11]
+ x := int(b[i+12])<<8 | int(b[i+13])
+ switch {
+ case units == 1 && x > 0:
+ return x, nil
+ case units == 2 && x > 0:
+ return int(float64(x)*2.54 + 0.5), nil
+ }
+ return 0, fmt.Errorf("the image's JFIF header gives no resolution")
+ }
+ if marker == 0xDA || n < 2 {
+ break
+ }
+ i += 2 + n
+ }
+ return 0, fmt.Errorf("the image has no JFIF header, so its resolution is unknown")
+}
diff --git a/modules/scan/cmd/scan/escl_test.go b/modules/scan/cmd/scan/escl_test.go
new file mode 100644
index 00000000..1b346f58
--- /dev/null
+++ b/modules/scan/cmd/scan/escl_test.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+func squeeze(s string) string { return strings.ReplaceAll(s, "\n", "") }
+
+// 1440/AC1: the captured capabilities, job creation, document and status replies, served by a test server.
+func TestScanPostsOnePlatenJobOverTheWholeGlassAndFetchesTheDocumentOnce(t *testing.T) {
+ f := newFakeScanner(t, fixture(t, "scan.jpg"))
+ image, err := NewClient(f.base()).Scan(context.Background(), 300, "color")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.posts != 1 {
+ t.Fatalf("%d ScanJobs posts, want 1", f.posts)
+ }
+ // The body is the request the real scanner accepted (testdata/escl/settings.xml): Platen, 2550x3507
+ // 300ths of an inch from the capabilities, RGB24, 300 dpi, image/jpeg.
+ if got, want := squeeze(f.postBody), squeeze(string(fixture(t, "settings.xml"))); got != want {
+ t.Fatalf("the job is not the captured request\n got: %s\nwant: %s", got, want)
+ }
+ if f.documents != 1 {
+ t.Fatalf("%d NextDocument requests, want exactly 1", f.documents)
+ }
+ if want := fixture(t, "scan.jpg"); string(image) != string(want) {
+ t.Fatalf("the returned image is %d bytes, want the scanner's %d", len(image), len(want))
+ }
+}
+
+// 1440/AC1: the colour mode and resolution asked are the configured ones.
+func TestScanAsksForTheConfiguredResolutionAndColourMode(t *testing.T) {
+ f := newFakeScanner(t, fixture(t, "scan.jpg"))
+ if _, err := NewClient(f.base()).Scan(context.Background(), 600, "gray"); err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"Grayscale8", "600", "600"} {
+ if !strings.Contains(f.postBody, want) {
+ t.Errorf("the job lacks %s:\n%s", want, f.postBody)
+ }
+ }
+}
+
+// 1440/AC1: a job the scanner refuses is an error that says so, and no page is fetched.
+func TestScanSaysWhyAScannerRefusal(t *testing.T) {
+ f := newFakeScanner(t, fixture(t, "scan.jpg"))
+ f.refuse = 503
+ _, err := NewClient(f.base()).Scan(context.Background(), 300, "color")
+ if err == nil || !strings.Contains(err.Error(), "503") {
+ t.Fatalf("error = %v, want the refusal's 503", err)
+ }
+ if f.documents != 0 {
+ t.Fatalf("%d documents fetched after a refusal", f.documents)
+ }
+}
+
+// 1440/AC2: the image's own density, never the request's.
+func TestDensityIsTheImagesOwn(t *testing.T) {
+ dpi, err := Density(fixture(t, "scan.jpg"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if dpi != 200 {
+ t.Fatalf("density = %d dpi, want the captured scan's 200 (the request said 300)", dpi)
+ }
+}
+
+func TestDensityOfAnImageWithNoneIsAnError(t *testing.T) {
+ if _, err := Density([]byte{0xFF, 0xD8, 0xFF, 0xDA, 0, 2}); err == nil {
+ t.Fatal("an image with no JFIF header has a resolution")
+ }
+}
+
+// 1440/AC8: the captured status replies.
+func TestStatusReadsStateAndAdfState(t *testing.T) {
+ f := newFakeScanner(t, nil)
+ s, err := NewClient(f.base()).Status(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.State != "Idle" || s.AdfState != "ScannerAdfEmpty" {
+ t.Fatalf("status = %+v, want Idle / ScannerAdfEmpty", s)
+ }
+}
+
+// 1440/AC7: which value is unset is said by name.
+func TestMissingNamesWhatIsUnset(t *testing.T) {
+ for _, c := range []struct {
+ s Settings
+ want string
+ }{
+ {Settings{Folder: "/x"}, "scanner"},
+ {Settings{Scanner: "http://192.0.2.10/eSCL"}, "scan-folder"},
+ } {
+ err := c.s.Missing()
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("Missing(%+v) = %v, want it to name %s", c.s, err, c.want)
+ }
+ }
+ if err := (Settings{Scanner: "a", Folder: "/x"}).Missing(); err != nil {
+ t.Errorf("both set: %v", err)
+ }
+}
diff --git a/modules/scan/cmd/scan/helpers_test.go b/modules/scan/cmd/scan/helpers_test.go
new file mode 100644
index 00000000..0b5fff31
--- /dev/null
+++ b/modules/scan/cmd/scan/helpers_test.go
@@ -0,0 +1,81 @@
+package main
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+)
+
+// fixture reads a capture of the real scanner's answers (testdata/escl, 2026-10-20). Tests never reach a real scanner.
+func fixture(t *testing.T, name string) []byte {
+ t.Helper()
+ b, err := os.ReadFile(filepath.Join("testdata", "escl", name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+// fakeScanner serves the captured eSCL answers and records what it was asked.
+type fakeScanner struct {
+ *httptest.Server
+ mu sync.Mutex
+ image []byte
+ postBody string
+ posts int
+ documents int // NextDocument requests
+ refuse int // when set, the status ScanJobs answers instead of 201
+ noPage bool
+}
+
+func newFakeScanner(t *testing.T, image []byte) *fakeScanner {
+ t.Helper()
+ f := &fakeScanner{image: image}
+ mux := http.NewServeMux()
+ mux.HandleFunc("/eSCL/ScannerCapabilities", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/xml")
+ w.Write(fixture(t, "capabilities.xml"))
+ })
+ mux.HandleFunc("/eSCL/ScannerStatus", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/xml")
+ w.Write(fixture(t, "status-idle.xml"))
+ })
+ mux.HandleFunc("/eSCL/ScanJobs", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if r.Method != http.MethodPost {
+ http.Error(w, "post only", http.StatusMethodNotAllowed)
+ return
+ }
+ b := make([]byte, 1<<16)
+ n, _ := r.Body.Read(b)
+ f.postBody = string(b[:n])
+ f.posts++
+ if f.refuse != 0 {
+ http.Error(w, "busy", f.refuse)
+ return
+ }
+ // The captured Location names the real scanner's host; the test's own server stands in for it.
+ w.Header().Set("Location", f.URL+"/eSCL/ScanJobs/7320ead5")
+ w.WriteHeader(http.StatusCreated)
+ })
+ mux.HandleFunc("/eSCL/ScanJobs/7320ead5/NextDocument", func(w http.ResponseWriter, r *http.Request) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.documents++
+ if f.documents > 1 || f.noPage {
+ http.NotFound(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "image/jpeg")
+ w.Write(f.image)
+ })
+ f.Server = httptest.NewServer(mux)
+ t.Cleanup(f.Close)
+ return f
+}
+
+func (f *fakeScanner) base() string { return f.URL + "/eSCL" }
diff --git a/modules/scan/cmd/scan/settings.go b/modules/scan/cmd/scan/settings.go
new file mode 100644
index 00000000..3f6ddb8d
--- /dev/null
+++ b/modules/scan/cmd/scan/settings.go
@@ -0,0 +1,76 @@
+// Package main is the scan module's Go tools bundle (novox/hq ADR 0464, issue 1440): it scans the glass of a
+// network scanner over eSCL, crops the document, files it as a one-page PDF in a folder and says so on the bus.
+// A scan happens when a tool is called; the module runs no process of its own.
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// Settings are the module's five config values, as the mesh writes them into settings.json (ADR 0348).
+type Settings struct {
+ // Scanner is the eSCL base URL, such as http://192.0.2.10/eSCL.
+ Scanner string
+ // Folder is the absolute path the PDFs are filed in. It is not a manifest resource: a directory's path
+ // cannot take a config value (issue 1441), so the code creates it.
+ Folder string
+ // Resolution is the dpi asked of the scanner: 100, 200, 300 or 600.
+ Resolution int
+ // ColorMode is "color" or "gray".
+ ColorMode string
+ // Crop says whether the document is cut out of the platen's white.
+ Crop bool
+}
+
+// LoadSettings reads settings.json (the file named by SCAN_SETTINGS) and lays the defaults under it. A missing
+// or unreadable file is an error that names it; an unset scanner or folder is Missing's to say.
+func LoadSettings(path string) (Settings, error) {
+ s := Settings{Resolution: 300, ColorMode: "color", Crop: true}
+ if path == "" {
+ return s, fmt.Errorf("SCAN_SETTINGS names no settings file: the module's config does not reach the code")
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return s, fmt.Errorf("settings cannot be read: %w", err)
+ }
+ var in map[string]any
+ if err := json.Unmarshal(raw, &in); err != nil {
+ return s, fmt.Errorf("settings are not JSON (%s): %w", path, err)
+ }
+ if v, ok := in["scanner"].(string); ok {
+ s.Scanner = strings.TrimSpace(v)
+ }
+ if v, ok := in["scan-folder"].(string); ok {
+ s.Folder = strings.TrimSpace(v)
+ }
+ if v, ok := in["resolution"].(float64); ok && v > 0 {
+ s.Resolution = int(v)
+ }
+ if v, ok := in["color-mode"].(string); ok && v != "" {
+ s.ColorMode = v
+ }
+ if v, ok := in["crop"].(bool); ok {
+ s.Crop = v
+ }
+ return s, nil
+}
+
+// Missing is nil when both required values are set, and otherwise says which is not: no tool writes or
+// asks anything before that (1440/AC7).
+func (s Settings) Missing() error {
+ var unset []string
+ if s.Scanner == "" {
+ unset = append(unset, "scanner")
+ }
+ if s.Folder == "" {
+ unset = append(unset, "scan-folder")
+ }
+ if len(unset) == 0 {
+ return nil
+ }
+ return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the scan module on this node",
+ strings.Join(unset, " and "))
+}
diff --git a/modules/scan/cmd/scan/testdata/escl/capabilities.xml b/modules/scan/cmd/scan/testdata/escl/capabilities.xml
new file mode 100644
index 00000000..da671013
--- /dev/null
+++ b/modules/scan/cmd/scan/testdata/escl/capabilities.xml
@@ -0,0 +1,261 @@
+
+
+2.93
+Brother MFC-L8390CDW series
+E82414G4N581626
+Brother
+e3248000-80ce-11db-8000-94ddf83ec78a
+http://BRW58CDC9DFC80B.local./net/net/airprint.html
+http://BRW58CDC9DFC80B.local./icons/device-icons-128.png
+
+
+mopria-certified-scan
+1.4
+
+
+
+
+64
+2550
+64
+3507
+2486
+3443
+1
+
+
+
+BlackAndWhite1
+Grayscale8
+RGB24
+
+
+application/pdf
+image/jpeg
+application/pdf
+image/jpeg
+
+
+
+
+100
+100
+
+
+200
+200
+
+
+300
+300
+
+
+600
+600
+
+
+
+
+CMYK
+YCC
+sRGB
+
+
+Red
+Green
+Blue
+NTSC
+GrayCcd
+GrayCcdEmulated
+
+
+Halftone
+Threshold
+
+
+
+
+Document
+TextAndGraphic
+Photo
+Preview
+
+1200
+2400
+0
+0
+0
+0
+2550
+3507
+
+
+
+
+64
+2550
+1740
+4200
+2486
+2460
+1
+
+
+
+BlackAndWhite1
+Grayscale8
+RGB24
+
+
+application/pdf
+image/jpeg
+application/pdf
+image/jpeg
+
+
+
+
+100
+100
+
+
+200
+200
+
+
+300
+300
+
+
+600
+600
+
+
+
+
+CMYK
+YCC
+sRGB
+
+
+Red
+Green
+Blue
+NTSC
+GrayCcd
+GrayCcdEmulated
+
+
+Halftone
+Threshold
+
+
+
+
+Document
+TextAndGraphic
+Photo
+Preview
+
+1200
+600
+0
+0
+0
+0
+2550
+4200
+
+
+64
+2550
+1740
+4200
+2486
+2460
+1
+
+
+
+BlackAndWhite1
+Grayscale8
+RGB24
+
+
+application/pdf
+image/jpeg
+application/pdf
+image/jpeg
+
+
+
+
+100
+100
+
+
+200
+200
+
+
+300
+300
+
+
+600
+600
+
+
+
+
+CMYK
+YCC
+sRGB
+
+
+Red
+Green
+Blue
+NTSC
+GrayCcd
+GrayCcdEmulated
+
+
+Halftone
+Threshold
+
+
+
+
+Document
+TextAndGraphic
+Photo
+Preview
+
+600
+600
+0
+0
+0
+0
+2550
+4200
+
+50
+
+DetectPaperLoaded
+Duplex
+
+
+
+1
+3
+2
+1
+
+
diff --git a/modules/scan/cmd/scan/testdata/escl/doc-headers.txt b/modules/scan/cmd/scan/testdata/escl/doc-headers.txt
new file mode 100644
index 00000000..58ba6a22
--- /dev/null
+++ b/modules/scan/cmd/scan/testdata/escl/doc-headers.txt
@@ -0,0 +1,6 @@
+HTTP/1.1 200 OK
+Content-Location: /eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a/doc-1
+Accept-Encoding: identity
+Content-Type: image/jpeg
+Transfer-Encoding: chunked
+
diff --git a/modules/scan/cmd/scan/testdata/escl/post-headers.txt b/modules/scan/cmd/scan/testdata/escl/post-headers.txt
new file mode 100644
index 00000000..bde86cec
--- /dev/null
+++ b/modules/scan/cmd/scan/testdata/escl/post-headers.txt
@@ -0,0 +1,10 @@
+HTTP/1.1 201 Created
+Cache-Control: no-store
+Accept-Encoding: identity
+X-Frame-Options: DENY
+Content-Length: 0
+Content-Type: text/xml
+Connection: close
+Pragma: no-cache
+Location: http://192.168.1.171/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a
+
diff --git a/modules/scan/cmd/scan/testdata/escl/scan.jpg b/modules/scan/cmd/scan/testdata/escl/scan.jpg
new file mode 100644
index 00000000..7ba7149e
Binary files /dev/null and b/modules/scan/cmd/scan/testdata/escl/scan.jpg differ
diff --git a/modules/scan/cmd/scan/testdata/escl/settings.xml b/modules/scan/cmd/scan/testdata/escl/settings.xml
new file mode 100644
index 00000000..ad341752
--- /dev/null
+++ b/modules/scan/cmd/scan/testdata/escl/settings.xml
@@ -0,0 +1,9 @@
+
+
+2.63
+escl:ThreeHundredthsOfInches0025503507
+Platen
+RGB24
+300300
+image/jpeg
+
diff --git a/modules/scan/cmd/scan/testdata/escl/status-after.xml b/modules/scan/cmd/scan/testdata/escl/status-after.xml
new file mode 100644
index 00000000..ef3ac425
--- /dev/null
+++ b/modules/scan/cmd/scan/testdata/escl/status-after.xml
@@ -0,0 +1 @@
+2.93IdleScannerAdfEmpty/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78aurn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a929107111CompletedJobCompletedSuccessfully
\ No newline at end of file
diff --git a/modules/scan/cmd/scan/testdata/escl/status-idle.xml b/modules/scan/cmd/scan/testdata/escl/status-idle.xml
new file mode 100644
index 00000000..b16eb5d6
--- /dev/null
+++ b/modules/scan/cmd/scan/testdata/escl/status-idle.xml
@@ -0,0 +1 @@
+2.93IdleScannerAdfEmpty/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78aurn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a929219211CompletedJobCompletedSuccessfully
\ No newline at end of file
diff --git a/modules/scan/go.mod b/modules/scan/go.mod
new file mode 100644
index 00000000..86725f92
--- /dev/null
+++ b/modules/scan/go.mod
@@ -0,0 +1,3 @@
+module scan
+
+go 1.26.0