diff --git a/doc/conf.py b/doc/conf.py
index dea64997c5c73f553104c1f7d214f6181d209f87..89834add8f44768b812d513af75f5a6dbebc2a24 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -180,4 +180,5 @@ intersphinx_mapping = {
     "https://docs.python.org/3": None,
     "https://numpy.org/doc/stable": None,
     "https://documen.tician.de/pymbolic/": None,
+    "https://documen.tician.de/loopy/": None,
     }
diff --git a/doc/index.rst b/doc/index.rst
index 24e1d66373b8112d03b8ae9bc8ff343e50622ce8..1088516f820e07ea09eef446ea53e00e883ab0fb 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -9,6 +9,7 @@ Welcome to pytools's documentation!
     obj_array
     persistent_dict
     graph
+    tag
     codegen
     misc
 
diff --git a/doc/tag.rst b/doc/tag.rst
new file mode 100644
index 0000000000000000000000000000000000000000..fddab855ee1c70b74fb2c440b0059d34a7f95445
--- /dev/null
+++ b/doc/tag.rst
@@ -0,0 +1 @@
+.. automodule:: pytools.tag
diff --git a/pytools/tag.py b/pytools/tag.py
new file mode 100644
index 0000000000000000000000000000000000000000..e9a4cc69f766d0379bdc01334f2f5d40c30c7cc0
--- /dev/null
+++ b/pytools/tag.py
@@ -0,0 +1,133 @@
+from dataclasses import dataclass
+from typing import Tuple, Any, FrozenSet
+
+__copyright__ = """
+Copyright (C) 2020 Andreas Kloeckner
+Copyright (C) 2020 Matt Wala
+Copyright (C) 2020 Xiaoyu Wei
+"""
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+# {{{ docs
+
+__doc__ = """
+
+Tag Interface
+---------------
+
+.. autoclass:: Tag
+.. autoclass:: UniqueTag
+
+Supporting Functionality
+------------------------
+
+.. autoclass:: DottedName
+
+"""
+
+# }}}
+
+
+# {{{ dotted name
+
+
+class DottedName:
+    """
+    .. attribute:: name_parts
+
+        A tuple of strings, each of which is a valid
+        Python identifier. No name part may start with
+        a double underscore.
+
+    The name (at least morally) exists in the
+    name space defined by the Python module system.
+    It need not necessarily identify an importable
+    object.
+
+    .. automethod:: from_class
+    """
+
+    def __init__(self, name_parts: Tuple[str, ...]):
+        if len(name_parts) == 0:
+            raise ValueError("empty name parts")
+
+        for p in name_parts:
+            if not p.isidentifier():
+                raise ValueError(f"{p} is not a Python identifier")
+
+        self.name_parts = name_parts
+
+    @classmethod
+    def from_class(cls, argcls: Any) -> "DottedName":
+        name_parts = tuple(
+                [str(part) for part in argcls.__module__.split(".")]
+                + [str(argcls.__name__)])
+        if not all(not npart.startswith("__") for npart in name_parts):
+            raise ValueError(f"some name parts of {'.'.join(name_parts)} "
+                             "start with double underscores")
+        return cls(name_parts)
+
+
+# }}}
+
+# {{{ tag
+
+tag_dataclass = dataclass(init=True, eq=True, frozen=True, repr=True)
+
+
+@tag_dataclass
+class Tag:
+    """
+    Generic metadata, applied to, among other things,
+    pytato Arrays.
+
+    .. attribute:: tag_name
+
+        A fully qualified :class:`DottedName` that reflects
+        the class name of the tag.
+
+    Instances of this type must be immutable, hashable,
+    picklable, and have a reasonably concise :meth:`__repr__`
+    of the form ``dotted.name(attr1=value1, attr2=value2)``.
+    Positional arguments are not allowed.
+
+   .. automethod:: __repr__
+    """
+
+    @property
+    def tag_name(self) -> DottedName:
+        return DottedName.from_class(type(self))
+
+
+class UniqueTag(Tag):
+    """
+    Only one instance of this type of tag may be assigned
+    to a single tagged object.
+    """
+    pass
+
+
+TagsType = FrozenSet[Tag]
+
+# }}}
+
+# vim: foldmethod=marker
diff --git a/pytools/version.py b/pytools/version.py
index 2eb5614b1d13233f1a7ec3916f74603ab1b42b0a..77929bc6e5749dadb2aa46036e6b47b379abe0f2 100644
--- a/pytools/version.py
+++ b/pytools/version.py
@@ -1,3 +1,3 @@
-VERSION = (2020, 4, 1)
+VERSION = (2020, 4, 2)
 VERSION_STATUS = ""
 VERSION_TEXT = ".".join(str(x) for x in VERSION) + VERSION_STATUS
diff --git a/setup.py b/setup.py
index 48356c77060bbfa14a4228c75b5baa875729c765..21f2204bf7657940df987d139e57ed568320c66f 100644
--- a/setup.py
+++ b/setup.py
@@ -42,6 +42,7 @@ setup(name="pytools",
           "appdirs>=1.4.0",
           "six>=1.8.0",
           "numpy>=1.6.0",
+          "dataclasses>=0.7;python_version<='3.6'"
           ],
 
       package_data={"pytools": ["py.typed"]},