diff --git a/tools/patman/database.py b/tools/patman/database.py
index e1ec0dc00e2..edb7d116c33 100644
--- a/tools/patman/database.py
+++ b/tools/patman/database.py
@@ -19,7 +19,7 @@ from u_boot_pylib import tout
 from patman.series import Series
 
 # Schema version (version 0 means there is no database yet)
-LATEST = 5
+LATEST = 6
 
 # Information about a series/version record
 SerVer = namedtuple(
@@ -198,6 +198,21 @@ class Database:  # pylint:disable=R0904
         self.cur.execute('ALTER TABLE upstream ADD COLUMN no_tags BIT')
         self.cur.execute('ALTER TABLE ser_ver ADD COLUMN desc')
 
+    def _migrate_to_v6(self):
+        """Add workflow table for tracking todos and other workflow items
+
+        Fields:
+            id: Auto-increment primary key
+            type: Workflow-entry type, e.g. 'todo' or 'sent'
+            series_id: Foreign key referencing series.id
+            timestamp: Due/event time as 'YYYY-MM-DD HH:MM:SS'
+            archived: 0 for active entries, 1 for archived (soft-delete)
+        """
+        self.cur.execute(
+            'CREATE TABLE workflow (id INTEGER PRIMARY KEY AUTOINCREMENT,'
+            'type, series_id INTEGER, timestamp, archived BIT,'
+            'FOREIGN KEY (series_id) REFERENCES series (id))')
+
     def migrate_to(self, dest_version):
         """Migrate the database to the selected version
 
@@ -226,6 +241,8 @@ class Database:  # pylint:disable=R0904
                 self._migrate_to_v4()
             elif version == 5:
                 self._migrate_to_v5()
+            elif version == 6:
+                self._migrate_to_v6()
 
             # Save the new version if we have a schema_version table
             if version > 1:
@@ -1041,3 +1058,75 @@ class Database:  # pylint:disable=R0904
         if not recs:
             return None
         return recs[0]
+
+    # workflow functions
+
+    def workflow_add(self, wtype, series_id, timestamp):
+        """Add a workflow entry
+
+        Args:
+            wtype (str): Workflow type, e.g. 'todo'
+            series_id (int): ID of the series
+            timestamp (str): Timestamp string, e.g. '2025-01-15 10:30:00'
+        """
+        self.execute(
+            'INSERT INTO workflow (type, series_id, timestamp, archived) '
+            'VALUES (?, ?, ?, 0)', (wtype, series_id, timestamp))
+
+    def workflow_archive(self, wtype, series_id):
+        """Archive active workflow entries for a given type and series
+
+        Args:
+            wtype (str): Workflow type, e.g. 'todo'
+            series_id (int): ID of the series
+        """
+        self.execute(
+            'UPDATE workflow SET archived = 1 '
+            'WHERE type = ? AND series_id = ? AND archived = 0',
+            (wtype, series_id))
+
+    def workflow_get(self, wtype, series_id):
+        """Get the active workflow entry for a given type and series
+
+        Args:
+            wtype (str): Workflow type, e.g. 'todo'
+            series_id (int): ID of the series
+
+        Return:
+            str or None: Timestamp string if found, else None
+        """
+        res = self.execute(
+            'SELECT timestamp FROM workflow '
+            'WHERE type = ? AND series_id = ? AND archived = 0',
+            (wtype, series_id))
+        rec = res.fetchone()
+        if rec:
+            return rec[0]
+        return None
+
+    def workflow_get_by_type(self, wtype, before=None):
+        """Get active workflow entries for a given type, joined with series
+
+        Args:
+            wtype (str): Workflow type, e.g. 'todo'
+            before (str or None): If set, only return entries where
+                timestamp <= this value
+
+        Return:
+            list of tuple:
+                int: series ID
+                str: series name
+                str: series description
+                str: timestamp
+        """
+        query = ('SELECT s.id, s.name, s.desc, w.timestamp '
+                 'FROM workflow w '
+                 'JOIN series s ON w.series_id = s.id '
+                 'WHERE w.type = ? AND w.archived = 0 AND s.archived = 0')
+        params = [wtype]
+        if before is not None:
+            query += ' AND w.timestamp <= ?'
+            params.append(before)
+        query += ' ORDER BY w.timestamp'
+        res = self.execute(query, params)
+        return res.fetchall()
diff --git a/tools/patman/test_cseries.py b/tools/patman/test_cseries.py
index c0beb128265..798673e09cb 100644
--- a/tools/patman/test_cseries.py
+++ b/tools/patman/test_cseries.py
@@ -26,6 +26,7 @@ from patman import database
 from patman import patchstream
 from patman.patchwork import Patchwork
 from patman.test_common import TestCommon
+from patman import workflow as wf
 
 HASH_RE = r'[0-9a-f]+'
 #pylint: disable=protected-access
@@ -3556,7 +3557,7 @@ Date:   .*
             self.assertEqual(f'Update database to v{version}',
                              out.getvalue().strip())
             self.assertEqual(version, db.get_schema_version())
-        self.assertEqual(5, database.LATEST)
+        self.assertEqual(6, database.LATEST)
 
     def test_migrate_future_version(self):
         """Test that a database newer than patman is rejected"""
@@ -4130,3 +4131,48 @@ Date:   .*
             self.run_args('series', '-s', 'first', 'version-change',
                           '--new-version', '3', pwork=True)
         method.assert_called_once_with('first', None, 3, dry_run=False)
+
+    def test_workflow_db_methods(self):
+        """Test workflow database methods"""
+        cser = self.get_cser()
+        with terminal.capture():
+            cser.add('first', 'my description', allow_unmarked=True)
+
+        ser = cser.get_series_by_name('first')
+
+        # Initially there is no workflow entry
+        self.assertIsNone(cser.db.workflow_get('todo', ser.idnum))
+
+        # Add a todo entry
+        cser.db.workflow_add('todo', ser.idnum, '2025-03-15 10:00:00')
+        cser.commit()
+
+        # Should be able to read it back
+        ts = cser.db.workflow_get('todo', ser.idnum)
+        self.assertEqual('2025-03-15 10:00:00', ts)
+
+        # Get by type should return it
+        entries = cser.db.workflow_get_by_type('todo')
+        self.assertEqual(1, len(entries))
+        entry = entries[0]
+        self.assertEqual(ser.idnum, entry[0])
+        self.assertEqual('first', entry[1])
+        self.assertEqual('my description', entry[2])
+        self.assertEqual('2025-03-15 10:00:00', entry[3])
+
+        # Get by type with before filter
+        entries = cser.db.workflow_get_by_type(
+            'todo', before='2025-03-14 00:00:00')
+        self.assertEqual(0, len(entries))
+        entries = cser.db.workflow_get_by_type(
+            'todo', before='2025-03-16 00:00:00')
+        self.assertEqual(1, len(entries))
+
+        # Archive it - should no longer be active, but still in the table
+        cser.db.workflow_archive('todo', ser.idnum)
+        cser.commit()
+        self.assertIsNone(cser.db.workflow_get('todo', ser.idnum))
+        res = cser.db.execute(
+            'SELECT archived FROM workflow WHERE series_id = ?',
+            (ser.idnum,))
+        self.assertEqual(1, res.fetchone()[0])
diff --git a/tools/patman/workflow.py b/tools/patman/workflow.py
new file mode 100644
index 00000000000..37644a5de88
--- /dev/null
+++ b/tools/patman/workflow.py
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-2.0+
+#
+# Copyright 2025 Google LLC
+#
+
+"""Workflow types and operations for patman series management"""
+
+import enum
+
+
+class Wtype(enum.StrEnum):
+    """Types of workflow entry"""
+    TODO = 'todo'
