diff --git a/CLAUDE.md b/CLAUDE.md
index f2ae643a..91aa3e62 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,3 +122,14 @@ protected function isAccessible(User $user, ?string $path = null): bool
## HTTP Requests
- Never use the `request()` helper. Inject `Illuminate\Http\Request` into the controller method and read input from `$request` (e.g. `$request->integer('per_page', 15)`).
+
+## The Current User
+
+- Never call `auth()` inside a model or an action. Resolving the current user is the caller's job, and reaching for it deeper down couples domain code to an HTTP session it should know nothing about.
+- Models and actions that need a user accept one as a parameter, typed `?Authenticatable`.
+- Controllers, Filament pages, jobs, commands and views are the right places to resolve the user, using `Cachet::user()` where a request-bound guard is needed.
+
+## Transactions
+
+- Any action that writes more than once — a model plus its pivots, a change plus its audit record — wraps the writes in `DB::transaction()`, so a failure part-way through cannot leave orphaned or half-populated rows.
+- Events describing the completed change are dispatched after the transaction commits, never inside it.
diff --git a/database/migrations/2026_07_25_000001_create_component_status_changes_table.php b/database/migrations/2026_07_25_000001_create_component_status_changes_table.php
new file mode 100644
index 00000000..1c805758
--- /dev/null
+++ b/database/migrations/2026_07_25_000001_create_component_status_changes_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->unsignedInteger('component_id');
+ $table->unsignedTinyInteger('old_status')->nullable();
+ $table->unsignedTinyInteger('new_status');
+ $table->string('source');
+ $table->nullableMorphs('causer');
+ $table->text('reason')->nullable();
+ $table->timestamps();
+
+ $table->foreign('component_id')->references('id')->on('components')->cascadeOnDelete();
+ $table->index(['component_id', 'created_at']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('component_status_changes');
+ }
+};
diff --git a/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php b/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php
new file mode 100644
index 00000000..e3399ddd
--- /dev/null
+++ b/database/migrations/2026_07_25_000002_add_constraints_to_component_pivots.php
@@ -0,0 +1,80 @@
+
+ */
+ private const PIVOTS = [
+ 'incident_components' => 'incident_id',
+ 'schedule_components' => 'schedule_id',
+ ];
+
+ /**
+ * Run the migrations.
+ *
+ * Now that these rows decide what the status page displays, a component may
+ * only be attached to a given incident or schedule once. Duplicates that
+ * predate the constraint are collapsed to their most severe impact, which
+ * is the one that would have won anyway.
+ */
+ public function up(): void
+ {
+ foreach (self::PIVOTS as $table => $parentKey) {
+ $this->deduplicate($table, $parentKey);
+
+ Schema::table($table, function (Blueprint $table) use ($parentKey) {
+ $table->unique([$parentKey, 'component_id']);
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ foreach (self::PIVOTS as $table => $parentKey) {
+ Schema::table($table, function (Blueprint $table) use ($parentKey) {
+ $table->dropUnique([$parentKey, 'component_id']);
+ });
+ }
+ }
+
+ /**
+ * Collapse duplicate rows, keeping the most severe impact of each pair.
+ */
+ private function deduplicate(string $table, string $parentKey): void
+ {
+ DB::table($table)
+ ->select($parentKey, 'component_id')
+ ->groupBy($parentKey, 'component_id')
+ ->havingRaw('count(*) > 1')
+ ->get()
+ ->each(function (object $duplicate) use ($table, $parentKey) {
+ $keep = DB::table($table)
+ ->where($parentKey, $duplicate->{$parentKey})
+ ->where('component_id', $duplicate->component_id)
+ ->get()
+ ->sortByDesc(fn (object $row) => [
+ ComponentStatusEnum::tryFrom((int) $row->component_status)?->severity() ?? 0,
+ $row->id,
+ ])
+ ->first();
+
+ DB::table($table)
+ ->where($parentKey, $duplicate->{$parentKey})
+ ->where('component_id', $duplicate->component_id)
+ ->where('id', '!=', $keep->id)
+ ->delete();
+ });
+ }
+};
diff --git a/database/migrations/2026_07_25_000003_backfill_incident_status_from_updates.php b/database/migrations/2026_07_25_000003_backfill_incident_status_from_updates.php
new file mode 100644
index 00000000..702c4d09
--- /dev/null
+++ b/database/migrations/2026_07_25_000003_backfill_incident_status_from_updates.php
@@ -0,0 +1,55 @@
+where('updateable_type', Relation::getMorphAlias(Incident::class))
+ ->whereNotNull('status')
+ ->orderBy('created_at')
+ ->orderBy('id')
+ ->select(['updateable_id', 'status'])
+ ->chunk(1000, function ($updates) use (&$statuses): void {
+ foreach ($updates as $update) {
+ $statuses[$update->updateable_id] = $update->status;
+ }
+ });
+
+ collect($statuses)
+ ->groupBy(fn (int $status): int => $status, preserveKeys: true)
+ ->each(function ($incidents, int $status): void {
+ $incidents->keys()->chunk(1000)->each(fn ($ids) => DB::table('incidents')
+ ->whereIn('id', $ids->all())
+ ->where(fn ($query) => $query->where('status', '!=', $status)->orWhereNull('status'))
+ ->update(['status' => $status]));
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * The previous statuses were never stored, so there is nothing to restore.
+ */
+ public function down(): void
+ {
+ //
+ }
+};
diff --git a/database/migrations/2026_07_26_000001_backfill_component_status_and_make_it_required.php b/database/migrations/2026_07_26_000001_backfill_component_status_and_make_it_required.php
new file mode 100644
index 00000000..d10e979e
--- /dev/null
+++ b/database/migrations/2026_07_26_000001_backfill_component_status_and_make_it_required.php
@@ -0,0 +1,41 @@
+whereNull('status')
+ ->update(['status' => ComponentStatusEnum::unknown->value]);
+
+ Schema::table('components', function (Blueprint $table) {
+ $table->unsignedInteger('status')->change();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ *
+ * The original nulls were not preserved, so only the schema nullability can
+ * be restored here.
+ */
+ public function down(): void
+ {
+ Schema::table('components', function (Blueprint $table) {
+ $table->unsignedInteger('status')->nullable()->change();
+ });
+ }
+};
diff --git a/resources/lang/en/component.php b/resources/lang/en/component.php
index cc6c4af2..17b74976 100644
--- a/resources/lang/en/component.php
+++ b/resources/lang/en/component.php
@@ -39,6 +39,12 @@
'under_maintenance' => 'Under maintenance',
'unknown' => 'Unknown',
],
+ 'status_source' => [
+ 'manual' => 'Manual',
+ 'monitor' => 'Monitoring',
+ 'import' => 'Import',
+ 'system' => 'System',
+ ],
'overview' => [
'operational_components_label' => 'Operational components',
'operational_components_description' => 'Components that are fully operational.',
diff --git a/resources/lang/en/schedule.php b/resources/lang/en/schedule.php
index 4c06eb84..26acc7b9 100644
--- a/resources/lang/en/schedule.php
+++ b/resources/lang/en/schedule.php
@@ -42,6 +42,7 @@
'action_label' => 'Add component',
'header' => 'Affected components',
'component_label' => 'Component',
+ 'status_label' => 'Status during maintenance',
],
],
'add_update' => [
diff --git a/resources/views/components/component-group.blade.php b/resources/views/components/component-group.blade.php
index bb588112..7870b023 100644
--- a/resources/views/components/component-group.blade.php
+++ b/resources/views/components/component-group.blade.php
@@ -2,7 +2,7 @@
{{ \Cachet\Facades\CachetView::renderHook(\Cachet\View\RenderHook::STATUS_PAGE_COMPONENT_GROUPS_BEFORE) }}
@php($groupStatus = $componentGroup->worstComponentStatus())
-
isExpanded()) default-open @endif>
+isExpanded(auth()->user())) default-open @endif>