Documentation
fsds/catalog-item-status-scoping-and-gates.md
Item Status Scoping & Gates Implementation Plan
Overview
Implement enforcement of item status restrictions based on category/type and add status transition gates.
Key Decisions:
- Scope levels are mutually exclusive: Global, Category (multiple), or ItemType (multiple)
- Use
collection_levelvalues fromprism.platform_types(0=Global, 10=Category, 20=Type) - Single junction table
item_status_scopeswith FK toprism.passportsfor polymorphic entity references - Migrate existing junction table data, then remove old tables
- Status gates are permissive by default (no gates = all transitions allowed)
Stage 1: Database Schema Changes
1.1 Update ItemStatus Entity
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Database/ItemStatus.cs
Add column:
scope_level(smallint, default 0)0= Global (applies to any item)10= Category level (matchesplatform_types.collection_levelfor ITEM_CATEGORY)20= Type level (matchesplatform_types.collection_levelfor ITEM_TYPE)
Add navigation properties:
Scopes(collection ofItemStatusScope)OutgoingGates/IncomingGates(collections for status gates)
1.2 Create ItemStatusScope Entity (replaces both old junction tables)
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Database/ItemStatusScope.cs
[Table("item_status_scopes")]
[PrimaryKey(nameof(ItemStatusId), nameof(ScopePassportId))]
[Index(nameof(ScopePassportId))]
public class ItemStatusScope
{
public const string TABLE_NAME = "item_status_scopes";
[Column("item_status_id", Order = 0)]
[Required]
public Guid ItemStatusId { get; set; }
/// <summary>
/// FK to prism.passports.global_id - can reference ItemCategory or ItemType
/// </summary>
[Column("scope_passport_id", Order = 1)]
[Required]
public Guid ScopePassportId { get; set; }
// Navigation
[ForeignKey(nameof(ItemStatusId))]
public virtual ItemStatus ItemStatus { get; set; } = null!;
// Note: FK to prism.passports configured as external reference
}
1.3 Update ItemStatusGate Entity
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Database/ItemStatusGate.cs
Current entity exists but needs:
- Add
[PrimaryKey(nameof(CurrentItemStatusId), nameof(CanUpdateToItemStatusId))] - Add
TenantIdcolumn (currently missing) - Add
InversePropertyattributes for navigation properties - Add indexes
1.4 Update CatalogDb
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Database/CatalogDb.cs
- Add
DbSet<ItemStatusScope> - Add
DbSet<ItemStatusGate> - Configure external reference to
prism.passportsforScopePassportId - Configure cascade delete behaviors
1.5 Create Journal Entities (in Prism schema)
Journal tables follow ADR 035 - all journal tables reside in the prism schema.
File: engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/ItemStatusLog.cs
[Table(TABLE_NAME, Schema = "prism")]
[NoReorder]
public class ItemStatusLog : JournalTable
{
public const string TABLE_NAME = "catalog_item_statuses_journal";
[Column("id", Order = 0)]
public Guid Id { get; set; }
[Column("name", Order = 1)]
public string Name { get; set; } = null!;
[Column("description", Order = 2)]
public string? Description { get; set; }
[Column("reason_code_required", Order = 3)]
public bool ReasonCodeRequired { get; set; }
[Column("destroy_date_required", Order = 4)]
public bool DestroyDateRequired { get; set; }
[Column("point_of_use_prohibited", Order = 5)]
public bool PointOfUseProhibited { get; set; }
[Column("indicates_expiration", Order = 6)]
public bool IndicatesExpiration { get; set; }
[Column("scope_level", Order = 7)]
public short ScopeLevel { get; set; }
[Column("tenant_id", Order = 8)]
public Guid TenantId { get; set; }
[Column("platform_type_id", Order = 9)]
public short PlatformTypeId { get; set; }
}
File: engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/ItemStatusScopeLog.cs
[Table(TABLE_NAME, Schema = "prism")]
[NoReorder]
public class ItemStatusScopeLog : JournalTable
{
public const string TABLE_NAME = "catalog_item_status_scopes_journal";
[Column("item_status_id", Order = 0)]
public Guid ItemStatusId { get; set; }
[Column("scope_passport_id", Order = 1)]
public Guid ScopePassportId { get; set; }
}
File: engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/ItemStatusGateLog.cs
[Table(TABLE_NAME, Schema = "prism")]
[NoReorder]
public class ItemStatusGateLog : JournalTable
{
public const string TABLE_NAME = "catalog_item_status_gates_journal";
[Column("current_item_status_id", Order = 0)]
public Guid CurrentItemStatusId { get; set; }
[Column("can_update_to_item_status_id", Order = 1)]
public Guid CanUpdateToItemStatusId { get; set; }
[Column("tenant_id", Order = 2)]
public Guid TenantId { get; set; }
}
1.6 Update PrismDb for Journal Tables
File: engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/PrismDb.cs
Add DbSets:
public virtual DbSet<ItemStatusLog> ItemStatusesJournal { get; set; }
public virtual DbSet<ItemStatusScopeLog> ItemStatusScopesJournal { get; set; }
public virtual DbSet<ItemStatusGateLog> ItemStatusGatesJournal { get; set; }
File: engines/prism/src/Acsis.Dynaplex.Engines.Prism.Database/PrismDbModelConfiguration.cs
Add composite key configuration:
modelBuilder.Entity<ItemStatusLog>().HasKey(l => new { l.Id, l.SeqId });
modelBuilder.Entity<ItemStatusScopeLog>().HasKey(l => new { l.ItemStatusId, l.ScopePassportId, l.SeqId });
modelBuilder.Entity<ItemStatusGateLog>().HasKey(l => new { l.CurrentItemStatusId, l.CanUpdateToItemStatusId, l.SeqId });
1.7 Update CatalogDb for Journal Triggers
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Database/CatalogDb.cs
Configure HasJournal() on source entities:
modelBuilder.Entity<ItemStatus>().HasJournal();
modelBuilder.Entity<ItemStatusScope>().HasJournal();
modelBuilder.Entity<ItemStatusGate>().HasJournal();
1.8 Create Migrations
Migration 1: AddItemStatusScopingAndGates
-- Add scope_level to item_statuses
ALTER TABLE catalog.item_statuses
ADD COLUMN scope_level smallint NOT NULL DEFAULT 0;
-- Create item_status_scopes table
CREATE TABLE catalog.item_status_scopes (
item_status_id uuid NOT NULL,
scope_passport_id uuid NOT NULL,
PRIMARY KEY (item_status_id, scope_passport_id),
FOREIGN KEY (item_status_id) REFERENCES catalog.item_statuses(id) ON DELETE CASCADE,
FOREIGN KEY (scope_passport_id) REFERENCES prism.passports(global_id) ON DELETE CASCADE
);
CREATE INDEX IX_item_status_scopes_scope_passport_id ON catalog.item_status_scopes(scope_passport_id);
-- Create item_status_gates table
CREATE TABLE catalog.item_status_gates (
current_item_status_id uuid NOT NULL,
can_update_to_item_status_id uuid NOT NULL,
tenant_id uuid NOT NULL,
PRIMARY KEY (current_item_status_id, can_update_to_item_status_id),
FOREIGN KEY (current_item_status_id) REFERENCES catalog.item_statuses(id) ON DELETE CASCADE,
FOREIGN KEY (can_update_to_item_status_id) REFERENCES catalog.item_statuses(id) ON DELETE CASCADE,
FOREIGN KEY (tenant_id) REFERENCES identity.tenants(id) ON DELETE CASCADE
);
Migration 2: MigrateItemStatusScopeData
-- Migrate category associations (scope_level = 10)
-- Set scope_level for statuses with ONLY category associations
UPDATE catalog.item_statuses s
SET scope_level = 10
WHERE EXISTS (SELECT 1 FROM catalog.item_status_categories c WHERE c.item_status_id = s.id)
AND NOT EXISTS (SELECT 1 FROM catalog.item_status_types t WHERE t.item_status_id = s.id);
-- Insert category passport IDs into scopes table
INSERT INTO catalog.item_status_scopes (item_status_id, scope_passport_id)
SELECT c.item_status_id, c.item_category_id
FROM catalog.item_status_categories c
JOIN catalog.item_statuses s ON s.id = c.item_status_id
WHERE s.scope_level = 10;
-- Migrate type associations (scope_level = 20)
-- Set scope_level for statuses with type associations (takes precedence)
UPDATE catalog.item_statuses s
SET scope_level = 20
WHERE EXISTS (SELECT 1 FROM catalog.item_status_types t WHERE t.item_status_id = s.id);
-- Insert type passport IDs into scopes table
INSERT INTO catalog.item_status_scopes (item_status_id, scope_passport_id)
SELECT t.item_status_id, t.item_type_id
FROM catalog.item_status_types t
JOIN catalog.item_statuses s ON s.id = t.item_status_id
WHERE s.scope_level = 20;
Migration 3: DropOldItemStatusJunctionTables
DROP TABLE catalog.item_status_categories;
DROP TABLE catalog.item_status_types;
1.9 Create Prism Migration for Journal Tables
Migration (Prism): AddCatalogStatusJournalTables
-- Create journal tables
CREATE TABLE prism.catalog_item_statuses_journal (
id uuid NOT NULL,
name text NOT NULL,
description text,
reason_code_required boolean NOT NULL,
destroy_date_required boolean NOT NULL,
point_of_use_prohibited boolean NOT NULL,
indicates_expiration boolean NOT NULL,
scope_level smallint NOT NULL,
tenant_id uuid NOT NULL,
platform_type_id smallint NOT NULL,
seq_id integer NOT NULL,
valid_from timestamp NOT NULL,
valid_to timestamp,
initiated_by_user_id uuid NOT NULL,
invalidated_by_user_id uuid,
PRIMARY KEY (id, seq_id)
);
CREATE TABLE prism.catalog_item_status_scopes_journal (
item_status_id uuid NOT NULL,
scope_passport_id uuid NOT NULL,
seq_id integer NOT NULL,
valid_from timestamp NOT NULL,
valid_to timestamp,
initiated_by_user_id uuid NOT NULL,
invalidated_by_user_id uuid,
PRIMARY KEY (item_status_id, scope_passport_id, seq_id)
);
CREATE TABLE prism.catalog_item_status_gates_journal (
current_item_status_id uuid NOT NULL,
can_update_to_item_status_id uuid NOT NULL,
tenant_id uuid NOT NULL,
seq_id integer NOT NULL,
valid_from timestamp NOT NULL,
valid_to timestamp,
initiated_by_user_id uuid NOT NULL,
invalidated_by_user_id uuid,
PRIMARY KEY (current_item_status_id, can_update_to_item_status_id, seq_id)
);
-- Create triggers on source tables (in catalog schema)
CREATE TRIGGER trg_item_statuses_journal
AFTER INSERT OR UPDATE ON catalog.item_statuses
FOR EACH ROW EXECUTE FUNCTION prism.journal_writer();
CREATE TRIGGER trg_item_status_scopes_journal
AFTER INSERT OR UPDATE ON catalog.item_status_scopes
FOR EACH ROW EXECUTE FUNCTION prism.journal_writer();
CREATE TRIGGER trg_item_status_gates_journal
AFTER INSERT OR UPDATE ON catalog.item_status_gates
FOR EACH ROW EXECUTE FUNCTION prism.journal_writer();
Migration Order:
- Prism migration runs first (creates journal tables)
- Catalog migration runs second (creates source tables with triggers)
Stage 2: StatusDraft & API Updates
2.1 Update StatusDraft
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Abstractions/Drafts/StatusDraft.cs
public class StatusDraft {
public Guid? Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
// Scope configuration
/// <summary>
/// 0 = Global, 10 = Category, 20 = ItemType (from platform_types.collection_level)
/// </summary>
public short ScopeLevel { get; set; } = 0;
/// <summary>
/// Passport IDs for scoped entities (categories or types).
/// All must have matching collection_level in platform_types.
/// </summary>
public List<Guid> ScopePassportIds { get; set; } = new();
// DEPRECATED: Remove after migration
// public List<Guid> CategoryIds { get; set; }
}
2.2 Create StatusGateDraft
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Abstractions/Drafts/StatusGateDraft.cs
public class StatusGateDraft {
[Required] public Guid ToStatusId { get; set; }
}
2.3 Update StatusApi Endpoints
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog/ApiEngines/StatusApi.cs
Update existing:
POST /statuses- Handle ScopeLevel and ScopePassportIdsPUT /statuses/{id}- Handle scope updates
Add new endpoints:
GET /statuses/{id}/gates- Get allowed transitions from this statusPOST /statuses/{id}/gates- Add allowed transitionDELETE /statuses/{id}/gates/{toStatusId}- Remove transitionGET /statuses/gates- Get all defined gates
Validation on Create/Update:
- If ScopeLevel = 0, ScopePassportIds must be empty
- If ScopeLevel > 0, ScopePassportIds must have at least one entry
- All passport IDs must exist in
prism.passports - All referenced passports must have platform_type with matching
collection_level
Stage 3: Validation Service
3.1 Create StatusValidationService
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog/Services/StatusValidationService.cs
public class StatusValidationService(CatalogDb catalogDb, PrismDb prismDb) {
/// <summary>
/// Validates that a status can be assigned to an item of the given type.
/// </summary>
public async Task<StatusScopeValidationResult> ValidateStatusForItemAsync(
Guid statusId,
Guid itemTypeId,
CancellationToken ct)
{
var status = await catalogDb.ItemStatuses
.Include(s => s.Scopes)
.FirstOrDefaultAsync(s => s.Id == statusId, ct);
if (status is null)
return StatusScopeValidationResult.StatusNotFound(statusId);
// Global scope - always valid
if (status.ScopeLevel == 0)
return StatusScopeValidationResult.Valid();
// Get item type with its category
var itemType = await catalogDb.ItemTypes
.Where(t => t.Id == itemTypeId)
.Select(t => new { t.Id, t.ItemCategoryId })
.FirstOrDefaultAsync(ct);
if (itemType is null)
return StatusScopeValidationResult.ItemTypeNotFound(itemTypeId);
var scopePassportIds = status.Scopes.Select(s => s.ScopePassportId).ToHashSet();
// Category-scoped (level 10): check if item's category is in scopes
if (status.ScopeLevel == 10) {
if (itemType.ItemCategoryId.HasValue && scopePassportIds.Contains(itemType.ItemCategoryId.Value))
return StatusScopeValidationResult.Valid();
return StatusScopeValidationResult.StatusNotAllowedForCategory(status.Name);
}
// Type-scoped (level 20): check if item's type is in scopes
if (status.ScopeLevel == 20) {
if (scopePassportIds.Contains(itemTypeId))
return StatusScopeValidationResult.Valid();
return StatusScopeValidationResult.StatusNotAllowedForType(status.Name);
}
return StatusScopeValidationResult.Valid();
}
/// <summary>
/// Validates status transition (gate check). Permissive by default.
/// </summary>
public async Task<StatusGateValidationResult> ValidateStatusTransitionAsync(
Guid currentStatusId,
Guid newStatusId,
CancellationToken ct)
{
if (currentStatusId == newStatusId)
return StatusGateValidationResult.Valid();
// Check if any gates are defined for current status
var hasGates = await catalogDb.ItemStatusGates
.AnyAsync(g => g.CurrentItemStatusId == currentStatusId, ct);
// Permissive: no gates defined = all transitions allowed
if (!hasGates)
return StatusGateValidationResult.Valid();
// Gates defined - check if this specific transition is allowed
var allowed = await catalogDb.ItemStatusGates
.AnyAsync(g => g.CurrentItemStatusId == currentStatusId
&& g.CanUpdateToItemStatusId == newStatusId, ct);
if (allowed)
return StatusGateValidationResult.Valid();
return StatusGateValidationResult.TransitionNotAllowed(currentStatusId, newStatusId);
}
/// <summary>
/// Validates that all passport IDs have the same collection_level as specified.
/// </summary>
public async Task<bool> ValidateScopeConsistencyAsync(
short expectedScopeLevel,
IEnumerable<Guid> passportIds,
CancellationToken ct)
{
if (expectedScopeLevel == 0)
return !passportIds.Any(); // Global must have no scopes
var passportList = passportIds.ToList();
if (!passportList.Any())
return false; // Non-global must have at least one scope
// Check all passports have matching collection_level
var mismatchCount = await prismDb.Passports
.Where(p => passportList.Contains(p.GlobalId))
.Where(p => p.PlatformType.CollectionLevel != expectedScopeLevel)
.CountAsync(ct);
return mismatchCount == 0;
}
}
3.2 Create Result Types
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog.Abstractions/Validation/StatusValidationResults.cs
public record StatusScopeValidationResult(bool IsValid, string? ErrorCode, string? ErrorMessage) {
public static StatusScopeValidationResult Valid() => new(true, null, null);
public static StatusScopeValidationResult StatusNotFound(Guid id) =>
new(false, "STATUS_NOT_FOUND", $"Status {id} not found");
public static StatusScopeValidationResult ItemTypeNotFound(Guid id) =>
new(false, "ITEM_TYPE_NOT_FOUND", $"Item type {id} not found");
public static StatusScopeValidationResult StatusNotAllowedForCategory(string name) =>
new(false, "STATUS_NOT_ALLOWED_FOR_CATEGORY", $"Status '{name}' is not allowed for this item's category");
public static StatusScopeValidationResult StatusNotAllowedForType(string name) =>
new(false, "STATUS_NOT_ALLOWED_FOR_TYPE", $"Status '{name}' is not allowed for this item type");
}
public record StatusGateValidationResult(bool IsValid, string? ErrorCode, string? ErrorMessage) {
public static StatusGateValidationResult Valid() => new(true, null, null);
public static StatusGateValidationResult TransitionNotAllowed(Guid from, Guid to) =>
new(false, "STATUS_TRANSITION_NOT_ALLOWED", $"Transition from status {from} to {to} is not allowed");
}
Stage 4: ItemApi Integration
4.1 Update ItemsService
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog/Services/ItemsService.cs
Inject StatusValidationService and add validation to:
CreateItem (~line 93):
var scopeValidation = await statusValidation.ValidateStatusForItemAsync(
request.StatusId, request.ItemTypeId, ct);
if (!scopeValidation.IsValid)
return (false, scopeValidation.ErrorMessage);
UpdateItem (~line 953):
if (revision.StatusId.HasValue && revision.StatusId.Value != item.StatusId) {
// Validate scope
var scopeValidation = await statusValidation.ValidateStatusForItemAsync(
revision.StatusId.Value, item.ItemTypeId, ct);
if (!scopeValidation.IsValid)
return (false, scopeValidation.ErrorMessage);
// Validate gate
var gateValidation = await statusValidation.ValidateStatusTransitionAsync(
item.StatusId, revision.StatusId.Value, ct);
if (!gateValidation.IsValid)
return (false, gateValidation.ErrorMessage);
}
4.2 Register Service in DI
File: engines/catalog/src/Acsis.Dynaplex.Engines.Catalog/Program.cs
builder.Services.AddScoped<StatusValidationService>();
Stage 5: Cleanup
5.1 Remove Old Entity Files
After migration verification:
- Delete
ItemStatusCategory.cs - Delete
ItemStatusType.cs - Remove
StatusCategoriesandStatusTypescollections fromItemStatus.cs - Remove old DbSets from
CatalogDb.cs
5.2 Update Tests
- Add tests for scope validation (global, category, type)
- Add tests for gate validation (permissive default, explicit gates)
- Add tests for scope consistency validation
- Update existing status tests for new schema
Critical Files Summary
Catalog Component
| File | Action |
|---|---|
catalog/.../Database/ItemStatus.cs |
MODIFY (add scope_level, Scopes collection) |
catalog/.../Database/ItemStatusScope.cs |
CREATE |
catalog/.../Database/ItemStatusGate.cs |
MODIFY (add TenantId, PrimaryKey) |
catalog/.../Database/CatalogDb.cs |
MODIFY (add DbSets, HasJournal(), external ref) |
catalog/.../Drafts/StatusDraft.cs |
MODIFY (add ScopeLevel, ScopePassportIds) |
catalog/.../Drafts/StatusGateDraft.cs |
CREATE |
catalog/.../Validation/StatusValidationResults.cs |
CREATE |
catalog/.../Services/StatusValidationService.cs |
CREATE |
catalog/.../Services/ItemsService.cs |
MODIFY (add validation calls) |
catalog/.../ApiEngines/StatusApi.cs |
MODIFY (handle scopes, add gate endpoints) |
catalog/.../Program.cs |
MODIFY (register service) |
catalog/.../Database/ItemStatusCategory.cs |
DELETE (after migration) |
catalog/.../Database/ItemStatusType.cs |
DELETE (after migration) |
Prism Component (Journal Tables)
| File | Action |
|---|---|
prism/.../Database/ItemStatusLog.cs |
CREATE |
prism/.../Database/ItemStatusScopeLog.cs |
CREATE |
prism/.../Database/ItemStatusGateLog.cs |
CREATE |
prism/.../Database/PrismDb.cs |
MODIFY (add journal DbSets) |
prism/.../Database/PrismDbModelConfiguration.cs |
MODIFY (add composite keys) |
Database Schema Summary
Final catalog.item_statuses Table
| Column | Type | Notes |
|---|---|---|
| id | uuid | PK |
| name | text | Unique |
| description | text | Nullable |
| reason_code_required | boolean | |
| destroy_date_required | boolean | |
| point_of_use_prohibited | boolean | |
| indicates_expiration | boolean | |
| scope_level | smallint | NEW: 0=Global, 10=Category, 20=Type |
| tenant_id | uuid | FK to identity.tenants |
| platform_type_id | smallint | Always 312 |
New catalog.item_status_scopes Table
| Column | Type | Notes |
|---|---|---|
| item_status_id | uuid | PK part 1, FK to item_statuses |
| scope_passport_id | uuid | PK part 2, FK to prism.passports |
Stores the passport IDs (global IDs) of categories or types this status applies to.
The passport's platform_type_id → platform_types.collection_level determines the entity type.
New catalog.item_status_gates Table
| Column | Type | Notes |
|---|---|---|
| current_item_status_id | uuid | PK part 1, FK to item_statuses |
| can_update_to_item_status_id | uuid | PK part 2, FK to item_statuses |
| tenant_id | uuid | FK to identity.tenants |
Validation Rules
Scope Level Consistency:
- If
scope_level = 0(Global): No entries initem_status_scopes - If
scope_level = 10(Category): All scopes must reference ItemCategory passports (collection_level=10) - If
scope_level = 20(Type): All scopes must reference ItemType passports (collection_level=20)
- If
Gate Behavior (Permissive Default):
- If no gates defined for a status: All transitions from that status are allowed
- If gates defined: Only explicitly defined transitions are allowed