diff --git a/Makefile b/Makefile
index bbf5d2c8b170eef0fbcf2bdd2e9a5bf39889b2ed..90b56e81c91039242b8069365bdda9a4839ad8eb 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ Configure.LicenseIdentifier = Apache-2.0
 Configure.CopyrightText = Huawei Inc.
 
 include z.mk
-$(if $(value ZMK.Version),,$(error The project depends on zmk -- Get it from https://github.com/zyga/zmk or ./get-zmk.sh;))
+$(if $(value ZMK.Version),,$(error The project depends on zmk -- Get it from https://github.com/zyga/zmk;))
 
 $(eval $(call ZMK.Import,Directories))
 $(eval $(call ZMK.Import,Configure))
diff --git a/cmd/sysotad/cmdsysotad/sysotad.go b/cmd/sysotad/cmdsysotad/sysotad.go
index 7fe792d4ed3f1100d6c9d39124bfd022f7a008ae..fd46ad082d9807a72319e1ba6889baef55a9838f 100644
--- a/cmd/sysotad/cmdsysotad/sysotad.go
+++ b/cmd/sysotad/cmdsysotad/sysotad.go
@@ -14,6 +14,7 @@ import (
 	"time"
 
 	"booting.oniroproject.org/distro/components/sysota/dbusutil"
+	"booting.oniroproject.org/distro/components/sysota/errutil"
 	"booting.oniroproject.org/distro/components/sysota/ota"
 	"booting.oniroproject.org/distro/components/sysota/service"
 )
@@ -42,46 +43,51 @@ func DefaultOptions() *Options {
 	}
 }
 
-// Run implements the sysotad binary.
-func Run(opts *Options) (err error) {
-	// Load configuration.
-	conf, err := ota.LoadConfig(opts.ConfigFiles)
-	if err != nil {
-		return err
+type stateFile string
+
+// LoadState loads system state.
+func (sf stateFile) LoadState() (*ota.SystemState, error) {
+	return ota.LoadState(string(sf))
+}
+
+// SaveState saves system state.
+func (sf stateFile) SaveState(st *ota.SystemState) error {
+	err := ota.SaveState(st, string(sf))
+
+	// Ignore save errors if the state directory does not exist.
+	// TODO: this code should go away, it was created early on and does not make
+	// much sense anymore.
+	var pathErr *os.PathError
+	if errors.As(err, &pathErr) && os.IsNotExist(pathErr) {
+		return nil
 	}
 
-	// Load state and defer state save on return.
-	state, err := ota.LoadState(opts.StateFile)
-	if err != nil {
-		return err
+	// Did we save the state successfully?
+	if err == nil {
+		_, _ = fmt.Fprintf(Stdout, "System state saved to %s\n", string(sf))
+		return nil
 	}
 
-	defer func() {
-		e := ota.SaveState(state, opts.StateFile)
+	return err
+}
 
-		// Ignore save errors if the state directory does not exist.
-		var pathErr *os.PathError
-		if errors.As(e, &pathErr) && os.IsNotExist(pathErr) {
-			return
-		}
+type configFiles struct {
+	readFiles []string
+	writeFile string
+}
 
-		// Did we save the state successfully?
-		if e == nil {
-			_, _ = fmt.Fprintf(Stdout, "System state saved to %s\n", opts.StateFile)
-			return
-		}
+// LoadConfig loads service configuration.
+func (cf *configFiles) LoadConfig() (*ota.Config, error) {
+	return ota.LoadConfig(cf.readFiles)
+}
 
-		// We failed to save the state. What shall we do with the error (e)?
-		if err == nil {
-			// Propagate state save error if possible.
-			err = e
-		} else {
-			// Log state save error if it cannot be propagated (the service
-			// will exit with a failure code in both cases).
-			_, _ = fmt.Fprintf(Stderr, "%v\n", e)
-		}
-	}()
+// SaveConfig saves service configuration.
+func (cf *configFiles) SaveConfig(cfg *ota.Config) error {
+	return ota.SaveConfig(cfg, cf.writeFile)
+}
 
+// Run implements the sysotad binary.
+func Run(opts *Options) (err error) {
 	// Connect to the system bus and defer disconnect on return.
 	conn, err := dbusutil.SystemBus()
 	if err != nil {
@@ -89,14 +95,21 @@ func Run(opts *Options) (err error) {
 	}
 
 	defer func() {
-		e := conn.Close()
-		if err == nil {
-			err = e
-		}
+		errutil.Forward(&err, conn.Close())
 	}()
 
-	// Create the SystemOTA service
-	svc, err := service.New(conf, state, conn)
+	exitCh := make(chan struct{})
+	defer close(exitCh)
+
+	// Create the SystemOTA service.
+	svc, err := service.New(conn,
+		service.WithConfigFiles(&configFiles{
+			readFiles: opts.ConfigFiles,
+			writeFile: ota.DefaultStatefulConfigFile(),
+		}),
+		service.WithStateFile(stateFile(opts.StateFile)),
+		service.WithExitChannel(exitCh),
+	)
 	if err != nil {
 		return err
 	}
@@ -135,6 +148,10 @@ func Run(opts *Options) (err error) {
 		case sigNum := <-sigCh:
 			_, _ = fmt.Fprintf(Stdout, "Exiting due to signal %v\n", sigNum)
 
+			run = false
+		case <-exitCh:
+			_, _ = fmt.Fprintf(Stdout, "Exiting due to exit request\n")
+
 			run = false
 		}
 	}
diff --git a/cmd/sysotad/cmdsysotad/sysotad_test.go b/cmd/sysotad/cmdsysotad/sysotad_test.go
index e89fcc31f6cb2d53e4e5d842f41743451d567591..375b9a6c07860320439229de03c15a50c5cc3158 100644
--- a/cmd/sysotad/cmdsysotad/sysotad_test.go
+++ b/cmd/sysotad/cmdsysotad/sysotad_test.go
@@ -4,7 +4,6 @@ package cmdsysotad_test
 
 import (
 	"bytes"
-	"io/ioutil"
 	"os"
 	"path/filepath"
 	"testing"
@@ -59,80 +58,3 @@ func (s *sysotadSuite) TestStartupAndIdleShutdown(c *C) {
 		"Exiting due to inactivity\n")
 	c.Check(s.stderr.String(), Equals, "")
 }
-
-func (s *sysotadSuite) TestStateFileSavedOnExit(c *C) {
-	d := c.MkDir()
-	stateFile := filepath.Join(d, "state.ini")
-
-	opts := cmdsysotad.DefaultOptions()
-	opts.IdleDuration = time.Second
-	opts.StateFile = stateFile
-
-	err := cmdsysotad.Run(opts)
-
-	c.Assert(err, IsNil)
-	c.Check(s.stdout.String(), Equals, ""+
-		"Listening ...\n"+
-		"Exiting due to inactivity\n"+
-		"System state saved to "+stateFile+"\n")
-	c.Check(s.stderr.String(), Equals, "")
-
-	// The zero value is an empty state file but it gets created.
-	fi, err := os.Stat(stateFile)
-	c.Assert(err, IsNil)
-	c.Check(fi.Size(), Equals, int64(0))
-}
-
-func (s *sysotadSuite) TestStateLoadSaveDance(c *C) {
-	d := c.MkDir()
-	stateFile := filepath.Join(d, "state.ini")
-	err := ioutil.WriteFile(stateFile, []byte("[System]\nBootMode=try\n"), 0600)
-	c.Assert(err, IsNil)
-
-	opts := cmdsysotad.DefaultOptions()
-	opts.IdleDuration = time.Second
-	opts.StateFile = stateFile
-
-	err = cmdsysotad.Run(opts)
-	c.Assert(err, IsNil)
-
-	c.Check(s.stdout.String(), Equals, ""+
-		"Listening ...\n"+
-		"Exiting due to inactivity\n"+
-		"System state saved to "+stateFile+"\n")
-	c.Check(s.stderr.String(), Equals, "")
-
-	data, err := ioutil.ReadFile(stateFile)
-	c.Assert(err, IsNil)
-	c.Check(data, DeepEquals, []byte("[System]\nBootMode=try\n"))
-}
-
-func (s *sysotadSuite) TestBrokenDBusButWhatAboutState(c *C) {
-	restore, err := dbustest.SetDBusSystemBusAddress("potato")
-	c.Assert(err, IsNil)
-
-	defer func() {
-		c.Assert(restore(), IsNil)
-	}()
-
-	d := c.MkDir()
-	stateFile := filepath.Join(d, "state.ini")
-	err = ioutil.WriteFile(stateFile, []byte("[System]\nBootMode=try\n"), 0600)
-	c.Assert(err, IsNil)
-
-	opts := cmdsysotad.DefaultOptions()
-	opts.IdleDuration = time.Second
-	opts.StateFile = stateFile
-
-	// The service fails to start but doesn't forget to save the state.
-	err = cmdsysotad.Run(opts)
-	c.Assert(err, ErrorMatches, `dbus: invalid bus address \(no transport\)`)
-
-	c.Check(s.stdout.String(), Equals, ""+
-		"System state saved to "+stateFile+"\n")
-	c.Check(s.stderr.String(), Equals, "")
-
-	data, err := ioutil.ReadFile(stateFile)
-	c.Assert(err, IsNil)
-	c.Check(data, DeepEquals, []byte("[System]\nBootMode=try\n"))
-}
diff --git a/cmd/sysotad/spread.suite/introspection/introspection-expected-debug.txt b/cmd/sysotad/spread.suite/introspection/introspection-expected-debug.txt
index 22ff9cc0bc70e3b3b0c09f49bf1ed0fa2285ee3a..91d785a9fe4488f7cc3782b36b83498628362668 100644
--- a/cmd/sysotad/spread.suite/introspection/introspection-expected-debug.txt
+++ b/cmd/sysotad/spread.suite/introspection/introspection-expected-debug.txt
@@ -1,32 +1,38 @@
-NAME                                TYPE      SIGNATURE  RESULT/VALUE   FLAGS
-org.freedesktop.DBus.Introspectable interface -          -              -
-.Introspect                         method    -          s              -
-org.freedesktop.DBus.ObjectManager  interface -          -              -
-.GetManagedObjects                  method    -          a{oa{sa{sv}}}  -
-.InterfacesAdded                    signal    oa{sa{sv}} -              -
-.InterfacesRemoved                  signal    oas        -              -
-org.freedesktop.DBus.Properties     interface -          -              -
-.Get                                method    ss         v              -
-.GetAll                             method    s          a{sv}          -
-.Set                                method    ssv        -              -
-.PropertiesChanged                  signal    sa{sv}as   -              -
-org.oniroproject.sysota1.BootLoader interface -          -              -
-.CancelSwitch                       method    -          -              -
-.CommitSwitch                       method    -          -              -
-.QueryActive                        method    -          s              -
-.QueryInactive                      method    -          s              -
-.Reboot                             method    u          -              -
-.TrySwitch                          method    s          -              -
-org.oniroproject.sysota1.RAUC       interface -          -              -
-.GetPrimary                         method    -          s              -
-.GetState                           method    s          s              -
-.PostInstall                        method    as         -              -
-.PreInstall                         method    as         -              -
-.SetPrimary                         method    s          -              -
-.SetState                           method    ss         -              -
-org.oniroproject.sysota1.Service    interface -          -              -
-.UpdateDevice                       method    a{ss}      o              -
-.UpdateStreams                      method    -          -              -
-.Maker                              property  s          "dummy-maker"  const
-.Model                              property  s          "dummy-model"  const
-.Stream                             property  s          "dummy-stream" emits-change writable
+NAME                                TYPE      SIGNATURE  RESULT/VALUE  FLAGS
+org.freedesktop.DBus.Introspectable interface -          -             -
+.Introspect                         method    -          s             -
+org.freedesktop.DBus.ObjectManager  interface -          -             -
+.GetManagedObjects                  method    -          a{oa{sa{sv}}} -
+.InterfacesAdded                    signal    oa{sa{sv}} -             -
+.InterfacesRemoved                  signal    oas        -             -
+org.freedesktop.DBus.Properties     interface -          -             -
+.Get                                method    ss         v             -
+.GetAll                             method    s          a{sv}         -
+.Set                                method    ssv        -             -
+.PropertiesChanged                  signal    sa{sv}as   -             -
+org.oniroproject.sysota1.BootLoader interface -          -             -
+.CancelSwitch                       method    -          -             -
+.CommitSwitch                       method    -          -             -
+.QueryActive                        method    -          s             -
+.QueryInactive                      method    -          s             -
+.Reboot                             method    u          -             -
+.TrySwitch                          method    s          -             -
+org.oniroproject.sysota1.RAUC       interface -          -             -
+.GetPrimary                         method    -          s             -
+.GetState                           method    s          s             -
+.PostInstall                        method    as         -             -
+.PreInstall                         method    as         -             -
+.SetPrimary                         method    s          -             -
+.SetState                           method    ss         -             -
+org.oniroproject.sysota1.Service    interface -          -             -
+.UpdateDevice                       method    a{ss}      o             -
+.UpdateStreams                      method    -          -             -
+.Maker                              property  s          "test-maker"  const
+.Model                              property  s          "test-model"  const
+.Stream                             property  s          "test-stream" emits-change writable
+org.oniroproject.sysota1.Test       interface -          -             -
+.Exit                               method    -          -             -
+.LoadConfig                         method    -          -             -
+.LoadState                          method    -          -             -
+.SaveConfig                         method    -          -             -
+.SaveState                          method    -          -             -
diff --git a/cmd/sysotad/spread.suite/introspection/introspection-expected-production.txt b/cmd/sysotad/spread.suite/introspection/introspection-expected-production.txt
index 357c8212ec1c12ffe431582220df68e5b6b78ecd..a425b3c93dd4711d5924c0b7fcf6b2d1885a812a 100644
--- a/cmd/sysotad/spread.suite/introspection/introspection-expected-production.txt
+++ b/cmd/sysotad/spread.suite/introspection/introspection-expected-production.txt
@@ -1,25 +1,25 @@
-NAME                                TYPE      SIGNATURE  RESULT/VALUE   FLAGS
-org.freedesktop.DBus.Introspectable interface -          -              -
-.Introspect                         method    -          s              -
-org.freedesktop.DBus.ObjectManager  interface -          -              -
-.GetManagedObjects                  method    -          a{oa{sa{sv}}}  -
-.InterfacesAdded                    signal    oa{sa{sv}} -              -
-.InterfacesRemoved                  signal    oas        -              -
-org.freedesktop.DBus.Properties     interface -          -              -
-.Get                                method    ss         v              -
-.GetAll                             method    s          a{sv}          -
-.Set                                method    ssv        -              -
-.PropertiesChanged                  signal    sa{sv}as   -              -
-org.oniroproject.sysota1.RAUC       interface -          -              -
-.GetPrimary                         method    -          s              -
-.GetState                           method    s          s              -
-.PostInstall                        method    as         -              -
-.PreInstall                         method    as         -              -
-.SetPrimary                         method    s          -              -
-.SetState                           method    ss         -              -
-org.oniroproject.sysota1.Service    interface -          -              -
-.UpdateDevice                       method    a{ss}      o              -
-.UpdateStreams                      method    -          -              -
-.Maker                              property  s          "dummy-maker"  const
-.Model                              property  s          "dummy-model"  const
-.Stream                             property  s          "dummy-stream" emits-change writable
+NAME                                TYPE      SIGNATURE  RESULT/VALUE  FLAGS
+org.freedesktop.DBus.Introspectable interface -          -             -
+.Introspect                         method    -          s             -
+org.freedesktop.DBus.ObjectManager  interface -          -             -
+.GetManagedObjects                  method    -          a{oa{sa{sv}}} -
+.InterfacesAdded                    signal    oa{sa{sv}} -             -
+.InterfacesRemoved                  signal    oas        -             -
+org.freedesktop.DBus.Properties     interface -          -             -
+.Get                                method    ss         v             -
+.GetAll                             method    s          a{sv}         -
+.Set                                method    ssv        -             -
+.PropertiesChanged                  signal    sa{sv}as   -             -
+org.oniroproject.sysota1.RAUC       interface -          -             -
+.GetPrimary                         method    -          s             -
+.GetState                           method    s          s             -
+.PostInstall                        method    as         -             -
+.PreInstall                         method    as         -             -
+.SetPrimary                         method    s          -             -
+.SetState                           method    ss         -             -
+org.oniroproject.sysota1.Service    interface -          -             -
+.UpdateDevice                       method    a{ss}      o             -
+.UpdateStreams                      method    -          -             -
+.Maker                              property  s          "test-maker"  const
+.Model                              property  s          "test-model"  const
+.Stream                             property  s          "test-stream" emits-change writable
diff --git a/cmd/sysotad/spread.suite/introspection/task.yaml b/cmd/sysotad/spread.suite/introspection/task.yaml
index 25ccb7050a233d0392381a4d31d41b3a308fceb3..5bd242c4f68b708987b34657dd977c64bb9ee282 100644
--- a/cmd/sysotad/spread.suite/introspection/task.yaml
+++ b/cmd/sysotad/spread.suite/introspection/task.yaml
@@ -4,22 +4,22 @@
 summary: OTA Service publishes introspection data
 environment:
     BOOT_API_DEBUG/on: "true"
+    TEST_API_DEBUG/on: "true"
     EXPECTED/on: introspection-expected-debug.txt
     BOOT_API_DEBUG/off: "false"
+    TEST_API_DEBUG/off: "false"
     EXPECTED/off: introspection-expected-production.txt
 prepare: |
-    # We are not expecting a config file since we are clobbering things below.
-    test ! -e /etc/sysota/sysotad.conf
-
     systemctl stop sysotad.service
-    cat <<SYSOTAD_CONF >/etc/sysota/sysotad.conf
+    mkdir -p /run/sysota
+    cat <<SYSOTAD_CONF >/run/sysota/sysotad.conf
     [Debug]
     BootAPI = $BOOT_API_DEBUG
+    TestAPI = $TEST_API_DEBUG
     SYSOTAD_CONF
 execute: |
     busctl introspect org.oniroproject.sysota1 /org/oniroproject/sysota1/Service >introspection-actual.txt
     diff -u introspection-actual.txt "$EXPECTED"
 restore: |
     rm -f introspection-actual.txt
-    rm -f /etc/sysota/sysotad.conf
     systemctl stop sysotad.service
diff --git a/cmd/sysotad/spread.suite/smoke/task.yaml b/cmd/sysotad/spread.suite/smoke/task.yaml
index 53b9291de7f60785cecc0ad58764336a10c0c5ff..185dce93db77e7f61dbd6302fe2b5246c8e36d00 100644
--- a/cmd/sysotad/spread.suite/smoke/task.yaml
+++ b/cmd/sysotad/spread.suite/smoke/task.yaml
@@ -4,15 +4,20 @@
 summary: Initial smoke tests
 execute: |
     # The three properties can be read
-    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Maker)" = 's "dummy-maker"'
-    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Model)" = 's "dummy-model"'
-    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Stream)" = 's "dummy-stream"'
+    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Maker)" = 's "test-maker"'
+    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Model)" = 's "test-model"'
+    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Stream)" = 's "test-stream"'
 
     # The 'Stream' property can be written as well
-    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Stream)" = 's "dummy-stream"'
+    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Stream)" = 's "test-stream"'
     test "$(busctl set-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Stream s custom)" = ''
     test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Stream)" = 's "custom"'
 
+    systemctl restart sysotad.service
+
+    # Changes to the 'Stream' property are persistent.
+    test "$(busctl get-property org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service Stream)" = 's "custom"'
+
     # The UpdateStreams method can be called
     test "$(busctl call org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Service UpdateStreams)" = ''
     journalctl -u sysotad.service | MATCH 'called UpdateStreams'
diff --git a/cmd/sysotad/spread.suite/state-dir-disk-full/task.yaml b/cmd/sysotad/spread.suite/state-dir-disk-full/task.yaml
index b67689443891361e16181002769794adaf4a75b8..0b5efee7152e78c5a723e8354574c55c1e1a9013 100644
--- a/cmd/sysotad/spread.suite/state-dir-disk-full/task.yaml
+++ b/cmd/sysotad/spread.suite/state-dir-disk-full/task.yaml
@@ -6,10 +6,12 @@ prepare: |
   # Stop the service ahead of state modifications.
   systemctl stop sysotad.service
 
-  # Grab the cursor to the journal stream associated with the service. We can
-  # then look at all the messages logged since this log position. This can fail
-  # if the service never logged anything, so ignore errors.
-  journalctl -u sysotad.service --cursor-file=cursor >/dev/null || true
+  # Enable the load-save debug interface.
+  mkdir -p /run/sysota
+  cat <<SYSOTAD_CONF >/run/sysota/sysotad.conf
+  [Debug]
+  TestAPI = true
+  SYSOTAD_CONF
 
   # Mount a tmpfs with space for exactly two inodes.
   # XXX(zyga): why two and not one?
@@ -24,16 +26,11 @@ prepare: |
   truncate --size=0 /var/lib/sysota/state.ini
 
 execute: |
-  # Start the service and then stop it, forcing state save.
-  systemctl start sysotad.service
-  systemctl stop sysotad.service
+  # Ask the service to save its state through the debug interface.
+  busctl call org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Test SaveState 2>&1 | MATCH 'Call failed: cannot save state: open /var/lib/sysota/tmp-.*: no space left on device'
 
-  # The service has failed to write the state file.
-  test "$(systemctl is-failed sysotad.service)" = failed
-  journalctl -u sysotad.service --cursor-file=cursor | MATCH 'error: cannot save state: open /var/lib/sysota/tmp-[0-9]+: no space left on device'
 restore: |
-  rm -f cursor
   umount /var/lib/sysota
-  # This removes the file created by stopping sysotad.service, from the prepare stage.
-  rm -f /var/lib/sysota/state.ini
-  rmdir /var/lib/sysota
+  rm -f /run/sysotad/sysotad.conf
+
+  systemctl restart sysotad.service
diff --git a/cmd/sysotad/spread.suite/state-dir-read-only-fs/task.yaml b/cmd/sysotad/spread.suite/state-dir-read-only-fs/task.yaml
index 5e64acff89a3dfbc2d6dd79c4861fcc9b69a5c51..a9d276690ccabe21a21fe9d2d76a43efcb145a1f 100644
--- a/cmd/sysotad/spread.suite/state-dir-read-only-fs/task.yaml
+++ b/cmd/sysotad/spread.suite/state-dir-read-only-fs/task.yaml
@@ -6,10 +6,12 @@ prepare: |
   # Stop the service ahead of state modifications.
   systemctl stop sysotad.service
 
-  # Grab the cursor to the journal stream associated with the service. We can
-  # then look at all the messages logged since this log position. This can fail
-  # if the service never logged anything, so ignore errors.
-  journalctl -u sysotad.service --cursor-file=cursor >/dev/null || true
+  # Enable the load-save debug interface.
+  mkdir -p /run/sysota
+  cat <<SYSOTAD_CONF >/run/sysota/sysotad.conf
+  [Debug]
+  TestAPI = true
+  SYSOTAD_CONF
 
   # Create an empty state file. Later on the semantics of running sysotad with a
   # read-only /var/lib/sysota but without a state file may change, specifically
@@ -25,18 +27,14 @@ prepare: |
   # system read only. for the purpose of this test it is sufficient to present a
   # read-only view of the file system, which is allowed by the sandbox.
   mount -o bind,ro /var/lib/sysota /var/lib/sysota
+
 execute: |
-  # Start the service and then stop it, forcing state save.
-  systemctl start sysotad.service
-  systemctl stop sysotad.service
+  # Ask the service to save its state through the debug interface.
+  busctl call org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Test SaveState 2>&1 | MATCH 'Call failed: cannot save state: open /var/lib/sysota/tmp-.*: read-only file system'
 
-  # The service has failed to write the state file.
-  test "$(systemctl is-failed sysotad.service)" = failed
-  journalctl -u sysotad.service --cursor-file=cursor | MATCH 'error: cannot save state: open /var/lib/sysota/tmp-[0-9]+: read-only file system'
 restore: |
-  rm -f cursor
   # Remove the bind mount making the sysota directory read-only.
   umount /var/lib/sysota
-  # *Then* remove the file which is now normally writable.
-  rm -f /var/lib/sysota/state.ini
-  rmdir /var/lib/sysota
+  rm -f /run/sysotad/sysotad.conf
+
+  systemctl restart sysotad.service
diff --git a/cmd/sysotad/spread.suite/state-save-syscalls/task.yaml b/cmd/sysotad/spread.suite/state-save-syscalls/task.yaml
index 7afb19c7b3c1de2decff7d265f1f3baa9aa901f1..f592e86d7fe36b66e79174b0f670b9c53bc82198 100644
--- a/cmd/sysotad/spread.suite/state-save-syscalls/task.yaml
+++ b/cmd/sysotad/spread.suite/state-save-syscalls/task.yaml
@@ -7,30 +7,52 @@ details: |
   ota.SaveState. We can do that by running sysotad through strace and looking at
   the sequence of system calls used by sysotad on shutdown.
 prepare: |
-  # Ensure that the service is running so that we cannot claim the bus name and exit quickly.
-  systemctl start sysotad.service
+  # Stop the service ahead of state modifications.
+  systemctl stop sysotad.service
+
+  # Enable the load-save debug interface.
+  mkdir -p /run/sysota
+  cat <<SYSOTAD_CONF >/run/sysota/sysotad.conf
+  [Debug]
+  TestAPI = true
+  SYSOTAD_CONF
+
+  sed -e 's!ExecStart=.*!Environment=GOMAXPROCS=1\nExecStart=/usr/bin/strace --string-limit=128 --trace=%%file,%%desc,sync,fsync,fdatasync --follow-forks --output='"$(pwd)"'/sysotad.strace /usr/libexec/sysota/sysotad!' </usr/lib/systemd/system/sysotad.service >/etc/systemd/system/sysotad.service
+  systemctl daemon-reload
+  systemctl stop sysotad.service
 
   # Create a state file we will attempt to read and write to.
   mkdir -p /var/lib/sysota
   truncate --size=0 /var/lib/sysota/state.ini
+
 execute: |
-  GOMAXPROCS=1 strace --trace=%file,%desc,sync,fsync,fdatasync --follow-forks --output=sysotad.strace /usr/libexec/sysota/sysotad 2>&1 | MATCH 'error: cannot claim bus name org.oniroproject.sysota1'
+  busctl call org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Test LoadState
+  busctl call org.oniroproject.sysota1 /org/oniroproject/sysota1/Service org.oniroproject.sysota1.Test SaveState
+  systemctl stop sysotad.service
 
   # NOTE: MATCH is fancy way to call "grep -E" and show the input on failure.
 
-  # We open the file for reading on startup.
-  MATCH '^[0-9]+\s+openat\(AT_FDCWD, "/var/lib/sysota/state.ini", O_RDONLY\|O_CLOEXEC\) = 3$' <sysotad.strace
+  # We this is how we read the state.
+  MATCH '^[0-9]+\s+openat\(AT_FDCWD, "/var/lib/sysota/state.ini", O_RDONLY\|O_CLOEXEC\) = 7$' <sysotad.strace
+  MATCH '^[0-9]+\s+read\(7, "", 4096\)\s+= 0$' <sysotad.strace
 
+  # This is how we write the state.
+  #
   # We we do the open+rename+fsync+fsync (dir) dance on shutdown.
   # Note that fsync may be interrupted so we try to match both:
   # 47447 fsync(3 <unfinished ...>
   # 47447 fsync(7)                          = 0
-  MATCH '^[0-9]+\s+openat\(AT_FDCWD, "/var/lib/sysota/tmp-[0-9]+", O_RDWR|O_CREAT|O_EXCL\|O_CLOEXEC, 0600\) = 3$' <sysotad.strace
-  MATCH '^[0-9]+\s+openat\(AT_FDCWD, "/var/lib/sysota", O_RDONLY\|O_CLOEXEC\) = 7$' <sysotad.strace
-  MATCH '^[0-9]+\s+fsync\(3( <unfinished \.\.\.>|\)\s+= 0)$' <sysotad.strace
-  MATCH '^[0-9]+\s+renameat\(AT_FDCWD, "/var/lib/sysota/tmp-[0-9]+", AT_FDCWD, "/var/lib/sysota/state.ini"( <unfinished \.\.\.>|\) = 0)$' <sysotad.strace
+  MATCH '^[0-9]+\s+openat\(AT_FDCWD, "/var/lib/sysota/tmp-[0-9]+", O_RDWR|O_CREAT|O_EXCL\|O_CLOEXEC, 0600\) = 7$' <sysotad.strace
+  MATCH '^[0-9]+\s+openat\(AT_FDCWD, "/var/lib/sysota", O_RDONLY\|O_CLOEXEC\) = 8$' <sysotad.strace
   MATCH '^[0-9]+\s+fsync\(7( <unfinished \.\.\.>|\)\s+= 0)$' <sysotad.strace
+  MATCH '^[0-9]+\s+renameat\(AT_FDCWD, "/var/lib/sysota/tmp-[0-9]+", AT_FDCWD, "/var/lib/sysota/state.ini"( <unfinished \.\.\.>|\) = 0)$' <sysotad.strace
+  MATCH '^[0-9]+\s+fsync\(8( <unfinished \.\.\.>|\)\s+= 0)$' <sysotad.strace
 restore: |
   rm -f sysotad.strace
   rm -f /var/lib/sysota/state.ini
   rmdir /var/lib/sysota
+  rm -f /run/sysotad/sysotad.conf
+
+  rm -f /etc/systemd/system/sysotad.service
+  systemctl daemon-reload
+  systemctl restart sysotad.service
diff --git a/dbusutil/servicehost_test.go b/dbusutil/servicehost_test.go
index c5df56f1cbfa557d903b9e45f31005ac22498664..bc79a4ade735e72c91b4537c03bfe6beefeb856f 100644
--- a/dbusutil/servicehost_test.go
+++ b/dbusutil/servicehost_test.go
@@ -40,7 +40,6 @@ type TestService struct {
 
 func (s *serviceHostSuite) TestUsageInit(c *C) {
 	var sh dbusutil.ServiceHost
-
 	ts := TestService{}
 
 	sh.Init(s.conn)
@@ -54,7 +53,6 @@ func (s *serviceHostSuite) TestUsageInit(c *C) {
 
 	// Register a bus name to make testing easier.
 	reply, err := s.conn.RequestName("org.example.Test", dbus.NameFlagDoNotQueue)
-
 	c.Assert(err, IsNil)
 	c.Assert(reply, Equals, dbus.RequestNameReplyPrimaryOwner)
 
diff --git a/get-zmk.sh b/get-zmk.sh
deleted file mode 100755
index 8a618272a2bc271f3e0033ef6b70c210b6ab9e3c..0000000000000000000000000000000000000000
--- a/get-zmk.sh
+++ /dev/null
@@ -1,19 +0,0 @@
-#!/bin/sh
-# SPDX-License-Identifier: Apache-2.0
-# SPDX-FileCopyrightText: Huawei Inc.
-set -ex
-
-D="$(mktemp -d)"
-trap 'rm -rf "$D"' EXIT
-
-cd "$D"
-
-wget https://github.com/zyga/zmk/releases/download/v0.5.1/zmk-0.5.1.tar.gz
-wget https://github.com/zyga/zmk/releases/download/v0.5.1/zmk-0.5.1.tar.gz.asc
-
-gpg --keyserver keyserver.ubuntu.com --recv-keys B76CED9B45CAF1557D271A6A2894E93A28C67B47
-gpg --verify zmk-0.5.1.tar.gz.asc
-
-tar zxf zmk-0.5.1.tar.gz
-make -C zmk-0.5.1
-sudo make -C zmk-0.5.1 install
diff --git a/ota/ota.go b/ota/ota.go
index a424add93cc1f5de92c1ce9797f5e88c6475838c..8f3cdd26b206c11f721db2af275ba93e0c7303bc 100644
--- a/ota/ota.go
+++ b/ota/ota.go
@@ -108,6 +108,9 @@ type Config struct {
 	// to use it over D-Bus but making that available does help with testing.
 	DebugBootAPI bool `ini:"BootAPI,[Debug],omitempty"`
 
+	// DebugTestAPI enables exposing the state and config API over D-Bus.
+	DebugTestAPI bool `ini:"TestAPI,omitempty"`
+
 	// Properties of the device and the update server.
 
 	// DeviceMaker identifies the maker of the device.
diff --git a/ota/raucadapter/raucadapter.go b/ota/raucadapter/raucadapter.go
index 34b4b2070bfa891fba270f5d8f6392723777307b..6e5e1f6d51769ee600aec73bec3ab0f0a526b8cf 100644
--- a/ota/raucadapter/raucadapter.go
+++ b/ota/raucadapter/raucadapter.go
@@ -10,6 +10,7 @@ import (
 	"errors"
 
 	"booting.oniroproject.org/distro/components/sysota/boot"
+	"booting.oniroproject.org/distro/components/sysota/ota"
 	"booting.oniroproject.org/distro/components/sysota/rauc/installhandler"
 )
 
@@ -22,17 +23,8 @@ var ErrProtocolViolation = errors.New("violation of protocol between SystemOTA a
 
 // Adapter uses SystemOTA boot.Protocol and ota.SystemState to implement boothandler.Handler.
 type Adapter struct {
-	proto          boot.Protocol
-	bootModeHolder BootModeHolder
-}
-
-// BootModeHolder is an interface exposing boot mode control.
-//
-// This interface exits, mainly, so that modifications of BootMode can be
-// broadcast over D-Bus.
-type BootModeHolder interface {
-	BootMode() boot.Mode
-	SetBootMode(boot.Mode) error
+	proto boot.Protocol
+	ps    *ota.StateGuard
 }
 
 // New returns a new Adapter implemented wrapping given boot.Protocol.
@@ -43,8 +35,8 @@ type BootModeHolder interface {
 // interface and has only one element of state that is not captured in the boot
 // loader itself, namely boot.Mode, where the system may be expected to normally
 // or in the one-off try mode.
-func New(proto boot.Protocol, bootModeHolder BootModeHolder) *Adapter {
-	return &Adapter{proto: proto, bootModeHolder: bootModeHolder}
+func New(proto boot.Protocol, ps *ota.StateGuard) *Adapter {
+	return &Adapter{proto: proto, ps: ps}
 }
 
 // PrimarySlot returns the name of the RAUC primary boot slot.
@@ -53,7 +45,7 @@ func New(proto boot.Protocol, bootModeHolder BootModeHolder) *Adapter {
 // state is used to pick either active slot (in normal mode) or inactive slot
 // (in try mode).
 func (adapter *Adapter) PrimarySlot() (boot.Slot, error) {
-	switch adapter.bootModeHolder.BootMode() {
+	switch adapter.bootMode() {
 	case boot.Normal:
 		return adapter.queryActiveOrPristine()
 	case boot.Try:
@@ -85,7 +77,7 @@ func (adapter *Adapter) SetPrimarySlot(slot boot.Slot) error {
 		return err
 	}
 
-	switch adapter.bootModeHolder.BootMode() {
+	switch adapter.bootMode() {
 	case boot.Normal:
 		if slot == activeSlot {
 			// No-op, in normal mode, the active slot is always primary.
@@ -100,7 +92,7 @@ func (adapter *Adapter) SetPrimarySlot(slot boot.Slot) error {
 			return err
 		}
 
-		return adapter.bootModeHolder.SetBootMode(boot.Try)
+		return adapter.setBootMode(boot.Try)
 	case boot.Try:
 		if slot == activeSlot {
 			// Protocol violation, this does not happen during "rauc install".
@@ -182,13 +174,12 @@ func (adapter *Adapter) SetSlotState(slot boot.Slot, state boot.SlotState) error
 	}
 
 	inactive := boot.SynthesizeInactiveSlot(active)
-	bootMode := adapter.bootModeHolder.BootMode()
 
 	// Switch by boot mode, then switch by slot state and finally look at the
 	// slot we've got. This exact same logic can be re-written in several
 	// different styles but I've found this to be easiest to grasp while
 	// retaining consistency.
-	switch bootMode {
+	switch adapter.bootMode() {
 	case boot.Normal:
 		switch state {
 		case boot.GoodSlot:
@@ -227,13 +218,13 @@ func (adapter *Adapter) SetSlotState(slot boot.Slot, state boot.SlotState) error
 				return err
 			}
 
-			return adapter.bootModeHolder.SetBootMode(boot.Normal)
+			return adapter.setBootMode(boot.Normal)
 		case boot.BadSlot:
 			if slot == inactive {
 				// Rollback of an update transaction.
 				// Note that we set boot mode even if CancelSwitch failed because by
 				// this time, we have rolled back already.
-				if err := adapter.bootModeHolder.SetBootMode(boot.Normal); err != nil {
+				if err := adapter.setBootMode(boot.Normal); err != nil {
 					return err
 				}
 
@@ -294,3 +285,18 @@ func (adapter *Adapter) queryInactiveOrPristine() (boot.Slot, error) {
 
 	return active, err
 }
+
+func (adapter *Adapter) bootMode() boot.Mode {
+	return adapter.ps.State().BootMode
+}
+
+func (adapter *Adapter) setBootMode(bootMode boot.Mode) error {
+	return adapter.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		if st.BootMode == bootMode {
+			return false, nil
+		}
+
+		st.BootMode = bootMode
+		return true, nil
+	})
+}
diff --git a/ota/raucadapter/raucadapter_test.go b/ota/raucadapter/raucadapter_test.go
index fe0cf39e929bed6dcde2e049ac3262a74119d3c5..84b2267ee7a6bb32b87e512bee189c31ebb32945 100644
--- a/ota/raucadapter/raucadapter_test.go
+++ b/ota/raucadapter/raucadapter_test.go
@@ -11,6 +11,7 @@ import (
 
 	"booting.oniroproject.org/distro/components/sysota/boot"
 	"booting.oniroproject.org/distro/components/sysota/boot/testboot"
+	"booting.oniroproject.org/distro/components/sysota/ota"
 	"booting.oniroproject.org/distro/components/sysota/ota/raucadapter"
 	"booting.oniroproject.org/distro/components/sysota/rauc/installhandler"
 	"booting.oniroproject.org/distro/components/sysota/rauc/rauctest"
@@ -21,10 +22,10 @@ func Test(t *testing.T) { TestingT(t) }
 var errBoom = errors.New("boom")
 
 type adapterSuite struct {
-	proto     testboot.Protocol
-	bootState rauctest.BootState
-	adapter   *raucadapter.Adapter
-	called    int
+	proto   testboot.Protocol
+	ps      ota.StateGuard
+	adapter *raucadapter.Adapter
+	called  int
 
 	// variant holds the description of the test suite variant.
 	variant CommentInterface
@@ -38,19 +39,22 @@ type adapterSuite struct {
 }
 
 func (s *adapterSuite) SetUpSuite(c *C) {
-	s.adapter = raucadapter.New(&s.proto, &s.bootState)
+	s.adapter = raucadapter.New(&s.proto, &s.ps)
 }
 
 func (s *adapterSuite) SetUpTest(c *C) {
 	s.cleanSlate(c)
 	// This is only set in the per-test setup so that individual tests
 	// can call cleanSlate and still observe the changes to the boot mode.
-	c.Assert(s.bootState.SetBootMode(boot.Normal), IsNil)
+	err := s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Normal
+		return true, nil
+	})
+	c.Assert(err, IsNil)
 }
 
 func (s *adapterSuite) cleanSlate(c *C) {
 	s.proto.ResetCallbacks()
-	s.bootState.BootModeChanged = nil
 	s.called = 0
 }
 
@@ -97,7 +101,7 @@ func (s *adapterSuite) TestInstallGoodPlaythrough(c *C) {
 	err := s.adapter.SetSlotState(s.toSlot, boot.BadSlot)
 	c.Assert(err, IsNil)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 
 	// custom: set-primary $toSlot
 	s.cleanSlate(c)
@@ -124,7 +128,7 @@ func (s *adapterSuite) TestInstallGoodPlaythrough(c *C) {
 	err = s.adapter.SetPrimarySlot(s.toSlot)
 	c.Assert(err, IsNil)
 	c.Assert(s.called, Equals, 2, s.variant)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 
 	// TODO(zyga): Conceptually, we would reboot with the special flag here.
 	// This is not tested yet, as it requires cooperation abetween RAUC
@@ -147,7 +151,7 @@ func (s *adapterSuite) TestInstallGoodPlaythrough(c *C) {
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, s.toSlot)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 
 	// custom: get-state $toSlot
 	s.cleanSlate(c)
@@ -165,7 +169,7 @@ func (s *adapterSuite) TestInstallGoodPlaythrough(c *C) {
 	c.Assert(err, IsNil)
 	c.Check(state, Equals, boot.BadSlot)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 
 	// custom: get-state $fromSlot
 	s.cleanSlate(c)
@@ -183,7 +187,7 @@ func (s *adapterSuite) TestInstallGoodPlaythrough(c *C) {
 	c.Assert(err, IsNil)
 	c.Check(state, Equals, boot.GoodSlot, s.variant)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 
 	// custom: set-state $toSlot good
 	s.cleanSlate(c)
@@ -206,7 +210,7 @@ func (s *adapterSuite) TestInstallGoodPlaythrough(c *C) {
 	c.Assert(err, IsNil)
 	c.Assert(s.called, Equals, 2)
 	// Note that boot.Mode switches to Normal.
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 }
 
 func (s *adapterSuite) TestInstallBadPlaythrough(c *C) {
@@ -230,7 +234,7 @@ func (s *adapterSuite) TestInstallBadPlaythrough(c *C) {
 	err := s.adapter.SetSlotState(s.toSlot, boot.BadSlot)
 	c.Assert(err, IsNil)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 
 	// custom: set-primary $toSlot
 	s.cleanSlate(c)
@@ -256,7 +260,7 @@ func (s *adapterSuite) TestInstallBadPlaythrough(c *C) {
 	err = s.adapter.SetPrimarySlot(s.toSlot)
 	c.Assert(err, IsNil)
 	c.Assert(s.called, Equals, 2, s.variant)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 
 	// TODO(zyga): Conceptually, we would reboot with the special flag here.
 	// This is not tested yet, as it requires cooperation abetween RAUC
@@ -278,7 +282,7 @@ func (s *adapterSuite) TestInstallBadPlaythrough(c *C) {
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, s.toSlot)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 
 	// custom: get-state $toSlot
 	s.cleanSlate(c)
@@ -296,7 +300,7 @@ func (s *adapterSuite) TestInstallBadPlaythrough(c *C) {
 	c.Assert(err, IsNil)
 	c.Check(state, Equals, boot.BadSlot)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 
 	// custom: get-state $fromSlot
 	s.cleanSlate(c)
@@ -314,7 +318,7 @@ func (s *adapterSuite) TestInstallBadPlaythrough(c *C) {
 	c.Assert(err, IsNil)
 	c.Check(state, Equals, boot.GoodSlot)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 
 	// custom: set-state $toSlot bad
 	s.cleanSlate(c)
@@ -337,13 +341,16 @@ func (s *adapterSuite) TestInstallBadPlaythrough(c *C) {
 	c.Assert(err, IsNil)
 	c.Assert(s.called, Equals, 2)
 	// Note that boot.Mode switches to Normal.
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 }
 
 // Tests for BootProtocolAdapter.PrimarySlot.
 
 func (s *adapterSuite) TestPrimarySlotInNormalMode(c *C) {
-	c.Assert(s.bootState.SetBootMode(boot.Normal), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Normal
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		s.called++
 
@@ -357,11 +364,14 @@ func (s *adapterSuite) TestPrimarySlotInNormalMode(c *C) {
 	c.Assert(err, IsNil)
 	c.Check(slot, Equals, s.fromSlot)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 }
 
 func (s *adapterSuite) TestPrimarySlotInTryMode(c *C) {
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryInactiveFn = func() (boot.Slot, error) {
 		s.called++
 
@@ -375,11 +385,14 @@ func (s *adapterSuite) TestPrimarySlotInTryMode(c *C) {
 	c.Assert(err, IsNil, s.variant)
 	c.Check(slot, Equals, s.toSlot)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 }
 
 func (s *adapterSuite) TestPrimarySlotInInvalidMode(c *C) {
-	c.Assert(s.bootState.SetBootMode(boot.InvalidBootMode), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.InvalidBootMode
+		return true, nil
+	}), IsNil)
 	_, err := s.adapter.PrimarySlot()
 	c.Assert(err, ErrorMatches, `invalid boot mode`)
 }
@@ -387,7 +400,10 @@ func (s *adapterSuite) TestPrimarySlotInInvalidMode(c *C) {
 // Tests for BootProtocolAdapter.SetPrimarySlot.
 
 func (s *adapterSuite) TestSetPrimarySlotToActiveInNormalMode(c *C) {
-	c.Assert(s.bootState.SetBootMode(boot.Normal), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Normal
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		s.called++
 
@@ -400,7 +416,7 @@ func (s *adapterSuite) TestSetPrimarySlotToActiveInNormalMode(c *C) {
 	err := s.adapter.SetPrimarySlot(s.fromSlot)
 	c.Assert(err, IsNil)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 }
 
 func (s *adapterSuite) TestSetPrimaryToInactiveInNormalMode(c *C) {
@@ -418,11 +434,14 @@ func (s *adapterSuite) TestSetPrimaryToInactiveInNormalMode(c *C) {
 	}
 	err := s.adapter.SetPrimarySlot(s.toSlot)
 	c.Assert(err, IsNil)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try, s.variant)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try, s.variant)
 }
 
 func (s *adapterSuite) TestSetPrimarySlotToActiveInTryMode(c *C) {
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		if s.pristine {
 			return boot.InvalidSlot, boot.ErrPristineBootConfig
@@ -443,7 +462,10 @@ func (s *adapterSuite) TestSetPrimarySlotToInactiveInTryMode(c *C) {
 	activeSlot := s.fromSlot
 	inactiveSlot := boot.SynthesizeInactiveSlot(activeSlot)
 
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		s.called++
 
@@ -456,7 +478,7 @@ func (s *adapterSuite) TestSetPrimarySlotToInactiveInTryMode(c *C) {
 	err := s.adapter.SetPrimarySlot(inactiveSlot)
 	c.Assert(err, IsNil, s.variant)
 	c.Assert(s.called, Equals, 1)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 }
 
 func (s *adapterSuite) TestSetPrimarySlotToPrimarySlot(c *C) {
@@ -478,15 +500,17 @@ func (s *adapterSuite) TestSetPrimarySlotToPrimarySlot(c *C) {
 
 	for _, bootMode := range []boot.Mode{boot.Normal, boot.Try} {
 		comment := Commentf("%s, boot mode: %s", s.variant.CheckCommentString(), bootMode)
-		err := s.bootState.SetBootMode(bootMode)
-		c.Assert(err, IsNil, comment)
+		c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+			st.BootMode = bootMode
+			return true, nil
+		}), IsNil, comment)
 
 		primarySlot, err := s.adapter.PrimarySlot()
 		c.Assert(err, IsNil, comment)
 		err = s.adapter.SetPrimarySlot(primarySlot)
 		c.Assert(err, IsNil, comment)
 
-		c.Check(s.bootState.BootMode(), Equals, bootMode, comment)
+		c.Check(s.ps.State().BootMode, Equals, bootMode, comment)
 	}
 }
 
@@ -519,12 +543,14 @@ func (s *adapterSuite) TestSetPrimaryToInactiveButTrySwitchFailed(c *C) {
 	c.Assert(err, ErrorMatches, `boom`, s.variant)
 
 	// Note that a transaction was not started.
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 }
 
 func (s *adapterSuite) TestSetPrimaryButBootModeIsInvalid(c *C) {
-	err := s.bootState.SetBootMode(boot.InvalidBootMode)
-	c.Check(err, IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.InvalidBootMode
+		return true, nil
+	}), IsNil)
 
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		if s.pristine {
@@ -534,7 +560,7 @@ func (s *adapterSuite) TestSetPrimaryButBootModeIsInvalid(c *C) {
 		return s.fromSlot, nil
 	}
 
-	err = s.adapter.SetPrimarySlot(s.fromSlot)
+	err := s.adapter.SetPrimarySlot(s.fromSlot)
 	c.Assert(err, ErrorMatches, `invalid boot mode`, s.variant)
 }
 
@@ -645,7 +671,10 @@ func (s *adapterSuite) TestSetSlotStateOfActiveSlotInTryModeToBad(c *C) {
 	// that rauc reads and writes the state and any combination can be
 	// expressed. SystemOTA synthesizes the state that RAUC expects based on the
 	// boot.Mode alone (that is, one bit of information).
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		if s.pristine {
 			return boot.InvalidSlot, boot.ErrPristineBootConfig
@@ -658,7 +687,10 @@ func (s *adapterSuite) TestSetSlotStateOfActiveSlotInTryModeToBad(c *C) {
 }
 
 func (s *adapterSuite) TestSetSlotStateOfActiveSlotInTryModeToGood(c *C) {
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		if s.pristine {
 			return boot.InvalidSlot, boot.ErrPristineBootConfig
@@ -673,7 +705,10 @@ func (s *adapterSuite) TestSetSlotStateOfActiveSlotInTryModeToGood(c *C) {
 func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToBad(c *C) {
 	// While in try-boot mode, setting the state of the inactive slot
 	// to bad cancels the update transaction.
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		s.called++
 		if s.pristine {
@@ -692,13 +727,16 @@ func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToBad(c *C) {
 	err := s.adapter.SetSlotState(s.toSlot, boot.BadSlot)
 	c.Assert(err, IsNil, s.variant)
 	c.Assert(s.called, Equals, 2)
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 }
 
 func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToGood(c *C) {
 	// While in try-boot mode, setting the state of the inactive slot
 	// to good commits the update transaction.
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		s.called++
 		// Note that $fromSlot is active.
@@ -713,7 +751,7 @@ func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToGood(c *C) {
 	err := s.adapter.SetSlotState(s.toSlot, boot.GoodSlot)
 	c.Assert(err, IsNil)
 	c.Assert(s.called, Equals, 2)
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 }
 
 func (s *adapterSuite) TestSetSlotStateOfInvalidSlot(c *C) {
@@ -737,7 +775,10 @@ func (s *adapterSuite) TestSetSlotStateButQueryActiveFailed(c *C) {
 
 func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToBadButSetBootModeFailed(c *C) {
 	// We are trying to cancel the transaction but we failed to set the new boot mode.
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		s.called++
 		if s.pristine {
@@ -746,21 +787,26 @@ func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToBadButSetBootMod
 
 		return s.fromSlot, nil
 	}
-	s.bootState.BootModeChanged = func(bootMode boot.Mode) error {
-		c.Assert(bootMode, Equals, boot.Normal)
+	s.ps.RegisterObserver("test", func(st *ota.SystemState) error {
+		c.Assert(st.BootMode, Equals, boot.Normal)
 		s.called++
 
 		return errBoom
-	}
+	})
+	defer s.ps.RemoveObserver("test")
+
 	err := s.adapter.SetSlotState(s.toSlot, boot.BadSlot)
 	c.Assert(err, ErrorMatches, `boom`, s.variant)
 	c.Assert(s.called, Equals, 2)
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 }
 
 func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToBadButCancelSwitchFailed(c *C) {
 	// We are trying to cancel the transaction but that operation fails.
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		s.called++
 
@@ -786,13 +832,16 @@ func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToBadButCancelSwit
 	//
 	// CancelSwitch exists only to (perhaps) remove temporary files that were
 	// required for the duration of TrySwitch lifetime.
-	c.Check(s.bootState.BootMode(), Equals, boot.Normal)
+	c.Check(s.ps.State().BootMode, Equals, boot.Normal)
 }
 
 func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToGoodButCommitSwitchFailed(c *C) {
 	// While in try-boot mode, setting the state of the inactive slot
 	// to good commits the update transaction.
-	c.Assert(s.bootState.SetBootMode(boot.Try), IsNil)
+	c.Assert(s.ps.AlterState(func(st *ota.SystemState) (bool, error) {
+		st.BootMode = boot.Try
+		return true, nil
+	}), IsNil)
 	s.proto.QueryActiveFn = func() (boot.Slot, error) {
 		s.called++
 
@@ -821,7 +870,7 @@ func (s *adapterSuite) TestSetSlotStateOfInactiveSlotInTryModeToGoodButCommitSwi
 	// reboot.
 	//
 	// XXX(zyga): This may warrant a special error type to handle properly.
-	c.Check(s.bootState.BootMode(), Equals, boot.Try)
+	c.Check(s.ps.State().BootMode, Equals, boot.Try)
 }
 
 func (s *adapterSuite) TestPreInstallHandlerWithUnawareBootProtocol(c *C) {
@@ -843,7 +892,7 @@ func (s *adapterSuite) TestPreInstallHandlerWithAwareBootProtocol(c *C) {
 			},
 		},
 	}
-	adapter := raucadapter.New(proto, &s.bootState)
+	adapter := raucadapter.New(proto, &s.ps)
 
 	err := adapter.PreInstallHandler(ctx)
 	c.Assert(err, IsNil)
@@ -864,7 +913,7 @@ func (s *adapterSuite) TestPostInstallHandlerWithUnawareBootProtocol(c *C) {
 			},
 		},
 	}
-	adapter := raucadapter.New(proto, &s.bootState)
+	adapter := raucadapter.New(proto, &s.ps)
 
 	err := adapter.PostInstallHandler(ctx)
 	c.Assert(err, IsNil)
diff --git a/ota/raucinterface/service.go b/ota/raucinterface/service.go
index a86c292a2a5379dd9c71c8b74b1758f7bce9d8a5..25a9404f02fe6648eccf2e78c9fe394640e09f59 100644
--- a/ota/raucinterface/service.go
+++ b/ota/raucinterface/service.go
@@ -34,8 +34,8 @@ const (
 	contextArgName = "context"
 )
 
-// Service implements dbusutil.HostedService exposing boothandler.Handler and installhandler.Participant over D-Bus.
-type Service struct {
+// HostedService implements dbusutil.HostedService exposing boothandler.Handler and installhandler.Participant over D-Bus.
+type HostedService struct {
 	bootHandler    boothandler.Handler
 	installHandler installhandler.Participant
 
@@ -43,12 +43,12 @@ type Service struct {
 }
 
 // NewService returns a service with the given boot backend.
-func NewService(bootHandler boothandler.Handler, installHandler installhandler.Participant) *Service {
-	return &Service{bootHandler: bootHandler, installHandler: installHandler}
+func NewService(bootHandler boothandler.Handler, installHandler installhandler.Participant) *HostedService {
+	return &HostedService{bootHandler: bootHandler, installHandler: installHandler}
 }
 
 // JoinServiceHost integrates with dbusutil.ServiceHost.
-func (svc *Service) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
+func (svc *HostedService) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
 	svc.conn = reg.DBusConn()
 
 	methods := map[string]interface{}{
@@ -143,7 +143,7 @@ func (svc *Service) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
 }
 
 // GetPrimary maps boothandler.Handler.PrimarySlot to D-Bus.
-func (svc *Service) GetPrimary() (slotName string, dbusErr *dbus.Error) {
+func (svc *HostedService) GetPrimary() (slotName string, dbusErr *dbus.Error) {
 	slot, err := svc.bootHandler.PrimarySlot()
 	if err != nil {
 		return "", dbus.MakeFailedError(err)
@@ -153,7 +153,7 @@ func (svc *Service) GetPrimary() (slotName string, dbusErr *dbus.Error) {
 }
 
 // SetPrimary maps boothandler.Handler.SetPrimarySlot to D-Bus.
-func (svc *Service) SetPrimary(slotName string) (dbusErr *dbus.Error) {
+func (svc *HostedService) SetPrimary(slotName string) (dbusErr *dbus.Error) {
 	var slot boot.Slot
 
 	if err := slot.UnmarshalText([]byte(slotName)); err != nil {
@@ -168,7 +168,7 @@ func (svc *Service) SetPrimary(slotName string) (dbusErr *dbus.Error) {
 }
 
 // GetState maps boothandler.Handler.SlotState to D-Bus.
-func (svc *Service) GetState(slotName string) (bootState string, dbusErr *dbus.Error) {
+func (svc *HostedService) GetState(slotName string) (bootState string, dbusErr *dbus.Error) {
 	var slot boot.Slot
 
 	if err := slot.UnmarshalText([]byte(slotName)); err != nil {
@@ -184,7 +184,7 @@ func (svc *Service) GetState(slotName string) (bootState string, dbusErr *dbus.E
 }
 
 // SetState maps boothandler.Handler.SetSlotState to D-Bus.
-func (svc *Service) SetState(slotName string, stateName string) (dbusErr *dbus.Error) {
+func (svc *HostedService) SetState(slotName string, stateName string) (dbusErr *dbus.Error) {
 	var slot boot.Slot
 
 	if err := slot.UnmarshalText([]byte(slotName)); err != nil {
@@ -204,7 +204,7 @@ func (svc *Service) SetState(slotName string, stateName string) (dbusErr *dbus.E
 	return nil
 }
 
-func (svc *Service) populateHandlerPID(ctx *installhandler.HandlerContext, sender dbus.Sender) error {
+func (svc *HostedService) populateHandlerPID(ctx *installhandler.HandlerContext, sender dbus.Sender) error {
 	// Find the pid of the sender and store it int the handler context.
 	// See https://dbus.freedesktop.org/doc/dbus-specification.html
 	var pid uint32
@@ -219,7 +219,7 @@ func (svc *Service) populateHandlerPID(ctx *installhandler.HandlerContext, sende
 }
 
 // PreInstall maps installhandler.Participant.PreInstallHandler to D-Bus.
-func (svc *Service) PreInstall(sender dbus.Sender, env []string) (dbusErr *dbus.Error) {
+func (svc *HostedService) PreInstall(sender dbus.Sender, env []string) (dbusErr *dbus.Error) {
 	ctx, err := installhandler.NewHandlerContext(env)
 	if err != nil {
 		return dbus.MakeFailedError(err)
@@ -237,7 +237,7 @@ func (svc *Service) PreInstall(sender dbus.Sender, env []string) (dbusErr *dbus.
 }
 
 // PostInstall maps installhandler.Participant.PostInstallHandler to D-Bus.
-func (svc *Service) PostInstall(sender dbus.Sender, env []string) (dbusErr *dbus.Error) {
+func (svc *HostedService) PostInstall(sender dbus.Sender, env []string) (dbusErr *dbus.Error) {
 	ctx, err := installhandler.NewHandlerContext(env)
 	if err != nil {
 		return dbus.MakeFailedError(err)
diff --git a/rauc/rauctest/boothandler.go b/rauc/rauctest/boothandler.go
index ac6c173b1cd79961650080c97a7156928c4015eb..8b6ca11f4317cc20797ad41deabf6ab957f6d768 100644
--- a/rauc/rauctest/boothandler.go
+++ b/rauc/rauctest/boothandler.go
@@ -57,36 +57,3 @@ func (handler *BootHandler) SetSlotState(slot boot.Slot, state boot.SlotState) e
 
 	return handler.SetSlotStateFn(slot, state)
 }
-
-// BootState implements rauc.BootModeHolder for the BootProtocolAdapter
-type BootState struct {
-	bootMode boot.Mode
-
-	// BootModeChanged can be used to observe or verify changes to boot mode.
-	BootModeChanged func(boot.Mode) error
-}
-
-// BootMode returns the current boot mode.
-func (state *BootState) BootMode() boot.Mode {
-	return state.bootMode
-}
-
-// SetBootMode sets the new boot mode.
-//
-// Changes to boot mode are propagated to BootModeChanged callback before
-// becoming effective, enabling both notifications and validation use-cases.
-func (state *BootState) SetBootMode(bootMode boot.Mode) error {
-	if state.bootMode == bootMode {
-		return nil
-	}
-
-	if state.BootModeChanged != nil {
-		if err := state.BootModeChanged(bootMode); err != nil {
-			return err
-		}
-	}
-
-	state.bootMode = bootMode
-
-	return nil
-}
diff --git a/rauc/rauctest/boothandler_test.go b/rauc/rauctest/boothandler_test.go
index d1a49d0b36c19090d49a906c7e971e3db0de6065..609ee552b885697b39d43b812e0bacf8911c4632 100644
--- a/rauc/rauctest/boothandler_test.go
+++ b/rauc/rauctest/boothandler_test.go
@@ -102,54 +102,3 @@ func (s *bootBackendSuite) TestResetCallbacks(c *C) {
 
 	c.Check(backend, DeepEquals, rauctest.BootHandler{})
 }
-
-type bootStateSuite struct{}
-
-var _ = Suite(&bootStateSuite{})
-
-func (s *bootStateSuite) TestBootModeReadWrite(c *C) {
-	bootState := rauctest.BootState{}
-
-	c.Check(bootState.BootMode(), Equals, boot.Normal)
-
-	err := bootState.SetBootMode(boot.Try)
-	c.Assert(err, IsNil)
-	c.Check(bootState.BootMode(), Equals, boot.Try)
-}
-
-func (s *bootStateSuite) TestBootModeChangeObserver(c *C) {
-	changes := make([]boot.Mode, 0, 3)
-	bootState := rauctest.BootState{
-		BootModeChanged: func(newBootMode boot.Mode) error {
-			changes = append(changes, newBootMode)
-			return nil
-		},
-	}
-
-	c.Assert(bootState.SetBootMode(boot.Normal), IsNil)
-	c.Check(bootState.BootMode(), Equals, boot.Normal)
-	c.Assert(bootState.SetBootMode(boot.Try), IsNil)
-	c.Check(bootState.BootMode(), Equals, boot.Try)
-	c.Assert(bootState.SetBootMode(boot.Normal), IsNil)
-	c.Check(bootState.BootMode(), Equals, boot.Normal)
-
-	c.Check(changes, DeepEquals, []boot.Mode{boot.Try, boot.Normal})
-}
-
-func (s *bootStateSuite) TestBootModeChangeGuard(c *C) {
-	bootState := rauctest.BootState{
-		BootModeChanged: func(newBootMode boot.Mode) error {
-			if newBootMode != boot.Normal && newBootMode != boot.Try {
-				return boot.ErrInvalidBootMode
-			}
-			return nil
-		},
-	}
-
-	c.Assert(bootState.SetBootMode(boot.Normal), IsNil)
-	c.Check(bootState.BootMode(), Equals, boot.Normal)
-	c.Assert(bootState.SetBootMode(boot.Try), IsNil)
-	c.Check(bootState.BootMode(), Equals, boot.Try)
-	c.Assert(bootState.SetBootMode(boot.InvalidBootMode), ErrorMatches, `invalid boot mode`)
-	c.Check(bootState.BootMode(), Equals, boot.Try)
-}
diff --git a/service/bootprotoservice.go b/service/boothosted/boothosted.go
similarity index 74%
rename from service/bootprotoservice.go
rename to service/boothosted/boothosted.go
index 4ec07588edfe1cd46da576bf8e7ad2c9ec404102..13ebcbcf02c56c92dc43e504efb3844ccf48d690 100644
--- a/service/bootprotoservice.go
+++ b/service/boothosted/boothosted.go
@@ -1,8 +1,9 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-FileCopyrightText: Huawei Inc.
 
-// Package service implements the sysotad D-Bus service.
-package service
+// Package boothosted contains the implementation of hosted service exposing the
+// boot protocol over D-Bus.
+package boothosted
 
 import (
 	"errors"
@@ -16,13 +17,13 @@ import (
 )
 
 const (
-	// BootLoaderInterfaceName is the name of the SystemOTA BootLoader interface.
-	BootLoaderInterfaceName = "org.oniroproject.sysota1.BootLoader"
+	// InterfaceName is the name of the SystemOTA BootLoader interface.
+	InterfaceName = "org.oniroproject.sysota1.BootLoader"
 )
 
-// BootLoaderIntrospectData is the D-Bus introspection data for the BootLoader interface of the service object.
-var BootLoaderIntrospectData = introspect.Interface{
-	Name: BootLoaderInterfaceName,
+// IntrospectData is the D-Bus introspection data for the BootLoader interface of the service object.
+var IntrospectData = introspect.Interface{
+	Name: InterfaceName,
 	Methods: []introspect.Method{
 		{
 			Name: "QueryActive",
@@ -75,13 +76,17 @@ var BootLoaderIntrospectData = introspect.Interface{
 
 var errNoBootProtocol = errors.New("boot protocol is not set")
 
-// BootProtocolService is a dbusutil.HostedService exporting boot.Protocol over D-BUs.
-type BootProtocolService struct {
+// HostedService is a dbusutil.HostedService exporting boot.Protocol over D-BUs.
+type HostedService struct {
 	bootProto boot.Protocol
 }
 
+func (svc *HostedService) Init(p boot.Protocol) {
+	svc.bootProto = p
+}
+
 // JoinServiceHost integrates with dbusutil.ServiceHost.
-func (svc *BootProtocolService) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
+func (svc *HostedService) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
 	methods := map[string]interface{}{
 		"QueryActive":   svc.QueryActive,
 		"QueryInactive": svc.QueryInactive,
@@ -91,11 +96,11 @@ func (svc *BootProtocolService) JoinServiceHost(reg dbusutil.ServiceRegistration
 		"CancelSwitch":  svc.CancelSwitch,
 	}
 
-	return reg.RegisterObject(ota.ServiceObjectPath, BootLoaderInterfaceName, methods, BootLoaderIntrospectData)
+	return reg.RegisterObject(ota.ServiceObjectPath, InterfaceName, methods, IntrospectData)
 }
 
 // QueryActive exposes the boot.Protocol.QueryActive method over D-Bus.
-func (svc *BootProtocolService) QueryActive() (string, *dbus.Error) {
+func (svc *HostedService) QueryActive() (string, *dbus.Error) {
 	if svc.bootProto == nil {
 		return "", dbus.MakeFailedError(errNoBootProtocol)
 	}
@@ -109,7 +114,7 @@ func (svc *BootProtocolService) QueryActive() (string, *dbus.Error) {
 }
 
 // QueryInactive exposes the boot.Protocol.QueryInactive method over D-Bus.
-func (svc *BootProtocolService) QueryInactive() (string, *dbus.Error) {
+func (svc *HostedService) QueryInactive() (string, *dbus.Error) {
 	if svc.bootProto == nil {
 		return "", dbus.MakeFailedError(errNoBootProtocol)
 	}
@@ -123,7 +128,7 @@ func (svc *BootProtocolService) QueryInactive() (string, *dbus.Error) {
 }
 
 // TrySwitch exposes the boot.Protocol.TrySwitch method over D-Bus.
-func (svc *BootProtocolService) TrySwitch(slotName string) *dbus.Error {
+func (svc *HostedService) TrySwitch(slotName string) *dbus.Error {
 	if svc.bootProto == nil {
 		return dbus.MakeFailedError(errNoBootProtocol)
 	}
@@ -143,7 +148,7 @@ func (svc *BootProtocolService) TrySwitch(slotName string) *dbus.Error {
 // Reboot exposes the boot.Protocol.Reboot method over D-Bus.
 //
 // The flags argument is not translated in any way.
-func (svc *BootProtocolService) Reboot(flags uint) *dbus.Error {
+func (svc *HostedService) Reboot(flags uint) *dbus.Error {
 	if svc.bootProto == nil {
 		return dbus.MakeFailedError(errNoBootProtocol)
 	}
@@ -156,7 +161,7 @@ func (svc *BootProtocolService) Reboot(flags uint) *dbus.Error {
 }
 
 // CommitSwitch exposes the boot.Protocol.CommitSwitch method over D-Bus.
-func (svc *BootProtocolService) CommitSwitch() *dbus.Error {
+func (svc *HostedService) CommitSwitch() *dbus.Error {
 	if svc.bootProto == nil {
 		return dbus.MakeFailedError(errNoBootProtocol)
 	}
@@ -169,7 +174,7 @@ func (svc *BootProtocolService) CommitSwitch() *dbus.Error {
 }
 
 // CancelSwitch exposes the boot.Protocol.CancelSwitch method over D-Bus.
-func (svc *BootProtocolService) CancelSwitch() *dbus.Error {
+func (svc *HostedService) CancelSwitch() *dbus.Error {
 	if svc.bootProto == nil {
 		return dbus.MakeFailedError(errNoBootProtocol)
 	}
diff --git a/service/export_test.go b/service/export_test.go
index a56d447a7b259ef648a7e6ce987ddaf79afe6b96..9cdd67f6713a2319f5c1d0a48414e4d03029a427 100644
--- a/service/export_test.go
+++ b/service/export_test.go
@@ -1,5 +1,6 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-FileCopyrightText: Huawei Inc.
+
 package service
 
 import (
diff --git a/service/rauchosted/rauchosted.go b/service/rauchosted/rauchosted.go
new file mode 100644
index 0000000000000000000000000000000000000000..797a0ee090f5c7990b3d79952990e9f893fd0d2e
--- /dev/null
+++ b/service/rauchosted/rauchosted.go
@@ -0,0 +1,260 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Huawei Inc.
+
+package rauchosted
+
+import (
+	"github.com/godbus/dbus/v5"
+	"github.com/godbus/dbus/v5/introspect"
+
+	"booting.oniroproject.org/distro/components/sysota/boot"
+	"booting.oniroproject.org/distro/components/sysota/dbusutil"
+	"booting.oniroproject.org/distro/components/sysota/ota"
+	"booting.oniroproject.org/distro/components/sysota/rauc/boothandler"
+	"booting.oniroproject.org/distro/components/sysota/rauc/installhandler"
+)
+
+const (
+	// InterfaceName is the name of the RAUC interface provided by SystemOTA.
+	InterfaceName = "org.oniroproject.sysota1.RAUC"
+)
+
+const (
+	// Methods of the boot handler
+
+	getPrimaryDBusName = "GetPrimary"
+	setPrimaryDBusName = "SetPrimary"
+	getStateDBusName   = "GetState"
+	setStateDBusName   = "SetState"
+
+	// Methods of the install handler
+
+	preInstallDBusName  = "PreInstall"
+	postInstallDBusName = "PostInstall"
+
+	// Argument names
+
+	slotArgName    = "slot"
+	stateArgName   = "state"
+	contextArgName = "context"
+)
+
+// HostedService implements dbusutil.HostedService exposing boothandler.Handler and installhandler.Participant over D-Bus.
+type HostedService struct {
+	bootHandler    boothandler.Handler
+	installHandler installhandler.Participant
+
+	conn *dbus.Conn
+}
+
+func (svc *HostedService) Init(bootHandler boothandler.Handler, installHandler installhandler.Participant) {
+	svc.bootHandler = bootHandler
+	svc.installHandler = installHandler
+}
+
+// JoinServiceHost integrates with dbusutil.ServiceHost.
+func (svc *HostedService) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
+	svc.conn = reg.DBusConn()
+
+	methods := map[string]interface{}{
+		getPrimaryDBusName: svc.GetPrimary,
+		setPrimaryDBusName: svc.SetPrimary,
+		getStateDBusName:   svc.GetState,
+		setStateDBusName:   svc.SetState,
+
+		preInstallDBusName:  svc.PreInstall,
+		postInstallDBusName: svc.PostInstall,
+	}
+
+	metadata := introspect.Interface{
+		Name: InterfaceName,
+		Methods: []introspect.Method{
+			{
+				Name: getPrimaryDBusName,
+				Args: []introspect.Arg{
+					{
+						Name:      slotArgName,
+						Type:      "s",
+						Direction: dbusutil.Out,
+					},
+				},
+			},
+			{
+				Name: setPrimaryDBusName,
+				Args: []introspect.Arg{
+					{
+						Name:      slotArgName,
+						Type:      "s",
+						Direction: dbusutil.In,
+					},
+				},
+			},
+			{
+				Name: getStateDBusName,
+				Args: []introspect.Arg{
+					{
+						Name:      slotArgName,
+						Type:      "s",
+						Direction: dbusutil.In,
+					},
+					{
+						Name:      stateArgName,
+						Type:      "s",
+						Direction: dbusutil.Out,
+					},
+				},
+			},
+			{
+				Name: setStateDBusName,
+				Args: []introspect.Arg{
+					{
+						Name:      slotArgName,
+						Type:      "s",
+						Direction: dbusutil.In,
+					},
+					{
+						Name:      stateArgName,
+						Type:      "s",
+						Direction: dbusutil.In,
+					},
+				},
+			},
+			{
+				Name: preInstallDBusName,
+				Args: []introspect.Arg{
+					{
+						Name: contextArgName,
+						// Pass environment block as input.
+						// This allows the context to evolve without changing the API.
+						Type:      "as",
+						Direction: dbusutil.In,
+					},
+				},
+			},
+			{
+				Name: postInstallDBusName,
+				Args: []introspect.Arg{
+					{
+						Name:      contextArgName,
+						Type:      "as",
+						Direction: dbusutil.In,
+					},
+				},
+			},
+		},
+	}
+
+	return reg.RegisterObject(ota.ServiceObjectPath, InterfaceName, methods, metadata)
+}
+
+// GetPrimary maps boothandler.Handler.PrimarySlot to D-Bus.
+func (svc *HostedService) GetPrimary() (slotName string, dbusErr *dbus.Error) {
+	slot, err := svc.bootHandler.PrimarySlot()
+	if err != nil {
+		return "", dbus.MakeFailedError(err)
+	}
+
+	return slot.String(), nil
+}
+
+// SetPrimary maps boothandler.Handler.SetPrimarySlot to D-Bus.
+func (svc *HostedService) SetPrimary(slotName string) (dbusErr *dbus.Error) {
+	var slot boot.Slot
+
+	if err := slot.UnmarshalText([]byte(slotName)); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	if err := svc.bootHandler.SetPrimarySlot(slot); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	return nil
+}
+
+// GetState maps boothandler.Handler.SlotState to D-Bus.
+func (svc *HostedService) GetState(slotName string) (bootState string, dbusErr *dbus.Error) {
+	var slot boot.Slot
+
+	if err := slot.UnmarshalText([]byte(slotName)); err != nil {
+		return "", dbus.MakeFailedError(err)
+	}
+
+	state, err := svc.bootHandler.SlotState(slot)
+	if err != nil {
+		return "", dbus.MakeFailedError(err)
+	}
+
+	return state.String(), nil
+}
+
+// SetState maps boothandler.Handler.SetSlotState to D-Bus.
+func (svc *HostedService) SetState(slotName string, stateName string) (dbusErr *dbus.Error) {
+	var slot boot.Slot
+
+	if err := slot.UnmarshalText([]byte(slotName)); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	var state boot.SlotState
+
+	if err := state.UnmarshalText([]byte(stateName)); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	if err := svc.bootHandler.SetSlotState(slot, state); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	return nil
+}
+
+func (svc *HostedService) populateHandlerPID(ctx *installhandler.HandlerContext, sender dbus.Sender) error {
+	// Find the pid of the sender and store it int the handler context.
+	// See https://dbus.freedesktop.org/doc/dbus-specification.html
+	var pid uint32
+
+	if err := svc.conn.BusObject().Call("org.freedesktop.DBus.GetConnectionUnixProcessID", 0, sender).Store(&pid); err != nil {
+		return err
+	}
+
+	ctx.InitialHandlerPID = int(pid)
+
+	return nil
+}
+
+// PreInstall maps installhandler.Participant.PreInstallHandler to D-Bus.
+func (svc *HostedService) PreInstall(sender dbus.Sender, env []string) (dbusErr *dbus.Error) {
+	ctx, err := installhandler.NewHandlerContext(env)
+	if err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	if err := svc.populateHandlerPID(ctx, sender); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	if err := svc.installHandler.PreInstallHandler(ctx); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	return nil
+}
+
+// PostInstall maps installhandler.Participant.PostInstallHandler to D-Bus.
+func (svc *HostedService) PostInstall(sender dbus.Sender, env []string) (dbusErr *dbus.Error) {
+	ctx, err := installhandler.NewHandlerContext(env)
+	if err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	if err := svc.populateHandlerPID(ctx, sender); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	if err := svc.installHandler.PostInstallHandler(ctx); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	return nil
+}
diff --git a/service/service.go b/service/service.go
index be9013f8e6b310b7c4720e508cd67baea54e1d4a..1b0b3416d7413491bc12dfccab70b8d16557c011 100644
--- a/service/service.go
+++ b/service/service.go
@@ -6,7 +6,6 @@ package service
 
 import (
 	"fmt"
-	"sync"
 
 	dbus "github.com/godbus/dbus/v5"
 
@@ -16,8 +15,11 @@ import (
 	"booting.oniroproject.org/distro/components/sysota/dirs"
 	"booting.oniroproject.org/distro/components/sysota/ota"
 	"booting.oniroproject.org/distro/components/sysota/ota/raucadapter"
-	"booting.oniroproject.org/distro/components/sysota/ota/raucinterface"
 	"booting.oniroproject.org/distro/components/sysota/picfg/pimodel"
+	"booting.oniroproject.org/distro/components/sysota/service/boothosted"
+	"booting.oniroproject.org/distro/components/sysota/service/rauchosted"
+	"booting.oniroproject.org/distro/components/sysota/service/testhosted"
+	"booting.oniroproject.org/distro/components/sysota/service/updatehosted"
 )
 
 // RequestBusName requests a well-known name on the bus
@@ -38,21 +40,6 @@ func RequestBusName(conn *dbus.Conn) error {
 	return nil
 }
 
-// Service implements the D-Bus service capable of downloading system updates.
-type Service struct {
-	m sync.RWMutex
-
-	state  *ota.SystemState
-	config *ota.Config
-
-	sh *dbusutil.ServiceHost
-
-	updateService UpdateService
-
-	raucAdapter *raucadapter.Adapter
-	raucService *raucinterface.Service
-}
-
 var bootProviders = map[ota.BootLoaderType]func(config *ota.Config) (boot.Protocol, error){
 	ota.InertBootLoader: func(config *ota.Config) (boot.Protocol, error) { return nil, nil },
 	ota.PiBoot: func(config *ota.Config) (proto boot.Protocol, err error) {
@@ -86,88 +73,150 @@ func configuredBootProtocol(config *ota.Config) (boot.Protocol, error) {
 	return nil, unsupportedBootLoaderError(config.BootLoaderType)
 }
 
-// New returns a new service.
-func New(config *ota.Config, state *ota.SystemState, conn *dbus.Conn) (*Service, error) {
-	if config == nil {
-		panic("config cannot be nil")
-	}
+// Option is a function for configuring the service during construction.
+type Option func(*Service) error
 
-	if state == nil {
-		panic("state cannot be nil")
+// WithConfigFiles returns a ServiceOption for defining the service ConfigFiles interface.
+//
+// When used, the service knows how to load and save configuration on demand. Without it
+// the service runs with all-defaults that are never saved.
+func WithConfigFiles(cf ota.ConfigFiles) Option {
+	return func(svc *Service) error {
+		return svc.confGuard.BindAndLoad(cf)
 	}
+}
 
-	if conn == nil {
-		panic("conn cannot be nil")
+// WithStateFile returns a ServiceOption for defining the service StateFile interface.
+//
+// When used, the service knows how to load and save the state on demand. Without it
+// the service is stateless and all state changes are discarded.
+func WithStateFile(sf ota.StateFile) Option {
+	return func(svc *Service) error {
+		return svc.stateGuard.BindAndLoad(sf)
 	}
+}
 
-	bootProto, err := configuredBootProtocol(config)
-	if err != nil {
-		return nil, err
+// WithExitChannel returns a ServiceOption for defining a service exit channel.
+func WithExitChannel(exit chan<- struct{}) Option {
+	return func(svc *Service) error {
+		svc.exit = exit
+		return nil
 	}
+}
 
-	svc := &Service{state: state, config: config}
+// Service is implements D-Bus APIs of the SystemOTA device client.
+//
+// Individual parts of the service, modelled as distinct D-Bus interfaces, are
+// implemented as unexported fields. Internally those are UpdateService,
+// raucinterface.Service as well TestService.
+//
+// Service holds unexported PersistentState and PersistentConfig. Usually those
+// should be initialized with by passing WithStateFile and WithConfigFiles to
+// New. They are made available to several hosted services: update, boot, rauc
+// and test hosted services, which implement individual D-Bus interfaces.
+type Service struct {
+	dbusutil.ServiceHost
 
-	svc.raucAdapter = raucadapter.New(bootProto, svc)
-	svc.sh = dbusutil.NewServiceHost(conn)
+	stateGuard ota.StateGuard
+	confGuard  ota.ConfigGuard
 
-	// Configure and add the boot service if the corresponding
-	// debug flag is enabled.
-	if config.DebugBootAPI {
-		bootService := &BootProtocolService{
-			bootProto: bootProto,
+	bootProto   boot.Protocol
+	raucAdapter *raucadapter.Adapter
+	exit        chan<- struct{}
+
+	boot   boothosted.HostedService
+	update updatehosted.HostedService
+	rauc   rauchosted.HostedService
+	test   testhosted.HostedService
+}
+
+// New returns a new service.
+//
+// In practice you want to call it with both the WithConfigurer WithStater
+// service options. In their absence the service will run devoid of
+// configuration and state and all changes will be ephemeral.
+func New(conn *dbus.Conn, opts ...Option) (*Service, error) {
+	svc := &Service{}
+
+	for _, opt := range opts {
+		if err := opt(svc); err != nil {
+			return nil, err
 		}
-		svc.sh.AddHostedService(bootService)
 	}
 
-	// Configure and add the update service.
-	svc.updateService.maker = "dummy-maker"
-	svc.updateService.model = "dummy-model"
-	svc.updateService.stream = "dummy-stream"
-	svc.sh.AddHostedService(&svc.updateService)
+	svc.stateGuard.RegisterObserver("dbus", func(st *ota.SystemState) error {
+		// TODO: send dbus notifications once we expose the BootMode property.
+		return nil
+	})
+
+	svc.ServiceHost.Init(conn)
+
+	cfg := svc.confGuard.Config()
+	bootProto, err := configuredBootProtocol(&cfg)
+	if err != nil {
+		return nil, err
+	}
+
+	svc.bootProto = bootProto
 
-	// Configure and add the RAUC service
-	svc.raucService = raucinterface.NewService(svc.raucAdapter, svc.raucAdapter)
-	svc.sh.AddHostedService(svc.raucService)
+	svc.setupRaucAdapter()
+	svc.setupBootProtocolService()
+	svc.setupUpdateService()
+	svc.setupRaucService()
+	svc.setupTestService()
 
 	return svc, nil
 }
 
-// BootMode implements raucadapter.BootModeHolder and returns the current boot mode.
-func (svc *Service) BootMode() boot.Mode {
-	return svc.state.BootMode
+func (svc *Service) setupRaucAdapter() {
+	// RAUC adapter takes the boot protocol and a boot mode holder which is
+	// implemented by the service directly.
+	svc.raucAdapter = raucadapter.New(svc.bootProto, &svc.stateGuard)
 }
 
-// SetBootMode implements raucadapter.BootModeHolder and sets the new boot mode.
-func (svc *Service) SetBootMode(bootMode boot.Mode) error {
-	if svc.state.BootMode == bootMode {
-		return nil
+// setupBootProtocolService configures and adds the boot protocol service
+// if the corresponding debug flag is enabled in the configuration system.
+func (svc *Service) setupBootProtocolService() {
+	if !svc.confGuard.Config().DebugBootAPI {
+		return
 	}
 
-	svc.state.BootMode = bootMode
+	svc.boot.Init(svc.bootProto)
+	svc.AddHostedService(&svc.boot)
+}
 
-	// TODO(zyga): emit the PropertyChanged signal here.
-	// Any errors should be logged and ignored.
+func (svc *Service) setupUpdateService() {
+	svc.update.Init(&svc.confGuard)
 
-	return nil
+	svc.AddHostedService(&svc.update)
 }
 
-// Export exports the service object on the bus.
-func (svc *Service) Export() (err error) {
-	svc.m.Lock()
-	defer svc.m.Unlock()
+func (svc *Service) setupRaucService() {
+	svc.rauc.Init(svc.raucAdapter, svc.raucAdapter)
+	svc.AddHostedService(&svc.rauc)
+}
 
-	return svc.sh.Export()
+// Exit returns the exit channel of the service.
+//
+// The exit channel can be used to send an empty structure to cause the service
+// to stop processing requests and quit.
+func (svc *Service) Exit() chan<- struct{} {
+	return svc.exit
 }
 
-// Unexport removes the object from the set of published bus objects.
-func (svc *Service) Unexport() error {
-	svc.m.Lock()
-	defer svc.m.Unlock()
+// setupTestService configures and adds the test service
+// if the corresponding debug flag is enabled in the configuration system.
+func (svc *Service) setupTestService() {
+	if !svc.confGuard.Config().DebugTestAPI {
+		return
+	}
+
+	svc.test.Init(&svc.confGuard, &svc.stateGuard, svc.exit)
 
-	return svc.sh.Unexport()
+	svc.AddHostedService(&svc.test)
 }
 
 // IsIdle returns true if all the hosted service are idle.
 func (svc *Service) IsIdle() bool {
-	return svc.updateService.IsIdle()
+	return svc.update.IsIdle()
 }
diff --git a/service/service_test.go b/service/service_test.go
index a5c1639eaf9e4653269f189aff45889c4bf702f0..232a3b2d4e4a77bb9a06c1428649d8c859c7db44 100644
--- a/service/service_test.go
+++ b/service/service_test.go
@@ -44,13 +44,39 @@ func (s *serviceSuite) EnsureConn(c *C) *dbus.Conn {
 	return s.conn
 }
 
+type configPtr struct {
+	*ota.Config
+}
+
+func (j configPtr) LoadConfig() (*ota.Config, error) {
+	return j.Config, nil
+}
+
+func (j configPtr) SaveConfig(config *ota.Config) error {
+	return nil
+}
+
+type statePtr struct {
+	*ota.SystemState
+}
+
+func (j statePtr) LoadState() (*ota.SystemState, error) {
+	return j.SystemState, nil
+}
+
+func (j statePtr) SaveState(state *ota.SystemState) error {
+	return nil
+}
+
 func (s *serviceSuite) EnsureService(c *C) {
 	var err error
 
 	if s.svc == nil {
 		s.EnsureConn(c)
 
-		s.svc, err = service.New(&s.config, &s.state, s.conn)
+		s.svc, err = service.New(s.conn,
+			service.WithConfigFiles(configPtr{&s.config}),
+			service.WithStateFile(statePtr{&s.state}))
 		c.Assert(err, IsNil)
 
 		err := s.svc.Export()
@@ -62,7 +88,11 @@ func (s *serviceSuite) EnsureService(c *C) {
 }
 
 func (s *serviceSuite) SetUpTest(c *C) {
-	s.config = ota.Config{}
+	s.config = ota.Config{
+		DeviceMaker:  "test-maker",
+		DeviceModel:  "test-model",
+		UpdateStream: "test-stream",
+	}
 	s.state = ota.SystemState{}
 }
 
@@ -82,6 +112,16 @@ func (s *serviceSuite) TearDownTest(c *C) {
 	}
 }
 
+func (s *serviceSuite) TestExitChannel(c *C) {
+	ch := make(chan struct{}, 1)
+	s.EnsureConn(c)
+	svc, err := service.New(s.conn, service.WithExitChannel(ch))
+	c.Assert(err, IsNil)
+	svc.Exit() <- struct{}{}
+	_, ok := <-ch
+	c.Check(ok, Equals, true)
+}
+
 func (s *serviceSuite) TestSmokeAccessWithoutBootProtocolAcrossDBus(c *C) {
 	s.config.DebugBootAPI = true
 	s.EnsureService(c)
@@ -217,15 +257,15 @@ func (s *serviceSuite) TestAccessPropertiesAcrossDBus(c *C) {
 
 	err := svcObject.Call("org.freedesktop.DBus.Properties.Get", 0, "org.oniroproject.sysota1.Service", "Maker").Store(&propValue)
 	c.Assert(err, IsNil)
-	c.Check(propValue, Equals, dbus.MakeVariant("dummy-maker"))
+	c.Check(propValue, Equals, dbus.MakeVariant("test-maker"))
 
 	err = svcObject.Call("org.freedesktop.DBus.Properties.Get", 0, "org.oniroproject.sysota1.Service", "Model").Store(&propValue)
 	c.Assert(err, IsNil)
-	c.Check(propValue, Equals, dbus.MakeVariant("dummy-model"))
+	c.Check(propValue, Equals, dbus.MakeVariant("test-model"))
 
 	err = svcObject.Call("org.freedesktop.DBus.Properties.Get", 0, "org.oniroproject.sysota1.Service", "Stream").Store(&propValue)
 	c.Assert(err, IsNil)
-	c.Check(propValue, Equals, dbus.MakeVariant("dummy-stream"))
+	c.Check(propValue, Equals, dbus.MakeVariant("test-stream"))
 
 	err = svcObject.Call("org.freedesktop.DBus.Properties.Get", 0, "org.oniroproject.sysota1.Service", "Potato").Store(&propValue)
 	c.Assert(err, ErrorMatches, `org.freedesktop.DBus.Properties.Error.PropertyNotFound`)
@@ -238,9 +278,9 @@ func (s *serviceSuite) TestAccessPropertiesAcrossDBus(c *C) {
 	err = svcObject.Call("org.freedesktop.DBus.Properties.GetAll", 0, "org.oniroproject.sysota1.Service").Store(&propMap)
 	c.Assert(err, IsNil)
 	c.Check(propMap, DeepEquals, map[string]dbus.Variant{
-		"Maker":  dbus.MakeVariant("dummy-maker"),
-		"Model":  dbus.MakeVariant("dummy-model"),
-		"Stream": dbus.MakeVariant("dummy-stream"),
+		"Maker":  dbus.MakeVariant("test-maker"),
+		"Model":  dbus.MakeVariant("test-model"),
+		"Stream": dbus.MakeVariant("test-stream"),
 	})
 
 	err = svcObject.Call("org.freedesktop.DBus.Properties.GetAll", 0, "org.oniroproject.sysota1.Potato").Store(&propMap)
@@ -288,14 +328,3 @@ func (s *serviceSuite) TestGetManagedObjects(c *C) {
 	c.Assert(err, IsNil)
 	c.Check(objs, DeepEquals, map[dbus.ObjectPath]map[string]map[string]dbus.Variant{})
 }
-
-func (s *serviceSuite) TestBootMode(c *C) {
-	s.EnsureService(c)
-
-	c.Check(s.svc.BootMode(), Equals, boot.Normal)
-	// TODO(zyga): check that PropertiesChanged signal was *not* sent.
-	c.Check(s.svc.SetBootMode(boot.Mode(boot.Normal)), IsNil)
-	// TODO(zyga): check that PropertiesChanged signal was sent.
-	c.Check(s.svc.SetBootMode(boot.Mode(ota.TestBoot)), IsNil)
-	c.Check(s.svc.BootMode(), Equals, boot.Try)
-}
diff --git a/service/testhosted/testhosted.go b/service/testhosted/testhosted.go
new file mode 100644
index 0000000000000000000000000000000000000000..036aef7abe50666b9a5fcd92c64cbd864a96d834
--- /dev/null
+++ b/service/testhosted/testhosted.go
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: Apache-2.0
+// SPDX-FileCopyrightText: Huawei Inc.
+
+// Package testhosted contains a hosted service used for testing.
+package testhosted
+
+import (
+	dbus "github.com/godbus/dbus/v5"
+	"github.com/godbus/dbus/v5/introspect"
+
+	"booting.oniroproject.org/distro/components/sysota/dbusutil"
+	"booting.oniroproject.org/distro/components/sysota/ota"
+)
+
+const (
+	// InterfaceName is the name of the debug interface of the service.
+	InterfaceName = "org.oniroproject.sysota1.Test"
+
+	loadStateFunc = "LoadState"
+	saveStateFunc = "SaveState"
+
+	loadConfigFunc = "LoadConfig"
+	saveConfigFunc = "SaveConfig"
+
+	exitFunc = "Exit"
+)
+
+// IntrospectData is the D-Bus introspection data for DebugService interface of the service object.
+//
+// It is provided explicitly without using reflection for a bit more control
+// over the provided data, given that we implement the properties interface
+// ourselves.
+var IntrospectData = introspect.Interface{
+	Name: InterfaceName,
+	Methods: []introspect.Method{
+		{
+			Name: loadStateFunc,
+		},
+		{
+			Name: saveStateFunc,
+		},
+		{
+			Name: loadConfigFunc,
+		},
+		{
+			Name: saveConfigFunc,
+		},
+		{
+			Name: exitFunc,
+		},
+	},
+}
+
+// HostedService exposes debugging functions not meant for production.
+type HostedService struct {
+	cfg  *ota.ConfigGuard
+	st   *ota.StateGuard
+	exit chan<- struct{}
+}
+
+// Init initializes Service with parts of the Service data.
+func (svc *HostedService) Init(cfg *ota.ConfigGuard, st *ota.StateGuard, exit chan<- struct{}) {
+	svc.cfg = cfg
+	svc.st = st
+	svc.exit = exit
+}
+
+// JoinServiceHost integrates with dbusutil.ServiceHost.
+func (svc *HostedService) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
+	svcMethods := map[string]interface{}{
+		loadStateFunc:  svc.LoadState,
+		saveStateFunc:  svc.SaveState,
+		loadConfigFunc: svc.LoadConfig,
+		saveConfigFunc: svc.SaveConfig,
+		exitFunc:       svc.Exit,
+	}
+	if err := reg.RegisterObject(ota.ServiceObjectPath, InterfaceName, svcMethods, IntrospectData); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// LoadState replaces system state with that loaded from the state file.
+func (svc *HostedService) LoadState() *dbus.Error {
+	state, err := svc.st.StateFile().LoadState()
+	if err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	svc.st.SetState(state)
+
+	return nil
+}
+
+// SaveState saves system state to the state file.
+func (svc *HostedService) SaveState() *dbus.Error {
+	st := svc.st.State()
+
+	if err := svc.st.StateFile().SaveState(&st); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	return nil
+}
+
+// LoadConfig replaces service configuration with that loaded from the config files.
+func (svc *HostedService) LoadConfig() *dbus.Error {
+	cfg, err := svc.cfg.ConfigFiles().LoadConfig()
+	if err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	svc.cfg.SetConfig(cfg)
+
+	return nil
+}
+
+// SaveConfig saves service configuration to the config files.
+func (svc *HostedService) SaveConfig() *dbus.Error {
+	cfg := svc.cfg.Config()
+
+	if err := svc.cfg.ConfigFiles().SaveConfig(&cfg); err != nil {
+		return dbus.MakeFailedError(err)
+	}
+
+	return nil
+}
+
+// Exit causes the service to exit immediately.
+func (svc *HostedService) Exit() *dbus.Error {
+	svc.exit <- struct{}{}
+	return nil
+}
diff --git a/service/oper/oper.go b/service/updatehosted/oper/oper.go
similarity index 100%
rename from service/oper/oper.go
rename to service/updatehosted/oper/oper.go
diff --git a/service/updateservice.go b/service/updatehosted/updatehosted.go
similarity index 70%
rename from service/updateservice.go
rename to service/updatehosted/updatehosted.go
index 94669f7acf1aac8b3849d40cf92056f5dedd970b..fb507c45e0d596e047ee082f225b52f2429f56f1 100644
--- a/service/updateservice.go
+++ b/service/updatehosted/updatehosted.go
@@ -1,8 +1,7 @@
 // SPDX-License-Identifier: Apache-2.0
 // SPDX-FileCopyrightText: Huawei Inc.
 
-// Package service implements the sysotad D-Bus service.
-package service
+package updatehosted
 
 import (
 	"fmt"
@@ -15,26 +14,26 @@ import (
 	"booting.oniroproject.org/distro/components/sysota/dbusutil"
 	"booting.oniroproject.org/distro/components/sysota/dbusutil/objmgr"
 	"booting.oniroproject.org/distro/components/sysota/ota"
-	"booting.oniroproject.org/distro/components/sysota/service/oper"
+	"booting.oniroproject.org/distro/components/sysota/service/updatehosted/oper"
 )
 
 const (
 	// UpdateServiceInterfaceName is the name of the SystemOTA Service interface.
 	// TODO(zyga): re-think the name.
-	UpdateServiceInterfaceName = "org.oniroproject.sysota1.Service"
+	InterfaceName = "org.oniroproject.sysota1.Service"
 
 	makerProperty  = "Maker"
 	modelProperty  = "Model"
 	streamProperty = "Stream"
 )
 
-// ServiceIntrospectData is the D-Bus introspection data for Service interface of the service object.
+// UpdateServiceIntrospectData is the D-Bus introspection data for Service interface of the service object.
 //
 // It is provided explicitly without using reflection for a bit more control
 // over the provided data, given that we implement the properties interface
 // ourselves.
-var ServiceIntrospectData = introspect.Interface{
-	Name: UpdateServiceInterfaceName,
+var IntrospectData = introspect.Interface{
+	Name: InterfaceName,
 	Methods: []introspect.Method{
 		{
 			Name: "UpdateStreams",
@@ -92,7 +91,7 @@ var ServiceIntrospectData = introspect.Interface{
 	},
 }
 
-// UpdateService provides functions for downloading and applying updates.
+// HostedService provides functions for downloading and applying updates.
 //
 // Maker is a near-arbitrary string that identifier the device maker.
 // Model is a near-arbitrary string that is selected by the device maker.
@@ -100,21 +99,23 @@ var ServiceIntrospectData = introspect.Interface{
 // branches or stability levels to use.
 //
 // Available updates are specific to a given (maker,model,stream) tuple.
-type UpdateService struct {
-	m    sync.RWMutex
+type HostedService struct {
 	conn *dbus.Conn
 
-	maker  string
-	model  string
-	stream string
-
-	ops []*oper.Operation
+	pc *ota.ConfigGuard
 
+	opsMutex        sync.RWMutex
+	ops             []*oper.Operation
 	lastOperationID int
 }
 
+// Init initializes UpdateService with a given persistent config.
+func (svc *HostedService) Init(pc *ota.ConfigGuard) {
+	svc.pc = pc
+}
+
 // JoinServiceHost integrates with dbusutil.ServiceHost.
-func (svc *UpdateService) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
+func (svc *HostedService) JoinServiceHost(reg dbusutil.ServiceRegistration) error {
 	// Remember connection to send signals.
 	svc.conn = reg.DBusConn()
 
@@ -124,7 +125,7 @@ func (svc *UpdateService) JoinServiceHost(reg dbusutil.ServiceRegistration) erro
 		"UpdateStreams": svc.UpdateStreams,
 		"UpdateDevice":  svc.UpdateDevice,
 	}
-	if err := reg.RegisterObject(path, UpdateServiceInterfaceName, svcMethods, ServiceIntrospectData); err != nil {
+	if err := reg.RegisterObject(path, InterfaceName, svcMethods, IntrospectData); err != nil {
 		return err
 	}
 
@@ -147,57 +148,51 @@ func (svc *UpdateService) JoinServiceHost(reg dbusutil.ServiceRegistration) erro
 	return nil
 }
 
+/*
 // Maker returns the name of the maker of the device.
-func (svc *UpdateService) Maker() string {
-	svc.m.RLock()
-	defer svc.m.RUnlock()
-
-	return svc.maker
+func (svc *HostedService) Maker() string {
+	return svc.pc.Config().DeviceMaker
 }
 
 // Model returns the model name of the device.
-func (svc *UpdateService) Model() string {
-	svc.m.RLock()
-	defer svc.m.RUnlock()
-
-	return svc.model
+func (svc *HostedService) Model() string {
+	return svc.pc.Config().DeviceModel
 }
 
 // Stream returns the name of the update stream the device is subscribed to.
-func (svc *UpdateService) Stream() string {
-	svc.m.RLock()
-	defer svc.m.RUnlock()
-
-	return svc.stream
+func (svc *HostedService) Stream() string {
+	return svc.pc.Config().UpdateStream
 }
+*/
 
-// SetStream sets the new stream name to the given value.
-func (svc *UpdateService) SetStream(stream string) error {
-	svc.m.Lock()
-	defer svc.m.Unlock()
+// setStream sets the new stream name to the given value.
+func (svc *HostedService) setStream(stream string) error {
+	err := svc.pc.AlterConfig(func(cfg *ota.Config) (bool, error) {
+		if cfg.UpdateStream == stream {
+			return false, nil
+		}
 
-	return svc.setStreamUnlocked(stream)
-}
+		// TODO(zyga): check that stream is valid/known.
+		cfg.UpdateStream = stream
+
+		return true, nil
+	})
+	if err != nil {
+		return err
+	}
 
-// setStreamUnlocked sets the new stream name to the given value.
-//
-// It must be called with the write lock held.
-func (svc *UpdateService) setStreamUnlocked(stream string) error {
-	// TODO check that stream is valid/known.
-	svc.stream = stream
-	// TODO: persist the configuration
 	updated := map[string]dbus.Variant{
 		streamProperty: dbus.MakeVariant(stream),
 	}
 	invalidated := []string{}
 
-	return svc.conn.Emit(ota.ServiceObjectPath, dbusutil.PropertiesChangedSignal, UpdateServiceInterfaceName, updated, invalidated)
+	return svc.conn.Emit(ota.ServiceObjectPath, dbusutil.PropertiesChangedSignal, InterfaceName, updated, invalidated)
 }
 
 // IsIdle returns true if the OTA service is idle and can be shut down.
-func (svc *UpdateService) IsIdle() bool {
-	svc.m.RLock()
-	defer svc.m.RUnlock()
+func (svc *HostedService) IsIdle() bool {
+	svc.opsMutex.RLock()
+	defer svc.opsMutex.RUnlock()
 
 	return len(svc.ops) == 0
 }
@@ -210,9 +205,9 @@ func (svc *UpdateService) IsIdle() bool {
 // TODO: Implement this for real by calling thing from the OTA backend.
 // XXX: The return type is probably wrong. It should return "ao"
 // representing the known streams.
-func (svc *UpdateService) UpdateStreams() *dbus.Error {
-	svc.m.Lock()
-	defer svc.m.Unlock()
+func (svc *HostedService) UpdateStreams() *dbus.Error {
+	svc.opsMutex.Lock()
+	defer svc.opsMutex.Unlock()
 
 	fmt.Printf("called UpdateStreams\n")
 
@@ -225,14 +220,14 @@ func (svc *UpdateService) UpdateStreams() *dbus.Error {
 // and returns an object which can be used to track the progress and result.
 //
 // TODO: Implement this for real by calling thing from the OTA backend.
-func (svc *UpdateService) UpdateDevice(hints map[string]string) (dbus.ObjectPath, *dbus.Error) {
-	svc.m.Lock()
-	defer svc.m.Unlock()
+func (svc *HostedService) UpdateDevice(hints map[string]string) (dbus.ObjectPath, *dbus.Error) {
+	svc.opsMutex.Lock()
+	defer svc.opsMutex.Unlock()
 
 	// TODO: implement this for real
 	fmt.Printf("called UpdateDevice with hints %v\n", hints)
 
-	op, err := svc.makeOp()
+	op, err := svc.makeOpUnlocked()
 	if err != nil {
 		return "", dbus.MakeFailedError(err)
 	}
@@ -242,7 +237,7 @@ func (svc *UpdateService) UpdateDevice(hints map[string]string) (dbus.ObjectPath
 	return oper.ObjectPath(op.ID()), nil
 }
 
-func (svc *UpdateService) makeOp() (*oper.Operation, error) {
+func (svc *HostedService) makeOpUnlocked() (*oper.Operation, error) {
 	svc.lastOperationID++
 	op := oper.New(svc.conn, svc.lastOperationID)
 
@@ -273,19 +268,18 @@ func (svc *UpdateService) makeOp() (*oper.Operation, error) {
 // more precise control. We may drop this later, depending on how it gets used.
 
 // Get implements org.freedesktop.DBus.Properties.Get.
-func (svc *UpdateService) Get(iface, property string) (dbus.Variant, *dbus.Error) {
-	svc.m.RLock()
-	defer svc.m.RUnlock()
+func (svc *HostedService) Get(iface, property string) (dbus.Variant, *dbus.Error) {
+	cfg := svc.pc.Config()
 
 	switch iface {
-	case UpdateServiceInterfaceName:
+	case InterfaceName:
 		switch property {
 		case makerProperty:
-			return dbus.MakeVariant(svc.maker), nil
+			return dbus.MakeVariant(cfg.DeviceMaker), nil
 		case modelProperty:
-			return dbus.MakeVariant(svc.model), nil
+			return dbus.MakeVariant(cfg.DeviceModel), nil
 		case streamProperty:
-			return dbus.MakeVariant(svc.stream), nil
+			return dbus.MakeVariant(cfg.UpdateStream), nil
 		default:
 			return dbus.Variant{}, prop.ErrPropNotFound
 		}
@@ -295,16 +289,15 @@ func (svc *UpdateService) Get(iface, property string) (dbus.Variant, *dbus.Error
 }
 
 // GetAll implements org.freedesktop.DBus.Properties.GetAll.
-func (svc *UpdateService) GetAll(iface string) (map[string]dbus.Variant, *dbus.Error) {
-	svc.m.RLock()
-	defer svc.m.RUnlock()
+func (svc *HostedService) GetAll(iface string) (map[string]dbus.Variant, *dbus.Error) {
+	cfg := svc.pc.Config()
 
 	switch iface {
-	case UpdateServiceInterfaceName:
+	case InterfaceName:
 		return map[string]dbus.Variant{
-			makerProperty:  dbus.MakeVariant(svc.maker),
-			modelProperty:  dbus.MakeVariant(svc.model),
-			streamProperty: dbus.MakeVariant(svc.stream),
+			makerProperty:  dbus.MakeVariant(cfg.DeviceMaker),
+			modelProperty:  dbus.MakeVariant(cfg.DeviceModel),
+			streamProperty: dbus.MakeVariant(cfg.UpdateStream),
 		}, nil
 	default:
 		return nil, prop.ErrIfaceNotFound
@@ -312,21 +305,20 @@ func (svc *UpdateService) GetAll(iface string) (map[string]dbus.Variant, *dbus.E
 }
 
 // Set implements org.freedesktop.Properties.Set.
-func (svc *UpdateService) Set(iface, property string, newValue dbus.Variant) (err *dbus.Error) {
-	svc.m.Lock()
-	defer svc.m.Unlock()
-
+func (svc *HostedService) Set(iface, property string, newValue dbus.Variant) (err *dbus.Error) {
 	switch iface {
-	case UpdateServiceInterfaceName:
+	case InterfaceName:
 		switch property {
 		case makerProperty, modelProperty:
 			return prop.ErrReadOnly
 		case streamProperty:
-			if newValue.Signature() != dbus.SignatureOf(svc.stream) {
+			var strVal string
+
+			if newValue.Signature() != dbus.SignatureOf(strVal) {
 				return prop.ErrInvalidArg
 			}
 
-			if err := svc.setStreamUnlocked(newValue.Value().(string)); err != nil {
+			if err := svc.setStream(newValue.Value().(string)); err != nil {
 				return dbus.MakeFailedError(err)
 			}
 		default:
@@ -340,9 +332,9 @@ func (svc *UpdateService) Set(iface, property string, newValue dbus.Variant) (er
 }
 
 // GetManagedObjects implements org.freedesktop.DBus.ObjectManager.GetManagedObjects.
-func (svc *UpdateService) GetManagedObjects() (map[dbus.ObjectPath]map[string]map[string]dbus.Variant, *dbus.Error) {
-	svc.m.RLock()
-	defer svc.m.RUnlock()
+func (svc *HostedService) GetManagedObjects() (map[dbus.ObjectPath]map[string]map[string]dbus.Variant, *dbus.Error) {
+	svc.opsMutex.RLock()
+	defer svc.opsMutex.RUnlock()
 
 	objsIfacesProps := make(map[dbus.ObjectPath]map[string]map[string]dbus.Variant, len(svc.ops))
 
diff --git a/spread.yaml b/spread.yaml
index b44b78e7ac546e8ffb0350e8b6dfdb9f5beae6aa..418449dff19270aee9113ef55564a7f21e4c7c10 100644
--- a/spread.yaml
+++ b/spread.yaml
@@ -100,7 +100,17 @@ suites:
         prepare-each: |
             echo "Commencing $SPREAD_SUITE prepare-each" | systemd-cat -p info -t spread
 
+            systemctl stop sysotad.service
+
             mkdir -p /etc/sysota
+            mkdir -p /run/sysota
+            cat <<SYSOTAD_CONF >/etc/sysota/sysotad.conf
+            [Device]
+            Maker=test-maker
+            Model=test-model
+            [Update]
+            Stream=test-stream
+            SYSOTAD_CONF
 
             echo "Finished $SPREAD_SUITE prepare-each" | systemd-cat -p info -t spread
         restore-each: |
@@ -108,6 +118,7 @@ suites:
 
             # Remove any configuration changes.
             rm -f /etc/sysota/sysotad.conf
+            rm -f /run/sysota/sysotad.conf
             rmdir /etc/sysota
             rm -rf /run/sysotad