feat: require explicit model-operation holds (AEG-V15-037)

Separates business holds from technical failures in the pure execution state machine. Evidence: targeted Release tests 3/3 passed; TRX SHA256 2F2CD06B1DFD3F76F336A0636598553599CD05CF7FA82E477DB160425975085F.
This commit is contained in:
2026-08-09 02:14:55 +09:00
parent 00957bf384
commit d38dc32e7a
6 changed files with 114 additions and 5 deletions
@@ -15,7 +15,8 @@ public sealed record ModelOperationExecutionTransition(
ModelOperationExecutionState To,
string ReasonCode,
string EvidenceHash,
DateTimeOffset OccurredAt);
DateTimeOffset OccurredAt,
DateTimeOffset? HoldUntil);
public sealed class ModelOperationExecution
{
@@ -48,18 +49,30 @@ public sealed class ModelOperationExecution
public DateTimeOffset RequestedAt { get; }
public DateTimeOffset LastOccurredAt { get; private set; }
public ModelOperationExecutionState State { get; private set; }
public DateTimeOffset? HoldUntil { get; private set; }
public IReadOnlyList<ModelOperationExecutionTransition> Transitions => transitions;
public ModelOperationExecutionTransition MoveTo(ModelOperationExecutionState next, string reasonCode, string evidenceHash, DateTimeOffset occurredAt)
public ModelOperationExecutionTransition MoveTo(
ModelOperationExecutionState next,
string reasonCode,
string evidenceHash,
DateTimeOffset occurredAt,
DateTimeOffset? holdUntil = null)
{
if (!Allowed[State].Contains(next)) throw new InvalidOperationException($"Execution transition {State} -> {next} is not allowed.");
ArgumentException.ThrowIfNullOrWhiteSpace(reasonCode);
ArgumentException.ThrowIfNullOrWhiteSpace(evidenceHash);
if (occurredAt < LastOccurredAt) throw new InvalidOperationException("Execution transition time cannot move backwards.");
var transition = new ModelOperationExecutionTransition(State, next, reasonCode, evidenceHash, occurredAt);
if (next == ModelOperationExecutionState.BusinessHold && (!holdUntil.HasValue || holdUntil <= occurredAt))
throw new InvalidOperationException("Business hold requires a future hold-until instant.");
if (next != ModelOperationExecutionState.BusinessHold && holdUntil.HasValue)
throw new InvalidOperationException("Only a business hold may have a hold-until instant.");
var transition = new ModelOperationExecutionTransition(State, next, reasonCode, evidenceHash, occurredAt, holdUntil);
transitions.Add(transition);
State = next;
LastOccurredAt = occurredAt;
HoldUntil = holdUntil;
return transition;
}