From a4cf09bf46d312e0a3550a6ec5c1576c0dbeb9f5 Mon Sep 17 00:00:00 2001 From: Daniel Hokanson Date: Mon, 17 Aug 2026 01:29:09 -0600 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Phase-0=20construction=20blockers?= =?UTF-8?q?=20=E2=80=94=20part-less=20PO=20lines,=20Parts=20out=20of=20Fou?= =?UTF-8?q?ndations,=20self-gated=20manufacturing=20jobs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes that unblock a manufacturing-free install (construction vertical, Option B seams-only plan) and also repair the shipping Pro Services preset: 1. PurchaseOrderLine.PartId is nullable. The capability graph deliberately removed the PO->Parts dependency ("Quote/SO/PO schemas already make PartId optional") — true for quote/SO/invoice lines, false for PO lines until now. A part-less line (service, or material described in words) requires a Description, is never binned at receipt, and posts to operating expense rather than inventory (GRNI). VendorBillLine.PartId was already nullable — this closes the asymmetry. Touched consumers filter part-less lines where parts are the subject (MRP supply, reorder/burn-rate analysis, ABC costing, sankey vendor->part flow, material-ready check) and null-guard projections. Companion schema change: forge-db construction/phase0-po-partid-nullable (ALTER ... DROP NOT NULL — additive, no backfill). 2. CAP-MD-PARTS moved out of ModuleCatalog.Foundations. Foundations-membership made the first-run picker unable to express "no parts" while PRESET-08 removes the capability — the two mechanisms contradicted each other. The honest dependency edge CAP-INV-CORE -> CAP-MD-PARTS is added so an inventory-module install still closes over the item master. 3. Eight manufacturing jobs now self-gate on their owning capability, following the VarianceWatchdogJob pattern (first-line snapshot check; the schedule still ticks, the job no-ops, toggles take effect without a restart): MrpRunJob (CAP-PLAN-MRP), ReorderAnalysisJob (CAP-PLAN-SAFETYSTOCK), AutoPurchaseOrderJob (CAP-P2P-AUTOPO), CheckInventoryLevelsJob (CAP-INV-CORE), ChannelInventorySyncJob (CAP-EXT-ECOMMERCE), OverdueMaintenanceJob (CAP-MAINT-PM), ShipmentDeliverySweepJob (CAP-O2C-SHIP), CheckJobCostOverrunJob (CAP-MFG-WO-RELEASE). Demo seed data was already gated (SEED_DEMO_DATA, default false). Gates: dotnet build -warnaserror green; dotnet test = 2,176 passed with a failure set byte-identical to main (62 pre-existing docker/postgres-dependent tests that cannot run on this machine — verified by running the suite on a main worktree back to back). Co-Authored-By: Claude Opus 4.8 --- .../CapabilityCatalogRelations.cs | 5 +++ forge.api/Capabilities/ModuleCatalog.cs | 10 ++++-- .../ReceiptInventoryPostingService.cs | 33 ++++++++++--------- ...urchaseOrderReceived_CheckMaterialReady.cs | 3 +- .../Inventory/ReceivePurchaseOrder.cs | 9 +++-- .../Inventory/RunAbcClassification.cs | 3 +- .../PurchaseOrders/CreatePurchaseOrder.cs | 27 ++++++++++----- .../CreatePurchaseOrderRelease.cs | 2 +- .../PurchaseOrders/GetPurchaseOrderById.cs | 2 +- .../GetPurchaseOrderReleases.cs | 2 +- .../Features/PurchaseOrders/ReceiveItems.cs | 11 +++++-- .../Features/Replenishment/GetBurnRates.cs | 5 +-- forge.api/Jobs/AutoPurchaseOrderJob.cs | 17 ++++++++-- forge.api/Jobs/ChannelInventorySyncJob.cs | 12 ++++++- forge.api/Jobs/CheckInventoryLevelsJob.cs | 12 ++++++- forge.api/Jobs/CheckJobCostOverrunJob.cs | 12 ++++++- forge.api/Jobs/MrpRunJob.cs | 11 ++++++- forge.api/Jobs/OverdueMaintenanceJob.cs | 12 ++++++- forge.api/Jobs/ReorderAnalysisJob.cs | 17 ++++++++-- forge.api/Jobs/ShipmentDeliverySweepJob.cs | 12 ++++++- forge.api/Services/JobCostService.cs | 2 +- forge.api/Services/MrpService.cs | 5 +-- forge.core/Entities/PurchaseOrderLine.cs | 9 +++-- .../Models/CreatePurchaseOrderRequestModel.cs | 4 ++- .../Models/PurchaseOrderLineResponseModel.cs | 4 +-- .../PurchaseOrderReleaseResponseModel.cs | 2 +- .../Repositories/InventoryRepository.cs | 2 +- .../Repositories/SankeyReportRepository.cs | 5 +-- forge.data/Schema/forge-schema.sql | 2 +- .../Capabilities/ModuleCatalogTests.cs | 2 +- .../AutoPurchaseOrderJobMigrationTests.cs | 3 +- .../Jobs/ReorderAnalysisJobMigrationTests.cs | 6 ++-- 32 files changed, 197 insertions(+), 66 deletions(-) diff --git a/forge.api/Capabilities/CapabilityCatalogRelations.cs b/forge.api/Capabilities/CapabilityCatalogRelations.cs index 2aa9227e..ceb7bd5a 100644 --- a/forge.api/Capabilities/CapabilityCatalogRelations.cs +++ b/forge.api/Capabilities/CapabilityCatalogRelations.cs @@ -127,6 +127,11 @@ public static class CapabilityCatalogRelations new("CAP-O2C-SO-ACCEPTANCE", "CAP-O2C-SO"), new("CAP-O2C-RECURRING", "CAP-O2C-SO"), new("CAP-O2C-PICKPACK", "CAP-O2C-SO"), + // Inventory stores and moves *parts* — the item master is a real prerequisite. + // This edge was implicit while CAP-MD-PARTS sat in Foundations; when Parts moved + // out of Foundations (2026-08-17, services/construction installs) the dependency + // had to become explicit so an inventory-module install still closes over it. + new("CAP-INV-CORE", "CAP-MD-PARTS"), new("CAP-O2C-PICKPACK", "CAP-INV-CORE"), new("CAP-O2C-SHIP", "CAP-O2C-PICKPACK"), // Note: CAP-O2C-INVOICE depends on (CAP-ACCT-BUILTIN OR CAP-ACCT-EXTERNAL) per 4A — diff --git a/forge.api/Capabilities/ModuleCatalog.cs b/forge.api/Capabilities/ModuleCatalog.cs index af536eb5..f42231c2 100644 --- a/forge.api/Capabilities/ModuleCatalog.cs +++ b/forge.api/Capabilities/ModuleCatalog.cs @@ -35,8 +35,14 @@ public static class ModuleCatalog "CAP-CROSS-PERMS-MATRIX", "CAP-CROSS-ACTIVITY-LOG", "CAP-CROSS-LIST-UX", "CAP-CROSS-BULK-OPS", "CAP-CROSS-DOCS", "CAP-CROSS-ATTACHMENTS", "CAP-CROSS-NOTIFICATIONS", "CAP-CROSS-INTEG-FILE", "CAP-CROSS-CONCURRENCY", - // Core master data every flow leans on - "CAP-MD-PARTS", "CAP-MD-UOM", "CAP-MD-LOCATIONS", "CAP-MD-CURRENCIES", "CAP-MD-TAXCODES", + // Core master data every flow leans on. CAP-MD-PARTS is deliberately NOT + // here (moved out 2026-08-17): services shops and non-manufacturing verticals + // (construction) run without a Part catalog, and Foundations-membership made + // the module picker unable to express that while PRESET-08 (Pro Services) + // removes the capability — the two mechanisms contradicted each other. + // Modules that genuinely need the item master (inventory, production via + // BOM/routing) pull it back in through dependency closure instead. + "CAP-MD-UOM", "CAP-MD-LOCATIONS", "CAP-MD-CURRENCIES", "CAP-MD-TAXCODES", // Baseline dashboards + mobile shell. Operational reports (CAP-RPT-OPERATIONAL) // are deliberately NOT here: they require customer + vendor master data, so a // foundations slot would force Customers/Vendors on for every install (e.g. diff --git a/forge.api/Features/Accounting/ReceiptInventoryPostingService.cs b/forge.api/Features/Accounting/ReceiptInventoryPostingService.cs index d7c6aec8..bc1d7354 100644 --- a/forge.api/Features/Accounting/ReceiptInventoryPostingService.cs +++ b/forge.api/Features/Accounting/ReceiptInventoryPostingService.cs @@ -143,8 +143,8 @@ private async Task PostCoreAsync( // is the material price variance posted below. No resolver wired, or no resolvable standard, falls // back to landed actual (no variance) — backward compatible with actual-cost carrying. var stocked = IsStocked(line.Part); - var stdUnit = stocked && standardCost is not null - ? (await standardCost.ResolveAsync(line.PartId, ct)).Total + var stdUnit = stocked && standardCost is not null && line.PartId is int stdPartId + ? (await standardCost.ResolveAsync(stdPartId, ct)).Total : 0m; var inventoryAmount = stocked && stdUnit > 0m ? Math.Round(stdUnit * rec.QuantityReceived, 2, MidpointRounding.AwayFromZero) @@ -154,15 +154,15 @@ private async Task PostCoreAsync( { AccountKey = DebitKeyFor(line.Part), Debit = inventoryAmount, - Description = $"Receipt {receiptNumber} — {(line.Part?.PartNumber ?? $"part {line.PartId}")} x{rec.QuantityReceived}", + Description = $"Receipt {receiptNumber} — {(line.Part?.PartNumber ?? (line.PartId is int dp ? $"part {dp}" : line.Description))} x{rec.QuantityReceived}", }); totalBase += baseCost; totalFreight += freight; totalInventory += inventoryAmount; // Consumables/tools are expensed (not stocked) — only perpetual-stocked classes feed the store. - if (stocked) - valuationFeeds.Add((line.PartId, rec.QuantityReceived, inventoryAmount)); + if (stocked && line.PartId is int feedPartId) + valuationFeeds.Add((feedPartId, rec.QuantityReceived, inventoryAmount)); } if (totalBase + totalFreight <= 0m) @@ -234,16 +234,19 @@ private static bool IsStocked(Part? part) => part?.InventoryClass is InventoryClass.Raw or InventoryClass.Component or InventoryClass.Subassembly or InventoryClass.FinishedGood; /// Maps a received part's to the inventory determination key - /// it capitalizes to. Consumables / tools are expensed (not stocked-for-production); a null/unknown - /// class defaults to raw-materials inventory (a purchased input). - private static string DebitKeyFor(Part? part) => part?.InventoryClass switch - { - InventoryClass.Raw or InventoryClass.Component => KeyInventoryRaw, - InventoryClass.Subassembly => KeyInventorySubassembly, - InventoryClass.FinishedGood => KeyInventoryFg, - InventoryClass.Consumable or InventoryClass.Tool => KeyOperatingExpense, - _ => KeyInventoryRaw, - }; + /// it capitalizes to. Consumables / tools are expensed (not stocked-for-production). A line with NO + /// part at all (service / described material) is expensed — nothing enters inventory. A part with an + /// unknown class defaults to raw-materials inventory (a purchased input). + private static string DebitKeyFor(Part? part) => part is null + ? KeyOperatingExpense + : part.InventoryClass switch + { + InventoryClass.Raw or InventoryClass.Component => KeyInventoryRaw, + InventoryClass.Subassembly => KeyInventorySubassembly, + InventoryClass.FinishedGood => KeyInventoryFg, + InventoryClass.Consumable or InventoryClass.Tool => KeyOperatingExpense, + _ => KeyInventoryRaw, + }; private async Task TryAuditAsync( string receiptNumber, int purchaseOrderId, JournalEntry entry, decimal totalBase, decimal totalFreight, diff --git a/forge.api/Features/DomainEvents/Handlers/OnPurchaseOrderReceived_CheckMaterialReady.cs b/forge.api/Features/DomainEvents/Handlers/OnPurchaseOrderReceived_CheckMaterialReady.cs index 4d78d4bf..351ae283 100644 --- a/forge.api/Features/DomainEvents/Handlers/OnPurchaseOrderReceived_CheckMaterialReady.cs +++ b/forge.api/Features/DomainEvents/Handlers/OnPurchaseOrderReceived_CheckMaterialReady.cs @@ -56,7 +56,8 @@ public async Task Handle(PurchaseOrderReceivedEvent notification, CancellationTo .ToListAsync(ct); var receivedByPart = allPoLinesForJob - .GroupBy(l => l.PartId) + .Where(l => l.PartId != null) + .GroupBy(l => l.PartId!.Value) .ToDictionary(g => g.Key, g => g.All(l => l.ReceivedQuantity >= l.OrderedQuantity)); // Check if all BOM buy-type materials have been fully received diff --git a/forge.api/Features/Inventory/ReceivePurchaseOrder.cs b/forge.api/Features/Inventory/ReceivePurchaseOrder.cs index 28506bb7..b10d0475 100644 --- a/forge.api/Features/Inventory/ReceivePurchaseOrder.cs +++ b/forge.api/Features/Inventory/ReceivePurchaseOrder.cs @@ -76,6 +76,11 @@ public async Task Handle( // If location provided, create bin content if (data.LocationId.HasValue) { + // Part-less lines (service / described material) have nothing to stock. + if (line.PartId is not int stockPartId) + throw new InvalidOperationException( + $"PO line {line.Id} has no part; a part-less line cannot be received into a bin location."); + var location = await inventoryRepo.FindLocationAsync(data.LocationId.Value, cancellationToken) ?? throw new KeyNotFoundException($"Location {data.LocationId} not found"); @@ -83,7 +88,7 @@ public async Task Handle( { LocationId = data.LocationId.Value, EntityType = "part", - EntityId = line.PartId, + EntityId = stockPartId, Quantity = baseQuantityReceived, LotNumber = data.LotNumber, PlacedBy = userId, @@ -97,7 +102,7 @@ public async Task Handle( var movement = new BinMovement { EntityType = "part", - EntityId = line.PartId, + EntityId = stockPartId, Quantity = baseQuantityReceived, LotNumber = data.LotNumber, ToLocationId = data.LocationId.Value, diff --git a/forge.api/Features/Inventory/RunAbcClassification.cs b/forge.api/Features/Inventory/RunAbcClassification.cs index 3a4de6a8..58605035 100644 --- a/forge.api/Features/Inventory/RunAbcClassification.cs +++ b/forge.api/Features/Inventory/RunAbcClassification.cs @@ -41,7 +41,8 @@ public async Task Handle(RunAbcClassification // Get latest unit price per part from purchase order lines as cost proxy var latestCosts = await db.PurchaseOrderLines .AsNoTracking() - .GroupBy(pol => pol.PartId) + .Where(pol => pol.PartId != null) + .GroupBy(pol => pol.PartId!.Value) .Select(g => new { PartId = g.Key, UnitCost = g.OrderByDescending(pol => pol.Id).First().UnitPrice }) .ToDictionaryAsync(x => x.PartId, x => x.UnitCost, cancellationToken); diff --git a/forge.api/Features/PurchaseOrders/CreatePurchaseOrder.cs b/forge.api/Features/PurchaseOrders/CreatePurchaseOrder.cs index 18f5565d..cf1c4f7c 100644 --- a/forge.api/Features/PurchaseOrders/CreatePurchaseOrder.cs +++ b/forge.api/Features/PurchaseOrders/CreatePurchaseOrder.cs @@ -34,7 +34,12 @@ public CreatePurchaseOrderValidator() RuleFor(x => x.Lines).NotEmpty().WithMessage("At least one line item is required"); RuleForEach(x => x.Lines).ChildRules(line => { - line.RuleFor(l => l.PartId).GreaterThan(0); + line.RuleFor(l => l.PartId).GreaterThan(0).When(l => l.PartId.HasValue); + // A part-less line has no Part to describe it — the description is the line. + line.RuleFor(l => l.Description) + .NotEmpty() + .When(l => l.PartId is null) + .WithMessage("Description is required when the line has no part."); // Phase 3 / WU-10 — Quantity is decimal; allow fractional values // (e.g. 0.5 lb of solder), but disallow zero / negative. line.RuleFor(l => l.Quantity).GreaterThan(0m); @@ -77,9 +82,10 @@ public async Task Handle(CreatePurchaseOrderCommand Incoterm? defaultIncoterm = null; string? defaultCurrency = null; if (request.Lines.Count > 0 + && request.Lines[0].PartId is int firstPartId && (!request.Incoterm.HasValue || string.IsNullOrEmpty(request.QuoteCurrency))) { - var firstPartId = request.Lines[0].PartId; + var vp = await db.VendorParts .AsNoTracking() .Where(x => x.VendorId == request.VendorId && x.PartId == firstPartId) @@ -113,16 +119,21 @@ public async Task Handle(CreatePurchaseOrderCommand for (var i = 0; i < request.Lines.Count; i++) { var line = request.Lines[i]; - var part = await partRepo.FindAsync(line.PartId, cancellationToken); - // Phase 3 H2 / WU-12: part-active check on PO line. Obsolete parts - // are blocked from new POs; UI already filters them on the picker - // but a previously-loaded form could still target one. - ActiveCheck.EnsureActive(part, "Part", $"lines[{i}].partId", line.PartId); + Part? part = null; + if (line.PartId is int linePartId) + { + part = await partRepo.FindAsync(linePartId, cancellationToken); + // Phase 3 H2 / WU-12: part-active check on PO line. Obsolete parts + // are blocked from new POs; UI already filters them on the picker + // but a previously-loaded form could still target one. + ActiveCheck.EnsureActive(part, "Part", $"lines[{i}].partId", linePartId); + } po.Lines.Add(new PurchaseOrderLine { PartId = line.PartId, - Description = line.Description ?? part!.Description ?? part.Name, + // Part-less lines validated Description NotEmpty above. + Description = line.Description ?? part?.Description ?? part?.Name ?? string.Empty, OrderedQuantity = line.Quantity, UnitPrice = line.UnitPrice, Notes = line.Notes, diff --git a/forge.api/Features/PurchaseOrders/CreatePurchaseOrderRelease.cs b/forge.api/Features/PurchaseOrders/CreatePurchaseOrderRelease.cs index 3e84211e..f1af74ec 100644 --- a/forge.api/Features/PurchaseOrders/CreatePurchaseOrderRelease.cs +++ b/forge.api/Features/PurchaseOrders/CreatePurchaseOrderRelease.cs @@ -61,7 +61,7 @@ public async Task Handle(CreatePurchaseOrderR Id = release.Id, ReleaseNumber = release.ReleaseNumber, PurchaseOrderLineId = release.PurchaseOrderLineId, - PartNumber = line.Part.PartNumber, + PartNumber = line.Part?.PartNumber, PartDescription = line.Description, Quantity = release.Quantity, RequestedDeliveryDate = release.RequestedDeliveryDate, diff --git a/forge.api/Features/PurchaseOrders/GetPurchaseOrderById.cs b/forge.api/Features/PurchaseOrders/GetPurchaseOrderById.cs index 017d666a..d1c8b331 100644 --- a/forge.api/Features/PurchaseOrders/GetPurchaseOrderById.cs +++ b/forge.api/Features/PurchaseOrders/GetPurchaseOrderById.cs @@ -59,7 +59,7 @@ public async Task Handle(GetPurchaseOrderByIdQ po.Lines.Select(l => new PurchaseOrderLineResponseModel( l.Id, l.PartId, - l.Part.PartNumber, + l.Part!.PartNumber, l.Description, l.OrderedQuantity, l.ReceivedQuantity, diff --git a/forge.api/Features/PurchaseOrders/GetPurchaseOrderReleases.cs b/forge.api/Features/PurchaseOrders/GetPurchaseOrderReleases.cs index c0cfc5fa..8c1a7ea2 100644 --- a/forge.api/Features/PurchaseOrders/GetPurchaseOrderReleases.cs +++ b/forge.api/Features/PurchaseOrders/GetPurchaseOrderReleases.cs @@ -31,7 +31,7 @@ public async Task> Handle(GetPurchaseOrd Id = r.Id, ReleaseNumber = r.ReleaseNumber, PurchaseOrderLineId = r.PurchaseOrderLineId, - PartNumber = r.PurchaseOrderLine.Part.PartNumber, + PartNumber = r.PurchaseOrderLine.Part!.PartNumber, PartDescription = r.PurchaseOrderLine.Description, Quantity = r.Quantity, RequestedDeliveryDate = r.RequestedDeliveryDate, diff --git a/forge.api/Features/PurchaseOrders/ReceiveItems.cs b/forge.api/Features/PurchaseOrders/ReceiveItems.cs index 795c7229..1876cbbb 100644 --- a/forge.api/Features/PurchaseOrders/ReceiveItems.cs +++ b/forge.api/Features/PurchaseOrders/ReceiveItems.cs @@ -194,10 +194,15 @@ public async Task Handle(ReceiveItemsCommand request, CancellationToken cancella int? defaultBinId = null; foreach (var (req, line, rec) in newRecords) { + // A part-less line (service / described material) has nothing to stock: + // receipt updates the line quantity, but no bin content or movement exists. + if (line.PartId is not int stockPartId) + continue; + var locationId = req.StorageLocationId ?? (defaultBinId ??= await ResolveDefaultBinAsync(inventory, userId, clock, cancellationToken)); - var existing = await inventory.FindActiveBinContentByPartLocationAsync(line.PartId, locationId, cancellationToken); + var existing = await inventory.FindActiveBinContentByPartLocationAsync(stockPartId, locationId, cancellationToken); if (existing is not null) { existing.Quantity += rec.QuantityReceived; @@ -208,7 +213,7 @@ await inventory.AddBinContentAsync(new BinContent { LocationId = locationId, EntityType = "part", - EntityId = line.PartId, + EntityId = stockPartId, Quantity = rec.QuantityReceived, Status = BinContentStatus.Stored, PlacedBy = userId, @@ -219,7 +224,7 @@ await inventory.AddBinContentAsync(new BinContent await inventory.AddMovementAsync(new BinMovement { EntityType = "part", - EntityId = line.PartId, + EntityId = stockPartId, Quantity = rec.QuantityReceived, ToLocationId = locationId, MovedBy = userId, diff --git a/forge.api/Features/Replenishment/GetBurnRates.cs b/forge.api/Features/Replenishment/GetBurnRates.cs index 05f3b626..7529b59b 100644 --- a/forge.api/Features/Replenishment/GetBurnRates.cs +++ b/forge.api/Features/Replenishment/GetBurnRates.cs @@ -80,12 +80,13 @@ public async Task> Handle( var incomingRaw = await db.PurchaseOrderLines .Include(l => l.PurchaseOrder) - .Where(l => partIds.Contains(l.PartId) + .Where(l => l.PartId != null + && partIds.Contains(l.PartId.Value) && l.PurchaseOrder.DeletedAt == null && openStatuses.Contains(l.PurchaseOrder.Status)) .Select(l => new { - l.PartId, + PartId = l.PartId!.Value, RemainingQty = (decimal)(l.OrderedQuantity - l.ReceivedQuantity), ExpectedDate = l.PurchaseOrder.ExpectedDeliveryDate, }) diff --git a/forge.api/Jobs/AutoPurchaseOrderJob.cs b/forge.api/Jobs/AutoPurchaseOrderJob.cs index f9fc3857..c80c7e31 100644 --- a/forge.api/Jobs/AutoPurchaseOrderJob.cs +++ b/forge.api/Jobs/AutoPurchaseOrderJob.cs @@ -8,6 +8,8 @@ using Forge.Core.Interfaces; using Forge.Data.Context; +using Forge.Api.Capabilities; + namespace Forge.Api.Jobs; /// @@ -20,7 +22,8 @@ public class AutoPurchaseOrderJob( ISystemSettingRepository settingsRepo, PurchaseOrderGenerator poGenerator, IPartSourcingResolver sourcingResolver, - ILogger logger) + ILogger logger, + ICapabilitySnapshotProvider capabilities) { private static readonly PurchaseOrderStatus[] OpenPoStatuses = [ @@ -39,6 +42,13 @@ public class AutoPurchaseOrderJob( public async Task Execute(CancellationToken ct) { + // ── Capability gate (self-gating job — the VarianceWatchdogJob pattern): + // auto-PO is capability-owned; when the capability is off (services / + // construction installs) the schedule still ticks but the job is a no-op, + // so toggling the capability takes effect without a restart. + if (!capabilities.IsEnabled("CAP-P2P-AUTOPO")) + return; + var now = clock.UtcNow; logger.LogInformation("[AutoPO] Starting auto-PO analysis at {Time}", now); @@ -176,10 +186,11 @@ public async Task Execute(CancellationToken ct) var inTransitByPart = await db.PurchaseOrderLines .AsNoTracking() .Include(l => l.PurchaseOrder) - .Where(l => childPartIds.Contains(l.PartId) + .Where(l => l.PartId != null + && childPartIds.Contains(l.PartId.Value) && l.PurchaseOrder.DeletedAt == null && OpenPoStatuses.Contains(l.PurchaseOrder.Status)) - .GroupBy(l => l.PartId) + .GroupBy(l => l.PartId!.Value) .Select(g => new { PartId = g.Key, InTransit = g.Sum(l => l.OrderedQuantity - l.ReceivedQuantity) }) .ToDictionaryAsync(x => x.PartId, x => x.InTransit, ct); diff --git a/forge.api/Jobs/ChannelInventorySyncJob.cs b/forge.api/Jobs/ChannelInventorySyncJob.cs index fa2ee6b4..ad3f190a 100644 --- a/forge.api/Jobs/ChannelInventorySyncJob.cs +++ b/forge.api/Jobs/ChannelInventorySyncJob.cs @@ -4,6 +4,8 @@ using Forge.Core.Interfaces; using Forge.Data.Context; +using Forge.Api.Capabilities; + namespace Forge.Api.Jobs; /// @@ -24,10 +26,18 @@ public class ChannelInventorySyncJob( IECommerceServiceFactory connectorFactory, IECommerceCredentialProtector protector, IClock clock, - ILogger logger) + ILogger logger, + ICapabilitySnapshotProvider capabilities) { public async Task SyncAsync(CancellationToken ct = default) { + // ── Capability gate (self-gating job — the VarianceWatchdogJob pattern): + // channel inventory sync is capability-owned; when the capability is off (services / + // construction installs) the schedule still ticks but the job is a no-op, + // so toggling the capability takes effect without a restart. + if (!capabilities.IsEnabled("CAP-EXT-ECOMMERCE")) + return; + var channels = await db.SalesChannels .Include(c => c.ECommerceIntegration) .Where(c => c.IsActive diff --git a/forge.api/Jobs/CheckInventoryLevelsJob.cs b/forge.api/Jobs/CheckInventoryLevelsJob.cs index 76a2f132..9792416b 100644 --- a/forge.api/Jobs/CheckInventoryLevelsJob.cs +++ b/forge.api/Jobs/CheckInventoryLevelsJob.cs @@ -5,6 +5,8 @@ using Forge.Api.Features.DomainEvents; using Forge.Data.Context; +using Forge.Api.Capabilities; + namespace Forge.Api.Jobs; /// @@ -14,12 +16,20 @@ namespace Forge.Api.Jobs; public class CheckInventoryLevelsJob( AppDbContext db, IPublisher publisher, - ILogger logger) + ILogger logger, + ICapabilitySnapshotProvider capabilities) { private const int ChunkSize = 500; public async Task Execute(CancellationToken ct) { + // ── Capability gate (self-gating job — the VarianceWatchdogJob pattern): + // inventory levels is capability-owned; when the capability is off (services / + // construction installs) the schedule still ticks but the job is a no-op, + // so toggling the capability takes effect without a restart. + if (!capabilities.IsEnabled("CAP-INV-CORE")) + return; + // Get parts that have a reorder point defined var partsWithReorder = await db.Parts .Where(p => p.ReorderPoint.HasValue && p.ReorderPoint > 0 && p.DeletedAt == null) diff --git a/forge.api/Jobs/CheckJobCostOverrunJob.cs b/forge.api/Jobs/CheckJobCostOverrunJob.cs index 333e81b4..7a6900f2 100644 --- a/forge.api/Jobs/CheckJobCostOverrunJob.cs +++ b/forge.api/Jobs/CheckJobCostOverrunJob.cs @@ -7,6 +7,8 @@ using Forge.Core.Interfaces; using Forge.Data.Context; +using Forge.Api.Capabilities; + namespace Forge.Api.Jobs; /// @@ -17,13 +19,21 @@ public class CheckJobCostOverrunJob( AppDbContext db, IJobCostService costService, IPublisher publisher, - ILogger logger) + ILogger logger, + ICapabilitySnapshotProvider capabilities) { private const decimal VarianceThreshold = 0.10m; // 10% private const int ChunkSize = 100; public async Task Execute(CancellationToken ct) { + // ── Capability gate (self-gating job — the VarianceWatchdogJob pattern): + // work-order costing is capability-owned; when the capability is off (services / + // construction installs) the schedule still ticks but the job is a no-op, + // so toggling the capability takes effect without a restart. + if (!capabilities.IsEnabled("CAP-MFG-WO-RELEASE")) + return; + // Get active jobs with non-zero estimated costs var activeJobIds = await db.Jobs .AsNoTracking() diff --git a/forge.api/Jobs/MrpRunJob.cs b/forge.api/Jobs/MrpRunJob.cs index da824135..7a5afcd0 100644 --- a/forge.api/Jobs/MrpRunJob.cs +++ b/forge.api/Jobs/MrpRunJob.cs @@ -1,12 +1,21 @@ using Forge.Core.Interfaces; using Forge.Core.Models; +using Forge.Api.Capabilities; + namespace Forge.Api.Jobs; -public class MrpRunJob(IMrpService mrpService, ILogger logger) +public class MrpRunJob(IMrpService mrpService, ILogger logger, ICapabilitySnapshotProvider capabilities) { public async Task ExecuteNightlyRunAsync(CancellationToken cancellationToken = default) { + // ── Capability gate (self-gating job — the VarianceWatchdogJob pattern): + // MRP is capability-owned; when the capability is off (services / + // construction installs) the schedule still ticks but the job is a no-op, + // so toggling the capability takes effect without a restart. + if (!capabilities.IsEnabled("CAP-PLAN-MRP")) + return; + logger.LogInformation("Starting nightly MRP run"); try diff --git a/forge.api/Jobs/OverdueMaintenanceJob.cs b/forge.api/Jobs/OverdueMaintenanceJob.cs index 7b473e31..8e72eb35 100644 --- a/forge.api/Jobs/OverdueMaintenanceJob.cs +++ b/forge.api/Jobs/OverdueMaintenanceJob.cs @@ -9,6 +9,8 @@ using Forge.Core.Models; using Forge.Data.Context; +using Forge.Api.Capabilities; + namespace Forge.Api.Jobs; public class OverdueMaintenanceJob( @@ -16,10 +18,18 @@ public class OverdueMaintenanceJob( UserManager userManager, ISender mediator, IClock clock, - ILogger logger) + ILogger logger, + ICapabilitySnapshotProvider capabilities) { public async Task CheckOverdueMaintenanceAsync(CancellationToken ct = default) { + // ── Capability gate (self-gating job — the VarianceWatchdogJob pattern): + // preventive maintenance is capability-owned; when the capability is off (services / + // construction installs) the schedule still ticks but the job is a no-op, + // so toggling the capability takes effect without a restart. + if (!capabilities.IsEnabled("CAP-MAINT-PM")) + return; + var now = clock.UtcNow; var overdueSchedules = await db.MaintenanceSchedules diff --git a/forge.api/Jobs/ReorderAnalysisJob.cs b/forge.api/Jobs/ReorderAnalysisJob.cs index a7965b39..ff4fa775 100644 --- a/forge.api/Jobs/ReorderAnalysisJob.cs +++ b/forge.api/Jobs/ReorderAnalysisJob.cs @@ -5,6 +5,8 @@ using Forge.Core.Interfaces; using Forge.Data.Context; +using Forge.Api.Capabilities; + namespace Forge.Api.Jobs; /// @@ -15,7 +17,8 @@ public class ReorderAnalysisJob( AppDbContext db, IClock clock, IPartSourcingResolver sourcingResolver, - ILogger logger) + ILogger logger, + ICapabilitySnapshotProvider capabilities) { private const int ChunkSize = 500; @@ -32,6 +35,13 @@ public class ReorderAnalysisJob( public async Task RunAnalysisAsync(CancellationToken ct = default) { + // ── Capability gate (self-gating job — the VarianceWatchdogJob pattern): + // replenishment analysis is capability-owned; when the capability is off (services / + // construction installs) the schedule still ticks but the job is a no-op, + // so toggling the capability takes effect without a restart. + if (!capabilities.IsEnabled("CAP-PLAN-SAFETYSTOCK")) + return; + var now = clock.UtcNow; var cutoff90 = now.AddDays(-90); @@ -116,12 +126,13 @@ public async Task RunAnalysisAsync(CancellationToken ct = default) // Incoming PO quantities per part (for this chunk) var incomingRaw = await db.PurchaseOrderLines .Include(l => l.PurchaseOrder) - .Where(l => partIds.Contains(l.PartId) + .Where(l => l.PartId != null + && partIds.Contains(l.PartId.Value) && l.PurchaseOrder.DeletedAt == null && OpenPoStatuses.Contains(l.PurchaseOrder.Status)) .Select(l => new { - l.PartId, + PartId = l.PartId!.Value, RemainingQty = (decimal)(l.OrderedQuantity - l.ReceivedQuantity), l.PurchaseOrder.ExpectedDeliveryDate, }) diff --git a/forge.api/Jobs/ShipmentDeliverySweepJob.cs b/forge.api/Jobs/ShipmentDeliverySweepJob.cs index 7ca18153..2001765e 100644 --- a/forge.api/Jobs/ShipmentDeliverySweepJob.cs +++ b/forge.api/Jobs/ShipmentDeliverySweepJob.cs @@ -6,6 +6,8 @@ using Forge.Core.Enums; using Forge.Data.Context; +using Forge.Api.Capabilities; + namespace Forge.Api.Jobs; /// @@ -20,13 +22,21 @@ public class ShipmentDeliverySweepJob( AppDbContext db, Forge.Core.Interfaces.IShippingService shipping, IMediator mediator, - ILogger logger) + ILogger logger, + ICapabilitySnapshotProvider capabilities) { /// Cap per run — a large in-transit book drains over successive sweeps, not in one bite. public const int MaxPerSweep = 100; public async Task SweepAsync(CancellationToken ct) { + // ── Capability gate (self-gating job — the VarianceWatchdogJob pattern): + // shipping is capability-owned; when the capability is off (services / + // construction installs) the schedule still ticks but the job is a no-op, + // so toggling the capability takes effect without a restart. + if (!capabilities.IsEnabled("CAP-O2C-SHIP")) + return; + var candidates = await db.Shipments.AsNoTracking() .Where(s => (s.Status == ShipmentStatus.Shipped || s.Status == ShipmentStatus.InTransit) && s.TrackingNumber != null diff --git a/forge.api/Services/JobCostService.cs b/forge.api/Services/JobCostService.cs index 48853cbd..e4254323 100644 --- a/forge.api/Services/JobCostService.cs +++ b/forge.api/Services/JobCostService.cs @@ -99,7 +99,7 @@ public async Task GetActualSubcontractCostAsync(int jobId, Cancellation && pol.PurchaseOrder.Status != PurchaseOrderStatus.Cancelled) .Join(db.Operations.Where(o => o.IsSubcontract), pol => pol.PartId, - op => op.PartId, + op => (int?)op.PartId, (pol, op) => pol.UnitPrice * pol.OrderedQuantity) .SumAsync(ct); } diff --git a/forge.api/Services/MrpService.cs b/forge.api/Services/MrpService.cs index 4de0277f..f2c24f9b 100644 --- a/forge.api/Services/MrpService.cs +++ b/forge.api/Services/MrpService.cs @@ -128,7 +128,8 @@ public async Task ExecuteRunAsync(MrpRunOptions options, Ca .Include(l => l.PurchaseOrder) .Where(l => l.PurchaseOrder!.Status != PurchaseOrderStatus.Cancelled && l.PurchaseOrder!.Status != PurchaseOrderStatus.Closed - && partIds.Contains(l.PartId) + && l.PartId != null + && partIds.Contains(l.PartId.Value) && (l.OrderedQuantity - l.ReceivedQuantity) > 0) .Select(l => new { @@ -244,7 +245,7 @@ public async Task ExecuteRunAsync(MrpRunOptions options, Ca supplyRecords.Add(new MrpSupply { MrpRunId = mrpRun.Id, - PartId = po.PartId, + PartId = po.PartId!.Value, Source = MrpSupplySource.PurchaseOrder, SourceEntityId = po.Id, Quantity = po.Quantity, diff --git a/forge.core/Entities/PurchaseOrderLine.cs b/forge.core/Entities/PurchaseOrderLine.cs index c56e9a6a..ba45ef73 100644 --- a/forge.core/Entities/PurchaseOrderLine.cs +++ b/forge.core/Entities/PurchaseOrderLine.cs @@ -3,7 +3,12 @@ namespace Forge.Core.Entities; public class PurchaseOrderLine : BaseEntity { public int PurchaseOrderId { get; set; } - public int PartId { get; set; } + // Nullable since the construction/pro-services enablement (2026-08-17): a PO line may be a + // service or described material with no Part row. The capability graph already removed the + // PO -> Parts dependency ("Quote/SO/PO schemas already make PartId optional") — this makes + // that claim true for PO lines. Part-less lines require a Description, are never binned at + // receipt, and post to operating expense rather than inventory. + public int? PartId { get; set; } public string Description { get; set; } = string.Empty; // Phase 3 / WU-10 / F8-partial: quantities are decimal, not int. UoM-aware // shops need fractional quantities — material-by-weight (lb, kg), by-time @@ -47,7 +52,7 @@ public class PurchaseOrderLine : BaseEntity public decimal UnbilledReceivedQuantity => ReceivedQuantity - BilledQuantity; public PurchaseOrder PurchaseOrder { get; set; } = null!; - public Part Part { get; set; } = null!; + public Part? Part { get; set; } public MrpPlannedOrder? MrpPlannedOrder { get; set; } public UnitOfMeasure? Uom { get; set; } public PartPurchaseUnit? PurchaseUnit { get; set; } diff --git a/forge.core/Models/CreatePurchaseOrderRequestModel.cs b/forge.core/Models/CreatePurchaseOrderRequestModel.cs index cd5ee643..56d3d122 100644 --- a/forge.core/Models/CreatePurchaseOrderRequestModel.cs +++ b/forge.core/Models/CreatePurchaseOrderRequestModel.cs @@ -9,7 +9,9 @@ public record CreatePurchaseOrderRequestModel( // Phase 3 / WU-10 / F8-partial — Quantity is decimal (was int). UoM-aware shops // need fractional quantities — material-by-weight, by-time, by-volume. public record CreatePurchaseOrderLineModel( - int PartId, + // Null = a part-less line (a service, or material described in words rather than a Part + // row — services shops and construction installs). Description becomes required then. + int? PartId, string? Description, decimal Quantity, decimal UnitPrice, diff --git a/forge.core/Models/PurchaseOrderLineResponseModel.cs b/forge.core/Models/PurchaseOrderLineResponseModel.cs index 68baa339..8e755d77 100644 --- a/forge.core/Models/PurchaseOrderLineResponseModel.cs +++ b/forge.core/Models/PurchaseOrderLineResponseModel.cs @@ -6,8 +6,8 @@ namespace Forge.Core.Models; // "5 received / 5 short-closed / 10 ordered" without a separate query. public record PurchaseOrderLineResponseModel( int Id, - int PartId, - string PartNumber, + int? PartId, + string? PartNumber, string Description, decimal OrderedQuantity, decimal ReceivedQuantity, diff --git a/forge.core/Models/PurchaseOrderReleaseResponseModel.cs b/forge.core/Models/PurchaseOrderReleaseResponseModel.cs index b66282b4..a9d45193 100644 --- a/forge.core/Models/PurchaseOrderReleaseResponseModel.cs +++ b/forge.core/Models/PurchaseOrderReleaseResponseModel.cs @@ -7,7 +7,7 @@ public record PurchaseOrderReleaseResponseModel public int Id { get; init; } public int ReleaseNumber { get; init; } public int PurchaseOrderLineId { get; init; } - public string PartNumber { get; init; } = string.Empty; + public string? PartNumber { get; init; } = string.Empty; public string PartDescription { get; init; } = string.Empty; public decimal Quantity { get; init; } public DateTimeOffset RequestedDeliveryDate { get; init; } diff --git a/forge.data/Repositories/InventoryRepository.cs b/forge.data/Repositories/InventoryRepository.cs index 1f564071..b69d02a7 100644 --- a/forge.data/Repositories/InventoryRepository.cs +++ b/forge.data/Repositories/InventoryRepository.cs @@ -345,7 +345,7 @@ public async Task> GetReceivingHistoryAsync( r.PurchaseOrderLineId, r.PurchaseOrderLine.PurchaseOrder.PONumber, r.PurchaseOrderLine.PartId, - r.PurchaseOrderLine.Part.PartNumber, + r.PurchaseOrderLine.Part?.PartNumber, r.QuantityReceived, r.ReceivedBy, r.StorageLocationId, diff --git a/forge.data/Repositories/SankeyReportRepository.cs b/forge.data/Repositories/SankeyReportRepository.cs index 52d1be47..ca674140 100644 --- a/forge.data/Repositories/SankeyReportRepository.cs +++ b/forge.data/Repositories/SankeyReportRepository.cs @@ -206,11 +206,12 @@ public async Task> GetVendorSupplyChainFlowAsync(Cancellati { var poLines = await db.PurchaseOrderLines.AsNoTracking() .Include(l => l.PurchaseOrder) - .Where(l => l.PurchaseOrder != null) + // Part-less lines (services / described material) have no part node to flow to. + .Where(l => l.PurchaseOrder != null && l.PartId != null) .Select(l => new { VendorId = l.PurchaseOrder!.VendorId, - l.PartId, + PartId = l.PartId!.Value, l.OrderedQuantity, }) .ToListAsync(ct); diff --git a/forge.data/Schema/forge-schema.sql b/forge.data/Schema/forge-schema.sql index 9c9ef61c..3f634d75 100644 --- a/forge.data/Schema/forge-schema.sql +++ b/forge.data/Schema/forge-schema.sql @@ -6458,7 +6458,7 @@ ALTER TABLE ONLY public.projects CREATE TABLE public.purchase_order_lines ( id integer NOT NULL, purchase_order_id integer NOT NULL, - part_id integer NOT NULL, + part_id integer, description character varying(500) NOT NULL, ordered_quantity numeric(18,4) NOT NULL, received_quantity numeric(18,4) NOT NULL, diff --git a/forge.tests/Capabilities/ModuleCatalogTests.cs b/forge.tests/Capabilities/ModuleCatalogTests.cs index 19cfe9d2..05ef4145 100644 --- a/forge.tests/Capabilities/ModuleCatalogTests.cs +++ b/forge.tests/Capabilities/ModuleCatalogTests.cs @@ -16,7 +16,7 @@ public void InventoryOnly_enablesInventoryAndFoundations_butNotOtherModules() set.Should().Contain("CAP-INV-CORE"); set.Should().Contain("CAP-INV-ADJUST"); - set.Should().Contain("CAP-MD-PARTS"); // foundation + set.Should().Contain("CAP-MD-PARTS"); // closure via CAP-INV-CORE (no longer a foundation) set.Should().Contain("CAP-IDEN-USERS"); // foundation set.Should().NotContain("CAP-O2C-SO"); // sales not selected diff --git a/forge.tests/Jobs/AutoPurchaseOrderJobMigrationTests.cs b/forge.tests/Jobs/AutoPurchaseOrderJobMigrationTests.cs index 80b64e51..d105b8ac 100644 --- a/forge.tests/Jobs/AutoPurchaseOrderJobMigrationTests.cs +++ b/forge.tests/Jobs/AutoPurchaseOrderJobMigrationTests.cs @@ -143,7 +143,8 @@ private static AutoPurchaseOrderJob BuildJob(AppDbContext db) settingsRepo.Object, poGen, new PartSourcingResolver(db), - NullLogger.Instance); + NullLogger.Instance, + new StubCapabilitySnapshotProvider("CAP-P2P-AUTOPO")); } [Fact] diff --git a/forge.tests/Jobs/ReorderAnalysisJobMigrationTests.cs b/forge.tests/Jobs/ReorderAnalysisJobMigrationTests.cs index 46f72f49..eab4e549 100644 --- a/forge.tests/Jobs/ReorderAnalysisJobMigrationTests.cs +++ b/forge.tests/Jobs/ReorderAnalysisJobMigrationTests.cs @@ -76,7 +76,8 @@ public async Task RunAnalysis_NoPreferredVendorPart_FallsBackToDefaultLeadTime() var job = new ReorderAnalysisJob( db, new FixedClock(), new PartSourcingResolver(db), - NullLogger.Instance); + NullLogger.Instance, + new StubCapabilitySnapshotProvider("CAP-PLAN-SAFETYSTOCK")); // Act await job.RunAnalysisAsync(); @@ -124,7 +125,8 @@ public async Task RunAnalysis_PreferredVendorPartLeadTime_DrivesCoverThreshold() var job = new ReorderAnalysisJob( db, new FixedClock(), new PartSourcingResolver(db), - NullLogger.Instance); + NullLogger.Instance, + new StubCapabilitySnapshotProvider("CAP-PLAN-SAFETYSTOCK")); // Act await job.RunAnalysisAsync(); From 0ec8c8ca256947fddce2e803c93918957cc32928 Mon Sep 17 00:00:00 2001 From: Daniel Hokanson Date: Tue, 18 Aug 2026 15:07:44 -0600 Subject: [PATCH 2/4] feat(accounting): block raw FULLGL disable once a book's ledger is live Turning CAP-ACCT-FULLGL off via the capability toggle is now refused (409 capability-gl-live-ledger) when any active book has opening balances loaded. Stopping GL posting mid-period desyncs the sub-ledgers from the ledger; the off-ramp is the governed deactivation cutover (see fullgl-deactivation design). Mirrors the existing opening-balances enable-gate; reuses AreOpeningBalancesLoadedAsync. --- .../Capabilities/Toggle/ToggleCapability.cs | 37 +++++++++++++++++++ .../ToggleCapabilityFullGlGateTests.cs | 22 ++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/forge.api/Features/Capabilities/Toggle/ToggleCapability.cs b/forge.api/Features/Capabilities/Toggle/ToggleCapability.cs index e6ffec42..7c749e4f 100644 --- a/forge.api/Features/Capabilities/Toggle/ToggleCapability.cs +++ b/forge.api/Features/Capabilities/Toggle/ToggleCapability.cs @@ -180,6 +180,43 @@ public async Task Handle( ["dependents"] = dependents, }); } + + // Deactivation guard (fullgl-deactivation §3.5): once a book is live + // (opening balances loaded), CAP-ACCT-FULLGL must NOT be turned off by a + // raw capability toggle. Stopping posting mid-period silently desyncs the + // sub-ledgers from the general ledger (a half-posted period), which is the + // one failure mode the ledger design must make impossible. Turning the + // ledger off is a governed, dated cutover (close & tie out the period, then + // deactivate with a successor system) — not a capability flip. Books with no + // loaded opening balances have no ledger history to protect and stay toggle-able. + if (request.Code == FullGlCapability) + { + var activeBookIds = await db.Books + .AsNoTracking() + .Where(b => b.IsActive) + .Select(b => b.Id) + .ToListAsync(cancellationToken); + + foreach (var bookId in activeBookIds) + { + if (await glGate.AreOpeningBalancesLoadedAsync(bookId, cancellationToken)) + { + throw new CapabilityMutationException( + StatusCodes.Status409Conflict, + "capability-gl-live-ledger", + $"CAP-ACCT-FULLGL cannot be turned off for book {bookId} by toggling the " + + "capability: the ledger is live (opening balances are loaded), so stopping " + + "posting mid-period would desync the sub-ledgers from the general ledger. Turn " + + "the ledger off through the governed deactivation cutover (close and tie out the " + + "period, then deactivate with a successor system).", + new Dictionary + { + ["capability"] = request.Code, + ["bookId"] = bookId, + }); + } + } + } } } diff --git a/forge.tests/Capabilities/ToggleCapabilityFullGlGateTests.cs b/forge.tests/Capabilities/ToggleCapabilityFullGlGateTests.cs index 7924a3ec..d4a66d43 100644 --- a/forge.tests/Capabilities/ToggleCapabilityFullGlGateTests.cs +++ b/forge.tests/Capabilities/ToggleCapabilityFullGlGateTests.cs @@ -122,8 +122,10 @@ public async Task Enabling_FULLGL_succeeds_once_the_conversion_journal_is_posted } [Fact] - public async Task Disabling_FULLGL_is_not_gated_on_opening_balances() + public async Task Disabling_FULLGL_is_allowed_when_no_ledger_history_exists() { + // No posted opening journal → the book is not live → nothing to protect, + // so a raw disable is still permitted (fullgl-deactivation §3.5). await using var db = SeededDb(withOpeningJournal: false); db.Capabilities.Single(c => c.Code == "CAP-ACCT-FULLGL").Enabled = true; db.SaveChanges(); @@ -131,4 +133,22 @@ public async Task Disabling_FULLGL_is_not_gated_on_opening_balances() var result = await Handler(db).Handle(new ToggleCapabilityCommand("CAP-ACCT-FULLGL", Enabled: false), default); result.Enabled.Should().BeFalse(); } + + [Fact] + public async Task Disabling_FULLGL_is_refused_once_the_ledger_is_live() + { + // Opening balances loaded → the ledger is live. A raw capability-off would + // stop posting mid-period and desync the sub-ledgers from the GL, so it is + // refused (409 capability-gl-live-ledger) — deactivation must go through the + // governed cutover instead (fullgl-deactivation §3.5). + await using var db = SeededDb(withOpeningJournal: true); + db.Capabilities.Single(c => c.Code == "CAP-ACCT-FULLGL").Enabled = true; + db.SaveChanges(); + + var act = () => Handler(db).Handle(new ToggleCapabilityCommand("CAP-ACCT-FULLGL", Enabled: false), default); + + var ex = await act.Should().ThrowAsync(); + ex.Which.Message.Should().Contain("deactivation cutover"); + db.Capabilities.Single(c => c.Code == "CAP-ACCT-FULLGL").Enabled.Should().BeTrue(); + } } From 03ca2b1a4925adb9a94048af5584985e80edf96d Mon Sep 17 00:00:00 2001 From: Daniel Hokanson Date: Tue, 18 Aug 2026 15:48:34 -0600 Subject: [PATCH 3/4] feat(barcodes): manually add alternate barcode values on top of the auto code Adds a Source {System|Manual} axis to the barcode registry. The one auto-assigned System code per entity (internal code or GS1 GTIN) is unchanged and RefreshPartBarcode now only touches it; users can register additional Manual barcodes (manufacturer UPC, vendor SKU, legacy label) that coexist and all resolve on scan (global value uniqueness). New POST /barcodes and DELETE /barcodes/{id} (manual-only). Tests cover coexistence, scan resolution, uniqueness, manual-only removal, and refresh not clobbering aliases. --- forge.api/Controllers/BarcodesController.cs | 23 ++++ .../Features/Barcodes/AddManualBarcode.cs | 98 ++++++++++++++ .../Features/Barcodes/GetEntityBarcodes.cs | 7 +- .../Features/Barcodes/RemoveManualBarcode.cs | 48 +++++++ forge.api/Services/BarcodeService.cs | 6 +- forge.core/Entities/Barcode.cs | 5 + forge.core/Enums/BarcodeSource.cs | 16 +++ forge.core/Models/BarcodeResponseModel.cs | 4 +- .../Configuration/BarcodeConfiguration.cs | 1 + .../Handlers/Barcodes/ManualBarcodeTests.cs | 122 ++++++++++++++++++ 10 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 forge.api/Features/Barcodes/AddManualBarcode.cs create mode 100644 forge.api/Features/Barcodes/RemoveManualBarcode.cs create mode 100644 forge.core/Enums/BarcodeSource.cs create mode 100644 forge.tests/Handlers/Barcodes/ManualBarcodeTests.cs diff --git a/forge.api/Controllers/BarcodesController.cs b/forge.api/Controllers/BarcodesController.cs index 53339ccf..f518ee83 100644 --- a/forge.api/Controllers/BarcodesController.cs +++ b/forge.api/Controllers/BarcodesController.cs @@ -34,6 +34,29 @@ public async Task Regenerate( cancellationToken); return Ok(result); } + + /// Add a manual alternate barcode (manufacturer UPC, vendor SKU, legacy label) on top of the + /// entity's auto-assigned code. The value must be globally unique. + [HttpPost] + public async Task AddManual( + [FromBody] AddManualBarcodeRequestModel request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new AddManualBarcodeCommand(request.EntityType, request.EntityId, request.Value), + cancellationToken); + return Ok(result); + } + + /// Remove a manually-added alternate barcode. The auto-assigned code cannot be removed. + [HttpDelete("{id:int}")] + public async Task RemoveManual(int id, CancellationToken cancellationToken) + { + await mediator.Send(new RemoveManualBarcodeCommand(id), cancellationToken); + return NoContent(); + } } public record RegenerateBarcodeRequestModel(BarcodeEntityType EntityType, int EntityId, string NaturalIdentifier); + +public record AddManualBarcodeRequestModel(BarcodeEntityType EntityType, int EntityId, string Value); diff --git a/forge.api/Features/Barcodes/AddManualBarcode.cs b/forge.api/Features/Barcodes/AddManualBarcode.cs new file mode 100644 index 00000000..237e95df --- /dev/null +++ b/forge.api/Features/Barcodes/AddManualBarcode.cs @@ -0,0 +1,98 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Core.Models; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Barcodes; + +/// +/// Manually add an alternate barcode value to an entity — a manufacturer UPC, a vendor SKU, or a legacy +/// label — on top of its auto-generated system code. The value must be globally unique so a scan resolves +/// to exactly one entity; it then scans the same as the system code. Removable via +/// (the system code is not). +/// +public record AddManualBarcodeCommand(BarcodeEntityType EntityType, int EntityId, string Value) + : IRequest; + +public class AddManualBarcodeHandler(AppDbContext db) : IRequestHandler +{ + public async Task Handle(AddManualBarcodeCommand request, CancellationToken cancellationToken) + { + var value = (request.Value ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(value)) + throw new InvalidOperationException("A barcode value is required."); + + if (!await EntityExistsAsync(request.EntityType, request.EntityId, cancellationToken)) + throw new KeyNotFoundException($"{request.EntityType} {request.EntityId} not found."); + + // Global uniqueness (matches the ix_barcodes_value unique index) so a scan maps to one entity. + if (await db.Barcodes.AnyAsync(b => b.Value == value, cancellationToken)) + throw new InvalidOperationException($"Barcode value '{value}' is already in use."); + + var barcode = new Barcode + { + Value = value, + EntityType = request.EntityType, + IsActive = true, + IdentityType = BarcodeIdentityType.Internal, + Source = BarcodeSource.Manual, + }; + SetEntityFk(barcode, request.EntityType, request.EntityId); + + db.Barcodes.Add(barcode); + db.LogActivityAt( + "barcode-manual-added", + $"Alternate barcode added: {value}", + (ParentEntityName(request.EntityType), request.EntityId)); + await db.SaveChangesAsync(cancellationToken); + + return new BarcodeResponseModel( + barcode.Id, barcode.Value, barcode.EntityType.ToString(), barcode.IsActive, barcode.CreatedAt, + barcode.Source.ToString(), barcode.IdentityType.ToString()); + } + + private Task EntityExistsAsync(BarcodeEntityType type, int id, CancellationToken ct) => type switch + { + BarcodeEntityType.Part => db.Parts.AnyAsync(p => p.Id == id, ct), + BarcodeEntityType.Job => db.Jobs.AnyAsync(j => j.Id == id, ct), + BarcodeEntityType.SalesOrder => db.SalesOrders.AnyAsync(s => s.Id == id, ct), + BarcodeEntityType.PurchaseOrder => db.PurchaseOrders.AnyAsync(p => p.Id == id, ct), + BarcodeEntityType.Asset => db.Assets.AnyAsync(a => a.Id == id, ct), + BarcodeEntityType.StorageLocation => db.StorageLocations.AnyAsync(l => l.Id == id, ct), + BarcodeEntityType.Lot => db.LotRecords.AnyAsync(l => l.Id == id, ct), + BarcodeEntityType.User => db.Users.AnyAsync(u => u.Id == id, ct), + _ => Task.FromResult(false), + }; + + private static void SetEntityFk(Barcode b, BarcodeEntityType type, int id) + { + switch (type) + { + case BarcodeEntityType.User: b.UserId = id; break; + case BarcodeEntityType.Part: b.PartId = id; break; + case BarcodeEntityType.Job: b.JobId = id; break; + case BarcodeEntityType.SalesOrder: b.SalesOrderId = id; break; + case BarcodeEntityType.PurchaseOrder: b.PurchaseOrderId = id; break; + case BarcodeEntityType.Asset: b.AssetId = id; break; + case BarcodeEntityType.StorageLocation: b.StorageLocationId = id; break; + case BarcodeEntityType.Lot: b.LotRecordId = id; break; + } + } + + private static string ParentEntityName(BarcodeEntityType type) => type switch + { + BarcodeEntityType.User => "ApplicationUser", + BarcodeEntityType.Part => "Part", + BarcodeEntityType.Job => "Job", + BarcodeEntityType.SalesOrder => "SalesOrder", + BarcodeEntityType.PurchaseOrder => "PurchaseOrder", + BarcodeEntityType.Asset => "Asset", + BarcodeEntityType.StorageLocation => "StorageLocation", + BarcodeEntityType.Lot => "Lot", + _ => "Barcode", + }; +} diff --git a/forge.api/Features/Barcodes/GetEntityBarcodes.cs b/forge.api/Features/Barcodes/GetEntityBarcodes.cs index 4841a9fe..ebd5f1fb 100644 --- a/forge.api/Features/Barcodes/GetEntityBarcodes.cs +++ b/forge.api/Features/Barcodes/GetEntityBarcodes.cs @@ -30,9 +30,12 @@ public async Task> Handle(GetEntityBarcodesQuery requ }; return await query - .OrderByDescending(b => b.CreatedAt) + // System (auto-assigned) code first, then manual aliases oldest-first. + .OrderByDescending(b => b.Source == BarcodeSource.System) + .ThenBy(b => b.CreatedAt) .Select(b => new BarcodeResponseModel( - b.Id, b.Value, b.EntityType.ToString(), b.IsActive, b.CreatedAt)) + b.Id, b.Value, b.EntityType.ToString(), b.IsActive, b.CreatedAt, + b.Source.ToString(), b.IdentityType.ToString())) .ToListAsync(cancellationToken); } } diff --git a/forge.api/Features/Barcodes/RemoveManualBarcode.cs b/forge.api/Features/Barcodes/RemoveManualBarcode.cs new file mode 100644 index 00000000..500ce506 --- /dev/null +++ b/forge.api/Features/Barcodes/RemoveManualBarcode.cs @@ -0,0 +1,48 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Barcodes; + +/// +/// Remove a manually-added alternate barcode. Only rows can be +/// removed — the auto-assigned System code is regenerated (RegenerateBarcode), never deleted, so an +/// entity is never left unscannable. +/// +public record RemoveManualBarcodeCommand(int BarcodeId) : IRequest; + +public class RemoveManualBarcodeHandler(AppDbContext db) : IRequestHandler +{ + public async Task Handle(RemoveManualBarcodeCommand request, CancellationToken cancellationToken) + { + var barcode = await db.Barcodes.FirstOrDefaultAsync(b => b.Id == request.BarcodeId, cancellationToken) + ?? throw new KeyNotFoundException($"Barcode {request.BarcodeId} not found."); + + if (barcode.Source != BarcodeSource.Manual) + throw new InvalidOperationException( + "Only manually-added barcodes can be removed. The auto-assigned code is regenerated, not deleted."); + + var parent = ParentRef(barcode); + // Hard delete (not soft): the value must be freed from the global unique index so it can be + // re-registered later; the GetEntityBarcodes list filters on DeletedAt anyway. + db.Barcodes.Remove(barcode); + if (parent is { } p) + db.LogActivityAt("barcode-manual-removed", $"Alternate barcode removed: {barcode.Value}", p); + await db.SaveChangesAsync(cancellationToken); + } + + private static (string EntityType, int EntityId)? ParentRef(Barcode b) => + b.PartId is int part ? ("Part", part) + : b.JobId is int job ? ("Job", job) + : b.SalesOrderId is int so ? ("SalesOrder", so) + : b.PurchaseOrderId is int po ? ("PurchaseOrder", po) + : b.AssetId is int asset ? ("Asset", asset) + : b.StorageLocationId is int loc ? ("StorageLocation", loc) + : b.LotRecordId is int lot ? ("Lot", lot) + : b.UserId is int user ? ("ApplicationUser", user) + : null; +} diff --git a/forge.api/Services/BarcodeService.cs b/forge.api/Services/BarcodeService.cs index b2b865bb..f70719d2 100644 --- a/forge.api/Services/BarcodeService.cs +++ b/forge.api/Services/BarcodeService.cs @@ -159,7 +159,10 @@ public async Task RefreshPartBarcodeAsync(int partId, CancellationToken cancella identity = BarcodeIdentityType.Internal; } - var barcode = await db.Barcodes.FirstOrDefaultAsync(b => b.PartId == partId && b.IsActive, cancellationToken); + // Only the System (auto-generated) row is re-synced — manual alternate barcodes on the + // same part (manufacturer UPCs, vendor SKUs, legacy labels) are left untouched. + var barcode = await db.Barcodes.FirstOrDefaultAsync( + b => b.PartId == partId && b.IsActive && b.Source == BarcodeSource.System, cancellationToken); if (barcode is null) { if (identity == BarcodeIdentityType.Internal @@ -172,6 +175,7 @@ public async Task RefreshPartBarcodeAsync(int partId, CancellationToken cancella PartId = partId, IsActive = true, IdentityType = identity, + Source = BarcodeSource.System, }); } else diff --git a/forge.core/Entities/Barcode.cs b/forge.core/Entities/Barcode.cs index 0b56bd5a..14e8bebc 100644 --- a/forge.core/Entities/Barcode.cs +++ b/forge.core/Entities/Barcode.cs @@ -16,6 +16,11 @@ public class Barcode : BaseAuditableEntity /// marketplace export) tell a globally-unique GTIN from an internal-only code. public BarcodeIdentityType IdentityType { get; set; } = BarcodeIdentityType.Internal; + /// System = the entity's auto-generated code (one per entity, kept in sync, not user-removable); + /// Manual = a user-added alternate value (manufacturer UPC, vendor SKU, legacy label) that coexists with + /// the system code and resolves the same on scan. Refresh only touches the System row. + public BarcodeSource Source { get; set; } = BarcodeSource.System; + // ─── Dedicated FKs (exactly one is non-null) ─── public int? UserId { get; set; } diff --git a/forge.core/Enums/BarcodeSource.cs b/forge.core/Enums/BarcodeSource.cs new file mode 100644 index 00000000..4159c66c --- /dev/null +++ b/forge.core/Enums/BarcodeSource.cs @@ -0,0 +1,16 @@ +namespace Forge.Core.Enums; + +/// +/// How a barcode row came to exist: the entity's single auto-generated code, or a +/// user-added alternate value that coexists with it. +/// +public enum BarcodeSource +{ + /// Auto-generated and auto-maintained — exactly one per entity, kept in sync with the + /// entity's identity (part number / GTIN), and not user-removable. + System, + + /// A manually-added alternate value (a manufacturer UPC, a vendor SKU, a legacy label) + /// that coexists with the system code, is resolvable on scan, and is user-removable. + Manual, +} diff --git a/forge.core/Models/BarcodeResponseModel.cs b/forge.core/Models/BarcodeResponseModel.cs index 98651055..6bb2a14b 100644 --- a/forge.core/Models/BarcodeResponseModel.cs +++ b/forge.core/Models/BarcodeResponseModel.cs @@ -5,4 +5,6 @@ public record BarcodeResponseModel( string Value, string EntityType, bool IsActive, - DateTimeOffset CreatedAt); + DateTimeOffset CreatedAt, + string Source = "System", + string IdentityType = "Internal"); diff --git a/forge.data/Configuration/BarcodeConfiguration.cs b/forge.data/Configuration/BarcodeConfiguration.cs index 648967a4..a8cc4008 100644 --- a/forge.data/Configuration/BarcodeConfiguration.cs +++ b/forge.data/Configuration/BarcodeConfiguration.cs @@ -14,6 +14,7 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.Value).HasMaxLength(500); builder.Property(e => e.EntityType).HasConversion().HasMaxLength(50); builder.Property(e => e.IdentityType).HasConversion().HasMaxLength(20); + builder.Property(e => e.Source).HasConversion().HasMaxLength(20); builder.HasIndex(e => e.Value).IsUnique(); builder.HasIndex(e => e.EntityType); diff --git a/forge.tests/Handlers/Barcodes/ManualBarcodeTests.cs b/forge.tests/Handlers/Barcodes/ManualBarcodeTests.cs new file mode 100644 index 00000000..59a3051b --- /dev/null +++ b/forge.tests/Handlers/Barcodes/ManualBarcodeTests.cs @@ -0,0 +1,122 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +using Forge.Api.Features.Barcodes; +using Forge.Api.Services; +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Data.Context; +using Forge.Tests.Helpers; + +namespace Forge.Tests.Handlers.Barcodes; + +/// +/// Manual alternate barcodes: a user can register extra scannable values (manufacturer UPC, vendor +/// SKU, legacy label) on top of an entity's auto-assigned system code. They coexist, resolve on scan, +/// are the only ones removable, and survive a system-code re-sync. +/// +public class ManualBarcodeTests +{ + private const int PartId = 1; + + private static AppDbContext SeededDb() + { + var db = TestDbContextFactory.Create(); + db.Parts.Add(new Part { Id = PartId, PartNumber = "P-001", Name = "Bracket", Description = "Bracket" }); + db.Barcodes.Add(new Barcode + { + Id = 1, + Value = "PRT-P-001", + EntityType = BarcodeEntityType.Part, + PartId = PartId, + IsActive = true, + IdentityType = BarcodeIdentityType.Internal, + Source = BarcodeSource.System, + }); + db.SaveChanges(); + return db; + } + + [Fact] + public async Task AddManualBarcode_coexists_with_the_system_code_and_resolves_on_scan() + { + await using var db = SeededDb(); + + var result = await new AddManualBarcodeHandler(db) + .Handle(new AddManualBarcodeCommand(BarcodeEntityType.Part, PartId, " 049000042566 "), default); + + result.Value.Should().Be("049000042566"); // trimmed + result.Source.Should().Be("Manual"); + + db.Barcodes.Count(b => b.PartId == PartId).Should().Be(2); // system + manual, side by side + var resolved = await new BarcodeService(db, new HttpContextAccessor()) + .FindByValueAsync("049000042566"); + resolved!.PartId.Should().Be(PartId); // a scan of the alias maps to the part + } + + [Fact] + public async Task AddManualBarcode_rejects_a_value_already_in_use() + { + await using var db = SeededDb(); + var act = () => new AddManualBarcodeHandler(db) + .Handle(new AddManualBarcodeCommand(BarcodeEntityType.Part, PartId, "PRT-P-001"), default); + + (await act.Should().ThrowAsync()).Which.Message.Should().Contain("already in use"); + } + + [Fact] + public async Task AddManualBarcode_rejects_an_empty_value() + { + await using var db = SeededDb(); + var act = () => new AddManualBarcodeHandler(db) + .Handle(new AddManualBarcodeCommand(BarcodeEntityType.Part, PartId, " "), default); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task AddManualBarcode_rejects_a_missing_entity() + { + await using var db = SeededDb(); + var act = () => new AddManualBarcodeHandler(db) + .Handle(new AddManualBarcodeCommand(BarcodeEntityType.Part, 999, "X-1"), default); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RemoveManualBarcode_removes_the_alias_but_refuses_the_system_code() + { + await using var db = SeededDb(); + var manual = await new AddManualBarcodeHandler(db) + .Handle(new AddManualBarcodeCommand(BarcodeEntityType.Part, PartId, "ALT-1"), default); + + // The system code cannot be removed. + var refuse = () => new RemoveManualBarcodeHandler(db).Handle(new RemoveManualBarcodeCommand(1), default); + await refuse.Should().ThrowAsync(); + + // The manual alias can. + await new RemoveManualBarcodeHandler(db).Handle(new RemoveManualBarcodeCommand(manual.Id), default); + db.Barcodes.Any(b => b.Id == manual.Id).Should().BeFalse(); + db.Barcodes.Count(b => b.PartId == PartId).Should().Be(1); // only the system code remains + } + + [Fact] + public async Task RefreshPartBarcode_leaves_manual_aliases_untouched() + { + await using var db = SeededDb(); + await new AddManualBarcodeHandler(db) + .Handle(new AddManualBarcodeCommand(BarcodeEntityType.Part, PartId, "ALT-1"), default); + + // Assign a GTIN and re-sync the system code — the manual alias must survive. + var part = await db.Parts.FirstAsync(p => p.Id == PartId); + part.Gtin = "0614141000012"; + await db.SaveChangesAsync(); + await new BarcodeService(db, new HttpContextAccessor()).RefreshPartBarcodeAsync(PartId); + + var codes = db.Barcodes.Where(b => b.PartId == PartId).ToList(); + codes.Should().Contain(b => b.Source == BarcodeSource.Manual && b.Value == "ALT-1"); + codes.Should().Contain(b => b.Source == BarcodeSource.System && b.Value == "0614141000012"); + } +} From 18bf5c12a7958167c7ba0d59a593dce852eab855 Mon Sep 17 00:00:00 2001 From: Daniel Hokanson Date: Tue, 18 Aug 2026 18:16:21 -0600 Subject: [PATCH 4/4] =?UTF-8?q?feat(sequences):=20Gated=20Sequence=20Engin?= =?UTF-8?q?e=20=E2=80=94=20versioned=20gated-process=20primitive=20(CAP-CR?= =?UTF-8?q?OSS-SEQUENCES)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A general-purpose Petri-net-with-guards-and-clocks module for routing gates, inspection sign-offs, lot expiry and permit/inspection chains. Open core per the construction-vertical boundary rule (private holds operations, never workflow). - forge.core: 8 enums, 9 entities (SequenceDefinition/Step/Edge/Gate, SequenceInstance/StepInstance/GateInstance, SequenceEvent append-only log, SequenceResourceClock that travels with a resource), pure engine in Forge.Core.Sequences (SequenceNet, SequenceNetValidator, SequenceEvaluator, IGateSource, contexts/results), ISequenceEvaluationService, 14 models. - forge.data: DbSets + 9 configurations; embedded forge-schema.sql regenerated from forge-db (9 tables, 14 indexes). - forge.api: Features/Sequences (definitions lifecycle: create/update/publish/ new-version/retire; instances: start/list/get/events/reevaluate/cancel/rework; steps start/complete/skip; gates clear/override; resource clocks), built-in gate sources ManualClearance/TimeWindow/ResourceClock/Approval + Custom (fail-closed when unregistered), SequenceEvaluationService, SequencesController at api/v1/sequences gated by CAP-CROSS-SEQUENCES (new catalog row, off by default, 165 total; CLAUDE.md count bumped), SequenceClockJob (Hangfire minutely — fires resource/dwell clocks once, re-evaluates time gates), domain events SequenceStepReady/InstanceCompleted/ClockExpired, reaction OnApprovalCompleted_ReevaluateSequences. - forge.tests/Sequences: 27 tests (validator, evaluator idempotency/joins/ override/blocked, definition lifecycle, full instance flows, time-window + resource-clock + dwell via the clock job, approval reaction, custom source). - CLAUDE.md: Gated Sequence Engine section (rules for extending it). Release -warnaserror clean; 2,210 tests pass (62 Postgres/Testcontainers tests need Docker, unavailable on this box). Design + implementation record in forge/docs/delivery/in-progress/gated-sequence-engine/. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 24 +- forge.api/Capabilities/CapabilityCatalog.cs | 1 + forge.api/Controllers/SequencesController.cs | 136 ++++++++ ...OnApprovalCompleted_ReevaluateSequences.cs | 37 +++ .../DomainEvents/SequenceClockExpiredEvent.cs | 20 ++ .../SequenceInstanceCompletedEvent.cs | 6 + .../DomainEvents/SequenceStepReadyEvent.cs | 6 + .../Sequences/CancelSequenceInstance.cs | 36 +++ .../Features/Sequences/ClearSequenceGate.cs | 40 +++ .../Sequences/CompleteSequenceStep.cs | 40 +++ .../Sequences/CreateSequenceDefinition.cs | 40 +++ .../Sequences/CreateSequenceResourceClock.cs | 30 ++ .../Sequences/DeleteSequenceResourceClock.cs | 23 ++ .../GateSources/ApprovalGateSource.cs | 36 +++ .../GateSources/ManualClearanceGateSource.cs | 20 ++ .../GateSources/ResourceClockGateSource.cs | 37 +++ .../GateSources/SequenceGateConfig.cs | 30 ++ .../GateSources/TimeWindowGateSource.cs | 24 ++ .../Sequences/GetSequenceDefinition.cs | 19 ++ .../Sequences/GetSequenceDefinitions.cs | 22 ++ .../Features/Sequences/GetSequenceEvents.cs | 19 ++ .../Features/Sequences/GetSequenceInstance.cs | 19 ++ .../Sequences/GetSequenceInstances.cs | 25 ++ .../Sequences/GetSequenceResourceClocks.cs | 24 ++ .../Sequences/NewSequenceDefinitionVersion.cs | 47 +++ .../Sequences/OverrideSequenceGate.cs | 38 +++ .../Sequences/PublishSequenceDefinition.cs | 40 +++ .../Features/Sequences/ReevaluateSequence.cs | 22 ++ .../Sequences/RetireSequenceDefinition.cs | 25 ++ .../Features/Sequences/ReworkSequence.cs | 54 ++++ .../Sequences/SequenceDefinitionGraph.cs | 37 +++ .../Features/Sequences/SequenceMapping.cs | 59 ++++ .../Features/Sequences/SequenceQueries.cs | 25 ++ .../Sequences/SequenceStepCommands.cs | 28 ++ .../Features/Sequences/SkipSequenceStep.cs | 41 +++ .../Sequences/StartSequenceInstance.cs | 65 ++++ .../Features/Sequences/StartSequenceStep.cs | 40 +++ .../Sequences/UpdateSequenceDefinition.cs | 38 +++ forge.api/Jobs/SequenceClockJob.cs | 111 +++++++ forge.api/Program.cs | 13 + .../Services/SequenceEvaluationService.cs | 74 +++++ forge.core/Entities/SequenceDefinition.cs | 35 ++ forge.core/Entities/SequenceEdgeDefinition.cs | 19 ++ forge.core/Entities/SequenceEvent.cs | 24 ++ forge.core/Entities/SequenceGateDefinition.cs | 31 ++ forge.core/Entities/SequenceGateInstance.cs | 34 ++ forge.core/Entities/SequenceInstance.cs | 42 +++ forge.core/Entities/SequenceResourceClock.cs | 27 ++ forge.core/Entities/SequenceStepDefinition.cs | 32 ++ forge.core/Entities/SequenceStepInstance.cs | 34 ++ forge.core/Enums/SequenceDefinitionStatus.cs | 10 + forge.core/Enums/SequenceEventType.cs | 21 ++ forge.core/Enums/SequenceExpiryAction.cs | 12 + forge.core/Enums/SequenceGateSourceType.cs | 20 ++ forge.core/Enums/SequenceGateVerdict.cs | 9 + forge.core/Enums/SequenceInstanceStatus.cs | 9 + forge.core/Enums/SequenceJoinPolicy.cs | 8 + forge.core/Enums/SequenceStepStatus.cs | 17 + .../Interfaces/ISequenceEvaluationService.cs | 13 + .../Models/SequenceDefinitionRequestModel.cs | 11 + .../Models/SequenceDefinitionResponseModel.cs | 18 ++ .../Models/SequenceEdgeDefinitionModel.cs | 4 + .../Models/SequenceEventResponseModel.cs | 12 + .../Models/SequenceGateDefinitionModel.cs | 13 + .../SequenceGateInstanceResponseModel.cs | 17 + .../Models/SequenceInstanceResponseModel.cs | 21 ++ .../Models/SequenceReasonRequestModel.cs | 4 + .../SequenceResourceClockRequestModel.cs | 11 + .../SequenceResourceClockResponseModel.cs | 14 + .../Models/SequenceReworkRequestModel.cs | 4 + .../Models/SequenceStepDefinitionModel.cs | 14 + .../SequenceStepInstanceResponseModel.cs | 21 ++ .../Models/StartSequenceRequestModel.cs | 4 + forge.core/Sequences/IGateSource.cs | 19 ++ forge.core/Sequences/SequenceEvaluation.cs | 20 ++ forge.core/Sequences/SequenceEvaluator.cs | 138 ++++++++ forge.core/Sequences/SequenceGateContext.cs | 11 + .../Sequences/SequenceGateVerdictResult.cs | 11 + forge.core/Sequences/SequenceNet.cs | 67 ++++ forge.core/Sequences/SequenceNetValidator.cs | 70 ++++ .../SequenceDefinitionConfiguration.cs | 27 ++ .../SequenceEdgeDefinitionConfiguration.cs | 16 + .../SequenceEventConfiguration.cs | 18 ++ .../SequenceGateDefinitionConfiguration.cs | 21 ++ .../SequenceGateInstanceConfiguration.cs | 19 ++ .../SequenceInstanceConfiguration.cs | 28 ++ .../SequenceResourceClockConfiguration.cs | 20 ++ .../SequenceStepDefinitionConfiguration.cs | 20 ++ .../SequenceStepInstanceConfiguration.cs | 18 ++ forge.data/Context/AppDbContext.cs | 12 + forge.data/Schema/forge-schema.sql | 299 ++++++++++++++++++ .../SequenceDefinitionHandlerTests.cs | 50 +++ .../Sequences/SequenceEvaluatorTests.cs | 119 +++++++ .../Sequences/SequenceGateSourceTests.cs | 149 +++++++++ .../Sequences/SequenceHandlerFixture.cs | 47 +++ .../Sequences/SequenceInstanceHandlerTests.cs | 153 +++++++++ .../Sequences/SequenceNetValidatorTests.cs | 63 ++++ forge.tests/Sequences/SequenceTestNets.cs | 53 ++++ 98 files changed, 3503 insertions(+), 1 deletion(-) create mode 100644 forge.api/Controllers/SequencesController.cs create mode 100644 forge.api/Features/DomainEvents/Handlers/OnApprovalCompleted_ReevaluateSequences.cs create mode 100644 forge.api/Features/DomainEvents/SequenceClockExpiredEvent.cs create mode 100644 forge.api/Features/DomainEvents/SequenceInstanceCompletedEvent.cs create mode 100644 forge.api/Features/DomainEvents/SequenceStepReadyEvent.cs create mode 100644 forge.api/Features/Sequences/CancelSequenceInstance.cs create mode 100644 forge.api/Features/Sequences/ClearSequenceGate.cs create mode 100644 forge.api/Features/Sequences/CompleteSequenceStep.cs create mode 100644 forge.api/Features/Sequences/CreateSequenceDefinition.cs create mode 100644 forge.api/Features/Sequences/CreateSequenceResourceClock.cs create mode 100644 forge.api/Features/Sequences/DeleteSequenceResourceClock.cs create mode 100644 forge.api/Features/Sequences/GateSources/ApprovalGateSource.cs create mode 100644 forge.api/Features/Sequences/GateSources/ManualClearanceGateSource.cs create mode 100644 forge.api/Features/Sequences/GateSources/ResourceClockGateSource.cs create mode 100644 forge.api/Features/Sequences/GateSources/SequenceGateConfig.cs create mode 100644 forge.api/Features/Sequences/GateSources/TimeWindowGateSource.cs create mode 100644 forge.api/Features/Sequences/GetSequenceDefinition.cs create mode 100644 forge.api/Features/Sequences/GetSequenceDefinitions.cs create mode 100644 forge.api/Features/Sequences/GetSequenceEvents.cs create mode 100644 forge.api/Features/Sequences/GetSequenceInstance.cs create mode 100644 forge.api/Features/Sequences/GetSequenceInstances.cs create mode 100644 forge.api/Features/Sequences/GetSequenceResourceClocks.cs create mode 100644 forge.api/Features/Sequences/NewSequenceDefinitionVersion.cs create mode 100644 forge.api/Features/Sequences/OverrideSequenceGate.cs create mode 100644 forge.api/Features/Sequences/PublishSequenceDefinition.cs create mode 100644 forge.api/Features/Sequences/ReevaluateSequence.cs create mode 100644 forge.api/Features/Sequences/RetireSequenceDefinition.cs create mode 100644 forge.api/Features/Sequences/ReworkSequence.cs create mode 100644 forge.api/Features/Sequences/SequenceDefinitionGraph.cs create mode 100644 forge.api/Features/Sequences/SequenceMapping.cs create mode 100644 forge.api/Features/Sequences/SequenceQueries.cs create mode 100644 forge.api/Features/Sequences/SequenceStepCommands.cs create mode 100644 forge.api/Features/Sequences/SkipSequenceStep.cs create mode 100644 forge.api/Features/Sequences/StartSequenceInstance.cs create mode 100644 forge.api/Features/Sequences/StartSequenceStep.cs create mode 100644 forge.api/Features/Sequences/UpdateSequenceDefinition.cs create mode 100644 forge.api/Jobs/SequenceClockJob.cs create mode 100644 forge.api/Services/SequenceEvaluationService.cs create mode 100644 forge.core/Entities/SequenceDefinition.cs create mode 100644 forge.core/Entities/SequenceEdgeDefinition.cs create mode 100644 forge.core/Entities/SequenceEvent.cs create mode 100644 forge.core/Entities/SequenceGateDefinition.cs create mode 100644 forge.core/Entities/SequenceGateInstance.cs create mode 100644 forge.core/Entities/SequenceInstance.cs create mode 100644 forge.core/Entities/SequenceResourceClock.cs create mode 100644 forge.core/Entities/SequenceStepDefinition.cs create mode 100644 forge.core/Entities/SequenceStepInstance.cs create mode 100644 forge.core/Enums/SequenceDefinitionStatus.cs create mode 100644 forge.core/Enums/SequenceEventType.cs create mode 100644 forge.core/Enums/SequenceExpiryAction.cs create mode 100644 forge.core/Enums/SequenceGateSourceType.cs create mode 100644 forge.core/Enums/SequenceGateVerdict.cs create mode 100644 forge.core/Enums/SequenceInstanceStatus.cs create mode 100644 forge.core/Enums/SequenceJoinPolicy.cs create mode 100644 forge.core/Enums/SequenceStepStatus.cs create mode 100644 forge.core/Interfaces/ISequenceEvaluationService.cs create mode 100644 forge.core/Models/SequenceDefinitionRequestModel.cs create mode 100644 forge.core/Models/SequenceDefinitionResponseModel.cs create mode 100644 forge.core/Models/SequenceEdgeDefinitionModel.cs create mode 100644 forge.core/Models/SequenceEventResponseModel.cs create mode 100644 forge.core/Models/SequenceGateDefinitionModel.cs create mode 100644 forge.core/Models/SequenceGateInstanceResponseModel.cs create mode 100644 forge.core/Models/SequenceInstanceResponseModel.cs create mode 100644 forge.core/Models/SequenceReasonRequestModel.cs create mode 100644 forge.core/Models/SequenceResourceClockRequestModel.cs create mode 100644 forge.core/Models/SequenceResourceClockResponseModel.cs create mode 100644 forge.core/Models/SequenceReworkRequestModel.cs create mode 100644 forge.core/Models/SequenceStepDefinitionModel.cs create mode 100644 forge.core/Models/SequenceStepInstanceResponseModel.cs create mode 100644 forge.core/Models/StartSequenceRequestModel.cs create mode 100644 forge.core/Sequences/IGateSource.cs create mode 100644 forge.core/Sequences/SequenceEvaluation.cs create mode 100644 forge.core/Sequences/SequenceEvaluator.cs create mode 100644 forge.core/Sequences/SequenceGateContext.cs create mode 100644 forge.core/Sequences/SequenceGateVerdictResult.cs create mode 100644 forge.core/Sequences/SequenceNet.cs create mode 100644 forge.core/Sequences/SequenceNetValidator.cs create mode 100644 forge.data/Configuration/SequenceDefinitionConfiguration.cs create mode 100644 forge.data/Configuration/SequenceEdgeDefinitionConfiguration.cs create mode 100644 forge.data/Configuration/SequenceEventConfiguration.cs create mode 100644 forge.data/Configuration/SequenceGateDefinitionConfiguration.cs create mode 100644 forge.data/Configuration/SequenceGateInstanceConfiguration.cs create mode 100644 forge.data/Configuration/SequenceInstanceConfiguration.cs create mode 100644 forge.data/Configuration/SequenceResourceClockConfiguration.cs create mode 100644 forge.data/Configuration/SequenceStepDefinitionConfiguration.cs create mode 100644 forge.data/Configuration/SequenceStepInstanceConfiguration.cs create mode 100644 forge.tests/Sequences/SequenceDefinitionHandlerTests.cs create mode 100644 forge.tests/Sequences/SequenceEvaluatorTests.cs create mode 100644 forge.tests/Sequences/SequenceGateSourceTests.cs create mode 100644 forge.tests/Sequences/SequenceHandlerFixture.cs create mode 100644 forge.tests/Sequences/SequenceInstanceHandlerTests.cs create mode 100644 forge.tests/Sequences/SequenceNetValidatorTests.cs create mode 100644 forge.tests/Sequences/SequenceTestNets.cs diff --git a/CLAUDE.md b/CLAUDE.md index 74e31e52..a093e6d1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -531,7 +531,7 @@ readonly isStandalone = this.accountingService.isStandalone; ## Capability Gating (Phase 4) -The system runs on a **per-install capability gate**: 164 named capabilities (e.g., `CAP-MD-CUSTOMERS`, `CAP-INV-LOTS`, `CAP-EXT-AI-ASSISTANT`) are registered in a static catalog. Each install's capability state is stored in the `capabilities` table; controllers and Hangfire-fired commands carry `[RequiresCapability("CAP-...")]` attributes; the `CapabilityGateMiddleware` (controller side) and `CapabilityGateBehavior` (MediatR side) short-circuit with 403 + envelope when a capability is disabled. Bootstrap-exempt endpoints (auth, descriptor, capability admin) carry `[CapabilityBootstrap]` instead so admins are never locked out. +The system runs on a **per-install capability gate**: 165 named capabilities (e.g., `CAP-MD-CUSTOMERS`, `CAP-INV-LOTS`, `CAP-EXT-AI-ASSISTANT`) are registered in a static catalog. Each install's capability state is stored in the `capabilities` table; controllers and Hangfire-fired commands carry `[RequiresCapability("CAP-...")]` attributes; the `CapabilityGateMiddleware` (controller side) and `CapabilityGateBehavior` (MediatR side) short-circuit with 403 + envelope when a capability is disabled. Bootstrap-exempt endpoints (auth, descriptor, capability admin) carry `[CapabilityBootstrap]` instead so admins are never locked out. **Where things live:** - **Catalog (source of truth)**: `forge-api/forge.api/Capabilities/CapabilityCatalog.cs` — 157 capabilities with code, name, area, default-state, dependencies/mutexes @@ -729,6 +729,28 @@ Real: **Shopify**, **WooCommerce** (both storefronts — you are merchant of rec +## Gated Sequence Engine (Sequences, `CAP-CROSS-SEQUENCES`) + +Added 2026-08-18. A general-purpose gated-process primitive — a Petri net with guarded transitions and clocks — for +routing gates, inspection sign-offs, lot expiry, permit/inspection chains. Design + record: +`forge/docs/delivery/in-progress/gated-sequence-engine/`. Rules when touching it: + +- **Definition vs instance.** `SequenceDefinition` (`Code`+`Version`, Draft→Published→Retired) is immutable once + Published; instances pin the version they started on. Never mutate a Published definition — `new-version`. +- **The evaluator is pure.** `Forge.Core.Sequences.SequenceEvaluator` takes (net, instance, verdicts, now) and returns + events; it never touches storage, clocks, or gate sources. Storage/DI live in `forge.api/Services/SequenceEvaluationService`. + Keep it that way — it is what makes the engine unit-testable and idempotent. +- **Blocked is derived, never stored.** A step is Blocked when its predecessors are satisfied and a gate is not Go. + Do not add a Blocked status. +- **Every state change is a `SequenceEvent` row** (append-only) plus an `ActivityLog` row against the instance and its + subject (`SequenceQueries.IndexingPoints`). Override / skip / rework / cancel require a reason. +- **Adding a gate kind = registering an `IGateSource`** (`SourceType = Custom`, `CustomKey = ""`) in DI; the gate's + `config_json` names it via `{ "key": "" }`. Unknown keys fail closed (NoGo). Sources must be side-effect free. +- **Anything that can change a verdict must dispatch `ReevaluateSequenceCommand`.** Built-in triggers: step complete, + gate clear/override, rework, `SequenceClockJob` (minutely — the engine's only timer), `ApprovalCompletedEvent`. +- Domain events published: `SequenceStepReadyEvent`, `SequenceInstanceCompletedEvent`, `SequenceClockExpiredEvent`. + No default reactions — consumers subscribe. + ## What NOT to Do - Never use `FormsModule` / `ngModel` in features — always `ReactiveFormsModule` diff --git a/forge.api/Capabilities/CapabilityCatalog.cs b/forge.api/Capabilities/CapabilityCatalog.cs index 9c3141b2..308eb468 100644 --- a/forge.api/Capabilities/CapabilityCatalog.cs +++ b/forge.api/Capabilities/CapabilityCatalog.cs @@ -191,6 +191,7 @@ public static class CapabilityCatalog new("CAP-CROSS-WEBHOOKS", "CROSS", @"Outbound webhooks", @"Configurable webhook subscriptions on domain events, with delivery retry and failure tracking.", IsDefaultOn: false, RequiresRoles: null), new("CAP-CROSS-BI-EXPORT", "CROSS", @"BI tool data export", @"Read-only data export endpoints (Sankey reports, dynamic report builder, scheduled exports) consumable by external BI tools via API key.", IsDefaultOn: false, RequiresRoles: null), new("CAP-CROSS-CONCURRENCY", "CROSS", @"Optimistic locking + conflict resolution", @"RowVersion-based optimistic locking on transactional entities (Job, Invoice, PO, SO, Quote, Payment, Shipment); 409 Conflict + UI conflict-resolution dialog.", IsDefaultOn: true, RequiresRoles: null), + new("CAP-CROSS-SEQUENCES", "CROSS", @"Gated sequence engine", @"General-purpose gated process primitive (a Petri net with guarded transitions and clocks): versioned step/edge/gate definitions, runs against any entity, go/no-go gates (manual clearance, time window, resource clock, approval, module-custom), dwell and resource clocks with block/flag/escalate, override and rework with mandatory reasons, append-only event log. The substrate for routing gates, inspection sign-offs, lot expiry and permit/inspection chains. Off by default until a module needs it.", IsDefaultOn: false, RequiresRoles: null), new("CAP-ADMIN-I18N", "CROSS", @"UI label customization (i18n overrides)", @"Admin screen to customize UI label text per language (e.g. rename ""Customer"" to match business vocabulary). Overrides are stored per install and merged over the shipped i18n catalogs at load time; edits in one language fan out as machine translations to the other configured languages via the self-hosted AI module, flagged and individually editable.", IsDefaultOn: true, RequiresRoles: "Admin"), new("CAP-EXT-KANBAN", "EXT", @"Kanban-style job board", @"Visual kanban board for jobs/WOs with custom track types (Production, R&D, Maintenance, Other), multi-select bulk actions, real-time SignalR updates.", IsDefaultOn: true, RequiresRoles: null), new("CAP-EXT-KANBAN-REPLENISHMENT", "EXT", @"Replenishment kanban", @"Two-bin / kanban-card replenishment triggers tied to inventory consumption.", IsDefaultOn: false, RequiresRoles: null), diff --git a/forge.api/Controllers/SequencesController.cs b/forge.api/Controllers/SequencesController.cs new file mode 100644 index 00000000..320d6727 --- /dev/null +++ b/forge.api/Controllers/SequencesController.cs @@ -0,0 +1,136 @@ +using System.Security.Claims; + +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using Forge.Api.Capabilities; +using Forge.Api.Features.Sequences; +using Forge.Core.Enums; +using Forge.Core.Models; + +namespace Forge.Api.Controllers; + +/// Gated Sequence Engine — definitions (versioned templates), instances (runs), and resource clocks. +[ApiController] +[Route("api/v1/sequences")] +[Authorize] +[RequiresCapability("CAP-CROSS-SEQUENCES")] +public class SequencesController(IMediator mediator) : ControllerBase +{ + // ----- definitions ----- + + [HttpGet("definitions")] + public async Task GetDefinitions([FromQuery] string? code, [FromQuery] SequenceDefinitionStatus? status, CancellationToken ct) => + Ok(await mediator.Send(new GetSequenceDefinitionsQuery(code, status), ct)); + + [HttpGet("definitions/{id:int}")] + public async Task GetDefinition(int id, CancellationToken ct) => + Ok(await mediator.Send(new GetSequenceDefinitionQuery(id), ct)); + + [HttpPost("definitions")] + public async Task CreateDefinition([FromBody] SequenceDefinitionRequestModel model, CancellationToken ct) + { + var result = await mediator.Send(new CreateSequenceDefinitionCommand(model), ct); + return CreatedAtAction(nameof(GetDefinition), new { id = result.Id }, result); + } + + [HttpPut("definitions/{id:int}")] + public async Task UpdateDefinition(int id, [FromBody] SequenceDefinitionRequestModel model, CancellationToken ct) => + Ok(await mediator.Send(new UpdateSequenceDefinitionCommand(id, model), ct)); + + [HttpPost("definitions/{id:int}/publish")] + public async Task PublishDefinition(int id, CancellationToken ct) => + Ok(await mediator.Send(new PublishSequenceDefinitionCommand(id, GetUserId()), ct)); + + [HttpPost("definitions/{id:int}/new-version")] + public async Task NewVersion(int id, CancellationToken ct) + { + var result = await mediator.Send(new NewSequenceDefinitionVersionCommand(id), ct); + return CreatedAtAction(nameof(GetDefinition), new { id = result.Id }, result); + } + + [HttpDelete("definitions/{id:int}")] + public async Task RetireDefinition(int id, CancellationToken ct) + { + await mediator.Send(new RetireSequenceDefinitionCommand(id), ct); + return NoContent(); + } + + // ----- instances ----- + + [HttpGet("instances")] + public async Task GetInstances([FromQuery] string? subjectEntityType, [FromQuery] int? subjectEntityId, + [FromQuery] SequenceInstanceStatus? status, [FromQuery] int? definitionId, CancellationToken ct) => + Ok(await mediator.Send(new GetSequenceInstancesQuery(subjectEntityType, subjectEntityId, status, definitionId), ct)); + + [HttpGet("instances/{id:int}")] + public async Task GetInstance(int id, CancellationToken ct) => + Ok(await mediator.Send(new GetSequenceInstanceQuery(id), ct)); + + [HttpGet("instances/{id:int}/events")] + public async Task GetEvents(int id, CancellationToken ct) => + Ok(await mediator.Send(new GetSequenceEventsQuery(id), ct)); + + [HttpPost("instances")] + public async Task Start([FromBody] StartSequenceRequestModel model, CancellationToken ct) + { + var result = await mediator.Send(new StartSequenceInstanceCommand(model, GetUserId()), ct); + return CreatedAtAction(nameof(GetInstance), new { id = result.Id }, result); + } + + [HttpPost("instances/{id:int}/reevaluate")] + public async Task Reevaluate(int id, CancellationToken ct) => + Ok(await mediator.Send(new ReevaluateSequenceCommand(id, GetUserId()), ct)); + + [HttpPost("instances/{id:int}/cancel")] + public async Task Cancel(int id, [FromBody] SequenceReasonRequestModel model, CancellationToken ct) => + Ok(await mediator.Send(new CancelSequenceInstanceCommand(id, model.Reason, GetUserId()), ct)); + + [HttpPost("instances/{id:int}/rework")] + public async Task Rework(int id, [FromBody] SequenceReworkRequestModel model, CancellationToken ct) => + Ok(await mediator.Send(new ReworkSequenceCommand(id, model.TargetStepKey, model.Reason, GetUserId()), ct)); + + [HttpPost("instances/{id:int}/steps/{stepKey}/start")] + public async Task StartStep(int id, string stepKey, CancellationToken ct) => + Ok(await mediator.Send(new StartSequenceStepCommand(id, stepKey, GetUserId()), ct)); + + [HttpPost("instances/{id:int}/steps/{stepKey}/complete")] + public async Task CompleteStep(int id, string stepKey, CancellationToken ct) => + Ok(await mediator.Send(new CompleteSequenceStepCommand(id, stepKey, GetUserId()), ct)); + + [HttpPost("instances/{id:int}/steps/{stepKey}/skip")] + public async Task SkipStep(int id, string stepKey, [FromBody] SequenceReasonRequestModel model, CancellationToken ct) => + Ok(await mediator.Send(new SkipSequenceStepCommand(id, stepKey, model.Reason, GetUserId()), ct)); + + [HttpPost("instances/{id:int}/gates/{stepKey}/{gateKey}/clear")] + public async Task ClearGate(int id, string stepKey, string gateKey, CancellationToken ct) => + Ok(await mediator.Send(new ClearSequenceGateCommand(id, stepKey, gateKey, GetUserId()), ct)); + + [HttpPost("instances/{id:int}/gates/{stepKey}/{gateKey}/override")] + public async Task OverrideGate(int id, string stepKey, string gateKey, [FromBody] SequenceReasonRequestModel model, CancellationToken ct) => + Ok(await mediator.Send(new OverrideSequenceGateCommand(id, stepKey, gateKey, model.Reason, GetUserId()), ct)); + + // ----- resource clocks ----- + + [HttpGet("resource-clocks")] + public async Task GetResourceClocks([FromQuery] string? resourceType, [FromQuery] int? resourceId, [FromQuery] bool includeFired, CancellationToken ct) => + Ok(await mediator.Send(new GetSequenceResourceClocksQuery(resourceType, resourceId, includeFired), ct)); + + [HttpPost("resource-clocks")] + public async Task CreateResourceClock([FromBody] SequenceResourceClockRequestModel model, CancellationToken ct) => + Ok(await mediator.Send(new CreateSequenceResourceClockCommand(model), ct)); + + [HttpDelete("resource-clocks/{id:int}")] + public async Task DeleteResourceClock(int id, CancellationToken ct) + { + await mediator.Send(new DeleteSequenceResourceClockCommand(id), ct); + return NoContent(); + } + + private int GetUserId() + { + var claim = User.FindFirstValue(ClaimTypes.NameIdentifier); + return int.TryParse(claim, out var id) ? id : throw new UnauthorizedAccessException(); + } +} diff --git a/forge.api/Features/DomainEvents/Handlers/OnApprovalCompleted_ReevaluateSequences.cs b/forge.api/Features/DomainEvents/Handlers/OnApprovalCompleted_ReevaluateSequences.cs new file mode 100644 index 00000000..fe582d5d --- /dev/null +++ b/forge.api/Features/DomainEvents/Handlers/OnApprovalCompleted_ReevaluateSequences.cs @@ -0,0 +1,37 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Api.Features.Sequences; +using Forge.Core.Enums; +using Forge.Data.Context; + +namespace Forge.Api.Features.DomainEvents.Handlers; + +/// +/// Gated Sequence Engine reaction: when an approval reaches a terminal decision, re-evaluate every running instance +/// that has an Approval gate pointed at that entity — explicitly via config or via the instance's subject. +/// +public class OnApprovalCompleted_ReevaluateSequences(AppDbContext db, IMediator mediator) : INotificationHandler +{ + public async Task Handle(ApprovalCompletedEvent notification, CancellationToken cancellationToken) + { + var candidates = await db.SequenceInstances + .Where(i => i.Status == SequenceInstanceStatus.Running && i.DeletedAt == null) + .Where(i => i.Definition!.Gates.Any(g => g.SourceType == SequenceGateSourceType.Approval)) + .Select(i => new { i.Id, i.SubjectEntityType, i.SubjectEntityId, + Configs = i.Definition!.Gates.Where(g => g.SourceType == SequenceGateSourceType.Approval).Select(g => g.ConfigJson).ToList() }) + .ToListAsync(cancellationToken); + + foreach (var c in candidates) + { + var hit = c.Configs.Any(json => + { + var cfg = Forge.Api.Features.Sequences.GateSources.SequenceGateConfig.Parse(json); + var type = cfg.GetBool("fromSubject") ? c.SubjectEntityType : cfg.GetString("entityType"); + var id = cfg.GetBool("fromSubject") ? c.SubjectEntityId : cfg.GetInt("entityId"); + return type == notification.EntityType && id == notification.EntityId; + }); + if (hit) await mediator.Send(new ReevaluateSequenceCommand(c.Id, notification.DecidedById), cancellationToken); + } + } +} diff --git a/forge.api/Features/DomainEvents/SequenceClockExpiredEvent.cs b/forge.api/Features/DomainEvents/SequenceClockExpiredEvent.cs new file mode 100644 index 00000000..48e3892f --- /dev/null +++ b/forge.api/Features/DomainEvents/SequenceClockExpiredEvent.cs @@ -0,0 +1,20 @@ +using MediatR; + +using Forge.Core.Enums; + +namespace Forge.Api.Features.DomainEvents; + +/// +/// Gated Sequence Engine — a resource clock or a step dwell clock expired. Fired exactly once per clock (the job +/// stamps FiredAt). tells consumers whether this is informational (Flag), blocking (Block), +/// or needs routing to (Escalate). +/// +public record SequenceClockExpiredEvent( + string ClockKind, // "resource" | "dwell" + int? InstanceId, + string? StepKey, + string? ResourceType, + int? ResourceId, + SequenceExpiryAction Action, + string? EscalateRole, + DateTimeOffset ExpiredAt) : INotification; diff --git a/forge.api/Features/DomainEvents/SequenceInstanceCompletedEvent.cs b/forge.api/Features/DomainEvents/SequenceInstanceCompletedEvent.cs new file mode 100644 index 00000000..2498d721 --- /dev/null +++ b/forge.api/Features/DomainEvents/SequenceInstanceCompletedEvent.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace Forge.Api.Features.DomainEvents; + +/// Gated Sequence Engine — every step of a run is Complete/Skipped. +public record SequenceInstanceCompletedEvent(int InstanceId, int DefinitionId, string? SubjectEntityType, int? SubjectEntityId) : INotification; diff --git a/forge.api/Features/DomainEvents/SequenceStepReadyEvent.cs b/forge.api/Features/DomainEvents/SequenceStepReadyEvent.cs new file mode 100644 index 00000000..99c3ca99 --- /dev/null +++ b/forge.api/Features/DomainEvents/SequenceStepReadyEvent.cs @@ -0,0 +1,6 @@ +using MediatR; + +namespace Forge.Api.Features.DomainEvents; + +/// Gated Sequence Engine — a step became Ready (all predecessors done, every gate Go). Consumers: notifications, andon, routing UIs. +public record SequenceStepReadyEvent(int InstanceId, string StepKey, string? SubjectEntityType, int? SubjectEntityId) : INotification; diff --git a/forge.api/Features/Sequences/CancelSequenceInstance.cs b/forge.api/Features/Sequences/CancelSequenceInstance.cs new file mode 100644 index 00000000..c6131c1c --- /dev/null +++ b/forge.api/Features/Sequences/CancelSequenceInstance.cs @@ -0,0 +1,36 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Terminal cancel; reason required. +public record CancelSequenceInstanceCommand(int InstanceId, string Reason, int UserId) : IRequest; + +public class CancelSequenceInstanceHandler(AppDbContext db, IClock clock) : IRequestHandler +{ + public async Task Handle(CancelSequenceInstanceCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Reason)) throw new InvalidOperationException("A cancel reason is required."); + var i = await db.SequenceInstances.WithGraph().FirstOrDefaultAsync(x => x.Id == request.InstanceId && x.DeletedAt == null, cancellationToken) + ?? throw new KeyNotFoundException($"Sequence instance {request.InstanceId} not found."); + if (i.Status != SequenceInstanceStatus.Running) throw new InvalidOperationException($"Instance is already {i.Status}."); + + var now = clock.UtcNow; + i.Status = SequenceInstanceStatus.Cancelled; + i.CancelledAt = now; + i.CancelledByUserId = request.UserId; + i.CancelReason = request.Reason.Trim(); + db.SequenceEvents.Add(SequenceEvaluator.Event(i, SequenceEventType.InstanceCancelled, now, request.UserId, + payloadJson: $"{{\"reason\":\"{i.CancelReason.Replace("\"", "\\\"")}\"}}")); + db.LogActivityAt("sequence-cancelled", $"Sequence {i.Definition!.Code} cancelled: {i.CancelReason}", SequenceQueries.IndexingPoints(i)); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(i); + } +} diff --git a/forge.api/Features/Sequences/ClearSequenceGate.cs b/forge.api/Features/Sequences/ClearSequenceGate.cs new file mode 100644 index 00000000..ab511506 --- /dev/null +++ b/forge.api/Features/Sequences/ClearSequenceGate.cs @@ -0,0 +1,40 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Record a manual clearance on a ManualClearance gate — the record IS the sign-off. Idempotent. +public record ClearSequenceGateCommand(int InstanceId, string StepKey, string GateKey, int UserId) : IRequest; + +public class ClearSequenceGateHandler(AppDbContext db, ISequenceEvaluationService evaluation, IClock clock) + : IRequestHandler +{ + public async Task Handle(ClearSequenceGateCommand request, CancellationToken cancellationToken) + { + var i = await SequenceStepCommands.LoadRunning(db, request.InstanceId, cancellationToken); + var gate = SequenceStepCommands.Gate(i, request.StepKey, request.GateKey); + var def = i.Definition!.Gates.First(g => g.StepKey == gate.StepKey && g.Key == gate.GateKey); + if (def.SourceType != SequenceGateSourceType.ManualClearance) + throw new InvalidOperationException($"Gate '{def.Name}' is a {def.SourceType} gate; only ManualClearance gates are cleared by hand (use override to force others)."); + + if (!gate.ClearedAt.HasValue) + { + var now = clock.UtcNow; + gate.ClearedAt = now; + gate.ClearedByUserId = request.UserId; + db.SequenceEvents.Add(SequenceEvaluator.Event(i, SequenceEventType.GateCleared, now, request.UserId, gate.StepKey, gate.GateKey)); + db.LogActivityAt("sequence-gate-cleared", $"Gate '{def.Name}' cleared", SequenceQueries.IndexingPoints(i)); + await db.SaveChangesAsync(cancellationToken); + } + await evaluation.EvaluateAsync(i.Id, request.UserId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(await db.SequenceInstances.WithGraph().FirstAsync(x => x.Id == i.Id, cancellationToken)); + } +} diff --git a/forge.api/Features/Sequences/CompleteSequenceStep.cs b/forge.api/Features/Sequences/CompleteSequenceStep.cs new file mode 100644 index 00000000..52505b0b --- /dev/null +++ b/forge.api/Features/Sequences/CompleteSequenceStep.cs @@ -0,0 +1,40 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// InProgress (or Ready — a zero-duration step) → Complete, then re-evaluate so successors advance. +public record CompleteSequenceStepCommand(int InstanceId, string StepKey, int UserId) : IRequest; + +public class CompleteSequenceStepHandler(AppDbContext db, ISequenceEvaluationService evaluation, IClock clock) + : IRequestHandler +{ + public async Task Handle(CompleteSequenceStepCommand request, CancellationToken cancellationToken) + { + var i = await SequenceStepCommands.LoadRunning(db, request.InstanceId, cancellationToken); + var step = SequenceStepCommands.Step(i, request.StepKey); + if (step.Status is not (SequenceStepStatus.InProgress or SequenceStepStatus.Ready)) + throw new InvalidOperationException($"Step '{request.StepKey}' is {step.Status}; only Ready or InProgress steps can complete."); + + var now = clock.UtcNow; + var def = i.Definition!.Steps.First(s => s.Key == step.StepKey); + step.StartedAt ??= now; + step.StartedByUserId ??= request.UserId; + step.Status = SequenceStepStatus.Complete; + step.CompletedAt = now; + step.CompletedByUserId = request.UserId; + db.SequenceEvents.Add(SequenceEvaluator.Event(i, SequenceEventType.StepCompleted, now, request.UserId, step.StepKey)); + db.LogActivityAt("sequence-step-completed", $"Sequence step '{def.Name}' completed", SequenceQueries.IndexingPoints(i)); + await db.SaveChangesAsync(cancellationToken); + await evaluation.EvaluateAsync(i.Id, request.UserId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(await db.SequenceInstances.WithGraph().FirstAsync(x => x.Id == i.Id, cancellationToken)); + } +} diff --git a/forge.api/Features/Sequences/CreateSequenceDefinition.cs b/forge.api/Features/Sequences/CreateSequenceDefinition.cs new file mode 100644 index 00000000..4187b3e4 --- /dev/null +++ b/forge.api/Features/Sequences/CreateSequenceDefinition.cs @@ -0,0 +1,40 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Create a Draft definition (version 1 of a new code, or the next version if the code already exists). The whole graph is validated structurally on save. +public record CreateSequenceDefinitionCommand(SequenceDefinitionRequestModel Model) : IRequest; + +public class CreateSequenceDefinitionHandler(AppDbContext db) : IRequestHandler +{ + public async Task Handle(CreateSequenceDefinitionCommand request, CancellationToken cancellationToken) + { + var m = request.Model; + var code = (m.Code ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(code)) throw new InvalidOperationException("A definition code is required."); + if (string.IsNullOrWhiteSpace(m.Name)) throw new InvalidOperationException("A definition name is required."); + + var latest = await db.SequenceDefinitions.Where(d => d.Code == code && d.DeletedAt == null) + .MaxAsync(d => (int?)d.Version, cancellationToken) ?? 0; + + var def = new SequenceDefinition { Code = code, Version = latest + 1, Status = SequenceDefinitionStatus.Draft }; + SequenceDefinitionGraph.Apply(def, m); + + var errors = SequenceNetValidator.Validate(def); + if (errors.Count > 0) throw new InvalidOperationException("Invalid sequence definition: " + string.Join(" ", errors)); + + db.SequenceDefinitions.Add(def); + await db.SaveChangesAsync(cancellationToken); // need the id for the activity row + db.LogActivityAt("sequence-definition-created", $"Sequence definition {code} v{def.Version} created (draft)", ("SequenceDefinition", def.Id)); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(def); + } +} diff --git a/forge.api/Features/Sequences/CreateSequenceResourceClock.cs b/forge.api/Features/Sequences/CreateSequenceResourceClock.cs new file mode 100644 index 00000000..4083fb2c --- /dev/null +++ b/forge.api/Features/Sequences/CreateSequenceResourceClock.cs @@ -0,0 +1,30 @@ +using MediatR; + +using Forge.Core.Entities; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Attach a clock to a resource (lot, permit, sample...). It travels with the resource; ResourceClock gates read it. +public record CreateSequenceResourceClockCommand(SequenceResourceClockRequestModel Model) : IRequest; + +public class CreateSequenceResourceClockHandler(AppDbContext db, IClock clock) : IRequestHandler +{ + public async Task Handle(CreateSequenceResourceClockCommand request, CancellationToken cancellationToken) + { + var m = request.Model; + if (string.IsNullOrWhiteSpace(m.ResourceType)) throw new InvalidOperationException("A resource type is required."); + var c = new SequenceResourceClock + { + ResourceType = m.ResourceType.Trim(), ResourceId = m.ResourceId, ExpiresAt = m.ExpiresAt, + ExpiryAction = m.ExpiryAction, EscalateRole = m.EscalateRole, Note = m.Note, + }; + db.SequenceResourceClocks.Add(c); + db.LogActivityAt("sequence-clock-set", $"Clock set: expires {c.ExpiresAt:u} ({c.ExpiryAction})", (c.ResourceType, c.ResourceId)); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(c, clock.UtcNow); + } +} diff --git a/forge.api/Features/Sequences/DeleteSequenceResourceClock.cs b/forge.api/Features/Sequences/DeleteSequenceResourceClock.cs new file mode 100644 index 00000000..0874a609 --- /dev/null +++ b/forge.api/Features/Sequences/DeleteSequenceResourceClock.cs @@ -0,0 +1,23 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Interfaces; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Soft-delete a resource clock (e.g. the lot was consumed / the permit renewed). +public record DeleteSequenceResourceClockCommand(int Id) : IRequest; + +public class DeleteSequenceResourceClockHandler(AppDbContext db, IClock clock) : IRequestHandler +{ + public async Task Handle(DeleteSequenceResourceClockCommand request, CancellationToken cancellationToken) + { + var c = await db.SequenceResourceClocks.FirstOrDefaultAsync(x => x.Id == request.Id && x.DeletedAt == null, cancellationToken) + ?? throw new KeyNotFoundException($"Resource clock {request.Id} not found."); + c.DeletedAt = clock.UtcNow; + db.LogActivityAt("sequence-clock-removed", "Clock removed", (c.ResourceType, c.ResourceId)); + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/forge.api/Features/Sequences/GateSources/ApprovalGateSource.cs b/forge.api/Features/Sequences/GateSources/ApprovalGateSource.cs new file mode 100644 index 00000000..5cb44508 --- /dev/null +++ b/forge.api/Features/Sequences/GateSources/ApprovalGateSource.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Sequences; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences.GateSources; + +/// +/// Go when a terminal APPROVED ApprovalRequest exists for the referenced entity (config { "entityType", "entityId" } +/// or { "fromSubject": true }). Reuses the Approvals feature; re-evaluated on ApprovalCompletedEvent. +/// +public class ApprovalGateSource(AppDbContext db) : IGateSource +{ + public SequenceGateSourceType SourceType => SequenceGateSourceType.Approval; + + public string? CustomKey => null; + + public async Task EvaluateAsync(SequenceGateContext context, CancellationToken cancellationToken) + { + var cfg = SequenceGateConfig.Parse(context.Gate.ConfigJson); + var type = cfg.GetBool("fromSubject") ? context.Instance.SubjectEntityType : cfg.GetString("entityType"); + var id = cfg.GetBool("fromSubject") ? context.Instance.SubjectEntityId : cfg.GetInt("entityId"); + if (string.IsNullOrEmpty(type) || id is null) + return SequenceGateVerdictResult.NoGo("Gate config names no entity to approve"); + + var latest = await db.ApprovalRequests + .Where(r => r.EntityType == type && r.EntityId == id) + .OrderByDescending(r => r.RequestedAt) + .FirstOrDefaultAsync(cancellationToken); + if (latest is null) return SequenceGateVerdictResult.NoGo("No approval requested"); + return latest.Status is ApprovalRequestStatus.Approved or ApprovalRequestStatus.AutoApproved + ? SequenceGateVerdictResult.Go($"Approved {latest.CompletedAt:u}") + : SequenceGateVerdictResult.NoGo($"Approval {latest.Status}"); + } +} diff --git a/forge.api/Features/Sequences/GateSources/ManualClearanceGateSource.cs b/forge.api/Features/Sequences/GateSources/ManualClearanceGateSource.cs new file mode 100644 index 00000000..a7592106 --- /dev/null +++ b/forge.api/Features/Sequences/GateSources/ManualClearanceGateSource.cs @@ -0,0 +1,20 @@ +using Forge.Core.Enums; +using Forge.Core.Sequences; + +namespace Forge.Api.Features.Sequences.GateSources; + +/// Go once someone has recorded a clearance on the gate instance (POST .../gates/{step}/{gate}/clear). The record is the sign-off. +public class ManualClearanceGateSource : IGateSource +{ + public SequenceGateSourceType SourceType => SequenceGateSourceType.ManualClearance; + + public string? CustomKey => null; + + public Task EvaluateAsync(SequenceGateContext context, CancellationToken cancellationToken) + { + var gi = context.GateInstance; + return Task.FromResult(gi.ClearedAt.HasValue + ? SequenceGateVerdictResult.Go($"Cleared {gi.ClearedAt:u}") + : SequenceGateVerdictResult.NoGo("Awaiting clearance")); + } +} diff --git a/forge.api/Features/Sequences/GateSources/ResourceClockGateSource.cs b/forge.api/Features/Sequences/GateSources/ResourceClockGateSource.cs new file mode 100644 index 00000000..43a9b70e --- /dev/null +++ b/forge.api/Features/Sequences/GateSources/ResourceClockGateSource.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Sequences; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences.GateSources; + +/// +/// Go while the referenced resource has no expired clock. Config: { "resourceType", "resourceId" } or +/// { "fromSubject": true } to use the instance's subject. A resource with no clock at all is Go (nothing to +/// expire); an expired clock whose action is Flag does not block. +/// +public class ResourceClockGateSource(AppDbContext db) : IGateSource +{ + public SequenceGateSourceType SourceType => SequenceGateSourceType.ResourceClock; + + public string? CustomKey => null; + + public async Task EvaluateAsync(SequenceGateContext context, CancellationToken cancellationToken) + { + var cfg = SequenceGateConfig.Parse(context.Gate.ConfigJson); + var type = cfg.GetBool("fromSubject") ? context.Instance.SubjectEntityType : cfg.GetString("resourceType"); + var id = cfg.GetBool("fromSubject") ? context.Instance.SubjectEntityId : cfg.GetInt("resourceId"); + if (string.IsNullOrEmpty(type) || id is null) + return SequenceGateVerdictResult.NoGo("Gate config names no resource"); + + var clocks = await db.SequenceResourceClocks + .Where(c => c.ResourceType == type && c.ResourceId == id && c.DeletedAt == null) + .ToListAsync(cancellationToken); + var expired = clocks.Where(c => c.ExpiresAt <= context.Now && c.ExpiryAction != SequenceExpiryAction.Flag).ToList(); + if (expired.Count > 0) + return SequenceGateVerdictResult.NoGo($"{type} {id} expired {expired.Min(c => c.ExpiresAt):u}"); + var next = clocks.Where(c => c.ExpiresAt > context.Now).OrderBy(c => c.ExpiresAt).FirstOrDefault(); + return SequenceGateVerdictResult.Go(next is null ? null : $"Expires {next.ExpiresAt:u}"); + } +} diff --git a/forge.api/Features/Sequences/GateSources/SequenceGateConfig.cs b/forge.api/Features/Sequences/GateSources/SequenceGateConfig.cs new file mode 100644 index 00000000..a5cf45f2 --- /dev/null +++ b/forge.api/Features/Sequences/GateSources/SequenceGateConfig.cs @@ -0,0 +1,30 @@ +using System.Text.Json; + +namespace Forge.Api.Features.Sequences.GateSources; + +/// Tiny reader over a gate's config_json so sources don't each re-implement JsonDocument plumbing. +public sealed class SequenceGateConfig +{ + private readonly JsonElement _root; + + private SequenceGateConfig(JsonElement root) => _root = root; + + public static SequenceGateConfig Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new SequenceGateConfig(default); + using var doc = JsonDocument.Parse(json); + return new SequenceGateConfig(doc.RootElement.Clone()); + } + + public string? GetString(string name) => + _root.ValueKind == JsonValueKind.Object && _root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + + public int? GetInt(string name) => + _root.ValueKind == JsonValueKind.Object && _root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; + + public bool GetBool(string name) => + _root.ValueKind == JsonValueKind.Object && _root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.True; + + public DateTimeOffset? GetDate(string name) => + DateTimeOffset.TryParse(GetString(name), null, System.Globalization.DateTimeStyles.AssumeUniversal, out var d) ? d : null; +} diff --git a/forge.api/Features/Sequences/GateSources/TimeWindowGateSource.cs b/forge.api/Features/Sequences/GateSources/TimeWindowGateSource.cs new file mode 100644 index 00000000..387abf6d --- /dev/null +++ b/forge.api/Features/Sequences/GateSources/TimeWindowGateSource.cs @@ -0,0 +1,24 @@ +using Forge.Core.Enums; +using Forge.Core.Sequences; + +namespace Forge.Api.Features.Sequences.GateSources; + +/// Go while now is inside [notBefore, notAfter] (either bound optional). Re-evaluated by the clock job as boundaries pass. +public class TimeWindowGateSource : IGateSource +{ + public SequenceGateSourceType SourceType => SequenceGateSourceType.TimeWindow; + + public string? CustomKey => null; + + public Task EvaluateAsync(SequenceGateContext context, CancellationToken cancellationToken) + { + var cfg = SequenceGateConfig.Parse(context.Gate.ConfigJson); + var notBefore = cfg.GetDate("notBefore"); + var notAfter = cfg.GetDate("notAfter"); + if (notBefore.HasValue && context.Now < notBefore.Value) + return Task.FromResult(SequenceGateVerdictResult.NoGo($"Window opens {notBefore.Value:u}")); + if (notAfter.HasValue && context.Now > notAfter.Value) + return Task.FromResult(SequenceGateVerdictResult.NoGo($"Window closed {notAfter.Value:u}")); + return Task.FromResult(SequenceGateVerdictResult.Go()); + } +} diff --git a/forge.api/Features/Sequences/GetSequenceDefinition.cs b/forge.api/Features/Sequences/GetSequenceDefinition.cs new file mode 100644 index 00000000..d6e72a2d --- /dev/null +++ b/forge.api/Features/Sequences/GetSequenceDefinition.cs @@ -0,0 +1,19 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Models; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences; + +public record GetSequenceDefinitionQuery(int Id) : IRequest; + +public class GetSequenceDefinitionHandler(AppDbContext db) : IRequestHandler +{ + public async Task Handle(GetSequenceDefinitionQuery request, CancellationToken cancellationToken) + { + var def = await db.SequenceDefinitions.WithGraph().FirstOrDefaultAsync(d => d.Id == request.Id && d.DeletedAt == null, cancellationToken) + ?? throw new KeyNotFoundException($"Sequence definition {request.Id} not found."); + return SequenceMapping.ToModel(def); + } +} diff --git a/forge.api/Features/Sequences/GetSequenceDefinitions.cs b/forge.api/Features/Sequences/GetSequenceDefinitions.cs new file mode 100644 index 00000000..93930ef4 --- /dev/null +++ b/forge.api/Features/Sequences/GetSequenceDefinitions.cs @@ -0,0 +1,22 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Models; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences; + +public record GetSequenceDefinitionsQuery(string? Code = null, SequenceDefinitionStatus? Status = null) : IRequest>; + +public class GetSequenceDefinitionsHandler(AppDbContext db) : IRequestHandler> +{ + public async Task> Handle(GetSequenceDefinitionsQuery request, CancellationToken cancellationToken) + { + var q = db.SequenceDefinitions.WithGraph().Where(d => d.DeletedAt == null); + if (!string.IsNullOrWhiteSpace(request.Code)) q = q.Where(d => d.Code == request.Code); + if (request.Status.HasValue) q = q.Where(d => d.Status == request.Status); + var list = await q.OrderBy(d => d.Code).ThenByDescending(d => d.Version).ToListAsync(cancellationToken); + return list.Select(SequenceMapping.ToModel).ToList(); + } +} diff --git a/forge.api/Features/Sequences/GetSequenceEvents.cs b/forge.api/Features/Sequences/GetSequenceEvents.cs new file mode 100644 index 00000000..87cf2790 --- /dev/null +++ b/forge.api/Features/Sequences/GetSequenceEvents.cs @@ -0,0 +1,19 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Models; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences; + +public record GetSequenceEventsQuery(int InstanceId) : IRequest>; + +public class GetSequenceEventsHandler(AppDbContext db) : IRequestHandler> +{ + public async Task> Handle(GetSequenceEventsQuery request, CancellationToken cancellationToken) + { + var events = await db.SequenceEvents.Where(e => e.InstanceId == request.InstanceId) + .OrderBy(e => e.OccurredAt).ThenBy(e => e.Id).ToListAsync(cancellationToken); + return events.Select(SequenceMapping.ToModel).ToList(); + } +} diff --git a/forge.api/Features/Sequences/GetSequenceInstance.cs b/forge.api/Features/Sequences/GetSequenceInstance.cs new file mode 100644 index 00000000..a6505d3c --- /dev/null +++ b/forge.api/Features/Sequences/GetSequenceInstance.cs @@ -0,0 +1,19 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Models; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences; + +public record GetSequenceInstanceQuery(int Id) : IRequest; + +public class GetSequenceInstanceHandler(AppDbContext db) : IRequestHandler +{ + public async Task Handle(GetSequenceInstanceQuery request, CancellationToken cancellationToken) + { + var i = await db.SequenceInstances.WithGraph().FirstOrDefaultAsync(x => x.Id == request.Id && x.DeletedAt == null, cancellationToken) + ?? throw new KeyNotFoundException($"Sequence instance {request.Id} not found."); + return SequenceMapping.ToModel(i); + } +} diff --git a/forge.api/Features/Sequences/GetSequenceInstances.cs b/forge.api/Features/Sequences/GetSequenceInstances.cs new file mode 100644 index 00000000..bb50b627 --- /dev/null +++ b/forge.api/Features/Sequences/GetSequenceInstances.cs @@ -0,0 +1,25 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Models; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences; + +public record GetSequenceInstancesQuery(string? SubjectEntityType, int? SubjectEntityId, SequenceInstanceStatus? Status, int? DefinitionId) + : IRequest>; + +public class GetSequenceInstancesHandler(AppDbContext db) : IRequestHandler> +{ + public async Task> Handle(GetSequenceInstancesQuery request, CancellationToken cancellationToken) + { + var q = db.SequenceInstances.WithGraph().Where(i => i.DeletedAt == null); + if (!string.IsNullOrWhiteSpace(request.SubjectEntityType)) q = q.Where(i => i.SubjectEntityType == request.SubjectEntityType); + if (request.SubjectEntityId.HasValue) q = q.Where(i => i.SubjectEntityId == request.SubjectEntityId); + if (request.Status.HasValue) q = q.Where(i => i.Status == request.Status); + if (request.DefinitionId.HasValue) q = q.Where(i => i.DefinitionId == request.DefinitionId); + var list = await q.OrderByDescending(i => i.StartedAt).Take(500).ToListAsync(cancellationToken); + return list.Select(SequenceMapping.ToModel).ToList(); + } +} diff --git a/forge.api/Features/Sequences/GetSequenceResourceClocks.cs b/forge.api/Features/Sequences/GetSequenceResourceClocks.cs new file mode 100644 index 00000000..8190f772 --- /dev/null +++ b/forge.api/Features/Sequences/GetSequenceResourceClocks.cs @@ -0,0 +1,24 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences; + +public record GetSequenceResourceClocksQuery(string? ResourceType, int? ResourceId, bool IncludeFired = false) : IRequest>; + +public class GetSequenceResourceClocksHandler(AppDbContext db, IClock clock) : IRequestHandler> +{ + public async Task> Handle(GetSequenceResourceClocksQuery request, CancellationToken cancellationToken) + { + var q = db.SequenceResourceClocks.Where(c => c.DeletedAt == null); + if (!string.IsNullOrWhiteSpace(request.ResourceType)) q = q.Where(c => c.ResourceType == request.ResourceType); + if (request.ResourceId.HasValue) q = q.Where(c => c.ResourceId == request.ResourceId); + if (!request.IncludeFired) q = q.Where(c => c.FiredAt == null); + var now = clock.UtcNow; + var list = await q.OrderBy(c => c.ExpiresAt).Take(500).ToListAsync(cancellationToken); + return list.Select(c => SequenceMapping.ToModel(c, now)).ToList(); + } +} diff --git a/forge.api/Features/Sequences/NewSequenceDefinitionVersion.cs b/forge.api/Features/Sequences/NewSequenceDefinitionVersion.cs new file mode 100644 index 00000000..8e6c53f8 --- /dev/null +++ b/forge.api/Features/Sequences/NewSequenceDefinitionVersion.cs @@ -0,0 +1,47 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Core.Models; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Copy a definition into a new Draft version (Version = max+1) so it can be edited and published without touching in-flight runs. +public record NewSequenceDefinitionVersionCommand(int Id) : IRequest; + +public class NewSequenceDefinitionVersionHandler(AppDbContext db) : IRequestHandler +{ + public async Task Handle(NewSequenceDefinitionVersionCommand request, CancellationToken cancellationToken) + { + var src = await db.SequenceDefinitions.WithGraph().FirstOrDefaultAsync(d => d.Id == request.Id && d.DeletedAt == null, cancellationToken) + ?? throw new KeyNotFoundException($"Sequence definition {request.Id} not found."); + var latest = await db.SequenceDefinitions.Where(d => d.Code == src.Code && d.DeletedAt == null).MaxAsync(d => d.Version, cancellationToken); + if (await db.SequenceDefinitions.AnyAsync(d => d.Code == src.Code && d.Status == SequenceDefinitionStatus.Draft && d.DeletedAt == null, cancellationToken)) + throw new InvalidOperationException($"{src.Code} already has a draft version; edit or publish it first."); + + var copy = new SequenceDefinition + { + Code = src.Code, Version = latest + 1, Name = src.Name, Description = src.Description, + SubjectEntityType = src.SubjectEntityType, Status = SequenceDefinitionStatus.Draft, + }; + foreach (var s in src.Steps) copy.Steps.Add(new SequenceStepDefinition + { + Key = s.Key, Name = s.Name, Description = s.Description, SortOrder = s.SortOrder, JoinPolicy = s.JoinPolicy, + MaxDwellMinutes = s.MaxDwellMinutes, DwellExpiryAction = s.DwellExpiryAction, EscalateRole = s.EscalateRole, + }); + foreach (var e in src.Edges) copy.Edges.Add(new SequenceEdgeDefinition { FromStepKey = e.FromStepKey, ToStepKey = e.ToStepKey, IsRework = e.IsRework }); + foreach (var g in src.Gates) copy.Gates.Add(new SequenceGateDefinition + { + StepKey = g.StepKey, Key = g.Key, Name = g.Name, SourceType = g.SourceType, ConfigJson = g.ConfigJson, + ExpiryAction = g.ExpiryAction, EscalateRole = g.EscalateRole, + }); + + db.SequenceDefinitions.Add(copy); + db.LogActivityAt("sequence-definition-versioned", $"Sequence definition {copy.Code} v{copy.Version} drafted from v{src.Version}", ("SequenceDefinition", src.Id)); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(copy); + } +} diff --git a/forge.api/Features/Sequences/OverrideSequenceGate.cs b/forge.api/Features/Sequences/OverrideSequenceGate.cs new file mode 100644 index 00000000..4d8e984e --- /dev/null +++ b/forge.api/Features/Sequences/OverrideSequenceGate.cs @@ -0,0 +1,38 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Force a gate to Go with a mandatory reason. Sticky until the step is reset by rework. Fully audited. +public record OverrideSequenceGateCommand(int InstanceId, string StepKey, string GateKey, string Reason, int UserId) : IRequest; + +public class OverrideSequenceGateHandler(AppDbContext db, ISequenceEvaluationService evaluation, IClock clock) + : IRequestHandler +{ + public async Task Handle(OverrideSequenceGateCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Reason)) throw new InvalidOperationException("An override reason is required."); + var i = await SequenceStepCommands.LoadRunning(db, request.InstanceId, cancellationToken); + var gate = SequenceStepCommands.Gate(i, request.StepKey, request.GateKey); + var def = i.Definition!.Gates.First(g => g.StepKey == gate.StepKey && g.Key == gate.GateKey); + + var now = clock.UtcNow; + gate.OverriddenAt = now; + gate.OverriddenByUserId = request.UserId; + gate.OverrideReason = request.Reason.Trim(); + db.SequenceEvents.Add(SequenceEvaluator.Event(i, SequenceEventType.GateOverridden, now, request.UserId, gate.StepKey, gate.GateKey, + $"{{\"reason\":\"{gate.OverrideReason.Replace("\"", "\\\"")}\"}}")); + db.LogActivityAt("sequence-gate-overridden", $"Gate '{def.Name}' overridden: {gate.OverrideReason}", SequenceQueries.IndexingPoints(i)); + await db.SaveChangesAsync(cancellationToken); + await evaluation.EvaluateAsync(i.Id, request.UserId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(await db.SequenceInstances.WithGraph().FirstAsync(x => x.Id == i.Id, cancellationToken)); + } +} diff --git a/forge.api/Features/Sequences/PublishSequenceDefinition.cs b/forge.api/Features/Sequences/PublishSequenceDefinition.cs new file mode 100644 index 00000000..547a4e39 --- /dev/null +++ b/forge.api/Features/Sequences/PublishSequenceDefinition.cs @@ -0,0 +1,40 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Draft → Published (immutable, startable). Any older Published version of the same code is Retired. +public record PublishSequenceDefinitionCommand(int Id, int UserId) : IRequest; + +public class PublishSequenceDefinitionHandler(AppDbContext db, IClock clock) : IRequestHandler +{ + public async Task Handle(PublishSequenceDefinitionCommand request, CancellationToken cancellationToken) + { + var def = await db.SequenceDefinitions.WithGraph().FirstOrDefaultAsync(d => d.Id == request.Id && d.DeletedAt == null, cancellationToken) + ?? throw new KeyNotFoundException($"Sequence definition {request.Id} not found."); + if (def.Status != SequenceDefinitionStatus.Draft) + throw new InvalidOperationException($"Definition is {def.Status}; only drafts can be published."); + + var errors = SequenceNetValidator.Validate(def); + if (errors.Count > 0) throw new InvalidOperationException("Cannot publish an invalid definition: " + string.Join(" ", errors)); + + var older = await db.SequenceDefinitions + .Where(d => d.Code == def.Code && d.Id != def.Id && d.Status == SequenceDefinitionStatus.Published && d.DeletedAt == null) + .ToListAsync(cancellationToken); + foreach (var o in older) o.Status = SequenceDefinitionStatus.Retired; + + def.Status = SequenceDefinitionStatus.Published; + def.PublishedAt = clock.UtcNow; + def.PublishedByUserId = request.UserId; + db.LogActivityAt("sequence-definition-published", $"Sequence definition {def.Code} v{def.Version} published", ("SequenceDefinition", def.Id)); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(def); + } +} diff --git a/forge.api/Features/Sequences/ReevaluateSequence.cs b/forge.api/Features/Sequences/ReevaluateSequence.cs new file mode 100644 index 00000000..5feb9f87 --- /dev/null +++ b/forge.api/Features/Sequences/ReevaluateSequence.cs @@ -0,0 +1,22 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences; + +/// Explicit re-evaluation (idempotent). Also the command every reaction dispatches: gate cleared, approval decided, clock fired. +public record ReevaluateSequenceCommand(int InstanceId, int? UserId) : IRequest; + +public class ReevaluateSequenceHandler(AppDbContext db, ISequenceEvaluationService evaluation) : IRequestHandler +{ + public async Task Handle(ReevaluateSequenceCommand request, CancellationToken cancellationToken) + { + await evaluation.EvaluateAsync(request.InstanceId, request.UserId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + var i = await db.SequenceInstances.WithGraph().FirstAsync(x => x.Id == request.InstanceId, cancellationToken); + return SequenceMapping.ToModel(i); + } +} diff --git a/forge.api/Features/Sequences/RetireSequenceDefinition.cs b/forge.api/Features/Sequences/RetireSequenceDefinition.cs new file mode 100644 index 00000000..3ff26334 --- /dev/null +++ b/forge.api/Features/Sequences/RetireSequenceDefinition.cs @@ -0,0 +1,25 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Retire a definition (no new runs; in-flight runs continue). Drafts are soft-deleted outright. +public record RetireSequenceDefinitionCommand(int Id) : IRequest; + +public class RetireSequenceDefinitionHandler(AppDbContext db, IClock clock) : IRequestHandler +{ + public async Task Handle(RetireSequenceDefinitionCommand request, CancellationToken cancellationToken) + { + var def = await db.SequenceDefinitions.FirstOrDefaultAsync(d => d.Id == request.Id && d.DeletedAt == null, cancellationToken) + ?? throw new KeyNotFoundException($"Sequence definition {request.Id} not found."); + if (def.Status == SequenceDefinitionStatus.Draft) def.DeletedAt = clock.UtcNow; + else def.Status = SequenceDefinitionStatus.Retired; + db.LogActivityAt("sequence-definition-retired", $"Sequence definition {def.Code} v{def.Version} retired", ("SequenceDefinition", def.Id)); + await db.SaveChangesAsync(cancellationToken); + } +} diff --git a/forge.api/Features/Sequences/ReworkSequence.cs b/forge.api/Features/Sequences/ReworkSequence.cs new file mode 100644 index 00000000..b441767f --- /dev/null +++ b/forge.api/Features/Sequences/ReworkSequence.cs @@ -0,0 +1,54 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// +/// Controlled back-edge: reset the target step and everything downstream of it to Pending (clearing completion, +/// clocks, manual clearances and overrides on those steps), record why, and re-evaluate. Reason required. +/// +public record ReworkSequenceCommand(int InstanceId, string TargetStepKey, string Reason, int UserId) : IRequest; + +public class ReworkSequenceHandler(AppDbContext db, ISequenceEvaluationService evaluation, IClock clock) + : IRequestHandler +{ + public async Task Handle(ReworkSequenceCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Reason)) throw new InvalidOperationException("A rework reason is required."); + var i = await SequenceStepCommands.LoadRunning(db, request.InstanceId, cancellationToken); + var target = SequenceStepCommands.Step(i, request.TargetStepKey); + var net = new SequenceNet(i.Definition!); + var affected = net.Downstream(target.StepKey); + affected.Add(target.StepKey); + + var now = clock.UtcNow; + foreach (var step in i.Steps.Where(s => affected.Contains(s.StepKey))) + { + step.Status = SequenceStepStatus.Pending; + step.ReadyAt = null; step.StartedAt = null; step.StartedByUserId = null; + step.CompletedAt = null; step.CompletedByUserId = null; step.SkipReason = null; + step.DwellExpiresAt = null; step.DwellFiredAt = null; + db.SequenceEvents.Add(SequenceEvaluator.Event(i, SequenceEventType.StepReset, now, request.UserId, step.StepKey)); + } + foreach (var gate in i.Gates.Where(g => affected.Contains(g.StepKey))) + { + gate.Verdict = SequenceGateVerdict.Unknown; gate.Reason = null; gate.LastEvaluatedAt = null; + gate.ClearedAt = null; gate.ClearedByUserId = null; + gate.OverriddenAt = null; gate.OverriddenByUserId = null; gate.OverrideReason = null; + } + db.SequenceEvents.Add(SequenceEvaluator.Event(i, SequenceEventType.Reworked, now, request.UserId, target.StepKey, + payloadJson: $"{{\"reason\":\"{request.Reason.Trim().Replace("\"", "\\\"")}\",\"resetSteps\":[{string.Join(",", affected.OrderBy(k => k).Select(k => $"\"{k}\""))}]}}")); + db.LogActivityAt("sequence-reworked", $"Sequence reworked from '{target.StepKey}': {request.Reason.Trim()}", SequenceQueries.IndexingPoints(i)); + await db.SaveChangesAsync(cancellationToken); + await evaluation.EvaluateAsync(i.Id, request.UserId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(await db.SequenceInstances.WithGraph().FirstAsync(x => x.Id == i.Id, cancellationToken)); + } +} diff --git a/forge.api/Features/Sequences/SequenceDefinitionGraph.cs b/forge.api/Features/Sequences/SequenceDefinitionGraph.cs new file mode 100644 index 00000000..071154e7 --- /dev/null +++ b/forge.api/Features/Sequences/SequenceDefinitionGraph.cs @@ -0,0 +1,37 @@ +using Forge.Core.Entities; +using Forge.Core.Models; + +namespace Forge.Api.Features.Sequences; + +/// Replaces a draft definition's steps/edges/gates from a request model (whole-document semantics). +public static class SequenceDefinitionGraph +{ + public static void Apply(SequenceDefinition def, SequenceDefinitionRequestModel m) + { + def.Name = m.Name.Trim(); + def.Description = string.IsNullOrWhiteSpace(m.Description) ? null : m.Description.Trim(); + def.SubjectEntityType = string.IsNullOrWhiteSpace(m.SubjectEntityType) ? null : m.SubjectEntityType.Trim(); + + def.Steps.Clear(); + foreach (var s in m.Steps ?? []) + def.Steps.Add(new SequenceStepDefinition + { + Key = s.Key.Trim(), Name = s.Name.Trim(), Description = s.Description, SortOrder = s.SortOrder, + JoinPolicy = s.JoinPolicy, MaxDwellMinutes = s.MaxDwellMinutes, DwellExpiryAction = s.DwellExpiryAction, + EscalateRole = s.EscalateRole, + }); + + def.Edges.Clear(); + foreach (var e in m.Edges ?? []) + def.Edges.Add(new SequenceEdgeDefinition { FromStepKey = e.FromStepKey.Trim(), ToStepKey = e.ToStepKey.Trim(), IsRework = e.IsRework }); + + def.Gates.Clear(); + foreach (var g in m.Gates ?? []) + def.Gates.Add(new SequenceGateDefinition + { + StepKey = g.StepKey.Trim(), Key = g.Key.Trim(), Name = g.Name.Trim(), SourceType = g.SourceType, + ConfigJson = string.IsNullOrWhiteSpace(g.ConfigJson) ? "{}" : g.ConfigJson, ExpiryAction = g.ExpiryAction, + EscalateRole = g.EscalateRole, + }); + } +} diff --git a/forge.api/Features/Sequences/SequenceMapping.cs b/forge.api/Features/Sequences/SequenceMapping.cs new file mode 100644 index 00000000..10597962 --- /dev/null +++ b/forge.api/Features/Sequences/SequenceMapping.cs @@ -0,0 +1,59 @@ +using Forge.Core.Entities; +using Forge.Core.Models; +using Forge.Core.Sequences; + +namespace Forge.Api.Features.Sequences; + +/// Entity → response-model mapping for the Sequences feature (Blocked is derived here, never read from a column). +public static class SequenceMapping +{ + public static SequenceDefinitionResponseModel ToModel(SequenceDefinition d) => new( + d.Id, d.Code, d.Version, d.Name, d.Description, d.SubjectEntityType, d.Status, d.PublishedAt, + d.Steps.OrderBy(s => s.SortOrder).ThenBy(s => s.Key).Select(s => new SequenceStepDefinitionModel( + s.Key, s.Name, s.Description, s.SortOrder, s.JoinPolicy, s.MaxDwellMinutes, s.DwellExpiryAction, s.EscalateRole)).ToList(), + d.Edges.OrderBy(e => e.FromStepKey).ThenBy(e => e.ToStepKey).Select(e => new SequenceEdgeDefinitionModel(e.FromStepKey, e.ToStepKey, e.IsRework)).ToList(), + d.Gates.OrderBy(g => g.StepKey).ThenBy(g => g.Key).Select(g => new SequenceGateDefinitionModel( + g.StepKey, g.Key, g.Name, g.SourceType, g.ConfigJson, g.ExpiryAction, g.EscalateRole)).ToList(), + d.CreatedAt, d.UpdatedAt); + + public static SequenceInstanceResponseModel ToModel(SequenceInstance i) + { + var def = i.Definition ?? throw new InvalidOperationException("Instance loaded without its definition."); + var net = new SequenceNet(def); + var stepDefs = def.Steps.ToDictionary(s => s.Key, StringComparer.Ordinal); + var gateDefs = def.Gates.ToDictionary(g => (g.StepKey, g.Key)); + var gates = i.Gates.ToDictionary(g => (g.StepKey, g.GateKey)); + + var steps = i.Steps + .OrderBy(s => stepDefs.TryGetValue(s.StepKey, out var d) ? d.SortOrder : int.MaxValue).ThenBy(s => s.StepKey) + .Select(s => + { + stepDefs.TryGetValue(s.StepKey, out var d); + var blocked = SequenceEvaluator.IsBlocked(net, i, s.StepKey); + return new SequenceStepInstanceResponseModel( + s.StepKey, d?.Name ?? s.StepKey, d?.SortOrder ?? 0, s.Status, blocked, + blocked && d is not null ? SequenceEvaluator.BlockedReason(net, d, gates) : null, + net.PredecessorsOf(s.StepKey).ToList(), + s.ReadyAt, s.StartedAt, s.StartedByUserId, s.CompletedAt, s.CompletedByUserId, s.SkipReason, + s.DwellExpiresAt, s.DwellFiredAt); + }).ToList(); + + var gateModels = i.Gates.OrderBy(g => g.StepKey).ThenBy(g => g.GateKey).Select(g => + { + gateDefs.TryGetValue((g.StepKey, g.GateKey), out var gd); + return new SequenceGateInstanceResponseModel(g.StepKey, g.GateKey, gd?.Name ?? g.GateKey, + gd?.SourceType ?? default, g.Verdict, g.Reason, g.LastEvaluatedAt, g.ClearedAt, g.ClearedByUserId, + g.OverriddenAt, g.OverriddenByUserId, g.OverrideReason); + }).ToList(); + + return new SequenceInstanceResponseModel(i.Id, i.DefinitionId, def.Code, def.Version, def.Name, + i.SubjectEntityType, i.SubjectEntityId, i.Status, i.StartedAt, i.StartedByUserId, i.CompletedAt, + i.CancelledAt, i.CancelReason, i.Version, steps, gateModels); + } + + public static SequenceEventResponseModel ToModel(SequenceEvent e) => + new(e.Id, e.Type, e.StepKey, e.GateKey, e.PayloadJson, e.OccurredAt, e.ActorUserId); + + public static SequenceResourceClockResponseModel ToModel(SequenceResourceClock c, DateTimeOffset now) => + new(c.Id, c.ResourceType, c.ResourceId, c.ExpiresAt, c.ExpiryAction, c.EscalateRole, c.Note, c.FiredAt, c.ExpiresAt <= now); +} diff --git a/forge.api/Features/Sequences/SequenceQueries.cs b/forge.api/Features/Sequences/SequenceQueries.cs new file mode 100644 index 00000000..a09d85a5 --- /dev/null +++ b/forge.api/Features/Sequences/SequenceQueries.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Entities; + +namespace Forge.Api.Features.Sequences; + +/// Shared include graphs so every handler loads the same shape. +public static class SequenceQueries +{ + public static IQueryable WithGraph(this IQueryable q) => + q.Include(d => d.Steps).Include(d => d.Edges).Include(d => d.Gates); + + public static IQueryable WithGraph(this IQueryable q) => + q.Include(i => i.Definition!).ThenInclude(d => d.Steps) + .Include(i => i.Definition!).ThenInclude(d => d.Edges) + .Include(i => i.Definition!).ThenInclude(d => d.Gates) + .Include(i => i.Steps) + .Include(i => i.Gates); + + /// Activity-log indexing points for a run: the instance itself and, when present, its subject. + public static (string, int)[] IndexingPoints(SequenceInstance i) => + i.SubjectEntityType is not null && i.SubjectEntityId.HasValue + ? [("SequenceInstance", i.Id), (i.SubjectEntityType, i.SubjectEntityId.Value)] + : [("SequenceInstance", i.Id)]; +} diff --git a/forge.api/Features/Sequences/SequenceStepCommands.cs b/forge.api/Features/Sequences/SequenceStepCommands.cs new file mode 100644 index 00000000..fd7a2e7f --- /dev/null +++ b/forge.api/Features/Sequences/SequenceStepCommands.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Data.Context; + +namespace Forge.Api.Features.Sequences; + +/// Guards shared by the step/gate commands. +public static class SequenceStepCommands +{ + public static async Task LoadRunning(AppDbContext db, int instanceId, CancellationToken ct) + { + var i = await db.SequenceInstances.WithGraph().FirstOrDefaultAsync(x => x.Id == instanceId && x.DeletedAt == null, ct) + ?? throw new KeyNotFoundException($"Sequence instance {instanceId} not found."); + if (i.Status != SequenceInstanceStatus.Running) + throw new InvalidOperationException($"Sequence instance {instanceId} is {i.Status}."); + return i; + } + + public static SequenceStepInstance Step(SequenceInstance i, string stepKey) => + i.Steps.FirstOrDefault(s => s.StepKey == stepKey) + ?? throw new KeyNotFoundException($"Step '{stepKey}' is not part of this sequence."); + + public static SequenceGateInstance Gate(SequenceInstance i, string stepKey, string gateKey) => + i.Gates.FirstOrDefault(g => g.StepKey == stepKey && g.GateKey == gateKey) + ?? throw new KeyNotFoundException($"Gate '{gateKey}' on step '{stepKey}' is not part of this sequence."); +} diff --git a/forge.api/Features/Sequences/SkipSequenceStep.cs b/forge.api/Features/Sequences/SkipSequenceStep.cs new file mode 100644 index 00000000..b4e40be2 --- /dev/null +++ b/forge.api/Features/Sequences/SkipSequenceStep.cs @@ -0,0 +1,41 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Skip a not-yet-complete step with a required reason; it counts as complete for successors. +public record SkipSequenceStepCommand(int InstanceId, string StepKey, string Reason, int UserId) : IRequest; + +public class SkipSequenceStepHandler(AppDbContext db, ISequenceEvaluationService evaluation, IClock clock) + : IRequestHandler +{ + public async Task Handle(SkipSequenceStepCommand request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Reason)) throw new InvalidOperationException("A skip reason is required."); + var i = await SequenceStepCommands.LoadRunning(db, request.InstanceId, cancellationToken); + var step = SequenceStepCommands.Step(i, request.StepKey); + if (step.Status is SequenceStepStatus.Complete or SequenceStepStatus.Skipped) + throw new InvalidOperationException($"Step '{request.StepKey}' is already {step.Status}."); + + var now = clock.UtcNow; + var def = i.Definition!.Steps.First(s => s.Key == step.StepKey); + step.Status = SequenceStepStatus.Skipped; + step.SkipReason = request.Reason.Trim(); + step.CompletedAt = now; + step.CompletedByUserId = request.UserId; + db.SequenceEvents.Add(SequenceEvaluator.Event(i, SequenceEventType.StepSkipped, now, request.UserId, step.StepKey, + payloadJson: $"{{\"reason\":\"{step.SkipReason.Replace("\"", "\\\"")}\"}}")); + db.LogActivityAt("sequence-step-skipped", $"Sequence step '{def.Name}' skipped: {step.SkipReason}", SequenceQueries.IndexingPoints(i)); + await db.SaveChangesAsync(cancellationToken); + await evaluation.EvaluateAsync(i.Id, request.UserId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(await db.SequenceInstances.WithGraph().FirstAsync(x => x.Id == i.Id, cancellationToken)); + } +} diff --git a/forge.api/Features/Sequences/StartSequenceInstance.cs b/forge.api/Features/Sequences/StartSequenceInstance.cs new file mode 100644 index 00000000..80c8d730 --- /dev/null +++ b/forge.api/Features/Sequences/StartSequenceInstance.cs @@ -0,0 +1,65 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// +/// Start a run of a Published definition (by id, or latest published by code) against an optional subject. Creates one +/// step instance and one gate instance per definition row, then evaluates once so start steps become Ready/Blocked. +/// +public record StartSequenceInstanceCommand(StartSequenceRequestModel Model, int UserId) : IRequest; + +public class StartSequenceInstanceHandler(AppDbContext db, ISequenceEvaluationService evaluation, IClock clock) + : IRequestHandler +{ + public async Task Handle(StartSequenceInstanceCommand request, CancellationToken cancellationToken) + { + var m = request.Model; + SequenceDefinition? def = null; + if (m.DefinitionId.HasValue) + def = await db.SequenceDefinitions.WithGraph().FirstOrDefaultAsync(d => d.Id == m.DefinitionId && d.DeletedAt == null, cancellationToken); + else if (!string.IsNullOrWhiteSpace(m.Code)) + def = await db.SequenceDefinitions.WithGraph() + .Where(d => d.Code == m.Code && d.Status == SequenceDefinitionStatus.Published && d.DeletedAt == null) + .OrderByDescending(d => d.Version).FirstOrDefaultAsync(cancellationToken); + if (def is null) throw new KeyNotFoundException("No matching sequence definition."); + if (def.Status != SequenceDefinitionStatus.Published) + throw new InvalidOperationException($"Definition {def.Code} v{def.Version} is {def.Status}; only published definitions can start."); + if (def.SubjectEntityType is not null && !string.IsNullOrEmpty(m.SubjectEntityType) && def.SubjectEntityType != m.SubjectEntityType) + throw new InvalidOperationException($"Definition {def.Code} runs against {def.SubjectEntityType}, not {m.SubjectEntityType}."); + + var now = clock.UtcNow; + var instance = new SequenceInstance + { + DefinitionId = def.Id, + Definition = def, + SubjectEntityType = string.IsNullOrWhiteSpace(m.SubjectEntityType) ? null : m.SubjectEntityType, + SubjectEntityId = m.SubjectEntityId, + Status = SequenceInstanceStatus.Running, + StartedAt = now, + StartedByUserId = request.UserId, + }; + foreach (var s in def.Steps) instance.Steps.Add(new SequenceStepInstance { StepKey = s.Key, Status = SequenceStepStatus.Pending }); + foreach (var g in def.Gates) instance.Gates.Add(new SequenceGateInstance { StepKey = g.StepKey, GateKey = g.Key, Verdict = SequenceGateVerdict.Unknown }); + instance.Events.Add(SequenceEvaluator.Event(instance, SequenceEventType.InstanceStarted, now, request.UserId, + payloadJson: $"{{\"definitionId\":{def.Id},\"code\":\"{def.Code}\",\"version\":{def.Version}}}")); + + db.SequenceInstances.Add(instance); + await db.SaveChangesAsync(cancellationToken); // id needed for events + activity + + await evaluation.EvaluateAsync(instance.Id, request.UserId, cancellationToken); + db.LogActivityAt("sequence-started", $"Sequence {def.Code} v{def.Version} started", SequenceQueries.IndexingPoints(instance)); + await db.SaveChangesAsync(cancellationToken); + + var fresh = await db.SequenceInstances.WithGraph().FirstAsync(i => i.Id == instance.Id, cancellationToken); + return SequenceMapping.ToModel(fresh); + } +} diff --git a/forge.api/Features/Sequences/StartSequenceStep.cs b/forge.api/Features/Sequences/StartSequenceStep.cs new file mode 100644 index 00000000..098154f7 --- /dev/null +++ b/forge.api/Features/Sequences/StartSequenceStep.cs @@ -0,0 +1,40 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Ready → InProgress. Starts the step's dwell clock when the definition sets MaxDwellMinutes. +public record StartSequenceStepCommand(int InstanceId, string StepKey, int UserId) : IRequest; + +public class StartSequenceStepHandler(AppDbContext db, ISequenceEvaluationService evaluation, IClock clock) + : IRequestHandler +{ + public async Task Handle(StartSequenceStepCommand request, CancellationToken cancellationToken) + { + var i = await SequenceStepCommands.LoadRunning(db, request.InstanceId, cancellationToken); + var step = SequenceStepCommands.Step(i, request.StepKey); + if (step.Status != SequenceStepStatus.Ready) + throw new InvalidOperationException($"Step '{request.StepKey}' is {step.Status}; only Ready steps can start."); + + var now = clock.UtcNow; + var def = i.Definition!.Steps.First(s => s.Key == step.StepKey); + step.Status = SequenceStepStatus.InProgress; + step.StartedAt = now; + step.StartedByUserId = request.UserId; + step.DwellExpiresAt = def.MaxDwellMinutes.HasValue ? now.AddMinutes(def.MaxDwellMinutes.Value) : null; + step.DwellFiredAt = null; + db.SequenceEvents.Add(SequenceEvaluator.Event(i, SequenceEventType.StepStarted, now, request.UserId, step.StepKey)); + db.LogActivityAt("sequence-step-started", $"Sequence step '{def.Name}' started", SequenceQueries.IndexingPoints(i)); + await db.SaveChangesAsync(cancellationToken); + await evaluation.EvaluateAsync(i.Id, request.UserId, cancellationToken); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(await db.SequenceInstances.WithGraph().FirstAsync(x => x.Id == i.Id, cancellationToken)); + } +} diff --git a/forge.api/Features/Sequences/UpdateSequenceDefinition.cs b/forge.api/Features/Sequences/UpdateSequenceDefinition.cs new file mode 100644 index 00000000..81a7703f --- /dev/null +++ b/forge.api/Features/Sequences/UpdateSequenceDefinition.cs @@ -0,0 +1,38 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Core.Enums; +using Forge.Core.Models; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Features.Sequences; + +/// Replace a DRAFT definition's graph. Published/Retired definitions are immutable — use new-version. +public record UpdateSequenceDefinitionCommand(int Id, SequenceDefinitionRequestModel Model) : IRequest; + +public class UpdateSequenceDefinitionHandler(AppDbContext db) : IRequestHandler +{ + public async Task Handle(UpdateSequenceDefinitionCommand request, CancellationToken cancellationToken) + { + var def = await db.SequenceDefinitions.WithGraph().FirstOrDefaultAsync(d => d.Id == request.Id && d.DeletedAt == null, cancellationToken) + ?? throw new KeyNotFoundException($"Sequence definition {request.Id} not found."); + if (def.Status != SequenceDefinitionStatus.Draft) + throw new InvalidOperationException("Only draft definitions can be edited; create a new version instead."); + if (!string.Equals(def.Code, request.Model.Code?.Trim(), StringComparison.Ordinal)) + throw new InvalidOperationException("A definition's code cannot change."); + + db.SequenceStepDefinitions.RemoveRange(def.Steps); + db.SequenceEdgeDefinitions.RemoveRange(def.Edges); + db.SequenceGateDefinitions.RemoveRange(def.Gates); + SequenceDefinitionGraph.Apply(def, request.Model); + + var errors = SequenceNetValidator.Validate(def); + if (errors.Count > 0) throw new InvalidOperationException("Invalid sequence definition: " + string.Join(" ", errors)); + + db.LogActivityAt("sequence-definition-updated", $"Sequence definition {def.Code} v{def.Version} updated", ("SequenceDefinition", def.Id)); + await db.SaveChangesAsync(cancellationToken); + return SequenceMapping.ToModel(def); + } +} diff --git a/forge.api/Jobs/SequenceClockJob.cs b/forge.api/Jobs/SequenceClockJob.cs new file mode 100644 index 00000000..73ceb6fb --- /dev/null +++ b/forge.api/Jobs/SequenceClockJob.cs @@ -0,0 +1,111 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Api.Features.DomainEvents; +using Forge.Api.Features.Sequences; +using Forge.Api.Features.Sequences.GateSources; +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Data.Extensions; + +namespace Forge.Api.Jobs; + +/// +/// The engine's only timer (Hangfire, every minute). Three duties, each idempotent: +/// (1) fire due resource clocks once (FiredAt) and publish ; +/// (2) fire due step dwell clocks once (DwellFiredAt) likewise; +/// (3) re-evaluate running instances whose ResourceClock / TimeWindow gates may have crossed a boundary since +/// they were last evaluated, so time-based gates flip without anyone clicking. +/// +public class SequenceClockJob(AppDbContext db, ISequenceEvaluationService evaluation, IClock clock, IPublisher publisher, ILogger logger) +{ + public async Task ExecuteAsync(CancellationToken ct) + { + var now = clock.UtcNow; + var touchedResources = new HashSet<(string, int)>(); + + // (1) resource clocks + var dueClocks = await db.SequenceResourceClocks + .Where(c => c.DeletedAt == null && c.FiredAt == null && c.ExpiresAt <= now) + .ToListAsync(ct); + foreach (var c in dueClocks) + { + c.FiredAt = now; + touchedResources.Add((c.ResourceType, c.ResourceId)); + db.LogActivityAt("sequence-clock-expired", $"Clock expired ({c.ExpiryAction}){(c.EscalateRole is null ? "" : $" → {c.EscalateRole}")}", (c.ResourceType, c.ResourceId)); + await publisher.Publish(new SequenceClockExpiredEvent("resource", null, null, c.ResourceType, c.ResourceId, c.ExpiryAction, c.EscalateRole, c.ExpiresAt), ct); + } + + // (2) dwell clocks + var dueSteps = await db.SequenceStepInstances + .Include(s => s.Instance) + .Where(s => s.DwellFiredAt == null && s.DwellExpiresAt != null && s.DwellExpiresAt <= now + && s.Status == SequenceStepStatus.InProgress && s.Instance!.Status == SequenceInstanceStatus.Running) + .ToListAsync(ct); + foreach (var s in dueSteps) + { + var def = await db.SequenceStepDefinitions.FirstOrDefaultAsync(d => d.DefinitionId == s.Instance!.DefinitionId && d.Key == s.StepKey, ct); + var action = def?.DwellExpiryAction ?? SequenceExpiryAction.Flag; + s.DwellFiredAt = now; + db.SequenceEvents.Add(SequenceEvaluator.Event(s.Instance!, SequenceEventType.ClockExpired, now, null, s.StepKey, + payloadJson: $"{{\"kind\":\"dwell\",\"action\":\"{action}\",\"escalateRole\":{(def?.EscalateRole is null ? "null" : $"\"{def.EscalateRole}\"")}}}")); + if (action == SequenceExpiryAction.Escalate) + db.SequenceEvents.Add(SequenceEvaluator.Event(s.Instance!, SequenceEventType.Escalated, now, null, s.StepKey, + payloadJson: $"{{\"role\":{(def?.EscalateRole is null ? "null" : $"\"{def.EscalateRole}\"")}}}")); + db.LogActivityAt("sequence-dwell-expired", $"Step '{s.StepKey}' exceeded its dwell time ({action})", SequenceQueries.IndexingPoints(s.Instance!)); + await publisher.Publish(new SequenceClockExpiredEvent("dwell", s.InstanceId, s.StepKey, null, null, action, def?.EscalateRole, s.DwellExpiresAt!.Value), ct); + } + if (dueClocks.Count > 0 || dueSteps.Count > 0) await db.SaveChangesAsync(ct); + + // (3) time-sensitive gates + var candidates = await db.SequenceInstances + .Where(i => i.Status == SequenceInstanceStatus.Running && i.DeletedAt == null) + .Where(i => i.Definition!.Gates.Any(g => g.SourceType == SequenceGateSourceType.ResourceClock || g.SourceType == SequenceGateSourceType.TimeWindow)) + .Select(i => new { i.Id, i.SubjectEntityType, i.SubjectEntityId, + Gates = i.Definition!.Gates.Where(g => g.SourceType == SequenceGateSourceType.ResourceClock || g.SourceType == SequenceGateSourceType.TimeWindow) + .Select(g => new { g.StepKey, g.Key, g.SourceType, g.ConfigJson }).ToList(), + Evaluated = i.Gates.Select(g => new { g.StepKey, g.GateKey, g.LastEvaluatedAt }).ToList() }) + .ToListAsync(ct); + + var toEvaluate = new List(); + foreach (var c in candidates) + { + var due = false; + foreach (var g in c.Gates) + { + var last = c.Evaluated.FirstOrDefault(e => e.StepKey == g.StepKey && e.GateKey == g.Key)?.LastEvaluatedAt; + var cfg = SequenceGateConfig.Parse(g.ConfigJson); + if (g.SourceType == SequenceGateSourceType.TimeWindow) + { + var nb = cfg.GetDate("notBefore"); var na = cfg.GetDate("notAfter"); + due |= last is null || (nb.HasValue && Crossed(nb.Value, last.Value, now)) || (na.HasValue && Crossed(na.Value, last.Value, now)); + } + else + { + var type = cfg.GetBool("fromSubject") ? c.SubjectEntityType : cfg.GetString("resourceType"); + var id = cfg.GetBool("fromSubject") ? c.SubjectEntityId : cfg.GetInt("resourceId"); + due |= last is null || (type is not null && id.HasValue && touchedResources.Contains((type, id.Value))); + } + if (due) break; + } + if (due) toEvaluate.Add(c.Id); + } + + foreach (var id in toEvaluate) + { + try + { + await evaluation.EvaluateAsync(id, null, ct); + await db.SaveChangesAsync(ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "SequenceClockJob: re-evaluation of instance {InstanceId} failed", id); + } + } + } + + private static bool Crossed(DateTimeOffset boundary, DateTimeOffset last, DateTimeOffset now) => last < boundary && boundary <= now; +} diff --git a/forge.api/Program.cs b/forge.api/Program.cs index f6118a06..46401447 100644 --- a/forge.api/Program.cs +++ b/forge.api/Program.cs @@ -656,6 +656,15 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + + // Gated Sequence Engine (CAP-CROSS-SEQUENCES): storage-aware evaluator + built-in gate sources. + // Modules add their own gates by registering further IGateSource implementations (SourceType Custom + CustomKey). + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // BE-1 / F-030 builder.Services.AddScoped(); // AUDIT-19-S1 — price-list → line pricing @@ -1883,6 +1892,10 @@ "approval-escalations", job => job.ExecuteAsync(CancellationToken.None), Cron.Hourly); // Every hour + RecurringJob.AddOrUpdate( + "sequence-clocks", + job => job.ExecuteAsync(CancellationToken.None), + Cron.Minutely); // the Gated Sequence Engine's only timer: fires clocks, re-evaluates time gates RecurringJob.AddOrUpdate( "check-credit-reviews-due", job => job.ExecuteAsync(CancellationToken.None), diff --git a/forge.api/Services/SequenceEvaluationService.cs b/forge.api/Services/SequenceEvaluationService.cs new file mode 100644 index 00000000..ea5bb47b --- /dev/null +++ b/forge.api/Services/SequenceEvaluationService.cs @@ -0,0 +1,74 @@ +using MediatR; +using Microsoft.EntityFrameworkCore; + +using Forge.Api.Features.DomainEvents; +using Forge.Core.Enums; +using Forge.Core.Interfaces; +using Forge.Core.Sequences; +using Forge.Data.Context; + +namespace Forge.Api.Services; + +/// +/// The storage-aware half of the engine (the pure half is ). Resolves each gate's +/// from DI — built-ins by , Custom by config key — +/// and fails closed (NoGo) when a source is missing, so a misconfigured gate can never silently open a step. +/// +public class SequenceEvaluationService( + AppDbContext db, + IEnumerable gateSources, + IClock clock, + IPublisher publisher) : ISequenceEvaluationService +{ + public async Task EvaluateAsync(int instanceId, int? actorUserId, CancellationToken cancellationToken) + { + var instance = await db.SequenceInstances + .Include(i => i.Definition!).ThenInclude(d => d.Steps) + .Include(i => i.Definition!).ThenInclude(d => d.Edges) + .Include(i => i.Definition!).ThenInclude(d => d.Gates) + .Include(i => i.Steps) + .Include(i => i.Gates) + .FirstOrDefaultAsync(i => i.Id == instanceId, cancellationToken) + ?? throw new KeyNotFoundException($"Sequence instance {instanceId} not found."); + + if (instance.Status != SequenceInstanceStatus.Running) + return new SequenceEvaluation(); + + var now = clock.UtcNow; + var net = new SequenceNet(instance.Definition!); + var verdicts = new Dictionary<(string, string), SequenceGateVerdictResult>(); + foreach (var gate in instance.Gates) + { + var def = instance.Definition!.Gates.FirstOrDefault(g => g.StepKey == gate.StepKey && g.Key == gate.GateKey); + if (def is null) continue; + var source = Resolve(def); + verdicts[(gate.StepKey, gate.GateKey)] = source is null + ? SequenceGateVerdictResult.NoGo($"No gate source registered for {Describe(def)}") + : await source.EvaluateAsync(new SequenceGateContext(instance.Definition!, def, instance, gate, now), cancellationToken); + } + + var evaluation = SequenceEvaluator.Evaluate(net, instance, verdicts, now, actorUserId); + if (evaluation.Events.Count > 0) db.SequenceEvents.AddRange(evaluation.Events); + + foreach (var key in evaluation.NewlyReady) + await publisher.Publish(new SequenceStepReadyEvent(instance.Id, key, instance.SubjectEntityType, instance.SubjectEntityId), cancellationToken); + if (evaluation.CompletedInstance) + await publisher.Publish(new SequenceInstanceCompletedEvent(instance.Id, instance.DefinitionId, instance.SubjectEntityType, instance.SubjectEntityId), cancellationToken); + + return evaluation; + } + + private IGateSource? Resolve(Core.Entities.SequenceGateDefinition def) + { + if (def.SourceType != SequenceGateSourceType.Custom) + return gateSources.FirstOrDefault(s => s.SourceType == def.SourceType && s.CustomKey is null); + var key = Features.Sequences.GateSources.SequenceGateConfig.Parse(def.ConfigJson).GetString("key"); + return string.IsNullOrEmpty(key) ? null + : gateSources.FirstOrDefault(s => s.SourceType == SequenceGateSourceType.Custom && string.Equals(s.CustomKey, key, StringComparison.Ordinal)); + } + + private static string Describe(Core.Entities.SequenceGateDefinition def) => + def.SourceType == SequenceGateSourceType.Custom + ? $"custom key '{Features.Sequences.GateSources.SequenceGateConfig.Parse(def.ConfigJson).GetString("key")}'" + : def.SourceType.ToString(); +} diff --git a/forge.core/Entities/SequenceDefinition.cs b/forge.core/Entities/SequenceDefinition.cs new file mode 100644 index 00000000..f807b689 --- /dev/null +++ b/forge.core/Entities/SequenceDefinition.cs @@ -0,0 +1,35 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Entities; + +/// +/// Gated Sequence Engine — a versioned, immutable-once-published process template: steps, the edges between +/// them, and the gates each step must pass. Natural key is (, ); a new +/// version is a new row. Instances pin the version they started on (publishing v2 never touches v1 runs). +/// +public class SequenceDefinition : BaseAuditableEntity +{ + /// Stable code shared by all versions, e.g. "job-routing-standard". + public string Code { get; set; } = string.Empty; + + public int Version { get; set; } = 1; + + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + /// Optional: the entity type instances of this definition run against (e.g. "Job"). Null = any/none. + public string? SubjectEntityType { get; set; } + + public SequenceDefinitionStatus Status { get; set; } = SequenceDefinitionStatus.Draft; + + public DateTimeOffset? PublishedAt { get; set; } + + public int? PublishedByUserId { get; set; } + + public ICollection Steps { get; set; } = []; + + public ICollection Edges { get; set; } = []; + + public ICollection Gates { get; set; } = []; +} diff --git a/forge.core/Entities/SequenceEdgeDefinition.cs b/forge.core/Entities/SequenceEdgeDefinition.cs new file mode 100644 index 00000000..1288eaeb --- /dev/null +++ b/forge.core/Entities/SequenceEdgeDefinition.cs @@ -0,0 +1,19 @@ +namespace Forge.Core.Entities; + +/// +/// A directed dependency: may not become Ready until is complete +/// (subject to the target's join policy). Rework edges are the only permitted cycles. +/// +public class SequenceEdgeDefinition : BaseEntity +{ + public int DefinitionId { get; set; } + + public SequenceDefinition? Definition { get; set; } + + public string FromStepKey { get; set; } = string.Empty; + + public string ToStepKey { get; set; } = string.Empty; + + /// True for a declared back-edge (rework loop). The net validator allows cycles only through these. + public bool IsRework { get; set; } +} diff --git a/forge.core/Entities/SequenceEvent.cs b/forge.core/Entities/SequenceEvent.cs new file mode 100644 index 00000000..bb859e88 --- /dev/null +++ b/forge.core/Entities/SequenceEvent.cs @@ -0,0 +1,24 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Entities; + +/// Append-only history of a run. Never updated or deleted; the audit log and the idempotency record. +public class SequenceEvent : BaseEntity +{ + public int InstanceId { get; set; } + + public SequenceInstance? Instance { get; set; } + + public SequenceEventType Type { get; set; } + + public string? StepKey { get; set; } + + public string? GateKey { get; set; } + + /// Free-form JSON detail (verdict, reason, target step, escalate role, ...). + public string? PayloadJson { get; set; } + + public DateTimeOffset OccurredAt { get; set; } + + public int? ActorUserId { get; set; } +} diff --git a/forge.core/Entities/SequenceGateDefinition.cs b/forge.core/Entities/SequenceGateDefinition.cs new file mode 100644 index 00000000..952ed07f --- /dev/null +++ b/forge.core/Entities/SequenceGateDefinition.cs @@ -0,0 +1,31 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Entities; + +/// +/// A named go/no-go condition attached to a step. Gates are their own objects (not step properties) so the same +/// source type can be attached to any step in any definition, and a step can carry several. +/// +public class SequenceGateDefinition : BaseEntity +{ + public int DefinitionId { get; set; } + + public SequenceDefinition? Definition { get; set; } + + public string StepKey { get; set; } = string.Empty; + + /// Stable key within the step, e.g. "materials", "first-article". + public string Key { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public SequenceGateSourceType SourceType { get; set; } + + /// Source-specific configuration; shape documented per . + public string ConfigJson { get; set; } = "{}"; + + /// What happens when a clock this gate depends on expires. + public SequenceExpiryAction ExpiryAction { get; set; } = SequenceExpiryAction.Block; + + public string? EscalateRole { get; set; } +} diff --git a/forge.core/Entities/SequenceGateInstance.cs b/forge.core/Entities/SequenceGateInstance.cs new file mode 100644 index 00000000..a060749c --- /dev/null +++ b/forge.core/Entities/SequenceGateInstance.cs @@ -0,0 +1,34 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Entities; + +/// Per-run state of one gate. One row per gate definition, created at instance start. +public class SequenceGateInstance : BaseEntity +{ + public int InstanceId { get; set; } + + public SequenceInstance? Instance { get; set; } + + public string StepKey { get; set; } = string.Empty; + + public string GateKey { get; set; } = string.Empty; + + public SequenceGateVerdict Verdict { get; set; } = SequenceGateVerdict.Unknown; + + public DateTimeOffset? LastEvaluatedAt { get; set; } + + /// Why the source answered as it did (shown as the "blocked because" text). + public string? Reason { get; set; } + + /// ManualClearance: the recorded clearance. The record IS the sign-off. + public DateTimeOffset? ClearedAt { get; set; } + + public int? ClearedByUserId { get; set; } + + /// A forced Go. Sticky until the step is reset by rework. Reason is mandatory. + public DateTimeOffset? OverriddenAt { get; set; } + + public int? OverriddenByUserId { get; set; } + + public string? OverrideReason { get; set; } +} diff --git a/forge.core/Entities/SequenceInstance.cs b/forge.core/Entities/SequenceInstance.cs new file mode 100644 index 00000000..d363de5c --- /dev/null +++ b/forge.core/Entities/SequenceInstance.cs @@ -0,0 +1,42 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Entities; + +/// +/// A run of one published version against an optional polymorphic subject +/// (e.g. Job/123). Its marking lives in /; its history in . +/// +public class SequenceInstance : BaseAuditableEntity, IConcurrencyVersioned +{ + public int DefinitionId { get; set; } + + public SequenceDefinition? Definition { get; set; } + + /// Polymorphic subject (no FK): "Job", "Lot", "Permit", ... Null for a free-standing run. + public string? SubjectEntityType { get; set; } + + public int? SubjectEntityId { get; set; } + + public SequenceInstanceStatus Status { get; set; } = SequenceInstanceStatus.Running; + + public DateTimeOffset StartedAt { get; set; } + + public int? StartedByUserId { get; set; } + + public DateTimeOffset? CompletedAt { get; set; } + + public DateTimeOffset? CancelledAt { get; set; } + + public int? CancelledByUserId { get; set; } + + public string? CancelReason { get; set; } + + /// Optimistic concurrency token (uint, bumped by AppDbContext per IConcurrencyVersioned). + public uint Version { get; set; } = 1; + + public ICollection Steps { get; set; } = []; + + public ICollection Gates { get; set; } = []; + + public ICollection Events { get; set; } = []; +} diff --git a/forge.core/Entities/SequenceResourceClock.cs b/forge.core/Entities/SequenceResourceClock.cs new file mode 100644 index 00000000..87ab9d99 --- /dev/null +++ b/forge.core/Entities/SequenceResourceClock.cs @@ -0,0 +1,27 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Entities; + +/// +/// A clock attached to a RESOURCE (lot, permit, sample...) rather than to a step, so it travels with the resource +/// between steps and instances: a lot two days from expiry is two days from expiry wherever it goes. +/// ResourceClock gates read it; the clock job fires it once. +/// +public class SequenceResourceClock : BaseAuditableEntity +{ + /// Polymorphic resource (no FK): "Lot", "Permit", ... + public string ResourceType { get; set; } = string.Empty; + + public int ResourceId { get; set; } + + public DateTimeOffset ExpiresAt { get; set; } + + public SequenceExpiryAction ExpiryAction { get; set; } = SequenceExpiryAction.Block; + + public string? EscalateRole { get; set; } + + public string? Note { get; set; } + + /// Set once when the clock job fires the expiry; guards against double escalation. + public DateTimeOffset? FiredAt { get; set; } +} diff --git a/forge.core/Entities/SequenceStepDefinition.cs b/forge.core/Entities/SequenceStepDefinition.cs new file mode 100644 index 00000000..caf10058 --- /dev/null +++ b/forge.core/Entities/SequenceStepDefinition.cs @@ -0,0 +1,32 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Entities; + +/// A step (Petri-net transition) inside a . Keyed by within its definition. +public class SequenceStepDefinition : BaseEntity +{ + public int DefinitionId { get; set; } + + public SequenceDefinition? Definition { get; set; } + + /// Stable key within the definition, e.g. "cut", "inspect". Edges and gates reference it. + public string Key { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string? Description { get; set; } + + /// Display order only — readiness is decided by edges, never by this number. + public int SortOrder { get; set; } + + /// All predecessors must be complete (default) or any one of them. + public SequenceJoinPolicy JoinPolicy { get; set; } = SequenceJoinPolicy.All; + + /// Optional step clock: maximum minutes a step may sit InProgress before its dwell expires. + public int? MaxDwellMinutes { get; set; } + + public SequenceExpiryAction DwellExpiryAction { get; set; } = SequenceExpiryAction.Flag; + + /// Role notified when the dwell expiry action is Escalate. + public string? EscalateRole { get; set; } +} diff --git a/forge.core/Entities/SequenceStepInstance.cs b/forge.core/Entities/SequenceStepInstance.cs new file mode 100644 index 00000000..59f8e552 --- /dev/null +++ b/forge.core/Entities/SequenceStepInstance.cs @@ -0,0 +1,34 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Entities; + +/// Per-run state of one step. One row per step definition, created at instance start. +public class SequenceStepInstance : BaseEntity +{ + public int InstanceId { get; set; } + + public SequenceInstance? Instance { get; set; } + + public string StepKey { get; set; } = string.Empty; + + public SequenceStepStatus Status { get; set; } = SequenceStepStatus.Pending; + + public DateTimeOffset? ReadyAt { get; set; } + + public DateTimeOffset? StartedAt { get; set; } + + public int? StartedByUserId { get; set; } + + public DateTimeOffset? CompletedAt { get; set; } + + public int? CompletedByUserId { get; set; } + + /// Set for Skipped steps; the reason is mandatory. + public string? SkipReason { get; set; } + + /// StartedAt + MaxDwellMinutes, when the step defines a dwell clock. + public DateTimeOffset? DwellExpiresAt { get; set; } + + /// Set once when the dwell clock fires; the guard against double escalation. + public DateTimeOffset? DwellFiredAt { get; set; } +} diff --git a/forge.core/Enums/SequenceDefinitionStatus.cs b/forge.core/Enums/SequenceDefinitionStatus.cs new file mode 100644 index 00000000..5548f2f4 --- /dev/null +++ b/forge.core/Enums/SequenceDefinitionStatus.cs @@ -0,0 +1,10 @@ +namespace Forge.Core.Enums; + +/// Lifecycle of a version. Draft is editable; Published is +/// immutable and startable; Retired can no longer start new instances (in-flight ones keep running). +public enum SequenceDefinitionStatus +{ + Draft, + Published, + Retired, +} diff --git a/forge.core/Enums/SequenceEventType.cs b/forge.core/Enums/SequenceEventType.cs new file mode 100644 index 00000000..612c5b7c --- /dev/null +++ b/forge.core/Enums/SequenceEventType.cs @@ -0,0 +1,21 @@ +namespace Forge.Core.Enums; + +/// Append-only event kinds recorded on . This IS the audit trail. +public enum SequenceEventType +{ + InstanceStarted, + StepReady, + StepBlocked, + StepStarted, + StepCompleted, + StepSkipped, + StepReset, + GateEvaluated, + GateCleared, + GateOverridden, + ClockExpired, + Escalated, + Reworked, + InstanceCompleted, + InstanceCancelled, +} diff --git a/forge.core/Enums/SequenceExpiryAction.cs b/forge.core/Enums/SequenceExpiryAction.cs new file mode 100644 index 00000000..d0951c94 --- /dev/null +++ b/forge.core/Enums/SequenceExpiryAction.cs @@ -0,0 +1,12 @@ +namespace Forge.Core.Enums; + +/// What happens when a clock (resource or step dwell) expires. +public enum SequenceExpiryAction +{ + /// The dependent gate re-evaluates to NoGo and the branch cannot proceed until resolved (default). + Block, + /// The resource/step is flagged for review; the branch is not blocked. + Flag, + /// A notification is routed to the configured role, in addition to blocking. + Escalate, +} diff --git a/forge.core/Enums/SequenceGateSourceType.cs b/forge.core/Enums/SequenceGateSourceType.cs new file mode 100644 index 00000000..03ab65cd --- /dev/null +++ b/forge.core/Enums/SequenceGateSourceType.cs @@ -0,0 +1,20 @@ +namespace Forge.Core.Enums; + +/// +/// What a gate checks against. Built-in sources ship with the engine; is resolved by +/// config.key against a registered so modules can add their own +/// (materials readiness, permit validity, ...) without touching the engine. +/// +public enum SequenceGateSourceType +{ + /// Go once an authorised person records a clearance on the gate instance. + ManualClearance, + /// Go while "now" is inside the configured [notBefore, notAfter] window. + TimeWindow, + /// Go while the referenced resource's is unexpired. + ResourceClock, + /// Go when a terminal, approved ApprovalRequest exists for the referenced entity. + Approval, + /// Resolved by a module-registered gate source; an unknown key fails closed (NoGo). + Custom, +} diff --git a/forge.core/Enums/SequenceGateVerdict.cs b/forge.core/Enums/SequenceGateVerdict.cs new file mode 100644 index 00000000..1f17b732 --- /dev/null +++ b/forge.core/Enums/SequenceGateVerdict.cs @@ -0,0 +1,9 @@ +namespace Forge.Core.Enums; + +/// What a gate currently reads. Unknown = never evaluated (or its source could not answer). +public enum SequenceGateVerdict +{ + Unknown, + Go, + NoGo, +} diff --git a/forge.core/Enums/SequenceInstanceStatus.cs b/forge.core/Enums/SequenceInstanceStatus.cs new file mode 100644 index 00000000..071abf52 --- /dev/null +++ b/forge.core/Enums/SequenceInstanceStatus.cs @@ -0,0 +1,9 @@ +namespace Forge.Core.Enums; + +/// Lifecycle of a (a run of a published definition). +public enum SequenceInstanceStatus +{ + Running, + Completed, + Cancelled, +} diff --git a/forge.core/Enums/SequenceJoinPolicy.cs b/forge.core/Enums/SequenceJoinPolicy.cs new file mode 100644 index 00000000..ce6485fc --- /dev/null +++ b/forge.core/Enums/SequenceJoinPolicy.cs @@ -0,0 +1,8 @@ +namespace Forge.Core.Enums; + +/// How a step with several predecessors joins them: all must be complete (default) or any one. +public enum SequenceJoinPolicy +{ + All, + Any, +} diff --git a/forge.core/Enums/SequenceStepStatus.cs b/forge.core/Enums/SequenceStepStatus.cs new file mode 100644 index 00000000..558cd305 --- /dev/null +++ b/forge.core/Enums/SequenceStepStatus.cs @@ -0,0 +1,17 @@ +namespace Forge.Core.Enums; + +/// +/// State of one step inside a running sequence. "Blocked" is deliberately NOT a stored status — it is derived +/// (predecessors satisfied but at least one gate is not Go) so it can never go stale. +/// +public enum SequenceStepStatus +{ + /// Waiting on predecessors and/or gates. + Pending, + /// All predecessors satisfied and every gate reads Go; may be started. + Ready, + InProgress, + Complete, + /// Skipped by an authorised user with a reason; counts as complete for successors. + Skipped, +} diff --git a/forge.core/Interfaces/ISequenceEvaluationService.cs b/forge.core/Interfaces/ISequenceEvaluationService.cs new file mode 100644 index 00000000..329a8cd3 --- /dev/null +++ b/forge.core/Interfaces/ISequenceEvaluationService.cs @@ -0,0 +1,13 @@ +using Forge.Core.Sequences; + +namespace Forge.Core.Interfaces; + +/// +/// Runs one evaluation pass over a sequence instance: loads it with its definition, asks every gate's source for a +/// verdict, applies , appends the resulting events, and publishes domain events. +/// Callers save changes themselves (it participates in the caller's unit of work). +/// +public interface ISequenceEvaluationService +{ + Task EvaluateAsync(int instanceId, int? actorUserId, CancellationToken cancellationToken); +} diff --git a/forge.core/Models/SequenceDefinitionRequestModel.cs b/forge.core/Models/SequenceDefinitionRequestModel.cs new file mode 100644 index 00000000..33e45b02 --- /dev/null +++ b/forge.core/Models/SequenceDefinitionRequestModel.cs @@ -0,0 +1,11 @@ +namespace Forge.Core.Models; + +/// Create/update payload for a draft sequence definition — the whole graph in one document. +public record SequenceDefinitionRequestModel( + string Code, + string Name, + string? Description, + string? SubjectEntityType, + IReadOnlyList Steps, + IReadOnlyList Edges, + IReadOnlyList Gates); diff --git a/forge.core/Models/SequenceDefinitionResponseModel.cs b/forge.core/Models/SequenceDefinitionResponseModel.cs new file mode 100644 index 00000000..5bb1b4e3 --- /dev/null +++ b/forge.core/Models/SequenceDefinitionResponseModel.cs @@ -0,0 +1,18 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +public record SequenceDefinitionResponseModel( + int Id, + string Code, + int Version, + string Name, + string? Description, + string? SubjectEntityType, + SequenceDefinitionStatus Status, + DateTimeOffset? PublishedAt, + IReadOnlyList Steps, + IReadOnlyList Edges, + IReadOnlyList Gates, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); diff --git a/forge.core/Models/SequenceEdgeDefinitionModel.cs b/forge.core/Models/SequenceEdgeDefinitionModel.cs new file mode 100644 index 00000000..8fe79853 --- /dev/null +++ b/forge.core/Models/SequenceEdgeDefinitionModel.cs @@ -0,0 +1,4 @@ +namespace Forge.Core.Models; + +/// Dependency edge of a sequence definition (request and response shape). +public record SequenceEdgeDefinitionModel(string FromStepKey, string ToStepKey, bool IsRework = false); diff --git a/forge.core/Models/SequenceEventResponseModel.cs b/forge.core/Models/SequenceEventResponseModel.cs new file mode 100644 index 00000000..72a66cf4 --- /dev/null +++ b/forge.core/Models/SequenceEventResponseModel.cs @@ -0,0 +1,12 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +public record SequenceEventResponseModel( + int Id, + SequenceEventType Type, + string? StepKey, + string? GateKey, + string? PayloadJson, + DateTimeOffset OccurredAt, + int? ActorUserId); diff --git a/forge.core/Models/SequenceGateDefinitionModel.cs b/forge.core/Models/SequenceGateDefinitionModel.cs new file mode 100644 index 00000000..85f2fdf9 --- /dev/null +++ b/forge.core/Models/SequenceGateDefinitionModel.cs @@ -0,0 +1,13 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +/// Gate of a sequence definition (request and response shape). shape depends on . +public record SequenceGateDefinitionModel( + string StepKey, + string Key, + string Name, + SequenceGateSourceType SourceType, + string ConfigJson = "{}", + SequenceExpiryAction ExpiryAction = SequenceExpiryAction.Block, + string? EscalateRole = null); diff --git a/forge.core/Models/SequenceGateInstanceResponseModel.cs b/forge.core/Models/SequenceGateInstanceResponseModel.cs new file mode 100644 index 00000000..61ee93ae --- /dev/null +++ b/forge.core/Models/SequenceGateInstanceResponseModel.cs @@ -0,0 +1,17 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +public record SequenceGateInstanceResponseModel( + string StepKey, + string GateKey, + string Name, + SequenceGateSourceType SourceType, + SequenceGateVerdict Verdict, + string? Reason, + DateTimeOffset? LastEvaluatedAt, + DateTimeOffset? ClearedAt, + int? ClearedByUserId, + DateTimeOffset? OverriddenAt, + int? OverriddenByUserId, + string? OverrideReason); diff --git a/forge.core/Models/SequenceInstanceResponseModel.cs b/forge.core/Models/SequenceInstanceResponseModel.cs new file mode 100644 index 00000000..e1e84ee5 --- /dev/null +++ b/forge.core/Models/SequenceInstanceResponseModel.cs @@ -0,0 +1,21 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +public record SequenceInstanceResponseModel( + int Id, + int DefinitionId, + string DefinitionCode, + int DefinitionVersion, + string DefinitionName, + string? SubjectEntityType, + int? SubjectEntityId, + SequenceInstanceStatus Status, + DateTimeOffset StartedAt, + int? StartedByUserId, + DateTimeOffset? CompletedAt, + DateTimeOffset? CancelledAt, + string? CancelReason, + uint Version, + IReadOnlyList Steps, + IReadOnlyList Gates); diff --git a/forge.core/Models/SequenceReasonRequestModel.cs b/forge.core/Models/SequenceReasonRequestModel.cs new file mode 100644 index 00000000..65279786 --- /dev/null +++ b/forge.core/Models/SequenceReasonRequestModel.cs @@ -0,0 +1,4 @@ +namespace Forge.Core.Models; + +/// Payload for actions that require a stated reason (cancel, skip, override). +public record SequenceReasonRequestModel(string Reason); diff --git a/forge.core/Models/SequenceResourceClockRequestModel.cs b/forge.core/Models/SequenceResourceClockRequestModel.cs new file mode 100644 index 00000000..e47699bd --- /dev/null +++ b/forge.core/Models/SequenceResourceClockRequestModel.cs @@ -0,0 +1,11 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +public record SequenceResourceClockRequestModel( + string ResourceType, + int ResourceId, + DateTimeOffset ExpiresAt, + SequenceExpiryAction ExpiryAction = SequenceExpiryAction.Block, + string? EscalateRole = null, + string? Note = null); diff --git a/forge.core/Models/SequenceResourceClockResponseModel.cs b/forge.core/Models/SequenceResourceClockResponseModel.cs new file mode 100644 index 00000000..4d69cfe7 --- /dev/null +++ b/forge.core/Models/SequenceResourceClockResponseModel.cs @@ -0,0 +1,14 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +public record SequenceResourceClockResponseModel( + int Id, + string ResourceType, + int ResourceId, + DateTimeOffset ExpiresAt, + SequenceExpiryAction ExpiryAction, + string? EscalateRole, + string? Note, + DateTimeOffset? FiredAt, + bool IsExpired); diff --git a/forge.core/Models/SequenceReworkRequestModel.cs b/forge.core/Models/SequenceReworkRequestModel.cs new file mode 100644 index 00000000..1082df68 --- /dev/null +++ b/forge.core/Models/SequenceReworkRequestModel.cs @@ -0,0 +1,4 @@ +namespace Forge.Core.Models; + +/// Rework: reset and everything downstream of it to Pending. +public record SequenceReworkRequestModel(string TargetStepKey, string Reason); diff --git a/forge.core/Models/SequenceStepDefinitionModel.cs b/forge.core/Models/SequenceStepDefinitionModel.cs new file mode 100644 index 00000000..448dec46 --- /dev/null +++ b/forge.core/Models/SequenceStepDefinitionModel.cs @@ -0,0 +1,14 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +/// Step of a sequence definition (request and response shape). +public record SequenceStepDefinitionModel( + string Key, + string Name, + string? Description, + int SortOrder, + SequenceJoinPolicy JoinPolicy = SequenceJoinPolicy.All, + int? MaxDwellMinutes = null, + SequenceExpiryAction DwellExpiryAction = SequenceExpiryAction.Flag, + string? EscalateRole = null); diff --git a/forge.core/Models/SequenceStepInstanceResponseModel.cs b/forge.core/Models/SequenceStepInstanceResponseModel.cs new file mode 100644 index 00000000..8bee449f --- /dev/null +++ b/forge.core/Models/SequenceStepInstanceResponseModel.cs @@ -0,0 +1,21 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Models; + +/// Per-step marking. is derived (predecessors satisfied, a gate not Go). +public record SequenceStepInstanceResponseModel( + string StepKey, + string Name, + int SortOrder, + SequenceStepStatus Status, + bool IsBlocked, + string? BlockedReason, + IReadOnlyList Predecessors, + DateTimeOffset? ReadyAt, + DateTimeOffset? StartedAt, + int? StartedByUserId, + DateTimeOffset? CompletedAt, + int? CompletedByUserId, + string? SkipReason, + DateTimeOffset? DwellExpiresAt, + DateTimeOffset? DwellFiredAt); diff --git a/forge.core/Models/StartSequenceRequestModel.cs b/forge.core/Models/StartSequenceRequestModel.cs new file mode 100644 index 00000000..2a4ff516 --- /dev/null +++ b/forge.core/Models/StartSequenceRequestModel.cs @@ -0,0 +1,4 @@ +namespace Forge.Core.Models; + +/// Start a run: by definition id, or by code (latest published version). Subject is optional. +public record StartSequenceRequestModel(int? DefinitionId, string? Code, string? SubjectEntityType, int? SubjectEntityId); diff --git a/forge.core/Sequences/IGateSource.cs b/forge.core/Sequences/IGateSource.cs new file mode 100644 index 00000000..c1cb0a7b --- /dev/null +++ b/forge.core/Sequences/IGateSource.cs @@ -0,0 +1,19 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Sequences; + +/// +/// A pluggable go/no-go evaluator. Built-in sources cover the engine's own concepts; modules register additional +/// implementations with = Custom and a matched against +/// the gate's config.key. Implementations must be idempotent and side-effect free — they may be called +/// on every re-evaluation. +/// +public interface IGateSource +{ + SequenceGateSourceType SourceType { get; } + + /// For Custom sources, the key a gate config names to select this source; null otherwise. + string? CustomKey { get; } + + Task EvaluateAsync(SequenceGateContext context, CancellationToken cancellationToken); +} diff --git a/forge.core/Sequences/SequenceEvaluation.cs b/forge.core/Sequences/SequenceEvaluation.cs new file mode 100644 index 00000000..2f625a3b --- /dev/null +++ b/forge.core/Sequences/SequenceEvaluation.cs @@ -0,0 +1,20 @@ +using Forge.Core.Entities; + +namespace Forge.Core.Sequences; + +/// Outcome of one evaluator pass: the events to append and the flags callers react to. +public sealed class SequenceEvaluation +{ + public List Events { get; } = []; + + /// Step keys that transitioned Pending → Ready in this pass (domain-event candidates). + public List NewlyReady { get; } = []; + + /// Step keys that are blocked after this pass (predecessors satisfied, ≥1 gate not Go). + public List Blocked { get; } = []; + + /// True when the pass drove the instance to Completed. + public bool CompletedInstance { get; set; } + + public bool Changed => Events.Count > 0; +} diff --git a/forge.core/Sequences/SequenceEvaluator.cs b/forge.core/Sequences/SequenceEvaluator.cs new file mode 100644 index 00000000..162dc356 --- /dev/null +++ b/forge.core/Sequences/SequenceEvaluator.cs @@ -0,0 +1,138 @@ +using Forge.Core.Entities; +using Forge.Core.Enums; + +namespace Forge.Core.Sequences; + +/// +/// The pure marking evaluator. Given the net, the run's step/gate instances, and fresh gate verdicts, it applies +/// the verdicts and derives step readiness, appending one per change. Deterministic and +/// idempotent: running it twice with the same inputs changes nothing the second time. It never talks to storage, +/// clocks, or gate sources — callers gather verdicts first (see the api-side SequenceEvaluationService). +/// +/// Rules: +/// +/// An overridden gate stays Go regardless of its source's verdict. +/// A step is "predecessor-satisfied" when its join policy holds over predecessors in Complete/Skipped. +/// Pending → Ready when predecessor-satisfied and every gate is Go. Ready → Pending when a gate stops being Go +/// (the step has not started, so nothing is lost). Blocked = predecessor-satisfied ∧ ¬all-gates-Go (derived). +/// The instance completes when every step is Complete or Skipped. +/// +/// +public static class SequenceEvaluator +{ + public static SequenceEvaluation Evaluate( + SequenceNet net, + SequenceInstance instance, + IReadOnlyDictionary<(string StepKey, string GateKey), SequenceGateVerdictResult> verdicts, + DateTimeOffset now, + int? actorUserId = null) + { + var result = new SequenceEvaluation(); + var steps = instance.Steps.ToDictionary(s => s.StepKey, StringComparer.Ordinal); + var gates = instance.Gates.ToDictionary(g => (g.StepKey, g.GateKey)); + + // 1. apply verdicts + foreach (var gate in instance.Gates) + { + if (!verdicts.TryGetValue((gate.StepKey, gate.GateKey), out var v)) continue; + var effective = gate.OverriddenAt.HasValue ? SequenceGateVerdict.Go : v.Verdict; + var reason = gate.OverriddenAt.HasValue ? $"Overridden: {gate.OverrideReason}" : v.Reason; + gate.LastEvaluatedAt = now; + if (gate.Verdict == effective && gate.Reason == reason) continue; + gate.Verdict = effective; + gate.Reason = reason; + result.Events.Add(Event(instance, SequenceEventType.GateEvaluated, now, actorUserId, gate.StepKey, gate.GateKey, + $"{{\"verdict\":\"{effective}\",\"reason\":{Json(reason)}}}")); + } + + // 2. derive readiness (iterate to a fixed point — Skipped/Complete are only changed by commands, so one + // pass suffices for readiness, but a loop keeps this correct if that ever changes) + bool changed; + do + { + changed = false; + foreach (var stepDef in net.Steps) + { + if (!steps.TryGetValue(stepDef.Key, out var step)) continue; + if (step.Status is SequenceStepStatus.InProgress or SequenceStepStatus.Complete or SequenceStepStatus.Skipped) continue; + + var predsOk = PredecessorsSatisfied(net, stepDef, steps); + var gatesOk = net.GatesOf(stepDef.Key).All(g => + gates.TryGetValue((stepDef.Key, g.Key), out var gi) && gi.Verdict == SequenceGateVerdict.Go); + + if (predsOk && gatesOk && step.Status == SequenceStepStatus.Pending) + { + step.Status = SequenceStepStatus.Ready; + step.ReadyAt = now; + result.NewlyReady.Add(step.StepKey); + result.Events.Add(Event(instance, SequenceEventType.StepReady, now, actorUserId, step.StepKey)); + changed = true; + } + else if (!(predsOk && gatesOk) && step.Status == SequenceStepStatus.Ready) + { + step.Status = SequenceStepStatus.Pending; + step.ReadyAt = null; + result.Events.Add(Event(instance, SequenceEventType.StepBlocked, now, actorUserId, step.StepKey, + $"{{\"reason\":{Json(BlockedReason(net, stepDef, gates))}}}")); + changed = true; + } + + if (predsOk && !gatesOk) result.Blocked.Add(step.StepKey); + } + } while (changed); + + // 3. completion + if (instance.Status == SequenceInstanceStatus.Running && + steps.Values.All(s => s.Status is SequenceStepStatus.Complete or SequenceStepStatus.Skipped)) + { + instance.Status = SequenceInstanceStatus.Completed; + instance.CompletedAt = now; + result.CompletedInstance = true; + result.Events.Add(Event(instance, SequenceEventType.InstanceCompleted, now, actorUserId)); + } + + return result; + } + + /// Derived: predecessors satisfied but at least one gate is not Go. + public static bool IsBlocked(SequenceNet net, SequenceInstance instance, string stepKey) + { + var steps = instance.Steps.ToDictionary(s => s.StepKey, StringComparer.Ordinal); + if (!net.TryGetStep(stepKey, out var def) || !steps.TryGetValue(stepKey, out var step)) return false; + if (step.Status is not SequenceStepStatus.Pending) return false; + var gates = instance.Gates.ToDictionary(g => (g.StepKey, g.GateKey)); + return PredecessorsSatisfied(net, def, steps) && + !net.GatesOf(stepKey).All(g => gates.TryGetValue((stepKey, g.Key), out var gi) && gi.Verdict == SequenceGateVerdict.Go); + } + + public static bool PredecessorsSatisfied(SequenceNet net, SequenceStepDefinition step, IReadOnlyDictionary steps) + { + var preds = net.PredecessorsOf(step.Key).ToList(); + if (preds.Count == 0) return true; + bool Done(string k) => steps.TryGetValue(k, out var p) && p.Status is SequenceStepStatus.Complete or SequenceStepStatus.Skipped; + return step.JoinPolicy == SequenceJoinPolicy.Any ? preds.Any(Done) : preds.All(Done); + } + + public static string BlockedReason(SequenceNet net, SequenceStepDefinition step, IReadOnlyDictionary<(string, string), SequenceGateInstance> gates) + { + var parts = net.GatesOf(step.Key) + .Where(g => !(gates.TryGetValue((step.Key, g.Key), out var gi) && gi.Verdict == SequenceGateVerdict.Go)) + .Select(g => gates.TryGetValue((step.Key, g.Key), out var gi) && !string.IsNullOrEmpty(gi.Reason) ? $"{g.Name}: {gi.Reason}" : $"{g.Name}: not go"); + return string.Join("; ", parts); + } + + public static SequenceEvent Event(SequenceInstance instance, SequenceEventType type, DateTimeOffset now, int? actor, + string? stepKey = null, string? gateKey = null, string? payloadJson = null) => new() + { + InstanceId = instance.Id, + Instance = instance, + Type = type, + StepKey = stepKey, + GateKey = gateKey, + PayloadJson = payloadJson, + OccurredAt = now, + ActorUserId = actor, + }; + + private static string Json(string? s) => s is null ? "null" : "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; +} diff --git a/forge.core/Sequences/SequenceGateContext.cs b/forge.core/Sequences/SequenceGateContext.cs new file mode 100644 index 00000000..282f4a99 --- /dev/null +++ b/forge.core/Sequences/SequenceGateContext.cs @@ -0,0 +1,11 @@ +using Forge.Core.Entities; + +namespace Forge.Core.Sequences; + +/// Everything a gate source may look at when answering: the definition, the run, the gate's own instance row, and "now". +public sealed record SequenceGateContext( + SequenceDefinition Definition, + SequenceGateDefinition Gate, + SequenceInstance Instance, + SequenceGateInstance GateInstance, + DateTimeOffset Now); diff --git a/forge.core/Sequences/SequenceGateVerdictResult.cs b/forge.core/Sequences/SequenceGateVerdictResult.cs new file mode 100644 index 00000000..df845741 --- /dev/null +++ b/forge.core/Sequences/SequenceGateVerdictResult.cs @@ -0,0 +1,11 @@ +using Forge.Core.Enums; + +namespace Forge.Core.Sequences; + +/// A gate source's answer: the verdict plus the human-readable reason shown as "blocked because ...". +public sealed record SequenceGateVerdictResult(SequenceGateVerdict Verdict, string? Reason = null) +{ + public static SequenceGateVerdictResult Go(string? reason = null) => new(SequenceGateVerdict.Go, reason); + public static SequenceGateVerdictResult NoGo(string reason) => new(SequenceGateVerdict.NoGo, reason); + public static SequenceGateVerdictResult Unknown(string reason) => new(SequenceGateVerdict.Unknown, reason); +} diff --git a/forge.core/Sequences/SequenceNet.cs b/forge.core/Sequences/SequenceNet.cs new file mode 100644 index 00000000..6d9a7b2a --- /dev/null +++ b/forge.core/Sequences/SequenceNet.cs @@ -0,0 +1,67 @@ +using Forge.Core.Entities; + +namespace Forge.Core.Sequences; + +/// +/// Immutable graph view of one — steps by key, predecessor/successor adjacency, +/// gates by step. Built once per evaluation; validated by . +/// +public sealed class SequenceNet +{ + private readonly Dictionary _steps; + private readonly Dictionary> _incoming; + private readonly Dictionary> _outgoing; + private readonly Dictionary> _gates; + + public SequenceNet(SequenceDefinition definition) + { + Definition = definition; + _steps = definition.Steps.ToDictionary(s => s.Key, StringComparer.Ordinal); + _incoming = definition.Steps.ToDictionary(s => s.Key, _ => new List(), StringComparer.Ordinal); + _outgoing = definition.Steps.ToDictionary(s => s.Key, _ => new List(), StringComparer.Ordinal); + _gates = definition.Steps.ToDictionary(s => s.Key, _ => new List(), StringComparer.Ordinal); + foreach (var e in definition.Edges) + { + if (_incoming.TryGetValue(e.ToStepKey, out var inc)) inc.Add(e); + if (_outgoing.TryGetValue(e.FromStepKey, out var outg)) outg.Add(e); + } + foreach (var g in definition.Gates) + { + if (_gates.TryGetValue(g.StepKey, out var list)) list.Add(g); + } + } + + public SequenceDefinition Definition { get; } + + public IReadOnlyCollection Steps => _steps.Values; + + public bool TryGetStep(string key, out SequenceStepDefinition step) => _steps.TryGetValue(key, out step!); + + /// Non-rework predecessors — the ones that gate readiness. + public IEnumerable PredecessorsOf(string stepKey) => + _incoming.TryGetValue(stepKey, out var l) ? l.Where(e => !e.IsRework).Select(e => e.FromStepKey) : []; + + public IEnumerable SuccessorsOf(string stepKey) => + _outgoing.TryGetValue(stepKey, out var l) ? l.Where(e => !e.IsRework).Select(e => e.ToStepKey) : []; + + public IReadOnlyList GatesOf(string stepKey) => + _gates.TryGetValue(stepKey, out var l) ? l : []; + + /// Steps with no non-rework predecessors — where a run begins. + public IEnumerable StartSteps() => + _steps.Values.Where(s => !PredecessorsOf(s.Key).Any()); + + /// Every step reachable downstream of via non-rework edges (excluding itself). + public HashSet Downstream(string stepKey) + { + var seen = new HashSet(StringComparer.Ordinal); + var stack = new Stack(SuccessorsOf(stepKey)); + while (stack.Count > 0) + { + var k = stack.Pop(); + if (!seen.Add(k)) continue; + foreach (var s in SuccessorsOf(k)) stack.Push(s); + } + return seen; + } +} diff --git a/forge.core/Sequences/SequenceNetValidator.cs b/forge.core/Sequences/SequenceNetValidator.cs new file mode 100644 index 00000000..621e3cca --- /dev/null +++ b/forge.core/Sequences/SequenceNetValidator.cs @@ -0,0 +1,70 @@ +using Forge.Core.Entities; + +namespace Forge.Core.Sequences; + +/// +/// Structural rules a definition must satisfy before it can be published: unique step keys, edges between known +/// steps, gates on known steps with unique keys per step, at least one start step, every step reachable from a +/// start, and no cycles except through edges flagged IsRework. +/// +public static class SequenceNetValidator +{ + public static IReadOnlyList Validate(SequenceDefinition definition) + { + var errors = new List(); + var keys = new HashSet(StringComparer.Ordinal); + foreach (var s in definition.Steps) + { + if (string.IsNullOrWhiteSpace(s.Key)) errors.Add("A step has an empty key."); + else if (!keys.Add(s.Key)) errors.Add($"Duplicate step key '{s.Key}'."); + } + if (definition.Steps.Count == 0) errors.Add("A definition needs at least one step."); + + foreach (var e in definition.Edges) + { + if (!keys.Contains(e.FromStepKey)) errors.Add($"Edge from unknown step '{e.FromStepKey}'."); + if (!keys.Contains(e.ToStepKey)) errors.Add($"Edge to unknown step '{e.ToStepKey}'."); + if (e.FromStepKey == e.ToStepKey) errors.Add($"Step '{e.FromStepKey}' cannot depend on itself."); + } + + var gateKeys = new HashSet<(string, string)>(); + foreach (var g in definition.Gates) + { + if (!keys.Contains(g.StepKey)) errors.Add($"Gate '{g.Key}' is on unknown step '{g.StepKey}'."); + if (string.IsNullOrWhiteSpace(g.Key)) errors.Add($"A gate on step '{g.StepKey}' has an empty key."); + else if (!gateKeys.Add((g.StepKey, g.Key))) errors.Add($"Duplicate gate key '{g.Key}' on step '{g.StepKey}'."); + } + + if (errors.Count > 0) return errors; // graph checks need a well-formed key set + + var net = new SequenceNet(definition); + var starts = net.StartSteps().Select(s => s.Key).ToList(); + if (starts.Count == 0) errors.Add("No start step: every step has a predecessor (a cycle not marked as rework)."); + + // Reachability from the start steps over non-rework edges. + var reachable = new HashSet(starts, StringComparer.Ordinal); + foreach (var s in starts) reachable.UnionWith(net.Downstream(s)); + foreach (var k in keys.Where(k => !reachable.Contains(k))) + errors.Add($"Step '{k}' is unreachable from any start step."); + + // Cycle detection over non-rework edges (DFS colouring). + var colour = keys.ToDictionary(k => k, _ => 0, StringComparer.Ordinal); + foreach (var k in keys) + { + if (colour[k] == 0 && HasCycle(net, k, colour)) { errors.Add("The dependency graph has a cycle that is not marked as rework."); break; } + } + return errors; + } + + private static bool HasCycle(SequenceNet net, string key, Dictionary colour) + { + colour[key] = 1; + foreach (var next in net.SuccessorsOf(key)) + { + if (colour[next] == 1) return true; + if (colour[next] == 0 && HasCycle(net, next, colour)) return true; + } + colour[key] = 2; + return false; + } +} diff --git a/forge.data/Configuration/SequenceDefinitionConfiguration.cs b/forge.data/Configuration/SequenceDefinitionConfiguration.cs new file mode 100644 index 00000000..ecbd0043 --- /dev/null +++ b/forge.data/Configuration/SequenceDefinitionConfiguration.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceDefinitionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Ignore(e => e.IsDeleted); + builder.Property(e => e.Code).HasMaxLength(100); + builder.Property(e => e.Name).HasMaxLength(200); + builder.Property(e => e.Description).HasMaxLength(2000); + builder.Property(e => e.SubjectEntityType).HasMaxLength(50); + builder.Property(e => e.Status).HasConversion().HasMaxLength(20); + + // One row per (code, version) among live rows. + builder.HasIndex(e => new { e.Code, e.Version }).IsUnique().HasFilter("\"deleted_at\" IS NULL"); + builder.HasIndex(e => e.Status); + + builder.HasMany(e => e.Steps).WithOne(s => s.Definition).HasForeignKey(s => s.DefinitionId).OnDelete(DeleteBehavior.Cascade); + builder.HasMany(e => e.Edges).WithOne(s => s.Definition).HasForeignKey(s => s.DefinitionId).OnDelete(DeleteBehavior.Cascade); + builder.HasMany(e => e.Gates).WithOne(s => s.Definition).HasForeignKey(s => s.DefinitionId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/forge.data/Configuration/SequenceEdgeDefinitionConfiguration.cs b/forge.data/Configuration/SequenceEdgeDefinitionConfiguration.cs new file mode 100644 index 00000000..644a9ca4 --- /dev/null +++ b/forge.data/Configuration/SequenceEdgeDefinitionConfiguration.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceEdgeDefinitionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.FromStepKey).HasMaxLength(100); + builder.Property(e => e.ToStepKey).HasMaxLength(100); + builder.HasIndex(e => new { e.DefinitionId, e.FromStepKey, e.ToStepKey }).IsUnique(); + } +} diff --git a/forge.data/Configuration/SequenceEventConfiguration.cs b/forge.data/Configuration/SequenceEventConfiguration.cs new file mode 100644 index 00000000..840c66c7 --- /dev/null +++ b/forge.data/Configuration/SequenceEventConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Type).HasConversion().HasMaxLength(30); + builder.Property(e => e.StepKey).HasMaxLength(100); + builder.Property(e => e.GateKey).HasMaxLength(100); + builder.Property(e => e.PayloadJson).HasColumnType("jsonb"); + builder.HasIndex(e => new { e.InstanceId, e.OccurredAt }); + } +} diff --git a/forge.data/Configuration/SequenceGateDefinitionConfiguration.cs b/forge.data/Configuration/SequenceGateDefinitionConfiguration.cs new file mode 100644 index 00000000..a804b8f5 --- /dev/null +++ b/forge.data/Configuration/SequenceGateDefinitionConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceGateDefinitionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.StepKey).HasMaxLength(100); + builder.Property(e => e.Key).HasMaxLength(100); + builder.Property(e => e.Name).HasMaxLength(200); + builder.Property(e => e.SourceType).HasConversion().HasMaxLength(30); + builder.Property(e => e.ConfigJson).HasColumnType("jsonb"); + builder.Property(e => e.ExpiryAction).HasConversion().HasMaxLength(20); + builder.Property(e => e.EscalateRole).HasMaxLength(100); + builder.HasIndex(e => new { e.DefinitionId, e.StepKey, e.Key }).IsUnique(); + } +} diff --git a/forge.data/Configuration/SequenceGateInstanceConfiguration.cs b/forge.data/Configuration/SequenceGateInstanceConfiguration.cs new file mode 100644 index 00000000..6fdcbabf --- /dev/null +++ b/forge.data/Configuration/SequenceGateInstanceConfiguration.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceGateInstanceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.StepKey).HasMaxLength(100); + builder.Property(e => e.GateKey).HasMaxLength(100); + builder.Property(e => e.Verdict).HasConversion().HasMaxLength(10); + builder.Property(e => e.Reason).HasMaxLength(2000); + builder.Property(e => e.OverrideReason).HasMaxLength(2000); + builder.HasIndex(e => new { e.InstanceId, e.StepKey, e.GateKey }).IsUnique(); + } +} diff --git a/forge.data/Configuration/SequenceInstanceConfiguration.cs b/forge.data/Configuration/SequenceInstanceConfiguration.cs new file mode 100644 index 00000000..e02a1481 --- /dev/null +++ b/forge.data/Configuration/SequenceInstanceConfiguration.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceInstanceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Ignore(e => e.IsDeleted); + builder.Property(e => e.SubjectEntityType).HasMaxLength(50); + builder.Property(e => e.Status).HasConversion().HasMaxLength(20); + builder.Property(e => e.CancelReason).HasMaxLength(2000); + // uint Version for InMemory test compat, bumped by AppDbContext.SaveChangesAsync() per IConcurrencyVersioned. + builder.Property(e => e.Version).HasDefaultValue(1u); + + builder.HasIndex(e => e.DefinitionId); + builder.HasIndex(e => new { e.SubjectEntityType, e.SubjectEntityId }); + builder.HasIndex(e => e.Status); + + builder.HasOne(e => e.Definition).WithMany().HasForeignKey(e => e.DefinitionId).OnDelete(DeleteBehavior.Restrict); + builder.HasMany(e => e.Steps).WithOne(s => s.Instance).HasForeignKey(s => s.InstanceId).OnDelete(DeleteBehavior.Cascade); + builder.HasMany(e => e.Gates).WithOne(s => s.Instance).HasForeignKey(s => s.InstanceId).OnDelete(DeleteBehavior.Cascade); + builder.HasMany(e => e.Events).WithOne(s => s.Instance).HasForeignKey(s => s.InstanceId).OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/forge.data/Configuration/SequenceResourceClockConfiguration.cs b/forge.data/Configuration/SequenceResourceClockConfiguration.cs new file mode 100644 index 00000000..403ba727 --- /dev/null +++ b/forge.data/Configuration/SequenceResourceClockConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceResourceClockConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Ignore(e => e.IsDeleted); + builder.Property(e => e.ResourceType).HasMaxLength(50); + builder.Property(e => e.ExpiryAction).HasConversion().HasMaxLength(20); + builder.Property(e => e.EscalateRole).HasMaxLength(100); + builder.Property(e => e.Note).HasMaxLength(2000); + builder.HasIndex(e => new { e.ResourceType, e.ResourceId }); + builder.HasIndex(e => e.ExpiresAt).HasFilter("\"fired_at\" IS NULL AND \"deleted_at\" IS NULL"); + } +} diff --git a/forge.data/Configuration/SequenceStepDefinitionConfiguration.cs b/forge.data/Configuration/SequenceStepDefinitionConfiguration.cs new file mode 100644 index 00000000..d9a33c53 --- /dev/null +++ b/forge.data/Configuration/SequenceStepDefinitionConfiguration.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceStepDefinitionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.Key).HasMaxLength(100); + builder.Property(e => e.Name).HasMaxLength(200); + builder.Property(e => e.Description).HasMaxLength(2000); + builder.Property(e => e.JoinPolicy).HasConversion().HasMaxLength(10); + builder.Property(e => e.DwellExpiryAction).HasConversion().HasMaxLength(20); + builder.Property(e => e.EscalateRole).HasMaxLength(100); + builder.HasIndex(e => new { e.DefinitionId, e.Key }).IsUnique(); + } +} diff --git a/forge.data/Configuration/SequenceStepInstanceConfiguration.cs b/forge.data/Configuration/SequenceStepInstanceConfiguration.cs new file mode 100644 index 00000000..fcf27a2d --- /dev/null +++ b/forge.data/Configuration/SequenceStepInstanceConfiguration.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +using Forge.Core.Entities; + +namespace Forge.Data.Configuration; + +public class SequenceStepInstanceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(e => e.StepKey).HasMaxLength(100); + builder.Property(e => e.Status).HasConversion().HasMaxLength(20); + builder.Property(e => e.SkipReason).HasMaxLength(2000); + builder.HasIndex(e => new { e.InstanceId, e.StepKey }).IsUnique(); + builder.HasIndex(e => e.DwellExpiresAt).HasFilter("\"dwell_fired_at\" IS NULL AND \"dwell_expires_at\" IS NOT NULL"); + } +} diff --git a/forge.data/Context/AppDbContext.cs b/forge.data/Context/AppDbContext.cs index c990b4f5..816e26e5 100644 --- a/forge.data/Context/AppDbContext.cs +++ b/forge.data/Context/AppDbContext.cs @@ -547,6 +547,18 @@ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) public DbSet WorkflowRuns => Set(); public DbSet WorkflowRunEntities => Set(); public DbSet WorkflowDefinitions => Set(); + + // Gated Sequence Engine (CAP-CROSS-SEQUENCES) — versioned definitions, runs (instances) with their + // step/gate marking, the append-only event log, and resource clocks that travel with a resource. + public DbSet SequenceDefinitions => Set(); + public DbSet SequenceStepDefinitions => Set(); + public DbSet SequenceEdgeDefinitions => Set(); + public DbSet SequenceGateDefinitions => Set(); + public DbSet SequenceInstances => Set(); + public DbSet SequenceStepInstances => Set(); + public DbSet SequenceGateInstances => Set(); + public DbSet SequenceEvents => Set(); + public DbSet SequenceResourceClocks => Set(); public DbSet EntityReadinessValidators => Set(); public DbSet EntityCapabilityRequirements => Set(); public DbSet CostingProfiles => Set(); diff --git a/forge.data/Schema/forge-schema.sql b/forge.data/Schema/forge-schema.sql index 3f634d75..80ec3172 100644 --- a/forge.data/Schema/forge-schema.sql +++ b/forge.data/Schema/forge-schema.sql @@ -1363,6 +1363,8 @@ CREATE TABLE public.barcodes ( is_active boolean NOT NULL, -- 'Internal' (self-generated, unique within this install) | 'Gs1' (licensed GTIN, globally unique). identity_type character varying(20) DEFAULT 'Internal'::character varying NOT NULL, + -- 'System' (auto-generated, one per entity, not user-removable) | 'Manual' (user-added alternate/alias value). + source character varying(20) DEFAULT 'System'::character varying NOT NULL, user_id integer, part_id integer, job_id integer, @@ -6458,6 +6460,9 @@ ALTER TABLE ONLY public.projects CREATE TABLE public.purchase_order_lines ( id integer NOT NULL, purchase_order_id integer NOT NULL, + -- Nullable since the construction/pro-services enablement (2026-08-17): a PO line may be + -- a service or described material with no part row. Part-less lines carry a required + -- description, are never binned at receipt, and post to expense rather than inventory. part_id integer, description character varying(500) NOT NULL, ordered_quantity numeric(18,4) NOT NULL, @@ -7703,6 +7708,251 @@ ALTER TABLE public.scheduled_tasks ALTER COLUMN id ADD GENERATED BY DEFAULT AS I ALTER TABLE ONLY public.scheduled_tasks ADD CONSTRAINT pk_scheduled_tasks PRIMARY KEY (id); +CREATE TABLE public.sequence_definitions ( + id integer NOT NULL, + -- Gated Sequence Engine (CAP-CROSS-SEQUENCES). Natural key (code, version); immutable once Published. + code character varying(100) NOT NULL, + version integer NOT NULL, + name character varying(200) NOT NULL, + description character varying(2000), + subject_entity_type character varying(50), + -- 'Draft' | 'Published' | 'Retired' + status character varying(20) NOT NULL, + published_at timestamp with time zone, + published_by_user_id integer, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + deleted_at timestamp with time zone, + deleted_by text +); + +ALTER TABLE public.sequence_definitions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_definitions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_definitions + ADD CONSTRAINT pk_sequence_definitions PRIMARY KEY (id); + +CREATE TABLE public.sequence_edge_definitions ( + id integer NOT NULL, + definition_id integer NOT NULL, + from_step_key character varying(100) NOT NULL, + to_step_key character varying(100) NOT NULL, + -- declared back-edge (rework loop); the only permitted cycles + is_rework boolean NOT NULL +); + +ALTER TABLE public.sequence_edge_definitions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_edge_definitions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_edge_definitions + ADD CONSTRAINT pk_sequence_edge_definitions PRIMARY KEY (id); + +CREATE TABLE public.sequence_events ( + id integer NOT NULL, + instance_id integer NOT NULL, + -- append-only audit trail; SequenceEventType name + type character varying(30) NOT NULL, + step_key character varying(100), + gate_key character varying(100), + payload_json jsonb, + occurred_at timestamp with time zone NOT NULL, + actor_user_id integer +); + +ALTER TABLE public.sequence_events ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_events + ADD CONSTRAINT pk_sequence_events PRIMARY KEY (id); + +CREATE TABLE public.sequence_gate_definitions ( + id integer NOT NULL, + definition_id integer NOT NULL, + step_key character varying(100) NOT NULL, + key character varying(100) NOT NULL, + name character varying(200) NOT NULL, + -- 'ManualClearance' | 'TimeWindow' | 'ResourceClock' | 'Approval' | 'Custom' + source_type character varying(30) NOT NULL, + config_json jsonb NOT NULL, + -- 'Block' | 'Flag' | 'Escalate' + expiry_action character varying(20) NOT NULL, + escalate_role character varying(100) +); + +ALTER TABLE public.sequence_gate_definitions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_gate_definitions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_gate_definitions + ADD CONSTRAINT pk_sequence_gate_definitions PRIMARY KEY (id); + +CREATE TABLE public.sequence_gate_instances ( + id integer NOT NULL, + instance_id integer NOT NULL, + step_key character varying(100) NOT NULL, + gate_key character varying(100) NOT NULL, + -- 'Unknown' | 'Go' | 'NoGo' + verdict character varying(10) NOT NULL, + last_evaluated_at timestamp with time zone, + reason character varying(2000), + cleared_at timestamp with time zone, + cleared_by_user_id integer, + overridden_at timestamp with time zone, + overridden_by_user_id integer, + override_reason character varying(2000) +); + +ALTER TABLE public.sequence_gate_instances ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_gate_instances_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_gate_instances + ADD CONSTRAINT pk_sequence_gate_instances PRIMARY KEY (id); + +CREATE TABLE public.sequence_instances ( + id integer NOT NULL, + definition_id integer NOT NULL, + subject_entity_type character varying(50), + subject_entity_id integer, + -- 'Running' | 'Completed' | 'Cancelled' + status character varying(20) NOT NULL, + started_at timestamp with time zone NOT NULL, + started_by_user_id integer, + completed_at timestamp with time zone, + cancelled_at timestamp with time zone, + cancelled_by_user_id integer, + cancel_reason character varying(2000), + version bigint DEFAULT 1 NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + deleted_at timestamp with time zone, + deleted_by text +); + +ALTER TABLE public.sequence_instances ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_instances_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_instances + ADD CONSTRAINT pk_sequence_instances PRIMARY KEY (id); + +CREATE TABLE public.sequence_resource_clocks ( + id integer NOT NULL, + -- clock keyed by RESOURCE (polymorphic, no FK) so it travels with the resource between steps/instances + resource_type character varying(50) NOT NULL, + resource_id integer NOT NULL, + expires_at timestamp with time zone NOT NULL, + -- 'Block' | 'Flag' | 'Escalate' + expiry_action character varying(20) NOT NULL, + escalate_role character varying(100), + note character varying(2000), + fired_at timestamp with time zone, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + deleted_at timestamp with time zone, + deleted_by text +); + +ALTER TABLE public.sequence_resource_clocks ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_resource_clocks_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_resource_clocks + ADD CONSTRAINT pk_sequence_resource_clocks PRIMARY KEY (id); + +CREATE TABLE public.sequence_step_definitions ( + id integer NOT NULL, + definition_id integer NOT NULL, + key character varying(100) NOT NULL, + name character varying(200) NOT NULL, + description character varying(2000), + sort_order integer NOT NULL, + -- 'All' | 'Any' + join_policy character varying(10) NOT NULL, + max_dwell_minutes integer, + -- 'Block' | 'Flag' | 'Escalate' + dwell_expiry_action character varying(20) NOT NULL, + escalate_role character varying(100) +); + +ALTER TABLE public.sequence_step_definitions ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_step_definitions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_step_definitions + ADD CONSTRAINT pk_sequence_step_definitions PRIMARY KEY (id); + +CREATE TABLE public.sequence_step_instances ( + id integer NOT NULL, + instance_id integer NOT NULL, + step_key character varying(100) NOT NULL, + -- 'Pending' | 'Ready' | 'InProgress' | 'Complete' | 'Skipped' (Blocked is derived, never stored) + status character varying(20) NOT NULL, + ready_at timestamp with time zone, + started_at timestamp with time zone, + started_by_user_id integer, + completed_at timestamp with time zone, + completed_by_user_id integer, + skip_reason character varying(2000), + dwell_expires_at timestamp with time zone, + dwell_fired_at timestamp with time zone +); + +ALTER TABLE public.sequence_step_instances ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.sequence_step_instances_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.sequence_step_instances + ADD CONSTRAINT pk_sequence_step_instances PRIMARY KEY (id); + CREATE TABLE public.serial_histories ( id integer NOT NULL, serial_number_id integer NOT NULL, @@ -10881,6 +11131,27 @@ ALTER TABLE ONLY public.scheduled_tasks ALTER TABLE ONLY public.scheduled_tasks ADD CONSTRAINT fk_scheduled_tasks_reference_data_internal_project_type_id FOREIGN KEY (internal_project_type_id) REFERENCES public.reference_data(id) ON DELETE SET NULL; +ALTER TABLE ONLY public.sequence_edge_definitions + ADD CONSTRAINT fk_sequence_edge_definitions__sequence_definitions_definition_id FOREIGN KEY (definition_id) REFERENCES public.sequence_definitions(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.sequence_events + ADD CONSTRAINT fk_sequence_events__sequence_instances_instance_id FOREIGN KEY (instance_id) REFERENCES public.sequence_instances(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.sequence_gate_definitions + ADD CONSTRAINT fk_sequence_gate_definitions__sequence_definitions_definition_id FOREIGN KEY (definition_id) REFERENCES public.sequence_definitions(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.sequence_gate_instances + ADD CONSTRAINT fk_sequence_gate_instances__sequence_instances_instance_id FOREIGN KEY (instance_id) REFERENCES public.sequence_instances(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.sequence_instances + ADD CONSTRAINT fk_sequence_instances__sequence_definitions_definition_id FOREIGN KEY (definition_id) REFERENCES public.sequence_definitions(id) ON DELETE RESTRICT; + +ALTER TABLE ONLY public.sequence_step_definitions + ADD CONSTRAINT fk_sequence_step_definitions__sequence_definitions_definition_id FOREIGN KEY (definition_id) REFERENCES public.sequence_definitions(id) ON DELETE CASCADE; + +ALTER TABLE ONLY public.sequence_step_instances + ADD CONSTRAINT fk_sequence_step_instances__sequence_instances_instance_id FOREIGN KEY (instance_id) REFERENCES public.sequence_instances(id) ON DELETE CASCADE; + ALTER TABLE ONLY public.serial_histories ADD CONSTRAINT fk_serial_histories__serial_numbers_serial_number_id FOREIGN KEY (serial_number_id) REFERENCES public.serial_numbers(id) ON DELETE CASCADE; @@ -12688,6 +12959,34 @@ CREATE INDEX ix_scheduled_tasks_next_run_at ON public.scheduled_tasks USING btre CREATE INDEX ix_scheduled_tasks_track_type_id ON public.scheduled_tasks USING btree (track_type_id); +CREATE UNIQUE INDEX ix_sequence_definitions_code_version ON public.sequence_definitions USING btree (code, version) WHERE (deleted_at IS NULL); + +CREATE INDEX ix_sequence_definitions_status ON public.sequence_definitions USING btree (status); + +CREATE UNIQUE INDEX ix_sequence_edge_definitions_definition_id_from_step_key_to_step_key ON public.sequence_edge_definitions USING btree (definition_id, from_step_key, to_step_key); + +CREATE INDEX ix_sequence_events_instance_id_occurred_at ON public.sequence_events USING btree (instance_id, occurred_at); + +CREATE UNIQUE INDEX ix_sequence_gate_definitions_definition_id_step_key_key ON public.sequence_gate_definitions USING btree (definition_id, step_key, key); + +CREATE UNIQUE INDEX ix_sequence_gate_instances_instance_id_step_key_gate_key ON public.sequence_gate_instances USING btree (instance_id, step_key, gate_key); + +CREATE INDEX ix_sequence_instances_definition_id ON public.sequence_instances USING btree (definition_id); + +CREATE INDEX ix_sequence_instances_status ON public.sequence_instances USING btree (status); + +CREATE INDEX ix_sequence_instances_subject_entity_type_subject_entity_id ON public.sequence_instances USING btree (subject_entity_type, subject_entity_id); + +CREATE INDEX ix_sequence_resource_clocks_expires_at ON public.sequence_resource_clocks USING btree (expires_at) WHERE ((fired_at IS NULL) AND (deleted_at IS NULL)); + +CREATE INDEX ix_sequence_resource_clocks_resource_type_resource_id ON public.sequence_resource_clocks USING btree (resource_type, resource_id); + +CREATE UNIQUE INDEX ix_sequence_step_definitions_definition_id_key ON public.sequence_step_definitions USING btree (definition_id, key); + +CREATE INDEX ix_sequence_step_instances_dwell_expires_at ON public.sequence_step_instances USING btree (dwell_expires_at) WHERE ((dwell_fired_at IS NULL) AND (dwell_expires_at IS NOT NULL)); + +CREATE UNIQUE INDEX ix_sequence_step_instances_instance_id_step_key ON public.sequence_step_instances USING btree (instance_id, step_key); + CREATE INDEX ix_serial_histories_actor_id ON public.serial_histories USING btree (actor_id); CREATE INDEX ix_serial_histories_occurred_at ON public.serial_histories USING btree (occurred_at); diff --git a/forge.tests/Sequences/SequenceDefinitionHandlerTests.cs b/forge.tests/Sequences/SequenceDefinitionHandlerTests.cs new file mode 100644 index 00000000..eaad2dbe --- /dev/null +++ b/forge.tests/Sequences/SequenceDefinitionHandlerTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; + +using Forge.Api.Features.Sequences; +using Forge.Core.Enums; +using Forge.Core.Models; + +namespace Forge.Tests.Sequences; + +public class SequenceDefinitionHandlerTests +{ + private static SequenceDefinitionRequestModel Serial(string code = "job-basic") => new(code, "Basic routing", null, "Job", + [new("cut", "Cut", null, 0), new("inspect", "Inspect", null, 1), new("ship", "Ship", null, 2)], + [new("cut", "inspect"), new("inspect", "ship")], + [new("inspect", "qc", "First article", SequenceGateSourceType.ManualClearance)]); + + [Fact] + public async Task Create_publish_new_version_and_retire_follow_the_lifecycle() + { + await using var f = new SequenceHandlerFixture(); + var v1 = await new CreateSequenceDefinitionHandler(f.Db).Handle(new CreateSequenceDefinitionCommand(Serial()), default); + v1.Version.Should().Be(1); v1.Status.Should().Be(SequenceDefinitionStatus.Draft); v1.Steps.Should().HaveCount(3); + + var published = await new PublishSequenceDefinitionHandler(f.Db, f.Clock).Handle(new PublishSequenceDefinitionCommand(v1.Id, SequenceHandlerFixture.UserId), default); + published.Status.Should().Be(SequenceDefinitionStatus.Published); + published.PublishedAt.Should().Be(f.Clock.UtcNow); + + // published is immutable + var edit = () => new UpdateSequenceDefinitionHandler(f.Db).Handle(new UpdateSequenceDefinitionCommand(v1.Id, Serial()), default); + await edit.Should().ThrowAsync(); + + var v2 = await new NewSequenceDefinitionVersionHandler(f.Db).Handle(new NewSequenceDefinitionVersionCommand(v1.Id), default); + v2.Version.Should().Be(2); v2.Status.Should().Be(SequenceDefinitionStatus.Draft); v2.Gates.Should().HaveCount(1); + + // publishing v2 retires v1 + await new PublishSequenceDefinitionHandler(f.Db, f.Clock).Handle(new PublishSequenceDefinitionCommand(v2.Id, SequenceHandlerFixture.UserId), default); + (await new GetSequenceDefinitionHandler(f.Db).Handle(new GetSequenceDefinitionQuery(v1.Id), default)).Status.Should().Be(SequenceDefinitionStatus.Retired); + + var all = await new GetSequenceDefinitionsHandler(f.Db).Handle(new GetSequenceDefinitionsQuery("job-basic"), default); + all.Select(d => d.Version).Should().Equal(2, 1); + } + + [Fact] + public async Task Create_rejects_a_structurally_invalid_graph() + { + await using var f = new SequenceHandlerFixture(); + var bad = Serial() with { Edges = [new("cut", "inspect"), new("inspect", "cut")] }; // non-rework cycle + var act = () => new CreateSequenceDefinitionHandler(f.Db).Handle(new CreateSequenceDefinitionCommand(bad), default); + (await act.Should().ThrowAsync()).Which.Message.Should().Contain("Invalid sequence definition"); + } +} diff --git a/forge.tests/Sequences/SequenceEvaluatorTests.cs b/forge.tests/Sequences/SequenceEvaluatorTests.cs new file mode 100644 index 00000000..0db4881e --- /dev/null +++ b/forge.tests/Sequences/SequenceEvaluatorTests.cs @@ -0,0 +1,119 @@ +using FluentAssertions; + +using Forge.Core.Enums; +using Forge.Core.Sequences; + +using static Forge.Tests.Sequences.SequenceTestNets; + +namespace Forge.Tests.Sequences; + +public class SequenceEvaluatorTests +{ + private static readonly DateTimeOffset T0 = new(2026, 8, 18, 12, 0, 0, TimeSpan.Zero); + + [Fact] + public void Start_steps_become_ready_and_the_rest_wait() + { + var d = Serial(); var i = Instance(d); + var r = Eval(d, i, now: T0); + + i.Step("a").Status.Should().Be(SequenceStepStatus.Ready); + i.Step("a").ReadyAt.Should().Be(T0); + i.Step("b").Status.Should().Be(SequenceStepStatus.Pending); + r.NewlyReady.Should().Equal("a"); + r.Events.Should().ContainSingle(e => e.Type == SequenceEventType.StepReady && e.StepKey == "a"); + } + + [Fact] + public void Evaluation_is_idempotent() + { + var d = Serial(); var i = Instance(d); + Eval(d, i, now: T0); + var second = Eval(d, i, now: T0.AddMinutes(1)); + second.Changed.Should().BeFalse(); + second.Events.Should().BeEmpty(); + } + + [Fact] + public void A_gate_that_is_not_go_blocks_the_step_and_reports_why() + { + var d = Serial(); var i = Instance(d); + i.Step("a").Status = SequenceStepStatus.Complete; + var r = Eval(d, i, Verdicts((("b", "inspect"), SequenceGateVerdictResult.NoGo("Awaiting clearance"))), T0); + + i.Step("b").Status.Should().Be(SequenceStepStatus.Pending); + r.Blocked.Should().Equal("b"); + SequenceEvaluator.IsBlocked(new SequenceNet(d), i, "b").Should().BeTrue(); + i.Gate("b", "inspect").Verdict.Should().Be(SequenceGateVerdict.NoGo); + i.Gate("b", "inspect").Reason.Should().Be("Awaiting clearance"); + } + + [Fact] + public void Gate_go_makes_the_step_ready_and_a_later_no_go_returns_it_to_pending() + { + var d = Serial(); var i = Instance(d); + i.Step("a").Status = SequenceStepStatus.Complete; + Eval(d, i, Verdicts((("b", "inspect"), SequenceGateVerdictResult.Go())), T0); + i.Step("b").Status.Should().Be(SequenceStepStatus.Ready); + + var r = Eval(d, i, Verdicts((("b", "inspect"), SequenceGateVerdictResult.NoGo("expired"))), T0.AddHours(1)); + i.Step("b").Status.Should().Be(SequenceStepStatus.Pending); + r.Events.Should().Contain(e => e.Type == SequenceEventType.StepBlocked && e.StepKey == "b"); + } + + [Fact] + public void An_overridden_gate_stays_go_whatever_the_source_says() + { + var d = Serial(); var i = Instance(d); + i.Step("a").Status = SequenceStepStatus.Complete; + i.Gate("b", "inspect").OverriddenAt = T0; i.Gate("b", "inspect").OverrideReason = "supervisor waived"; + Eval(d, i, Verdicts((("b", "inspect"), SequenceGateVerdictResult.NoGo("Awaiting clearance"))), T0); + + i.Gate("b", "inspect").Verdict.Should().Be(SequenceGateVerdict.Go); + i.Gate("b", "inspect").Reason.Should().StartWith("Overridden:"); + i.Step("b").Status.Should().Be(SequenceStepStatus.Ready); + } + + [Fact] + public void Join_all_waits_for_every_predecessor_and_join_any_for_one() + { + var d = ForkJoin(); var i = Instance(d); + i.Step("prep1").Status = SequenceStepStatus.Complete; + Eval(d, i, now: T0); + i.Step("assemble").Status.Should().Be(SequenceStepStatus.Pending, "prep2 is not done"); + + i.Step("prep2").Status = SequenceStepStatus.Skipped; // skipped counts as done + Eval(d, i, now: T0); + i.Step("assemble").Status.Should().Be(SequenceStepStatus.Ready); + + var any = ForkJoin(); any.Steps.First(s => s.Key == "assemble").JoinPolicy = SequenceJoinPolicy.Any; + var j = Instance(any); + j.Step("prep1").Status = SequenceStepStatus.Complete; + Eval(any, j, now: T0); + j.Step("assemble").Status.Should().Be(SequenceStepStatus.Ready, "join policy Any"); + } + + [Fact] + public void Instance_completes_when_every_step_is_complete_or_skipped() + { + var d = Serial(); var i = Instance(d); + foreach (var s in i.Steps) s.Status = SequenceStepStatus.Complete; + i.Step("c").Status = SequenceStepStatus.Skipped; + var r = Eval(d, i, now: T0); + + r.CompletedInstance.Should().BeTrue(); + i.Status.Should().Be(SequenceInstanceStatus.Completed); + i.CompletedAt.Should().Be(T0); + r.Events.Should().ContainSingle(e => e.Type == SequenceEventType.InstanceCompleted); + } + + [Fact] + public void Downstream_of_a_step_follows_only_non_rework_edges() + { + var d = ForkJoin().Edge("ship", "prep1", rework: true); + var net = new SequenceNet(d); + net.Downstream("prep1").Should().BeEquivalentTo(["assemble", "ship"]); + net.Downstream("ship").Should().BeEmpty(); + net.StartSteps().Select(s => s.Key).Should().BeEquivalentTo(["prep1", "prep2"]); + } +} diff --git a/forge.tests/Sequences/SequenceGateSourceTests.cs b/forge.tests/Sequences/SequenceGateSourceTests.cs new file mode 100644 index 00000000..b77bf28b --- /dev/null +++ b/forge.tests/Sequences/SequenceGateSourceTests.cs @@ -0,0 +1,149 @@ +using FluentAssertions; +using MediatR; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +using Forge.Api.Features.DomainEvents; +using Forge.Api.Features.DomainEvents.Handlers; +using Forge.Api.Features.Sequences; +using Forge.Api.Jobs; +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Core.Models; +using Forge.Core.Sequences; + +namespace Forge.Tests.Sequences; + +/// Built-in gate sources + the clock job + the approval reaction, end to end through the real evaluation service. +public class SequenceGateSourceTests +{ + private const int U = SequenceHandlerFixture.UserId; + + private static async Task StartWithGate(SequenceHandlerFixture f, SequenceGateDefinitionModel gate, string? subjectType = "Lot", int? subjectId = 9) + { + var model = new SequenceDefinitionRequestModel("g", "Gate test", null, null, + [new("a", "A", null, 0), new("b", "B", null, 1)], [new("a", "b")], [gate]); + var d = await new CreateSequenceDefinitionHandler(f.Db).Handle(new CreateSequenceDefinitionCommand(model), default); + await new PublishSequenceDefinitionHandler(f.Db, f.Clock).Handle(new PublishSequenceDefinitionCommand(d.Id, U), default); + var i = await new StartSequenceInstanceHandler(f.Db, f.Evaluation, f.Clock) + .Handle(new StartSequenceInstanceCommand(new StartSequenceRequestModel(d.Id, null, subjectType, subjectId), U), default); + return await new CompleteSequenceStepHandler(f.Db, f.Evaluation, f.Clock).Handle(new CompleteSequenceStepCommand(i.Id, "a", U), default); + } + + private static SequenceStepInstanceResponseModel B(SequenceInstanceResponseModel i) => i.Steps.First(s => s.StepKey == "b"); + + [Fact] + public async Task Time_window_gate_opens_and_closes_with_the_clock_via_the_clock_job() + { + await using var f = new SequenceHandlerFixture(); + var opens = f.Clock.UtcNow.AddHours(1); var closes = f.Clock.UtcNow.AddHours(3); + var i = await StartWithGate(f, new("b", "window", "Permit window", SequenceGateSourceType.TimeWindow, + $"{{\"notBefore\":\"{opens:O}\",\"notAfter\":\"{closes:O}\"}}")); + B(i).IsBlocked.Should().BeTrue(); + B(i).BlockedReason.Should().Contain("Window opens"); + + var job = new SequenceClockJob(f.Db, f.Evaluation, f.Clock, f.Publisher.Object, NullLogger.Instance); + f.Clock.Advance(TimeSpan.FromHours(2)); // inside the window + await job.ExecuteAsync(default); + (await Get(f, i.Id)).Steps.First(s => s.StepKey == "b").Status.Should().Be(SequenceStepStatus.Ready); + + f.Clock.Advance(TimeSpan.FromHours(2)); // past notAfter + await job.ExecuteAsync(default); + var after = (await Get(f, i.Id)).Steps.First(s => s.StepKey == "b"); + after.Status.Should().Be(SequenceStepStatus.Pending); + after.BlockedReason.Should().Contain("Window closed"); + } + + [Fact] + public async Task Resource_clock_gate_blocks_once_the_subjects_clock_expires_and_the_job_fires_it_exactly_once() + { + await using var f = new SequenceHandlerFixture(); + await new CreateSequenceResourceClockHandler(f.Db, f.Clock).Handle(new CreateSequenceResourceClockCommand( + new SequenceResourceClockRequestModel("Lot", 9, f.Clock.UtcNow.AddDays(2), SequenceExpiryAction.Escalate, "QA")), default); + var i = await StartWithGate(f, new("b", "fresh", "Lot unexpired", SequenceGateSourceType.ResourceClock, "{\"fromSubject\":true}")); + B(i).Status.Should().Be(SequenceStepStatus.Ready); + i.Gates.Single().Reason.Should().StartWith("Expires"); + + var job = new SequenceClockJob(f.Db, f.Evaluation, f.Clock, f.Publisher.Object, NullLogger.Instance); + f.Clock.Advance(TimeSpan.FromDays(3)); + await job.ExecuteAsync(default); + await job.ExecuteAsync(default); // second pass must not re-fire + + f.Published.OfType().Should().ContainSingle(e => e.ClockKind == "resource" && e.ResourceId == 9 && e.EscalateRole == "QA"); + var b = (await Get(f, i.Id)).Steps.First(s => s.StepKey == "b"); + b.Status.Should().Be(SequenceStepStatus.Pending); + b.BlockedReason.Should().Contain("expired"); + (await new GetSequenceResourceClocksHandler(f.Db, f.Clock).Handle(new GetSequenceResourceClocksQuery("Lot", 9, IncludeFired: true), default)) + .Single().FiredAt.Should().NotBeNull(); + } + + [Fact] + public async Task Dwell_clock_fires_once_and_escalates_to_the_configured_role() + { + await using var f = new SequenceHandlerFixture(); + var model = new SequenceDefinitionRequestModel("dwell", "Dwell", null, null, + [new("hold", "Holding", null, 0, MaxDwellMinutes: 10, DwellExpiryAction: SequenceExpiryAction.Escalate, EscalateRole: "Lead")], [], []); + var d = await new CreateSequenceDefinitionHandler(f.Db).Handle(new CreateSequenceDefinitionCommand(model), default); + await new PublishSequenceDefinitionHandler(f.Db, f.Clock).Handle(new PublishSequenceDefinitionCommand(d.Id, U), default); + var i = await new StartSequenceInstanceHandler(f.Db, f.Evaluation, f.Clock).Handle(new StartSequenceInstanceCommand(new StartSequenceRequestModel(d.Id, null, null, null), U), default); + await new StartSequenceStepHandler(f.Db, f.Evaluation, f.Clock).Handle(new StartSequenceStepCommand(i.Id, "hold", U), default); + + var job = new SequenceClockJob(f.Db, f.Evaluation, f.Clock, f.Publisher.Object, NullLogger.Instance); + f.Clock.Advance(TimeSpan.FromMinutes(11)); + await job.ExecuteAsync(default); + await job.ExecuteAsync(default); + + f.Published.OfType().Should().ContainSingle(e => e.ClockKind == "dwell" && e.StepKey == "hold" && e.EscalateRole == "Lead"); + var events = await new GetSequenceEventsHandler(f.Db).Handle(new GetSequenceEventsQuery(i.Id), default); + events.Count(e => e.Type == SequenceEventType.ClockExpired).Should().Be(1); + events.Count(e => e.Type == SequenceEventType.Escalated).Should().Be(1); + } + + [Fact] + public async Task Approval_gate_goes_when_the_subjects_approval_completes_and_the_reaction_reevaluates() + { + await using var f = new SequenceHandlerFixture(); + f.Db.ApprovalWorkflows.Add(new ApprovalWorkflow { Id = 1, Name = "wf", EntityType = "Job" }); + f.Db.ApprovalRequests.Add(new ApprovalRequest { Id = 1, WorkflowId = 1, EntityType = "Job", EntityId = 5, Status = ApprovalRequestStatus.Pending, RequestedAt = f.Clock.UtcNow }); + await f.Db.SaveChangesAsync(); + var i = await StartWithGate(f, new("b", "signoff", "Engineering sign-off", SequenceGateSourceType.Approval, "{\"fromSubject\":true}"), "Job", 5); + B(i).BlockedReason.Should().Contain("Approval Pending"); + + var req = f.Db.ApprovalRequests.First(); + req.Status = ApprovalRequestStatus.Approved; req.CompletedAt = f.Clock.UtcNow; + await f.Db.SaveChangesAsync(); + + // the reaction dispatches ReevaluateSequenceCommand through MediatR — run the handler directly here + var mediator = new Mock(); + mediator.Setup(m => m.Send(It.IsAny(), It.IsAny())) + .Returns((c, ct) => new ReevaluateSequenceHandler(f.Db, f.Evaluation).Handle(c, ct)); + await new OnApprovalCompleted_ReevaluateSequences(f.Db, mediator.Object).Handle(new ApprovalCompletedEvent("Job", 5, true, U, null), default); + + (await Get(f, i.Id)).Steps.First(s => s.StepKey == "b").Status.Should().Be(SequenceStepStatus.Ready); + } + + [Fact] + public async Task Custom_gate_with_no_registered_source_fails_closed_and_a_registered_one_is_consulted() + { + await using var unknown = new SequenceHandlerFixture(); + var i = await StartWithGate(unknown, new("b", "mat", "Materials", SequenceGateSourceType.Custom, "{\"key\":\"materials-ready\"}")); + B(i).IsBlocked.Should().BeTrue(); + B(i).BlockedReason.Should().Contain("No gate source registered"); + + await using var known = new SequenceHandlerFixture(extraSources: new StubMaterialsGate()); + var j = await StartWithGate(known, new("b", "mat", "Materials", SequenceGateSourceType.Custom, "{\"key\":\"materials-ready\"}")); + B(j).Status.Should().Be(SequenceStepStatus.Ready); + j.Gates.Single().Reason.Should().Be("All BOM lines issued"); + } + + private static Task Get(SequenceHandlerFixture f, int id) => + new GetSequenceInstanceHandler(f.Db).Handle(new GetSequenceInstanceQuery(id), default); + + private sealed class StubMaterialsGate : IGateSource + { + public SequenceGateSourceType SourceType => SequenceGateSourceType.Custom; + public string? CustomKey => "materials-ready"; + public Task EvaluateAsync(SequenceGateContext context, CancellationToken cancellationToken) => + Task.FromResult(SequenceGateVerdictResult.Go("All BOM lines issued")); + } +} diff --git a/forge.tests/Sequences/SequenceHandlerFixture.cs b/forge.tests/Sequences/SequenceHandlerFixture.cs new file mode 100644 index 00000000..8a2998ff --- /dev/null +++ b/forge.tests/Sequences/SequenceHandlerFixture.cs @@ -0,0 +1,47 @@ +using MediatR; +using Moq; + +using Forge.Api.Features.Sequences.GateSources; +using Forge.Api.Services; +using Forge.Core.Interfaces; +using Forge.Core.Sequences; +using Forge.Data.Context; +using Forge.Tests.Helpers; + +namespace Forge.Tests.Sequences; + +/// Wires the real evaluation service (real gate sources, InMemory db, fixed clock, capturing publisher) for handler tests. +public sealed class SequenceHandlerFixture : IAsyncDisposable +{ + public SequenceHandlerFixture(DateTimeOffset? now = null, params IGateSource[] extraSources) + { + Db = TestDbContextFactory.Create(); + Db.CurrentUserId = UserId; + Clock = new MutableClock(now ?? new DateTimeOffset(2026, 8, 18, 12, 0, 0, TimeSpan.Zero)); + Publisher = new Mock(); + Publisher.Setup(p => p.Publish(It.IsAny(), It.IsAny())) + .Callback((n, _) => Published.Add(n)) + .Returns(Task.CompletedTask); + var sources = new List + { + new ManualClearanceGateSource(), new TimeWindowGateSource(), new ResourceClockGateSource(Db), new ApprovalGateSource(Db), + }; + sources.AddRange(extraSources); + Evaluation = new SequenceEvaluationService(Db, sources, Clock, Publisher.Object); + } + + public const int UserId = 7; + public AppDbContext Db { get; } + public MutableClock Clock { get; } + public Mock Publisher { get; } + public List Published { get; } = []; + public ISequenceEvaluationService Evaluation { get; } + + public ValueTask DisposeAsync() => Db.DisposeAsync(); + + public sealed class MutableClock(DateTimeOffset start) : IClock + { + public DateTimeOffset UtcNow { get; set; } = start; + public void Advance(TimeSpan by) => UtcNow += by; + } +} diff --git a/forge.tests/Sequences/SequenceInstanceHandlerTests.cs b/forge.tests/Sequences/SequenceInstanceHandlerTests.cs new file mode 100644 index 00000000..50520761 --- /dev/null +++ b/forge.tests/Sequences/SequenceInstanceHandlerTests.cs @@ -0,0 +1,153 @@ +using FluentAssertions; + +using Forge.Api.Features.DomainEvents; +using Forge.Api.Features.Sequences; +using Forge.Core.Enums; +using Forge.Core.Models; + +namespace Forge.Tests.Sequences; + +public class SequenceInstanceHandlerTests +{ + private const int U = SequenceHandlerFixture.UserId; + + private static async Task PublishedSerial(SequenceHandlerFixture f, params SequenceGateDefinitionModel[] gates) + { + var model = new SequenceDefinitionRequestModel("job-basic", "Basic routing", null, "Job", + [new("cut", "Cut", null, 0), new("inspect", "Inspect", null, 1, MaxDwellMinutes: 30, DwellExpiryAction: SequenceExpiryAction.Escalate, EscalateRole: "Supervisor"), new("ship", "Ship", null, 2)], + [new("cut", "inspect"), new("inspect", "ship")], + gates.Length > 0 ? gates : [new("inspect", "qc", "First article", SequenceGateSourceType.ManualClearance)]); + var d = await new CreateSequenceDefinitionHandler(f.Db).Handle(new CreateSequenceDefinitionCommand(model), default); + await new PublishSequenceDefinitionHandler(f.Db, f.Clock).Handle(new PublishSequenceDefinitionCommand(d.Id, U), default); + return d.Id; + } + + private static Task Start(SequenceHandlerFixture f, int defId, int? subjectId = 42) => + new StartSequenceInstanceHandler(f.Db, f.Evaluation, f.Clock).Handle(new StartSequenceInstanceCommand(new StartSequenceRequestModel(defId, null, "Job", subjectId), U), default); + + private static SequenceStepInstanceResponseModel Step(SequenceInstanceResponseModel i, string key) => i.Steps.First(s => s.StepKey == key); + + [Fact] + public async Task Start_makes_the_first_step_ready_and_a_manual_gate_blocks_the_second_until_cleared() + { + await using var f = new SequenceHandlerFixture(); + var defId = await PublishedSerial(f); + var i = await Start(f, defId); + + i.Status.Should().Be(SequenceInstanceStatus.Running); + Step(i, "cut").Status.Should().Be(SequenceStepStatus.Ready); + Step(i, "inspect").Status.Should().Be(SequenceStepStatus.Pending); + f.Published.OfType().Should().ContainSingle(e => e.StepKey == "cut" && e.SubjectEntityId == 42); + + i = await new CompleteSequenceStepHandler(f.Db, f.Evaluation, f.Clock).Handle(new CompleteSequenceStepCommand(i.Id, "cut", U), default); + Step(i, "cut").Status.Should().Be(SequenceStepStatus.Complete); + Step(i, "inspect").IsBlocked.Should().BeTrue(); + Step(i, "inspect").BlockedReason.Should().Contain("First article").And.Contain("Awaiting clearance"); + + i = await new ClearSequenceGateHandler(f.Db, f.Evaluation, f.Clock).Handle(new ClearSequenceGateCommand(i.Id, "inspect", "qc", U), default); + Step(i, "inspect").Status.Should().Be(SequenceStepStatus.Ready); + i.Gates.Single().Verdict.Should().Be(SequenceGateVerdict.Go); + i.Gates.Single().ClearedByUserId.Should().Be(U); + } + + [Fact] + public async Task Complete_every_step_completes_the_instance_and_the_event_log_tells_the_story() + { + await using var f = new SequenceHandlerFixture(); + var defId = await PublishedSerial(f); + var i = await Start(f, defId); + var complete = new CompleteSequenceStepHandler(f.Db, f.Evaluation, f.Clock); + var start = new StartSequenceStepHandler(f.Db, f.Evaluation, f.Clock); + + await complete.Handle(new CompleteSequenceStepCommand(i.Id, "cut", U), default); + await new ClearSequenceGateHandler(f.Db, f.Evaluation, f.Clock).Handle(new ClearSequenceGateCommand(i.Id, "inspect", "qc", U), default); + i = await start.Handle(new StartSequenceStepCommand(i.Id, "inspect", U), default); + Step(i, "inspect").Status.Should().Be(SequenceStepStatus.InProgress); + Step(i, "inspect").DwellExpiresAt.Should().Be(f.Clock.UtcNow.AddMinutes(30)); + await complete.Handle(new CompleteSequenceStepCommand(i.Id, "inspect", U), default); + i = await complete.Handle(new CompleteSequenceStepCommand(i.Id, "ship", U), default); + + i.Status.Should().Be(SequenceInstanceStatus.Completed); + f.Published.OfType().Should().ContainSingle(); + + var events = await new GetSequenceEventsHandler(f.Db).Handle(new GetSequenceEventsQuery(i.Id), default); + events.Select(e => e.Type).Should().ContainInOrder( + SequenceEventType.InstanceStarted, SequenceEventType.StepReady, SequenceEventType.StepCompleted, + SequenceEventType.GateCleared, SequenceEventType.GateEvaluated, SequenceEventType.StepReady, + SequenceEventType.StepStarted, SequenceEventType.StepCompleted, SequenceEventType.StepReady, + SequenceEventType.StepCompleted, SequenceEventType.InstanceCompleted); + + // a completed run refuses further step commands + var again = () => complete.Handle(new CompleteSequenceStepCommand(i.Id, "ship", U), default); + await again.Should().ThrowAsync(); + } + + [Fact] + public async Task Override_forces_a_gate_go_with_a_reason_and_skip_counts_as_done() + { + await using var f = new SequenceHandlerFixture(); + var defId = await PublishedSerial(f); + var i = await Start(f, defId); + await new CompleteSequenceStepHandler(f.Db, f.Evaluation, f.Clock).Handle(new CompleteSequenceStepCommand(i.Id, "cut", U), default); + + var noReason = () => new OverrideSequenceGateHandler(f.Db, f.Evaluation, f.Clock).Handle(new OverrideSequenceGateCommand(i.Id, "inspect", "qc", " ", U), default); + await noReason.Should().ThrowAsync(); + + i = await new OverrideSequenceGateHandler(f.Db, f.Evaluation, f.Clock).Handle(new OverrideSequenceGateCommand(i.Id, "inspect", "qc", "Supervisor waived FAI", U), default); + Step(i, "inspect").Status.Should().Be(SequenceStepStatus.Ready); + i.Gates.Single().OverrideReason.Should().Be("Supervisor waived FAI"); + + i = await new SkipSequenceStepHandler(f.Db, f.Evaluation, f.Clock).Handle(new SkipSequenceStepCommand(i.Id, "inspect", "Customer waived inspection", U), default); + Step(i, "inspect").Status.Should().Be(SequenceStepStatus.Skipped); + Step(i, "ship").Status.Should().Be(SequenceStepStatus.Ready, "a skipped predecessor counts as done"); + } + + [Fact] + public async Task Rework_resets_the_target_and_everything_downstream_including_clearances_and_overrides() + { + await using var f = new SequenceHandlerFixture(); + var defId = await PublishedSerial(f); + var i = await Start(f, defId); + var complete = new CompleteSequenceStepHandler(f.Db, f.Evaluation, f.Clock); + await complete.Handle(new CompleteSequenceStepCommand(i.Id, "cut", U), default); + await new ClearSequenceGateHandler(f.Db, f.Evaluation, f.Clock).Handle(new ClearSequenceGateCommand(i.Id, "inspect", "qc", U), default); + await complete.Handle(new CompleteSequenceStepCommand(i.Id, "inspect", U), default); + + i = await new ReworkSequenceHandler(f.Db, f.Evaluation, f.Clock).Handle(new ReworkSequenceCommand(i.Id, "cut", "Wrong material", U), default); + Step(i, "cut").Status.Should().Be(SequenceStepStatus.Ready, "cut is a start step, so it is immediately ready again"); + Step(i, "inspect").Status.Should().Be(SequenceStepStatus.Pending); + Step(i, "inspect").CompletedAt.Should().BeNull(); + i.Gates.Single().ClearedAt.Should().BeNull("clearances downstream of the rework point are void"); + i.Gates.Single().Verdict.Should().Be(SequenceGateVerdict.NoGo, "re-evaluated: awaiting clearance again"); + (await new GetSequenceEventsHandler(f.Db).Handle(new GetSequenceEventsQuery(i.Id), default)) + .Should().Contain(e => e.Type == SequenceEventType.Reworked && e.PayloadJson!.Contains("Wrong material")); + } + + [Fact] + public async Task Cancel_is_terminal_and_requires_a_reason() + { + await using var f = new SequenceHandlerFixture(); + var defId = await PublishedSerial(f); + var i = await Start(f, defId); + var h = new CancelSequenceInstanceHandler(f.Db, f.Clock); + await (((Func)(() => h.Handle(new CancelSequenceInstanceCommand(i.Id, "", U), default))).Should().ThrowAsync()); + i = await h.Handle(new CancelSequenceInstanceCommand(i.Id, "Order withdrawn", U), default); + i.Status.Should().Be(SequenceInstanceStatus.Cancelled); + i.CancelReason.Should().Be("Order withdrawn"); + } + + [Fact] + public async Task Start_refuses_drafts_and_resolves_latest_published_by_code() + { + await using var f = new SequenceHandlerFixture(); + var defId = await PublishedSerial(f); + var draft = await new NewSequenceDefinitionVersionHandler(f.Db).Handle(new NewSequenceDefinitionVersionCommand(defId), default); + var startDraft = () => Start(f, draft.Id); + await startDraft.Should().ThrowAsync(); + + var byCode = await new StartSequenceInstanceHandler(f.Db, f.Evaluation, f.Clock) + .Handle(new StartSequenceInstanceCommand(new StartSequenceRequestModel(null, "job-basic", "Job", 1), U), default); + byCode.DefinitionId.Should().Be(defId); + byCode.DefinitionVersion.Should().Be(1); + } +} diff --git a/forge.tests/Sequences/SequenceNetValidatorTests.cs b/forge.tests/Sequences/SequenceNetValidatorTests.cs new file mode 100644 index 00000000..3afa90b6 --- /dev/null +++ b/forge.tests/Sequences/SequenceNetValidatorTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; + +using Forge.Core.Sequences; + +using static Forge.Tests.Sequences.SequenceTestNets; + +namespace Forge.Tests.Sequences; + +public class SequenceNetValidatorTests +{ + [Fact] + public void Accepts_a_serial_and_a_fork_join_net() + { + SequenceNetValidator.Validate(Serial()).Should().BeEmpty(); + SequenceNetValidator.Validate(ForkJoin()).Should().BeEmpty(); + } + + [Fact] + public void Rejects_duplicate_step_keys_and_dangling_edges() + { + var d = Definition("x", "a", "a").Edge("a", "zzz"); + var errors = SequenceNetValidator.Validate(d); + errors.Should().Contain(e => e.Contains("Duplicate step key 'a'")); + errors.Should().Contain(e => e.Contains("unknown step 'zzz'")); + } + + [Fact] + public void Rejects_a_cycle_unless_it_is_a_rework_edge() + { + var cyclic = Definition("c", "a", "b").Edge("a", "b").Edge("b", "a"); + SequenceNetValidator.Validate(cyclic).Should().Contain(e => e.Contains("cycle") || e.Contains("No start step")); + + var rework = Definition("r", "a", "b").Edge("a", "b").Edge("b", "a", rework: true); + SequenceNetValidator.Validate(rework).Should().BeEmpty(); + } + + [Fact] + public void Rejects_gates_on_unknown_steps() + { + var d = Definition("g", "a", "b").Edge("a", "b").Gate("nope", "g"); + SequenceNetValidator.Validate(d).Should().Contain(e => e.Contains("unknown step 'nope'")); + } + + [Fact] + public void A_step_whose_only_incoming_edge_is_rework_is_a_start_step() + { + // rework edges never gate readiness, so 'island' has no real predecessor and starts immediately — valid. + var d = Definition("u", "a", "b", "island").Edge("a", "b").Edge("b", "island", rework: true); + SequenceNetValidator.Validate(d).Should().BeEmpty(); + new SequenceNet(d).StartSteps().Select(s => s.Key).Should().BeEquivalentTo(["a", "island"]); + } + + [Fact] + public void A_closed_cycle_off_the_main_path_is_unreachable_and_reported() + { + // a → b is fine; c ⇄ d has no entry point → both a cycle and unreachable steps. + var d = Definition("cyc", "a", "b", "c", "d").Edge("a", "b").Edge("c", "d").Edge("d", "c"); + var errors = SequenceNetValidator.Validate(d); + errors.Should().Contain(e => e.Contains("'c' is unreachable")); + errors.Should().Contain(e => e.Contains("'d' is unreachable")); + errors.Should().Contain(e => e.Contains("cycle")); + } +} diff --git a/forge.tests/Sequences/SequenceTestNets.cs b/forge.tests/Sequences/SequenceTestNets.cs new file mode 100644 index 00000000..1b9365b0 --- /dev/null +++ b/forge.tests/Sequences/SequenceTestNets.cs @@ -0,0 +1,53 @@ +using Forge.Core.Entities; +using Forge.Core.Enums; +using Forge.Core.Sequences; + +namespace Forge.Tests.Sequences; + +/// Builders for the small nets the engine tests use. +public static class SequenceTestNets +{ + public static SequenceDefinition Definition(string code, params string[] steps) + { + var d = new SequenceDefinition { Id = 1, Code = code, Version = 1, Name = code, Status = SequenceDefinitionStatus.Published }; + var i = 0; + foreach (var s in steps) d.Steps.Add(new SequenceStepDefinition { Key = s, Name = s.ToUpperInvariant(), SortOrder = i++ }); + return d; + } + + public static SequenceDefinition Edge(this SequenceDefinition d, string from, string to, bool rework = false) + { + d.Edges.Add(new SequenceEdgeDefinition { FromStepKey = from, ToStepKey = to, IsRework = rework }); + return d; + } + + public static SequenceDefinition Gate(this SequenceDefinition d, string step, string key, SequenceGateSourceType type = SequenceGateSourceType.ManualClearance, string config = "{}") + { + d.Gates.Add(new SequenceGateDefinition { StepKey = step, Key = key, Name = key, SourceType = type, ConfigJson = config }); + return d; + } + + /// A serial a → b → c with a manual gate on b. + public static SequenceDefinition Serial() => Definition("serial", "a", "b", "c").Edge("a", "b").Edge("b", "c").Gate("b", "inspect"); + + /// Fork/join: prep1, prep2 → assemble → ship. + public static SequenceDefinition ForkJoin() => + Definition("forkjoin", "prep1", "prep2", "assemble", "ship").Edge("prep1", "assemble").Edge("prep2", "assemble").Edge("assemble", "ship"); + + public static SequenceInstance Instance(SequenceDefinition d, int id = 1) + { + var i = new SequenceInstance { Id = id, DefinitionId = d.Id, Definition = d, Status = SequenceInstanceStatus.Running, StartedAt = DateTimeOffset.UnixEpoch }; + foreach (var s in d.Steps) i.Steps.Add(new SequenceStepInstance { InstanceId = id, StepKey = s.Key }); + foreach (var g in d.Gates) i.Gates.Add(new SequenceGateInstance { InstanceId = id, StepKey = g.StepKey, GateKey = g.Key }); + return i; + } + + public static Dictionary<(string, string), SequenceGateVerdictResult> Verdicts(params ((string, string) Gate, SequenceGateVerdictResult V)[] items) => + items.ToDictionary(x => x.Gate, x => x.V); + + public static SequenceEvaluation Eval(SequenceDefinition d, SequenceInstance i, Dictionary<(string, string), SequenceGateVerdictResult>? verdicts = null, DateTimeOffset? now = null) => + SequenceEvaluator.Evaluate(new SequenceNet(d), i, verdicts ?? new(), now ?? DateTimeOffset.UnixEpoch); + + public static SequenceStepInstance Step(this SequenceInstance i, string key) => i.Steps.First(s => s.StepKey == key); + public static SequenceGateInstance Gate(this SequenceInstance i, string step, string key) => i.Gates.First(g => g.StepKey == step && g.GateKey == key); +}