diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs b/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs
new file mode 100644
index 0000000..b963b73
--- /dev/null
+++ b/SeaHaven.Services/Helpers/WorkOrderBoardPatchLifecycleRules.cs
@@ -0,0 +1,52 @@
+using Data.SeaHavenIndustries.Enums;
+using SeaHaven.Services.Exceptions;
+
+namespace SeaHaven.Services.Helpers
+{
+ ///
+ /// SH-169 manual transition guard for the board lifecycleStatus PATCH.
+ /// Incomplete and Scheduled are derived by the server; a direct request may only
+ /// restate what derivation already produces, never forge a new automatic state.
+ /// Past Due is a read-model overlay (),
+ /// not a value, so it cannot be requested at all.
+ ///
+ public static class WorkOrderBoardPatchLifecycleRules
+ {
+ public const string AutomaticStatusCode = "AutomaticLifecycleStatus";
+
+ public static bool IsAutomaticStatus(LifecycleStatus status)
+ => status == LifecycleStatus.Incomplete || status == LifecycleStatus.Scheduled;
+
+ public static void EnsureRequestAllowed(
+ LifecycleStatus? current,
+ LifecycleStatus requested,
+ DateTime? scheduledDate,
+ bool? scheduleWeekOnly)
+ {
+ if (!IsAutomaticStatus(requested))
+ return;
+
+ if (requested == LifecycleStatus.Scheduled
+ && !WorkOrderBoardMutationRules.HasConcreteSchedule(scheduledDate, scheduleWeekOnly))
+ {
+ throw new WorkOrderBoardValidationException(
+ "ScheduledRequiresDate",
+ "Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true.");
+ }
+
+ // why: the board client restates the derived status after a schedule patch; that is a no-op, not a forge.
+ if (current == requested)
+ return;
+
+ if (requested == LifecycleStatus.Scheduled
+ && WorkOrderBoardMutationRules.ShouldPromoteToScheduled(current, scheduledDate, scheduleWeekOnly))
+ {
+ return;
+ }
+
+ throw new WorkOrderBoardValidationException(
+ AutomaticStatusCode,
+ $"{LifecycleStatusMapper.ToFeLabel(requested)} is set automatically and cannot be selected manually.");
+ }
+ }
+}
diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
index 18604b2..533ece6 100644
--- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
+++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs
@@ -516,15 +516,11 @@ namespace SeaHaven.Services.Implementation
"Replace the provisional SH work order number before marking the work order Completed.");
}
- if (parsed == LifecycleStatus.Scheduled
- && !WorkOrderBoardMutationRules.HasConcreteSchedule(
- workOrder.ScheduledDate,
- workOrder.ScheduleWeekOnly))
- {
- throw new WorkOrderBoardValidationException(
- "ScheduledRequiresDate",
- "Scheduled requires a concrete scheduledDate when scheduleWeekOnly is not true.");
- }
+ WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed(
+ workOrder.LifecycleStatus,
+ parsed.Value,
+ workOrder.ScheduledDate,
+ workOrder.ScheduleWeekOnly);
var changes = new List();
var old = workOrder.LifecycleStatus?.ToString() ?? workOrder.Status;
diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs
new file mode 100644
index 0000000..050b78b
--- /dev/null
+++ b/SeaHavenIndustries.Tests/WorkOrderBoardPatchLifecycleRulesTests.cs
@@ -0,0 +1,171 @@
+using Data.SeaHavenIndustries;
+using Data.SeaHavenIndustries.Enums;
+using Microsoft.EntityFrameworkCore;
+using SeaHaven.DataServices.Implementation;
+using SeaHaven.Services.DTOs;
+using SeaHaven.Services.Exceptions;
+using SeaHaven.Services.Helpers;
+using SeaHaven.Services.Implementation;
+
+namespace SeaHavenIndustries.Tests;
+
+/// SH-169 / SH-357 / SH-358: automatic lifecycle states cannot be forged through the board PATCH.
+public class WorkOrderBoardPatchLifecycleRulesTests
+{
+ private static readonly byte[] Version = { 1, 0, 0, 0, 0, 0, 0, 1 };
+
+ private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ var context = new ApplicationDbContext(options);
+ var fieldLocks = new WorkOrderFieldLockService(new WorkOrderFieldLockDataService(context));
+ var audit = new WorkOrderAuditService(new WorkOrderAuditDataService(context), fieldLocks);
+ var service = new WorkOrderBoardUpdateService(
+ new WorkOrderBoardDataService(context),
+ new WorkOrderBoardMutationDataService(context),
+ audit);
+ return (context, service);
+ }
+
+ private static async Task SeedAsync(
+ ApplicationDbContext context,
+ LifecycleStatus status,
+ DateTime? scheduledDate = null)
+ {
+ context.workOrders.Add(new WorkOrder
+ {
+ Id = 1,
+ LifecycleStatus = status,
+ ScheduledDate = scheduledDate,
+ ScheduleWeekOnly = false,
+ RowVersion = Version
+ });
+ await context.SaveChangesAsync();
+ }
+
+ private static Task PatchStatusAsync(WorkOrderBoardUpdateService service, string value)
+ => service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto
+ {
+ Field = WorkOrderBoardFieldNames.LifecycleStatus,
+ Value = value,
+ WorkOrderVersion = Convert.ToBase64String(Version)
+ }, "actor-1");
+
+ [Theory]
+ [InlineData(LifecycleStatus.Pending, "Incomplete")]
+ [InlineData(LifecycleStatus.InProgress, "Incomplete")]
+ [InlineData(LifecycleStatus.EnRoute, "incomplete")]
+ [InlineData(LifecycleStatus.OnSite, "1")]
+ [InlineData(LifecycleStatus.Pending, "Open")]
+ public async Task PatchField_IncompleteRequestFromAnotherState_IsRejectedWithoutChangeOrAudit(
+ LifecycleStatus current,
+ string requested)
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, current);
+
+ var ex = await Assert.ThrowsAsync(
+ () => PatchStatusAsync(service, requested));
+
+ Assert.Equal(WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode, ex.Code);
+ Assert.Equal(current, (await context.workOrders.SingleAsync()).LifecycleStatus);
+ Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
+ }
+
+ [Theory]
+ [InlineData(LifecycleStatus.EnRoute)]
+ [InlineData(LifecycleStatus.OnSite)]
+ [InlineData(LifecycleStatus.InProgress)]
+ [InlineData(LifecycleStatus.PendingQuote)]
+ public async Task PatchField_ScheduledRequestWhenDerivationWouldNotPromote_IsRejected(LifecycleStatus current)
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, current, DateTime.UtcNow.Date.AddDays(7));
+
+ var ex = await Assert.ThrowsAsync(
+ () => PatchStatusAsync(service, "Scheduled"));
+
+ Assert.Equal(WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode, ex.Code);
+ Assert.Equal(current, (await context.workOrders.SingleAsync()).LifecycleStatus);
+ Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
+ }
+
+ [Theory]
+ [InlineData("Past Due")]
+ [InlineData("PastDue")]
+ public async Task PatchField_PastDueIsNotARequestableLifecycleStatus(string requested)
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, LifecycleStatus.Pending);
+
+ var ex = await Assert.ThrowsAsync(
+ () => PatchStatusAsync(service, requested));
+
+ Assert.Equal("InvalidValue", ex.Code);
+ Assert.Equal(LifecycleStatus.Pending, (await context.workOrders.SingleAsync()).LifecycleStatus);
+ }
+
+ [Fact]
+ public async Task PatchField_RestatingDerivedScheduledStatus_IsANoOp()
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, LifecycleStatus.Scheduled, DateTime.UtcNow.Date.AddDays(7));
+
+ var result = await PatchStatusAsync(service, "Scheduled");
+
+ Assert.Equal(LifecycleStatus.Scheduled, result.LifecycleStatus);
+ Assert.Empty(await context.WorkOrderAuditLogs.ToListAsync());
+ }
+
+ [Theory]
+ [InlineData(LifecycleStatus.Incomplete, "Pending")]
+ [InlineData(LifecycleStatus.Pending, "En Route")]
+ [InlineData(LifecycleStatus.EnRoute, "On Site")]
+ [InlineData(LifecycleStatus.OnSite, "In Progress")]
+ [InlineData(LifecycleStatus.InProgress, "Pending")]
+ public async Task PatchField_ManualTransition_PersistsAndAuditsActorPreviousAndNew(
+ LifecycleStatus current,
+ string requested)
+ {
+ var (context, service) = CreateSut();
+ await SeedAsync(context, current);
+
+ var result = await PatchStatusAsync(service, requested);
+
+ var expected = LifecycleStatusMapper.ParseLifecycleStatus(requested)!.Value;
+ Assert.Equal(expected, result.LifecycleStatus);
+ var audit = Assert.Single(await context.WorkOrderAuditLogs.ToListAsync());
+ Assert.Equal(AuditActionType.StatusChanged.ToString(), audit.Action);
+ Assert.Equal("actor-1", audit.UserId);
+ Assert.Equal(current.ToString(), audit.OldValue);
+ Assert.Equal(expected.ToString(), audit.NewValue);
+ Assert.NotEqual(default, audit.CreatedAt);
+ }
+
+ [Theory]
+ [InlineData(LifecycleStatus.Incomplete, LifecycleStatus.Incomplete, false)]
+ [InlineData(LifecycleStatus.Pending, LifecycleStatus.Incomplete, true)]
+ [InlineData(LifecycleStatus.Pending, LifecycleStatus.Pending, false)]
+ [InlineData(LifecycleStatus.Pending, LifecycleStatus.EnRoute, false)]
+ [InlineData(LifecycleStatus.InProgress, LifecycleStatus.Scheduled, true)]
+ [InlineData(LifecycleStatus.Incomplete, LifecycleStatus.Scheduled, false)]
+ [InlineData(LifecycleStatus.Pending, LifecycleStatus.Scheduled, false)]
+ public void EnsureRequestAllowed_WithConcreteSchedule(
+ LifecycleStatus current,
+ LifecycleStatus requested,
+ bool rejects)
+ {
+ var date = new DateTime(2026, 9, 21);
+ var ex = Record.Exception(() =>
+ WorkOrderBoardPatchLifecycleRules.EnsureRequestAllowed(current, requested, date, false));
+
+ if (rejects)
+ Assert.Equal(
+ WorkOrderBoardPatchLifecycleRules.AutomaticStatusCode,
+ Assert.IsType(ex).Code);
+ else
+ Assert.Null(ex);
+ }
+}