Plan — OrchestratorService Implementation
แผน implement รายละเอียดฝั่ง Orchestrator (Lane 2 adapters + saga)
อัปเดต: 2026-06-15
For agentic workers: REQUIRED SUB-SKILL: Use superpowers
(recommended) or superpowers to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: ต่อเติม OnboardingApprovalSaga ให้รองรับ 2-actor / 4-eye review + progress projection และเติม Lane 2 consumers/ports/outbox-handlers ที่ขาด เพื่อให้ Orchestrator เป็น control plane ที่คุม flow Lego ↔ Workflow ↔ Task ตาม contract matrix (M1-M14)
Architecture: Generic composite engine — OrchestrationDispatcher (dedup→correlate→dispatch→persist atomic) เรียก saga; saga เป็น pure brain ที่ mutate ProcessState (source of truth ของช่วง in-flight) + raise outbox events เท่านั้น ไม่แตะ DbContext/ports. Outbox events ถูก capture โดย OutboxInterceptor ใน transaction เดียวกับ state change แล้ว OutboxProcessor dispatch async ไปยัง IDomainEventHandler<T> ที่เรียก ports (ASB/HTTP).
Tech Stack: .NET 10 Clean Architecture (01.API / 02.Infrastructure / 03.Application / 04.Domain), EF Core (Npgsql), Azure Service Bus (native SDK — ServiceBusProcessor / ServiceBusSender), Backend_Package Outbox (OutboxInterceptor / OutboxProcessor / IDomainEventHandler<T> / DomainEventsDispatcher), xUnit + Moq
สถานะ codebase ปัจจุบัน (verified 2026-06-15 — อ่านก่อนเริ่ม)
Lane 1 + scaffolding ของ Lane 2 มีแล้ว งานนี้คือ delta เท่านั้น อย่าสร้างซ้ำ:
| มีแล้ว (อย่าสร้างใหม่) | path |
|---|---|
OnboardingApprovalSaga (OnFirstSubmit/OnResubmit/OnPickup/OnDecision/OnCancel) | src/Orchestrator03.Application/Orchestration/Sagas/OnboardingApprovalSaga.cs |
OnboardingProcessState (static consts) | ไฟล์เดียวกัน บรรทัด 12-20 |
Inbound events SubmitEvent/PickupEvent/DecisionEvent + DecisionAction/ReworkTarget | src/Orchestrator04.Domain/Orchestration/Messaging/InboundEvents.cs |
| Outbox events (Task/Workflow/Lego/Notification) | src/Orchestrator04.Domain/Orchestration/Events/OutboxEvents.cs |
Ports ILegoPort/IWorkflowPort/ITaskPort/INotifyPort/IMakerDirectoryPort | src/Orchestrator04.Domain/Ports/Orchestration/OrchestrationPorts.cs |
| Logging adapters (Lane 1 placeholder) | src/Orchestrator02.Infrastructure/Orchestration/Adapters/LoggingNoOpPorts.cs |
Consumers FlowSubmitted/WorkflowPickup/WorkflowDecision + base + wire DTOs + options | src/Orchestrator02.Infrastructure/Orchestration/Consumers/ |
| Outbox handlers (Task/Workflow/Lego/Notification) | src/Orchestrator03.Application/Orchestration/Handlers/ |
| DI ทั้ง 2 ชั้น | Orchestrator02.Infrastructure/DependencyInjection.cs + Orchestrator03.Application/DependencyInjection.cs |
| Saga unit tests (style reference) | tests/Orchestrator05.Tests/Application/Orchestration/OnboardingApprovalSagaTests.cs |
Test command (จาก repo root):
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_OrchestratorService/tests/Orchestrator05.Tests/Orchestrator05.Tests.csproj
รัน 1 test ด้วย filter:
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_OrchestratorService/tests/Orchestrator05.Tests/Orchestrator05.Tests.csproj --filter "FullyQualifiedName~OnboardingApprovalSagaTests.<Name>"
Verified facts:
Entity.DomainEventsมี (test ใช้i.DomainEvents.OfType<T>()); saga raise ด้วยinstance.Raise(...).DomainEventsDispatcherresolveIDomainEventHandler<TEvent>ด้วยGetServices(รองรับหลาย handler ต่อ event type) — ต้องลงทะเบียนทุก handler ในAddApplication().- ports frozen-Phase-0 comment ใน
OrchestrationPorts.cs(decision Q) — เราขยาย method เพิ่ม (progress + complete) แต่ contract ของ Workflow/Task HTTP shape = 🤝 assumption (§assumption ท้ายไฟล์). - OwnedFlowCodes ปัจจุบัน =
{"FX_ONBOARD"}(placeholder). flow จริง ="NEW_REQUESTOR"(verifiedStartRequestorFlowCommand.cs:39). AutoApproveWorker whitelist ={STANDARD_CUSTOMER_ONBOARDING, CUSTOMER_FIXED_ONBOARDING}(verifiedAutoApproveWorker.cs:22-26) → disjoint ✅.
ลำดับ task (dependency order)
- Task 1 — Saga 2-actor data +
UnderConsiderationstate (พื้นฐานที่ task อื่นพึ่ง) - Task 2 —
SentToReviewerEvent+OnSentToReviewer+LegoProgressRequestedoutbox event - Task 3 —
OnPickuptrack Maker + raise progress(InReview) - Task 4 —
OnDecision4-eye gate + TaskComplete(Approve)/TaskClose(Reject) - Task 5 —
OnResubmitตัด AdminNotify + stable FlowInstanceId - Task 6 —
OnCancelidempotent + reachable จาก Cancel ทุก state - Task 7 —
OwnedFlowCodes→NEW_REQUESTOR(S5, Phase-0 verify) - Task 8 — Cut
INotifyPort+IMakerDirectoryPort+ notification outbox events/handlers - Task 9 —
ILegoPort.PublishProgress+LegoProgressHandler(outbox handler D) - Task 10 —
TaskCompleteRequestedoutbox event +ITaskPort.Complete+ handler - Task 11 —
SentToReviewerconsumer (C4) +flow-cancel-requestedconsumer (C2) - Task 12 — Real ASB Lego adapter (P1) — progress + decision
- Task 13 — Real HTTP Workflow adapter (P2) — idempotent create/resubmit/force-stop 🤝
- Task 14 — Real HTTP Task adapter (P3) — idempotent create/rework/close/complete 🤝
- Task 15 — DI wiring swap (logging → real) + consumer registration + options
Task 1-7 = saga (unit-testable ด้วย
DomainEventsassertion, ไม่ต้อง DB/ASB). Task 8-15 = Lane 2 plumbing. ทำตามลำดับ — แต่ละ task TDD: failing test → fail → implement → pass → commit.
Task 1 — Saga 2-actor data + UnderConsideration state
ขยาย scratch data จาก single admin → { MakerUsername, ReviewerUsername } และเพิ่ม ProcessState ใหม่.
Step 1.1 — เพิ่ม UnderConsideration const
- เพิ่ม const ใน
OnboardingProcessState(src/Orchestrator03.Application/Orchestration/Sagas/OnboardingApprovalSaga.cs):
public const string AwaitingPickup = "AwaitingPickup";
public const string UnderReview = "UnderReview";
public const string UnderConsideration = "UnderConsideration"; // ← เพิ่ม (map Lego "Reviewed")
public const string ReworkRequested = "ReworkRequested";
public const string Approved = "Approved";
public const string Rejected = "Rejected";
public const string Cancelled = "Cancelled";
- รัน build (ยังไม่มี test ใหม่):
dotnet build C:/Source/AzureDevOps_SuperAPP/Backend_OrchestratorService/src/Orchestrator03.Application/Orchestrator03.Application.csproj→ ผ่าน. - commit:
git add -A && git commit -m "orch(saga): add UnderConsideration process state"
Step 1.2 — ขยาย OnboardingData เป็น 2-actor (failing test ก่อน)
- เขียน failing test ใน
OnboardingApprovalSagaTests.cs— ตรวจว่า OnPickup เก็บMakerUsername(ยังไม่ implement → fail เพราะ field เดิมชื่อAssignedAdminUsername):
[Fact]
public async Task Pickup_tracks_MakerUsername()
{
var i = Instance(OnboardingProcessState.AwaitingPickup);
await Saga().HandleAsync(i, new PickupEvent(Ref, "77", "maker.somchai"));
Assert.Contains("MakerUsername", i.Data);
Assert.Contains("maker.somchai", i.Data);
}
- รัน → fail (compile/assert).
- เปลี่ยน record ท้ายไฟล์ saga:
/// <summary>Saga-private scratch persisted to OrchestrationInstance.Data (jsonb). 2-actor 4-eye (decision D10).</summary>
private sealed record OnboardingData(string? MakerUsername, string? ReviewerUsername);
- แก้
ReadDatadefault →new OnboardingData(null, null). - แก้
OnPickup(เดิมSetData(JsonSerializer.Serialize(new OnboardingData(p.ActionByUsername)))) →
instance.SetData(JsonSerializer.Serialize(new OnboardingData(p.ActionByUsername, null)));
- แก้
OnResubmitที่อ่านAssignedAdminUsername→ ลบ (จะจัดการเต็มใน Task 5; ชั่วคราวคง compile ด้วยการลบ AdminNotification block — ดู Task 5). หากต้องคง compile ใน task นี้: เปลี่ยนReadData(instance).AssignedAdminUsername→ReadData(instance).MakerUsername. - แก้ test เดิม
Pickup_moves_to_UnderReview_and_stores_admin_and_wf(assertContains("somchai", i.Data)ยังผ่านเพราะ username อยู่ใน json) + แก้ helperInstance(...)ที่ serialize{"AssignedAdminUsername":...}→{"MakerUsername":...,"ReviewerUsername":null}. - รัน test → ผ่านทั้ง suite.
- commit:
git commit -am "orch(saga): expand scratch data to 2-actor Maker/Reviewer"
Task 2 — SentToReviewerEvent + OnSentToReviewer + LegoProgressRequested
Step 2.1 — inbound event SentToReviewerEvent
- เพิ่มใน
src/Orchestrator04.Domain/Orchestration/Messaging/InboundEvents.cs:
/// <summary>Maker forwarded the job to a Reviewer (= Workflow APPROVE on a non-final phase). 2-actor track #2.</summary>
public sealed record SentToReviewerEvent(
string RefNo,
string ReviewerUsername,
DateTime OccurredAt) : IOrchestrationEvent;
- build domain → ผ่าน. commit:
git commit -am "orch(domain): add SentToReviewerEvent inbound"
Step 2.2 — outbox event LegoProgressRequested
- เพิ่มใน
src/Orchestrator04.Domain/Orchestration/Events/OutboxEvents.cs(ใต้ section Lego):
/// <summary>Project an in-flight progress status (InReview/Reviewed) to Lego — status-only, idempotent, no side-effect (00 §3 Progress channel).</summary>
public sealed record LegoProgressRequested(
Guid FlowInstanceId,
string ProgressStatus, // "InReview" | "Reviewed"
DateTime OccurredAt) : OrchestrationOutboxEvent;
- build → ผ่าน. commit:
git commit -am "orch(domain): add LegoProgressRequested outbox event"
Step 2.3 — OnSentToReviewer handler (failing test ก่อน)
- failing test:
[Fact]
public async Task SentToReviewer_from_UnderReview_tracks_Reviewer_and_projects_Reviewed()
{
var i = Instance(OnboardingProcessState.UnderReview, Flow1);
await Saga().HandleAsync(i, new SentToReviewerEvent(Ref, "reviewer.anan", DateTime.UtcNow));
Assert.Equal(OnboardingProcessState.UnderConsideration, i.ProcessState);
Assert.Contains("reviewer.anan", i.Data);
var p = Assert.Single(i.DomainEvents.OfType<LegoProgressRequested>());
Assert.Equal("Reviewed", p.ProgressStatus);
Assert.Equal(Flow1, p.FlowInstanceId);
}
[Fact]
public async Task SentToReviewer_wrong_state_ignored()
{
var i = Instance(OnboardingProcessState.AwaitingPickup, Flow1);
await Saga().HandleAsync(i, new SentToReviewerEvent(Ref, "reviewer.anan", DateTime.UtcNow));
Assert.Equal(OnboardingProcessState.AwaitingPickup, i.ProcessState);
Assert.Empty(i.DomainEvents.OfType<LegoProgressRequested>());
}
- รัน → fail (ไม่มี case ใน switch).
- เพิ่ม case ใน
HandleAsyncswitch:
case SentToReviewerEvent r: OnSentToReviewer(instance, r); break;
- เพิ่ม method:
private void OnSentToReviewer(OrchestrationInstance instance, SentToReviewerEvent r)
{
if (!ExpectState(instance, OnboardingProcessState.UnderReview, nameof(OnSentToReviewer))) return;
var flowInstanceId = RequireFlowInstanceId(instance);
var data = ReadData(instance) with { ReviewerUsername = r.ReviewerUsername };
instance.SetData(JsonSerializer.Serialize(data));
instance.Raise(new LegoProgressRequested(flowInstanceId, "Reviewed", r.OccurredAt));
instance.Transition(OnboardingProcessState.UnderConsideration, Lifecycle.Running);
}
- รัน test → ผ่าน. commit:
git commit -am "orch(saga): OnSentToReviewer tracks Reviewer + projects Reviewed"
Task 3 — OnPickup track Maker + raise progress(InReview)
ปัจจุบัน OnPickup ไม่ raise progress กลับ Lego (00 §S4).
Step 3.1 — failing test
- เพิ่ม assert ใน test ที่มีอยู่ หรือ test ใหม่:
[Fact]
public async Task Pickup_projects_InReview_to_lego()
{
var i = Instance(OnboardingProcessState.AwaitingPickup, Flow1);
var at = DateTime.UtcNow;
await Saga().HandleAsync(i, new PickupEvent(Ref, "77", "maker.somchai"));
var p = Assert.Single(i.DomainEvents.OfType<LegoProgressRequested>());
Assert.Equal("InReview", p.ProgressStatus);
Assert.Equal(Flow1, p.FlowInstanceId);
}
- รัน → fail (ไม่ raise).
- แก้
OnPickup— เพิ่ม raise ก่อน Transition (ต้อง require FlowInstanceId; instance ถูก set FlowInstanceId ตั้งแต่ OnFirstSubmit):
private void OnPickup(OrchestrationInstance instance, PickupEvent p)
{
if (!ExpectState(instance, OnboardingProcessState.AwaitingPickup, nameof(OnPickup))) return;
var flowInstanceId = RequireFlowInstanceId(instance);
instance.SetCorrelation(CorrelationKeys.WfInstanceId, p.WfInstanceId);
instance.SetData(JsonSerializer.Serialize(new OnboardingData(p.ActionByUsername, null)));
instance.Raise(new LegoProgressRequested(flowInstanceId, "InReview", DateTime.UtcNow));
instance.Transition(OnboardingProcessState.UnderReview, Lifecycle.Running);
}
หมายเหตุ:
PickupEventปัจจุบันไม่มีOccurredAt. progress ordering ใช้occurredAt(00 §4.4). เพิ่มDateTime OccurredAtเข้าPickupEvent+PickupWire+ map ในWorkflowPickupConsumer(ดู Task 11 จะรวม) — ใน task นี้ ใช้DateTime.UtcNowชั่วคราวแล้วมาแทนด้วยp.OccurredAtตอน Task 11 (note ไว้ใน commit).
- รัน test → ผ่าน. commit:
git commit -am "orch(saga): OnPickup projects InReview to Lego"
Task 4 — OnDecision 4-eye gate + TaskComplete/TaskClose
กฎ grill (CONTEXT §Decision Authority): Approve รับเฉพาะ UnderConsideration (Reviewer); Reject/Rework รับ {UnderReview, UnderConsideration} (Maker kick-back ได้). Approve→+TaskComplete; Reject→+TaskClose.
ขึ้นกับ Task 10 (
TaskCompleteRequestedevent). ถ้ายังไม่ทำ Task 10 ให้ทำ Step 10.1 (เพิ่ม record) ก่อน step นี้ — แยก commit ได้.
Step 4.1 — failing test (4-eye)
- tests:
[Fact]
public async Task Approve_rejected_from_UnderReview_maker_cannot_approve()
{
var i = Instance(OnboardingProcessState.UnderReview, Flow1);
await Saga().HandleAsync(i, new DecisionEvent(Ref, "d-1", DecisionAction.Approve, null, null, "maker.somchai"));
Assert.Equal(OnboardingProcessState.UnderReview, i.ProcessState); // ignored
Assert.Empty(i.DomainEvents.OfType<LegoTransitionRequested>());
}
[Fact]
public async Task Approve_ok_from_UnderConsideration_and_completes_task()
{
var i = Instance(OnboardingProcessState.UnderConsideration, Flow1);
await Saga().HandleAsync(i, new DecisionEvent(Ref, "d-1", DecisionAction.Approve, null, null, "reviewer.anan"));
Assert.Equal(OnboardingProcessState.Approved, i.ProcessState);
Assert.Equal(Lifecycle.Completed, i.Lifecycle);
Assert.Equal(1, Count<TaskCompleteRequested>(i));
Assert.Single(i.DomainEvents.OfType<LegoTransitionRequested>());
}
[Theory]
[InlineData(OnboardingProcessState.UnderReview)]
[InlineData(OnboardingProcessState.UnderConsideration)]
public async Task Reject_accepted_from_both_review_states_and_closes_task(string state)
{
var i = Instance(state, Flow1);
await Saga().HandleAsync(i, new DecisionEvent(Ref, "d-1", DecisionAction.Reject, "no", null, "maker.somchai"));
Assert.Equal(OnboardingProcessState.Rejected, i.ProcessState);
Assert.Equal(1, Count<TaskCloseRequested>(i));
}
[Theory]
[InlineData(OnboardingProcessState.UnderReview)]
[InlineData(OnboardingProcessState.UnderConsideration)]
public async Task Rework_accepted_from_both_review_states(string state)
{
var i = Instance(state, Flow1);
var targets = new List<ReworkTarget> { new("StepA", "fix") };
await Saga().HandleAsync(i, new DecisionEvent(Ref, "d-1", DecisionAction.Rework, null, targets, "maker.somchai"));
Assert.Equal(OnboardingProcessState.ReworkRequested, i.ProcessState);
}
- แก้ test เดิม
Approve_completes_and_relays_to_legoให้ instance เริ่มจากUnderConsideration(เดิมUnderReview— Approve จะถูก gate ปฏิเสธ).Reject_completes/Rework_*เดิมเริ่มUnderReviewยังผ่าน (Reject/Rework รับ UnderReview). - รัน → fail.
- เขียน
OnDecisionใหม่ด้วย per-action gate:
private void OnDecision(OrchestrationInstance instance, DecisionEvent d)
{
if (d.Action == DecisionAction.Cancel) { OnCancel(instance, d); return; }
var flowInstanceId = RequireFlowInstanceId(instance);
switch (d.Action)
{
case DecisionAction.Approve:
// 4-eye: Reviewer only (UnderConsideration). Maker (UnderReview) cannot self-approve.
if (!ExpectState(instance, OnboardingProcessState.UnderConsideration, "OnDecision.Approve")) return;
instance.Raise(new LegoTransitionRequested(flowInstanceId, DecisionAction.Approve, d.DecisionId, d.Reason, null));
instance.Raise(new TaskCompleteRequested(d.RefNo));
instance.Transition(OnboardingProcessState.Approved, Lifecycle.Completed);
break;
case DecisionAction.Reject:
if (!ExpectAnyState(instance, "OnDecision.Reject",
OnboardingProcessState.UnderReview, OnboardingProcessState.UnderConsideration)) return;
instance.Raise(new LegoTransitionRequested(flowInstanceId, DecisionAction.Reject, d.DecisionId, d.Reason, null));
instance.Raise(new TaskCloseRequested(d.RefNo)); // กัน task ค้าง (decision D8)
instance.Transition(OnboardingProcessState.Rejected, Lifecycle.Completed);
break;
case DecisionAction.Rework:
if (!ExpectAnyState(instance, "OnDecision.Rework",
OnboardingProcessState.UnderReview, OnboardingProcessState.UnderConsideration)) return;
instance.Raise(new LegoTransitionRequested(flowInstanceId, DecisionAction.Rework, d.DecisionId, d.Reason, d.ReworkTargets));
instance.Transition(OnboardingProcessState.ReworkRequested, Lifecycle.Running);
break;
}
}
- เพิ่ม helper:
private bool ExpectAnyState(OrchestrationInstance instance, string op, params string[] expected)
{
if (expected.Contains(instance.ProcessState)) return true;
_logger.LogWarning("{Op}: instance {Id} in {Actual}, expected one of {Expected} — ignored",
op, instance.Id, instance.ProcessState, string.Join("/", expected));
return false;
}
- รัน test → ผ่าน. commit:
git commit -am "orch(saga): 4-eye decision gate + TaskComplete/TaskClose side-effects"
Task 5 — OnResubmit ตัด AdminNotify + stable FlowInstanceId
grill: Workflow notify maker เอง → orchestrator ตัด AdminNotificationRequested; D9 instance เดิม stable → ลบ assumption “FlowInstanceId per-submission overwrite” (ไม่ overwrite correlation).
Step 5.1 — failing test
- แก้/เพิ่ม test (เดิม
Resubmit_reuses_notifies_admin_and_overwrites_flowinstance...):
[Fact]
public async Task Resubmit_reuses_workflow_and_keeps_flowinstance_stable_no_admin_notify()
{
var i = Instance(OnboardingProcessState.ReworkRequested, Flow1, makerUsername: "maker.somchai");
await Saga().HandleAsync(i, new SubmitEvent(Ref, Flow2, "NEW_REQUESTOR", Guid.NewGuid(), IsResubmit: true));
Assert.Equal(OnboardingProcessState.UnderReview, i.ProcessState);
Assert.Equal(Flow1.ToString(), i.GetCorrelation(CorrelationKeys.FlowInstanceId)); // stable (D9) — ไม่ overwrite
Assert.Equal(1, Count<WorkflowResubmitRequested>(i));
Assert.Equal(1, Count<TaskReworkRequested>(i));
Assert.Equal(0, Count<TaskCreationRequested>(i));
Assert.Equal(0, Count<WorkflowCreationRequested>(i));
}
หมายเหตุ helper
Instance(...)param เปลี่ยนชื่อadminUsername→makerUsername(Task 1.2 แก้ json key แล้ว). ลบ assertAdminNotificationRequestedทั้งหมดในไฟล์ test.
- รัน → fail.
- แก้
OnResubmit:
private void OnResubmit(OrchestrationInstance instance, SubmitEvent s)
{
if (!ExpectState(instance, OnboardingProcessState.ReworkRequested, nameof(OnResubmit))) return;
// D9: FlowInstanceId stable across resubmit — ไม่ overwrite correlation, ไม่ recreate Task/Wf.
instance.Raise(new WorkflowResubmitRequested(s.RefNo)); // Workflow notify assignee maker เอง
instance.Raise(new TaskReworkRequested(s.RefNo));
instance.Transition(OnboardingProcessState.UnderReview, Lifecycle.Running);
}
- รัน → ผ่าน. commit:
git commit -am "orch(saga): OnResubmit drop AdminNotify + stable FlowInstanceId (D9)"
หมายเหตุ Lane 1 docs เดิม comment ว่า “FlowInstanceId per-submission overwrite (decision H)” ใน
CorrelationKeys.cs+OutboxEvents.cs(LegoTransitionRequestedxmldoc). ปล่อย comment ไว้—ไม่ใช่ scope (กฎ §3 แก้เฉพาะจุด) เว้นแต่ user สั่งล้าง. flag: comment ขัดกับ behavior ใหม่ (D9) — แจ้ง user.
Task 6 — OnCancel reachable จากทุก state + idempotent
ปัจจุบัน OnCancel ถูกเรียกจาก OnDecision (Workflow-initiated). ต้อง: (a) idempotent (ignore ถ้า Cancelled แล้ว) — แต่ engine guard Transition throw ถ้า terminal อยู่แล้ว; (b) Lego-initiated cancel ผ่าน CancelEvent ใหม่ (Task 11 C2). ใน task นี้ทำ saga-side idempotency + รับ Cancel จากทุก in-flight state.
Step 6.1 — failing test (idempotent double-sweep)
[Fact]
public async Task Cancel_is_idempotent_second_sweep_noop()
{
var i = Instance(OnboardingProcessState.UnderReview, Flow1);
await Saga().HandleAsync(i, new DecisionEvent(Ref, "d-1", DecisionAction.Cancel, "stop", null, "admin"));
Assert.Equal(Lifecycle.Cancelled, i.Lifecycle);
// second sweep (double-fire) → no throw, no duplicate teardown
await Saga().HandleAsync(i, new DecisionEvent(Ref, "d-2", DecisionAction.Cancel, "stop", null, "admin"));
Assert.Equal(Lifecycle.Cancelled, i.Lifecycle);
Assert.Equal(1, Count<TaskCloseRequested>(i)); // ยังคง 1 (ไม่ raise ซ้ำ)
Assert.Equal(1, Count<WorkflowForceStopRequested>(i));
}
- รัน → fail (
TransitionthrowInvalidOperationExceptionเพราะ instance terminal แล้ว). - แก้
OnCancel— guard idempotent นำหน้า:
private void OnCancel(OrchestrationInstance instance, DecisionEvent d)
{
if (instance.Lifecycle == Lifecycle.Cancelled)
{
_logger.LogInformation("OnCancel: instance {Id} already Cancelled — no-op (double-sweep)", instance.Id);
return;
}
var flowInstanceId = RequireFlowInstanceId(instance);
instance.Raise(new TaskCloseRequested(d.RefNo));
instance.Raise(new WorkflowForceStopRequested(d.RefNo));
instance.Raise(new LegoTransitionRequested(flowInstanceId, DecisionAction.Cancel, d.DecisionId, d.Reason, null));
instance.Transition(OnboardingProcessState.Cancelled, Lifecycle.Cancelled);
}
หมายเหตุ dispatcher correlate เฉพาะ
Lifecycle == Running(verifiedOrchestrationDispatcher.cs:48) → ใน production second-sweep จะไม่หา instance เจอ(เป็น Skipped/DeadLetter), แต่ saga-level guard กัน defensive + รองรับ unit test. Cancel จากทุก in-flight state ได้อยู่แล้วเพราะOnCancelไม่ExpectState.
- รัน → ผ่าน. commit:
git commit -am "orch(saga): OnCancel idempotent double-sweep guard"
Task 7 — OwnedFlowCodes → NEW_REQUESTOR (S5, Phase-0 verify)
verified: flow จริง = "NEW_REQUESTOR" (StartRequestorFlowCommand.cs:39); AutoApprove whitelist = {STANDARD_CUSTOMER_ONBOARDING, CUSTOMER_FIXED_ONBOARDING} → disjoint.
Step 7.1 — failing test
[Theory]
[InlineData("NEW_REQUESTOR", false, true)]
[InlineData("NEW_REQUESTOR", true, false)]
[InlineData("STANDARD_CUSTOMER_ONBOARDING", false, false)] // AutoApprove owns — disjoint
[InlineData("CUSTOMER_FIXED_ONBOARDING", false, false)]
[InlineData("FX_ONBOARD", false, false)] // placeholder เก่า — ไม่ owned แล้ว
public void CanStart_only_for_NEW_REQUESTOR_first_submit(string flowCode, bool isResubmit, bool expected)
{
var evt = new SubmitEvent(Ref, Flow1, flowCode, Guid.NewGuid(), isResubmit);
Assert.Equal(expected, Saga().CanStart(evt));
}
- แก้ test เดิม
CanStart_only_for_owned_first_submit(InlineDataFX_ONBOARD) → ลบหรือแทน; แก้FirstSubmit_*/Resubmit_*ที่ใช้"FX_ONBOARD"→"NEW_REQUESTOR". - รัน → fail.
- แก้
OwnedFlowCodes:
private static readonly HashSet<string> OwnedFlowCodes = new(StringComparer.OrdinalIgnoreCase)
{
"NEW_REQUESTOR",
};
- รัน → ผ่าน. commit:
git commit -am "orch(saga): own NEW_REQUESTOR flow code (disjoint from AutoApprove)"
🤝 Phase-0: ยืนยันกับทีม Lego ว่า requestor FX flow ที่ต้อง orchestrate ทั้งหมดมีแค่
NEW_REQUESTORหรือมี flow code อื่น (เช่น SA-1557 3 flows) ที่ต้องเพิ่มใน set — ดู memory “new-requestor-flow-not-code-seeded”. ถ้ามีเพิ่ม = เติม string ใน HashSet.
Task 8 — Cut INotifyPort + IMakerDirectoryPort + notification events/handlers
grill D5: orchestrator ไม่ทำ notification เลย. ลบ port + outbox event + handler + DI + logging adapter + OnFirstSubmit ไม่ raise MakersNotificationRequested.
Step 8.1 — saga ไม่ raise MakersNotification (failing test)
- แก้ test
FirstSubmit_*— ลบ assertMakersNotificationRequested, เพิ่มAssert.Equal(0, Count<...>)ชั่วคราว หรือลบ type อ้างอิง:
[Fact]
public async Task FirstSubmit_raises_task_and_workflow_only_no_notification()
{
var i = Instance(OnboardingProcessState.AwaitingPickup);
await Saga().HandleAsync(i, new SubmitEvent(Ref, Flow1, "NEW_REQUESTOR", Guid.NewGuid(), IsResubmit: false));
Assert.Equal(1, Count<TaskCreationRequested>(i));
Assert.Equal(1, Count<WorkflowCreationRequested>(i));
Assert.Equal(Flow1.ToString(), i.GetCorrelation(CorrelationKeys.FlowInstanceId));
}
- แก้
OnFirstSubmit— ลบinstance.Raise(new MakersNotificationRequested(s.RefNo));. - รัน → ผ่าน. commit:
git commit -am "orch(saga): OnFirstSubmit stop raising MakersNotification (Workflow owns maker notify)"
Step 8.2 — ลบ notification outbox events
- ลบ
MakersNotificationRequested+AdminNotificationRequestedจากOutboxEvents.cs(section Notification ทั้งบล็อก บรรทัด 16-21). - build → fail (handler + DI อ้างถึง). แก้ตาม step ถัดไป.
Step 8.3 — ลบ handlers + DI + ports + adapters
- ลบไฟล์
src/Orchestrator03.Application/Orchestration/Handlers/NotificationHandlers.cs. - ลบ 2 บรรทัด register ใน
Orchestrator03.Application/DependencyInjection.cs(MakersNotificationRequested/AdminNotificationRequestedhandlers). - ลบ
INotifyPort/NotificationContent/IMakerDirectoryPort/MakerจากOrchestrationPorts.cs. - ลบ
LoggingNotifyPort+LoggingMakerDirectoryPortจากLoggingNoOpPorts.cs(รวม usingSystem.Security.Cryptography/Textที่ orphan). - ลบ DI
services.AddScoped<INotifyPort,...>()+<IMakerDirectoryPort,...>()ในOrchestrator02.Infrastructure/DependencyInjection.cs. - build ทั้ง solution → ผ่าน. รัน test suite → ผ่าน.
- commit:
git commit -am "orch: remove INotifyPort + IMakerDirectoryPort (orchestrator does no notification — grill D5)"
Task 9 — ILegoPort.PublishProgress + LegoProgressHandler
outbox event LegoProgressRequested (Task 2) ต้องมี handler → port. Progress = ASB topic flow-progress-update (M6), แยกจาก decision channel.
Step 9.1 — ขยาย ILegoPort
- เพิ่ม method ใน
ILegoPort(OrchestrationPorts.cs):
public interface ILegoPort
{
Task Transition(Guid flowInstanceId, LegoAction action, string decisionId,
string? reason, IReadOnlyList<ReworkTarget>? reworkTargets);
/// <summary>flow-progress-update (ASB) — status-only projection, idempotent, no side-effect (M6).</summary>
Task PublishProgress(Guid flowInstanceId, string progressStatus, DateTime occurredAt);
}
- เพิ่ม impl ใน
LoggingLegoPort(LoggingNoOpPorts.cs):
public Task PublishProgress(Guid flowInstanceId, string progressStatus, DateTime occurredAt)
{
log.LogInformation("[Lego] flow-progress-update flow={Flow} {Status} at={At:o}", flowInstanceId, progressStatus, occurredAt);
rec.Record($"Lego.PublishProgress(flow={flowInstanceId:N}..., {progressStatus})");
return Task.CompletedTask;
}
- build → ผ่าน.
Step 9.2 — LegoProgressHandler (outbox handler D)
- เพิ่ม handler (ไฟล์ใหม่
src/Orchestrator03.Application/Orchestration/Handlers/LegoProgressHandler.cs):
using Orchestrator04.Domain.Orchestration.Events;
using Orchestrator04.Domain.Ports.Orchestration;
using SupApp_util_lib.Abstractions.Events;
namespace Orchestrator03.Application.Orchestration.Handlers;
/// <summary>Relay a progress projection to Lego (InReview/Reviewed) — Progress channel, no side-effect (M6).</summary>
public sealed class LegoProgressHandler : IDomainEventHandler<LegoProgressRequested>
{
private readonly ILegoPort _lego;
public LegoProgressHandler(ILegoPort lego) => _lego = lego;
public Task HandleAsync(LegoProgressRequested e, CancellationToken ct = default) =>
_lego.PublishProgress(e.FlowInstanceId, e.ProgressStatus, e.OccurredAt);
}
- register ใน
AddApplication():
services.AddScoped<IDomainEventHandler<LegoProgressRequested>, LegoProgressHandler>();
- build → ผ่าน. (handler ทดสอบง่าย: optional unit test ด้วย Moq
ILegoPortverifyPublishProgressถูกเรียก 1 ครั้ง.)
[Fact]
public async Task LegoProgressHandler_calls_port()
{
var lego = new Mock<ILegoPort>();
await new LegoProgressHandler(lego.Object).HandleAsync(new LegoProgressRequested(Flow1, "InReview", DateTime.UtcNow));
lego.Verify(p => p.PublishProgress(Flow1, "InReview", It.IsAny<DateTime>()), Times.Once);
}
- รัน → ผ่าน. commit:
git commit -am "orch: ILegoPort.PublishProgress + LegoProgressHandler (M6 progress channel)"
Task 10 — TaskCompleteRequested outbox event + ITaskPort.Complete + handler
(TaskCloseRequested มีแล้ว; ขาด complete-on-approve — decision D8).
Step 10.1 — event + port + handler
- เพิ่มใน
OutboxEvents.cs(section Task):
public sealed record TaskCompleteRequested(string RefNo) : OrchestrationOutboxEvent;
- เพิ่มใน
ITaskPort:
Task Complete(string refNo);
- เพิ่ม impl ใน
LoggingTaskPort:
public Task Complete(string refNo)
{ log.LogInformation("[Task] Complete ref={Ref}", refNo); rec.Record($"Task.Complete(ref={refNo})"); return Task.CompletedTask; }
- เพิ่ม handler ใน
TaskHandlers.cs:
public sealed class TaskCompleteHandler : IDomainEventHandler<TaskCompleteRequested>
{
private readonly ITaskPort _task;
public TaskCompleteHandler(ITaskPort task) => _task = task;
public Task HandleAsync(TaskCompleteRequested e, CancellationToken ct = default) => _task.Complete(e.RefNo);
}
- register ใน
AddApplication():
services.AddScoped<IDomainEventHandler<TaskCompleteRequested>, TaskCompleteHandler>();
- build → ผ่าน. (Task 4 test
Approve_ok_*อ้างTaskCompleteRequested— รัน suite → ผ่าน.) - commit:
git commit -am "orch: TaskCompleteRequested event + ITaskPort.Complete + handler (D8)"
Task 11 — Consumers C4 (SentToReviewer) + C2 (flow-cancel-requested) + Pickup OccurredAt
Step 11.1 — PickupEvent/PickupWire เพิ่ม OccurredAt
- เพิ่ม
DateTime OccurredAtเข้าPickupEvent(InboundEvents.cs) +PickupWire(WireMessages.cs). - แก้
WorkflowPickupConsumer.Map→new PickupEvent(w.RefNo, w.WfInstanceId, w.ActionBy, w.OccurredAt). - แก้
OnPickup(Task 3) → ใช้p.OccurredAtแทนDateTime.UtcNow. - แก้ test pickup ที่สร้าง
PickupEvent→ เพิ่ม argDateTime.UtcNow. - build + test → ผ่าน. commit:
git commit -am "orch: PickupEvent carries OccurredAt for progress ordering"
Step 11.2 — SentToReviewerConsumer (C4)
- เพิ่ม wire DTO ใน
WireMessages.cs:
public sealed record SentToReviewerWire(string? RefNo, string ReviewerUsername, DateTime OccurredAt);
- เพิ่ม endpoint ใน
OrchestrationOptions:
public ConsumerEndpoint SentToReviewer { get; set; } = new()
{ Topic = "workflow-sent-to-reviewer", Subscription = "orchestration-senttoreviewer-sub" };
- consumer ใหม่
Consumers/WorkflowSentToReviewerConsumer.cs(ตาม patternWorkflowPickupConsumer):
public sealed class WorkflowSentToReviewerConsumer : OrchestrationConsumerBase<SentToReviewerWire>
{
public WorkflowSentToReviewerConsumer(IOptions<OrchestrationOptions> o, ServiceBusClient c, IServiceScopeFactory s, ILogger<WorkflowSentToReviewerConsumer> l)
: base(o.Value.SentToReviewer, c, s, l) { }
protected override IOrchestrationEvent Map(SentToReviewerWire w)
{
if (string.IsNullOrEmpty(w.RefNo))
throw new FormatException("SentToReviewer has null RefNo (entry guard)");
return new SentToReviewerEvent(w.RefNo, w.ReviewerUsername, w.OccurredAt);
}
}
- register (Task 15). build → ผ่าน. commit:
git commit -am "orch: WorkflowSentToReviewerConsumer (C4)"
Step 11.3 — CancelEvent + FlowCancelRequestedConsumer (C2)
C2 = Lego-initiated cancel (flow-cancel-requested). ปัจจุบัน Cancel เข้า saga ผ่าน DecisionEvent{Action=Cancel} (Workflow ขา). Lego ขาต้องมี event ของตัวเอง → map ไป OnCancel.
- เพิ่ม inbound event (InboundEvents.cs):
/// <summary>Lego-initiated cancel (flow-cancel-requested, M2). initiatedBy/mode opaque — saga fan-out teardown เหมือนกัน.</summary>
public sealed record CancelEvent(
string RefNo,
string DecisionId,
string? Reason) : IOrchestrationEvent;
- เพิ่ม case ใน saga
HandleAsync:
case CancelEvent c: OnCancel(instance, c.RefNo, c.DecisionId, c.Reason); break;
- refactor
OnCancel(instance, DecisionEvent d)→OnCancel(instance, string refNo, string decisionId, string? reason); ปรับ caller จากOnDecision(OnCancel(instance, d.RefNo, d.DecisionId, d.Reason)). - failing test:
[Fact]
public async Task Lego_CancelEvent_tears_down_and_cancels()
{
var i = Instance(OnboardingProcessState.UnderReview, Flow1);
await Saga().HandleAsync(i, new CancelEvent(Ref, "c-1", "admin@lego cancel"));
Assert.Equal(Lifecycle.Cancelled, i.Lifecycle);
Assert.Equal(1, Count<TaskCloseRequested>(i));
Assert.Equal(1, Count<WorkflowForceStopRequested>(i));
Assert.Single(i.DomainEvents.OfType<LegoTransitionRequested>());
}
- wire DTO + endpoint + consumer:
// WireMessages.cs
public sealed record CancelWire(string? RefNo, string DecisionId, string? InitiatedBy, string? Mode, string? Reason);
// OrchestrationOptions
public ConsumerEndpoint Cancel { get; set; } = new()
{ Topic = "flow-cancel-requested", Subscription = "orchestration-cancel-sub" };
// Consumers/FlowCancelRequestedConsumer.cs
protected override IOrchestrationEvent Map(CancelWire w)
{
if (string.IsNullOrEmpty(w.RefNo)) throw new FormatException("Cancel has null RefNo (entry guard)");
return new CancelEvent(w.RefNo, w.DecisionId, w.Reason);
}
- build + test → ผ่าน. commit:
git commit -am "orch: CancelEvent + FlowCancelRequestedConsumer (C2 Lego-initiated cancel)"
หมายเหตุ dispatcher จะ DLQ Cancel/SentToReviewer ที่ไม่มี Running instance (orphan,
StartOrRejectAsync). ถูกต้องตาม decision L — ไม่ต้องแก้ dispatcher.
Task 12 — Real ASB Lego adapter (P1)
แทน LoggingLegoPort ด้วย adapter จริง — publish flow-approval-result (decision) + flow-progress-update (progress) ด้วย ServiceBusSender. ใช้ AsbAuditEventPublisher เป็น pattern (มี ServiceBusClient ใน DI แล้ว).
Step 12.1 — อ่าน pattern + Lego wire contract
- อ่าน
src/Orchestrator02.Infrastructure/Messaging/AsbAuditEventPublisher.cs(patternCreateSender+SendMessageAsync+MessageId). - อ่าน Lego consumer ฝั่ง UserService เพื่อ match payload (M7
flow-approval-result:decisionId, flowInstanceId, result, reworkTargets, reason; M6flow-progress-update:eventId, flowInstanceId, refNo, progressStatus, occurredAt). path: ค้นflow-approval-resultในBackend_UserService. ถ้า Lego consumer ฝั่ง progress ยังไม่มี → flag (ขึ้นกับ plan 01-userservice).
Step 12.2 — AsbLegoAdapter
- ไฟล์ใหม่
src/Orchestrator02.Infrastructure/Orchestration/Adapters/AsbLegoAdapter.cs— implementILegoPort:Transition(...): mapLegoAction→UPPER wire string (Approved/Rejected/CorrectionRequested→REWORK(หรือCORRECTION_REQUESTEDalias)/Cancel→CANCEL), serialize payload,MessageId = decisionId(dedup-stable), send toflow-approval-result.PublishProgress(...): serialize{eventId=Guid.NewGuid(), flowInstanceId, progressStatus, occurredAt}, send toflow-progress-update.MessageId = Guid.NewGuid()(progress idempotency อยู่ที่ Lego ด้วย occurredAt guard).- topic names จาก options (เพิ่ม
LegoApprovalResultTopic/LegoProgressTopicในOrchestrationOptionsหรือ section ใหม่LegoPublish).
- unit test (optional, integration-ish): verify map LegoAction → wire string ด้วย method แยก
static string ToWire(LegoAction)(test pure mapping). - build → ผ่าน. commit:
git commit -am "orch(infra): AsbLegoAdapter — flow-approval-result + flow-progress-update (P1)"
🤝 assumption flag: wire field names ของ
flow-progress-update(M6) เป็น contract ใหม่ user เป็นเจ้าของ (lock ได้) แต่ต้อง sync กับ plan 01-userservice-lego (progress consumer ฝั่ง Lego).flow-approval-result(M7) มี consumer เดิมแต่ขาดCANCEL— Lego ต้องเพิ่ม (ดู plan 01).
Task 13 — Real HTTP Workflow adapter (P2) 🤝
idempotent create (GET-by-document ก่อน POST) / resubmit / force-stop. HTTP shape = 🤝 assumption.
Step 13.1 — idempotency ผ่าน Correlations
- อ่าน
src/Orchestrator02.Infrastructure/Gateway/(ApimGatewayClient/HttpClientsExtensions) เป็น pattern HTTP call ผ่าน APIM. - ไฟล์ใหม่
Adapters/HttpWorkflowAdapter.cs— implementIWorkflowPort:Create(refNo, businessType): ตาม decision C idempotency — adapter ตรวจCorrelations.WfInstanceIdก่อน? แต่ adapter ไม่มี instance context (handler ส่งแค่ refNo). ทางเลือก: (a) adapter GET workflow by refNo ก่อน POST (server-side idempotency via UNIQUE), หรือ (b) handler อ่าน correlation. เลือก (a) — adapter GET/workflow?refNo=ถ้ามีแล้ว skip POST; ตรงกับ port contract “ids resolved by RefNo inside adapters” (OrchestrationPorts.cs comment).Resubmit(refNo)/ForceStop(refNo): resolve wfInstanceId by refNo (GET) → ถ้าไม่เจอ (cancel-vs-create race, ยังไม่สร้าง) → log + no-op (teardown ทน not-yet-created, decision C).
- build → ผ่าน. commit:
git commit -am "orch(infra): HttpWorkflowAdapter — idempotent create/resubmit/force-stop (P2, contract=assumption)"
🤝 assumption flag (ชัดเจน): Workflow HTTP endpoint paths, request/response shape, GET-by-refNo behavior, native(APPROVE/REJECT/OnRejectToPhaseOrder)→Decision mapping (ที่ Workflow publisher ทำก่อน publish) = ต้อง confirm กับทีม Workflow (M3-M5, M12-M14, Phase-0). adapter เขียนตาม contract ที่เสนอใน 03-workflowservice.md — ถ้าทีม Workflow ตอบต่าง = แก้ adapter เท่านั้น (port interface คงเดิม).
Task 14 — Real HTTP Task adapter (P3) 🤝
create (skip ถ้ามีแล้ว) / rework / close / complete. HTTP shape = 🤝 assumption.
Step 14.1
- ไฟล์ใหม่
Adapters/HttpTaskAdapter.cs— implementITaskPort(Create/MarkRework/Close/Complete):Create(refNo): GET task by refNo → ถ้ามี skip (server UNIQUE(RefNo) idempotency, ดู port comment); else POST.MarkRework/Close/Complete: resolve taskId by refNo → ถ้าไม่เจอ → no-op (teardown ทน not-yet-created).
- build → ผ่าน. commit:
git commit -am "orch(infra): HttpTaskAdapter — idempotent create/rework/close/complete (P3, contract=assumption)"
🤝 assumption flag: Task HTTP endpoints (create/rework/close/complete) + GET /ref miss-behavior = confirm กับทีม Task (M9-M11, Phase-0). ดู 04-taskservice.md.
Task 15 — DI wiring swap + consumer registration
Step 15.1 — swap ports logging → real
- ใน
Orchestrator02.Infrastructure/DependencyInjection.cs:services.AddScoped<ILegoPort, AsbLegoAdapter>();(แทนLoggingLegoPort)services.AddScoped<IWorkflowPort, HttpWorkflowAdapter>();services.AddScoped<ITaskPort, HttpTaskAdapter>();- ลบ register
INotifyPort/IMakerDirectoryPort(ทำใน Task 8 แล้ว — verify หายจริง) - คง
LoggingLegoPort/LoggingWorkflowPort/LoggingTaskPortไว้สำหรับ demo/test? — ตัดสินใจ: ถ้า PortCallLog demo flow ยังใช้ → เก็บ logging adapter ไว้แต่ไม่ register เป็น default; ถ้าไม่ → ลบ. flag ถาม user (ดู §open ด้านล่าง).
- register consumers ใหม่ ในบล็อก
if (!string.IsNullOrEmpty(sbHost) || ...):
services.AddHostedService<Orchestration.Consumers.WorkflowSentToReviewerConsumer>();
services.AddHostedService<Orchestration.Consumers.FlowCancelRequestedConsumer>();
- register HttpClient สำหรับ Workflow/Task adapter (
services.AddHttpClient<HttpWorkflowAdapter>(...)ผ่านAddExternalHttpClientsหรือ named client ตาม pattern Gateway). - build solution → ผ่าน. รัน test suite เต็ม → ผ่าน.
- commit:
git commit -am "orch(infra): wire real ASB/HTTP adapters + register C2/C4 consumers"
Step 15.2 — appsettings + IaC parity
- เพิ่ม config section
Orchestration(subscriptions ใหม่ SentToReviewer/Cancel) +LegoPublishtopics + Workflow/Task base URLs ในappsettings.{dev,sit,uat,prod}.json. - IaC parity (กฎบังคับ): เพิ่ม key เดียวกันใน IaC config repo ทุก env ที่กระทบ — มิฉะนั้น CI override ทับด้วยค่า IaC ตอน deploy = section หาย = consumer ไม่ start. ถ้ายังไม่รู้ค่าจริง → แจ้ง user ว่าต้องเติม env ไหนบ้าง (อย่าปล่อยเงียบ).
- commit:
git commit -am "orch: config sections for new consumers + Lego publish topics (+ IaC parity note)"
Final verification
-
dotnet test C:/Source/AzureDevOps_SuperAPP/Backend_OrchestratorService/tests/Orchestrator05.Tests/Orchestrator05.Tests.csproj→ all green -
dotnet buildsolution → no warning ใหม่ - เทียบ Test checklist ของ 02-orchestrator.md (บรรทัด 94-102): OnPickup track Maker+InReview ✅(T3) · OnSentToReviewer ✅(T2) · 4-eye ✅(T4) · Reject/Rework both states ✅(T4) · Decision-before-pickup ignored ✅(เดิม+T4) · OnCancel idempotent ✅(T6) · OwnedFlowCodes ✅(T7) · port idempotent/teardown ✅(T13/14) · dispatcher dedup ✅(เดิม)
จุด assumption / open items (ต้อง confirm — อย่าเดาเงียบ)
| # | assumption | กระทบ task | confirm กับ |
|---|---|---|---|
| 🤝1 | Workflow HTTP contract (create→WfInstanceId, resubmit, force-stop, GET-by-refNo) + native→Decision mapping ที่ publisher | T13, T11 | ทีม Workflow (Phase-0, M3-M5/M12-M14) |
| 🤝2 | Task HTTP contract (create/rework/close/complete, GET /ref miss-behavior) | T14 | ทีม Task (Phase-0, M9-M11) |
| 🤝3 | InReview มีที่มาไหม — ขึ้นกับ Workflow มี pickup/claim. ถ้า role-based ไม่มี pickup → C3 Pickup consumer ไม่มี trigger → OnPickup/InReview อาจตัด | T3, T11 | ทีม Workflow (CONTEXT Open item) |
| 🤝4 | flow-progress-update (M6) Lego consumer มีจริงไหม + field names | T12 | sync กับ plan 01-userservice-lego (user เป็นเจ้าของ — lock ได้) |
| 🤝5 | flow-approval-result CANCEL — Lego consumer เดิมขาด CANCEL (00 §7.2) | T12 | plan 01 (Lego ต้องเพิ่ม) |
| ❓6 | OwnedFlowCodes มีแค่ NEW_REQUESTOR หรือมี SA-1557 flows อื่น | T7 | ทีม Lego/user |
| ❓7 | เก็บ Logging*Port ไว้สำหรับ demo/PortCallLog flow หรือลบทิ้ง | T15 | user |
| ⚠️8 | comment “FlowInstanceId per-submission overwrite (decision H)” ใน CorrelationKeys.cs/OutboxEvents.cs ขัด D9 (stable) | T5 | user (แก้ comment หรือปล่อย) |