diff --git a/aleksis/apps/alsijil/migrations/0019_add_documentation_and_participation.py b/aleksis/apps/alsijil/migrations/0019_add_documentation_and_participation.py
new file mode 100644
index 0000000000000000000000000000000000000000..b85055df033eb9df52daaab1ecc0b50b27dfb328
--- /dev/null
+++ b/aleksis/apps/alsijil/migrations/0019_add_documentation_and_participation.py
@@ -0,0 +1,159 @@
+# Generated by Django 4.2.4 on 2023-08-13 14:53
+
+import aleksis.apps.alsijil.models
+import aleksis.core.managers
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("sites", "0002_alter_domain_unique"),
+        ("chronos", "0015_add_managed_by_app_label"),
+        ("core", "0052_site_related_name"),
+        ("cursus", "0001_initial"),
+        ("alsijil", "0018_add_managed_by_app_label"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="Documentation",
+            fields=[
+                (
+                    "calendarevent_ptr",
+                    models.OneToOneField(
+                        auto_created=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        parent_link=True,
+                        primary_key=True,
+                        serialize=False,
+                        to="core.calendarevent",
+                    ),
+                ),
+                (
+                    "topic",
+                    models.CharField(blank=True, max_length=255, verbose_name="Lesson topic"),
+                ),
+                ("homework", models.CharField(blank=True, max_length=255, verbose_name="Homework")),
+                (
+                    "group_note",
+                    models.CharField(blank=True, max_length=255, verbose_name="Group note"),
+                ),
+                (
+                    "course",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="documentations",
+                        to="cursus.course",
+                    ),
+                ),
+                (
+                    "lesson_event",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="documentation",
+                        to="chronos.lessonevent",
+                    ),
+                ),
+                (
+                    "subject",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="+",
+                        to="cursus.subject",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Teaching documentation",
+                "verbose_name_plural": "Teaching documentations",
+            },
+            bases=("core.calendarevent",),
+        ),
+        migrations.CreateModel(
+            name="Participation",
+            fields=[
+                (
+                    "id",
+                    models.BigAutoField(
+                        auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
+                    ),
+                ),
+                (
+                    "managed_by_app_label",
+                    models.CharField(
+                        blank=True,
+                        editable=False,
+                        max_length=255,
+                        verbose_name="App label of app responsible for managing this instance",
+                    ),
+                ),
+                ("extended_data", models.JSONField(default=dict, editable=False)),
+                ("remarks", models.CharField(blank=True, max_length=255)),
+                (
+                    "documentation",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="participations",
+                        to="alsijil.documentation",
+                    ),
+                ),
+                (
+                    "extra_marks",
+                    models.ManyToManyField(
+                        blank=True, to="alsijil.extramark", verbose_name="Extra marks"
+                    ),
+                ),
+                ("groups_of_person", models.ManyToManyField(related_name="+", to="core.group")),
+                (
+                    "person",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="participations",
+                        to="core.person",
+                    ),
+                ),
+                (
+                    "site",
+                    models.ForeignKey(
+                        default=1,
+                        editable=False,
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="+",
+                        to="sites.site",
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name": "Participation note",
+                "verbose_name_plural": "Participation notes",
+                "ordering": ["documentation", "person__last_name", "person__first_name"],
+            },
+            bases=(aleksis.apps.alsijil.models.RegisterObjectRelatedMixin, models.Model),
+            managers=[
+                ("objects", aleksis.core.managers.AlekSISBaseManager()),
+            ],
+        ),
+        migrations.AddConstraint(
+            model_name="participation",
+            constraint=models.UniqueConstraint(
+                fields=("documentation", "person"), name="unique_participation_per_documentation"
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="documentation",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    ("course__isnull", True), ("lesson_event__isnull", True), _negated=True
+                ),
+                name="either_course_or_lesson_event",
+            ),
+        ),
+    ]
diff --git a/aleksis/apps/alsijil/models.py b/aleksis/apps/alsijil/models.py
index 17a2552567df74f80b4c7aecfcf5465f7dd0bc76..4f2b215c2f7930610bab29623908f92fde6a2532 100644
--- a/aleksis/apps/alsijil/models.py
+++ b/aleksis/apps/alsijil/models.py
@@ -3,6 +3,7 @@ from typing import Optional, Union
 from urllib.parse import urlparse
 
 from django.db import models
+from django.db.models import QuerySet
 from django.db.models.constraints import CheckConstraint
 from django.db.models.query_utils import Q
 from django.urls import reverse
@@ -31,10 +32,12 @@ from aleksis.apps.alsijil.managers import (
 )
 from aleksis.apps.chronos.managers import GroupPropertiesMixin
 from aleksis.apps.chronos.mixins import WeekRelatedMixin
-from aleksis.apps.chronos.models import Event, ExtraLesson, LessonPeriod, TimePeriod
+from aleksis.apps.chronos.models import Event, ExtraLesson, LessonEvent, LessonPeriod, TimePeriod
+from aleksis.apps.chronos.util.format import format_m2m
+from aleksis.apps.cursus.models import Course, Subject
 from aleksis.core.data_checks import field_validation_data_check_factory
 from aleksis.core.mixins import ExtensibleModel, GlobalPermissionModel
-from aleksis.core.models import SchoolTerm
+from aleksis.core.models import CalendarEvent, Group, SchoolTerm
 from aleksis.core.util.core_helpers import get_site_preferences
 from aleksis.core.util.model_helpers import ICONS
 
@@ -439,6 +442,102 @@ class ExtraMark(ExtensibleModel):
         verbose_name_plural = _("Extra marks")
 
 
+class Documentation(CalendarEvent):
+    """A documentation on teaching content in a freely choosable time frame.
+
+    Non-personal, includes the topic and homework of the lesson.
+    """
+
+    # FIXME: DataCheck
+
+    course = models.ForeignKey(
+        Course, models.CASCADE, related_name="documentations", blank=True, null=True
+    )
+
+    lesson_event = models.ForeignKey(
+        LessonEvent, models.CASCADE, related_name="documentation", blank=True, null=True
+    )
+
+    subject = models.ForeignKey(
+        Subject, models.CASCADE, related_name="+", blank=True, null=True
+    )
+
+    topic = models.CharField(verbose_name=_("Lesson topic"), max_length=255, blank=True)
+    homework = models.CharField(verbose_name=_("Homework"), max_length=255, blank=True)
+    group_note = models.CharField(verbose_name=_("Group note"), max_length=255, blank=True)
+
+    def get_subject(self) -> str:
+        if self.subject:
+            return self.subject
+        if self.lesson_event:
+            if self.lesson_event.subject:
+                return self.lesson_event.subject
+            if self.lesson_event.course:
+                return self.lesson_event.course.subject
+        if self.course:
+            return self.course.subject
+
+    def get_groups(self) -> QuerySet[Group]:
+        if self.lesson_event:
+            return self.lesson_event.actual_groups
+        if self.course:
+            return self.course.groups.all()
+
+    def __str__(self) -> str:
+        start_datetime = CalendarEvent.value_start_datetime(self, None)
+        end_datetime = CalendarEvent.value_end_datetime(self, None)
+        return f"{format_m2m(self.get_groups())} {self.get_subject()} {start_datetime} - {end_datetime}"
+
+    class Meta:
+        verbose_name = _("Teaching documentation")
+        verbose_name_plural = _("Teaching documentations")
+        constraints = [
+            models.CheckConstraint(
+                check=~Q(course__isnull=True, lesson_event__isnull=True),
+                name="either_course_or_lesson_event",
+            ),
+        ]
+
+
+class Participation(RegisterObjectRelatedMixin, ExtensibleModel):
+    """A personal note about a single person.
+
+    Used in the class register to note participation and remarks about a student
+    in a documented unit (e.g. a single lesson event or a custom time frame; see Documentation).
+    """
+
+    # FIXME: DataChecks
+
+    person = models.ForeignKey("core.Person", models.CASCADE, related_name="participations")
+    groups_of_person = models.ManyToManyField("core.Group", related_name="+")
+
+    documentation = models.ForeignKey(
+        Documentation, models.CASCADE, related_name="participations"
+    )
+
+    remarks = models.CharField(max_length=255, blank=True)
+
+    extra_marks = models.ManyToManyField("ExtraMark", blank=True, verbose_name=_("Extra marks"))
+
+    def __str__(self) -> str:
+        return f"{self.documentation}, {self.person}"
+
+    class Meta:
+        verbose_name = _("Participation note")
+        verbose_name_plural = _("Participation notes")
+        ordering = [
+            "documentation",
+            "person__last_name",
+            "person__first_name",
+        ]
+        constraints = [
+            models.UniqueConstraint(
+                fields=("documentation", "person"),
+                name="unique_participation_per_documentation",
+            ),
+        ]
+
+
 class GroupRole(ExtensibleModel):
     data_checks = [field_validation_data_check_factory("alsijil", "GroupRole", "icon")]