Private Docs

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/ReworkTargetsrc/Orchestrator04.Domain/Orchestration/Messaging/InboundEvents.cs
Outbox events (Task/Workflow/Lego/Notification)src/Orchestrator04.Domain/Orchestration/Events/OutboxEvents.cs
Ports ILegoPort/IWorkflowPort/ITaskPort/INotifyPort/IMakerDirectoryPortsrc/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 + optionssrc/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(...).
  • DomainEventsDispatcher resolve IDomainEventHandler<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" (verified StartRequestorFlowCommand.cs:39). AutoApproveWorker whitelist = {STANDARD_CUSTOMER_ONBOARDING, CUSTOMER_FIXED_ONBOARDING} (verified AutoApproveWorker.cs:22-26) → disjoint ✅.

ลำดับ task (dependency order)

  1. Task 1 — Saga 2-actor data + UnderConsideration state (พื้นฐานที่ task อื่นพึ่ง)
  2. Task 2SentToReviewerEvent + OnSentToReviewer + LegoProgressRequested outbox event
  3. Task 3OnPickup track Maker + raise progress(InReview)
  4. Task 4OnDecision 4-eye gate + TaskComplete(Approve)/TaskClose(Reject)
  5. Task 5OnResubmit ตัด AdminNotify + stable FlowInstanceId
  6. Task 6OnCancel idempotent + reachable จาก Cancel ทุก state
  7. Task 7OwnedFlowCodesNEW_REQUESTOR (S5, Phase-0 verify)
  8. Task 8 — Cut INotifyPort + IMakerDirectoryPort + notification outbox events/handlers
  9. Task 9ILegoPort.PublishProgress + LegoProgressHandler (outbox handler D)
  10. Task 10TaskCompleteRequested outbox event + ITaskPort.Complete + handler
  11. Task 11SentToReviewer consumer (C4) + flow-cancel-requested consumer (C2)
  12. Task 12 — Real ASB Lego adapter (P1) — progress + decision
  13. Task 13 — Real HTTP Workflow adapter (P2) — idempotent create/resubmit/force-stop 🤝
  14. Task 14 — Real HTTP Task adapter (P3) — idempotent create/rework/close/complete 🤝
  15. Task 15 — DI wiring swap (logging → real) + consumer registration + options

Task 1-7 = saga (unit-testable ด้วย DomainEvents assertion, ไม่ต้อง 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);
  • แก้ ReadData default → 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).AssignedAdminUsernameReadData(instance).MakerUsername.
  • แก้ test เดิม Pickup_moves_to_UnderReview_and_stores_admin_and_wf (assert Contains("somchai", i.Data) ยังผ่านเพราะ username อยู่ใน json) + แก้ helper Instance(...) ที่ 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 ใน HandleAsync switch:
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 (TaskCompleteRequested event). ถ้ายังไม่ทำ 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 เปลี่ยนชื่อ adminUsernamemakerUsername (Task 1.2 แก้ json key แล้ว). ลบ assert AdminNotificationRequested ทั้งหมดในไฟล์ 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 (LegoTransitionRequested xmldoc). ปล่อย 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 (Transition throw InvalidOperationException เพราะ 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 (verified OrchestrationDispatcher.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 — OwnedFlowCodesNEW_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 (InlineData FX_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_* — ลบ assert MakersNotificationRequested, เพิ่ม 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/AdminNotificationRequested handlers).
  • ลบ INotifyPort/NotificationContent/IMakerDirectoryPort/Maker จาก OrchestrationPorts.cs.
  • ลบ LoggingNotifyPort + LoggingMakerDirectoryPort จาก LoggingNoOpPorts.cs (รวม using System.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 ILegoPort verify PublishProgress ถูกเรียก 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.Mapnew PickupEvent(w.RefNo, w.WfInstanceId, w.ActionBy, w.OccurredAt).
  • แก้ OnPickup (Task 3) → ใช้ p.OccurredAt แทน DateTime.UtcNow.
  • แก้ test pickup ที่สร้าง PickupEvent → เพิ่ม arg DateTime.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 (ตาม pattern WorkflowPickupConsumer):
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 (pattern CreateSender + SendMessageAsync + MessageId).
  • อ่าน Lego consumer ฝั่ง UserService เพื่อ match payload (M7 flow-approval-result: decisionId, flowInstanceId, result, reworkTargets, reason; M6 flow-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 — implement ILegoPort:
    • Transition(...): map LegoAction→UPPER wire string (Approved/Rejected/CorrectionRequested→REWORK(หรือ CORRECTION_REQUESTED alias)/Cancel→CANCEL), serialize payload, MessageId = decisionId (dedup-stable), send to flow-approval-result.
    • PublishProgress(...): serialize {eventId=Guid.NewGuid(), flowInstanceId, progressStatus, occurredAt}, send to flow-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 — implement IWorkflowPort:
    • 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 — implement ITaskPort (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) + LegoPublish topics + 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 build solution → 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กระทบ taskconfirm กับ
🤝1Workflow HTTP contract (create→WfInstanceId, resubmit, force-stop, GET-by-refNo) + native→Decision mapping ที่ publisherT13, T11ทีม Workflow (Phase-0, M3-M5/M12-M14)
🤝2Task HTTP contract (create/rework/close/complete, GET /ref miss-behavior)T14ทีม Task (Phase-0, M9-M11)
🤝3InReview มีที่มาไหม — ขึ้นกับ Workflow มี pickup/claim. ถ้า role-based ไม่มี pickup → C3 Pickup consumer ไม่มี trigger → OnPickup/InReview อาจตัดT3, T11ทีม Workflow (CONTEXT Open item)
🤝4flow-progress-update (M6) Lego consumer มีจริงไหม + field namesT12sync กับ plan 01-userservice-lego (user เป็นเจ้าของ — lock ได้)
🤝5flow-approval-result CANCEL — Lego consumer เดิมขาด CANCEL (00 §7.2)T12plan 01 (Lego ต้องเพิ่ม)
❓6OwnedFlowCodes มีแค่ NEW_REQUESTOR หรือมี SA-1557 flows อื่นT7ทีม Lego/user
❓7เก็บ Logging*Port ไว้สำหรับ demo/PortCallLog flow หรือลบทิ้งT15user
⚠️8comment “FlowInstanceId per-submission overwrite (decision H)” ใน CorrelationKeys.cs/OutboxEvents.cs ขัด D9 (stable)T5user (แก้ comment หรือปล่อย)