Module Collaboration Deep Dive
How Identity and Entitlements collaborate via Core abstractions, the NoOp safety pattern, SubscriptionChangedEvent lifecycle, and deployment topology impact.
SCRIPE has 5 modules (Identity, Entitlements, Compliance, Plugins, Marketplace). They are hermetically sealed — no cross-imports allowed. Yet they must collaborate to drive permissions, subscription features, and billing. The solution: the Core.Application.Abstractions layer acts as a typed, interface-based bridge. Every cross-module interaction flows through this bridge — never through direct module-to-module imports. This page documents every interface, pattern, and runtime flow that makes this work.
The Core Layer Bridge
Core.Application.Abstractions is the heart of cross-module communication. It defines 32+ interface contracts. Identity.Infrastructure and Entitlements.Infrastructure each implement their respective side of these contracts. The AstraFlow pipeline behaviors and module handlers consume only the interfaces — never the concrete implementations. This means the system compiles and runs identically whether Entitlements is deployed or not.
Complete Cross-Module Interface Catalog
The following table documents every interface that crosses module boundaries. Defined in Core.Application, these interfaces are the only legal way for modules to communicate with each other.
| Interface | Defined In | Implemented By | Purpose |
|---|---|---|---|
| IFeatureChecker | Core.Application | Entitlements.Infrastructure | Edition-based feature/quota gating for tenant commands |
| IPermissionReader | Core.Application | Identity.Infrastructure | Entitlements reads permission IDs by module name |
| ITenantPermissionManager | Core.Application | Identity.Infrastructure | Entitlements syncs permission pool after subscription change |
| ISubscriptionStatusProvider | Core.Application | Entitlements.Infrastructure | Identity enriches login response with subscription status |
| IAdminPermissionCache | Core.Application | Identity.Infrastructure | Server-side Redis cache for admin permissions/roles/fields |
| IOverflowResourceDeactivator | Core.Application | Identity.Infrastructure | Entitlements deactivates excess admins/users on quota reduce |
| ITenantProvisioner | Core.Application | Identity.Infrastructure | Entitlements triggers tenant provisioning on signup |
| IPublicEditionProvider | Core.Application | Entitlements.Infrastructure | Signup flow reads available editions without module dependency |
| IUserFeatureChecker | Core.Application | Entitlements.Infrastructure | User-level feature gating (separate from tenant-level) |
| IQuotaCounterService | Core.Application | Identity.Infrastructure | Real-time quota counting for admins/users per tenant |
The NoOp Safety Pattern
Core.Infrastructure registers a NoOp (no-operation) implementation for every cross-module interface. These are registered with TryAddScoped, meaning real module implementations override them when deployed. If a module fails to load, the NoOp silently keeps the system alive. Startup diagnostics detect when critical interfaces are still NoOp and emit LogCritical warnings.
/// <summary>
/// No-op feature checker — all features enabled, all limits unlimited.
/// Used when the Entitlements module is not deployed.
/// Entitlements module will register its own implementation to override this.
/// </summary>
public class NoOpFeatureChecker : IFeatureChecker
{
public Task<bool> IsEnabledAsync(Guid tenantId, string featureName, CancellationToken ct = default)
=> Task.FromResult(true); // All features enabled when no Entitlements
public Task<int> GetLimitAsync(Guid tenantId, string featureName, CancellationToken ct = default)
=> Task.FromResult(-1); // -1 = unlimited
public Task<int> GetTenantTierLevelAsync(Guid tenantId, CancellationToken ct = default)
=> Task.FromResult(0); // 0 = free tier
}| Interface | NoOp Class | NoOp Behavior |
|---|---|---|
| IFeatureChecker | NoOpFeatureChecker | All features enabled, all limits -1 (unlimited) |
| ISubscriptionStatusProvider | NoOpSubscriptionStatusProvider | Returns null (no subscription) for all tenants |
| IPublicEditionProvider | NoOpPublicEditionProvider | Returns empty edition list (signup won't work) |
| ITenantPermissionManager | NoOpTenantPermissionManager | Silently does nothing on permission sync |
| IPermissionReader | NoOpPermissionReader | Returns empty permission lists |
NoOp Registration — TryAddScoped vs AddScoped
The entire NoOp override mechanism depends on one critical rule: Core.Infrastructure registers NoOps with TryAddScoped. Real module implementations register with AddScoped. Because TryAddScoped only registers if no service is registered yet, calling AddScoped afterward unconditionally overrides it. The order matters: Core.Infrastructure always loads first (it's a transitive dependency of all module Infrastructure projects), so the NoOp is always registered first, and the module's real implementation always wins.
// Core.Infrastructure registers ALL NoOp fallbacks using TryAddScoped.
// Real modules override these with AddScoped (not Try) in their own DI.
services.TryAddScoped<IFeatureChecker, NoOpFeatureChecker>();
services.TryAddScoped<ISubscriptionStatusProvider, NoOpSubscriptionStatusProvider>();
services.TryAddScoped<IPublicEditionProvider, NoOpPublicEditionProvider>();
services.TryAddScoped<ITenantPermissionManager, NoOpTenantPermissionManager>();
services.TryAddScoped<IPermissionReader, NoOpPermissionReader>();
services.TryAddScoped<IOverflowResourceDeactivator, NoOpOverflowResourceDeactivator>();
services.TryAddScoped<IQuotaCounterService, NoOpQuotaCounterService>();
services.TryAddScoped<IUserFeatureChecker, NoOpUserFeatureChecker>();
// ⚠️ Real module DI overrides with AddScoped (no Try prefix):
// Entitlements.Infrastructure/DependencyInjection.cs:
services.AddScoped<IFeatureChecker, FeatureChecker>();
services.AddScoped<ISubscriptionStatusProvider, SubscriptionStatusProvider>();Startup Diagnostics — Detecting NoOp Leakage
PostBuildInitialization.cs runs after the DI container is built and all modules are registered. It checks the resolved type for critical interfaces. If the resolved type is still a NoOp implementation, it logs a LogCritical message. This is the production safety net — it doesn't crash the server, but it produces a visible alert in logs and monitoring dashboards that operators can act on immediately.
// PostBuildInitialization.cs checks for NoOp implementations at startup:
var featureChecker = app.Services.GetService<IFeatureChecker>();
if (featureChecker is NoOpFeatureChecker)
{
startupLogger.LogCritical(
"ENTITLEMENTS SAFETY: IFeatureChecker is NoOp. " +
"All features will be enabled and all quotas unlimited. " +
"Load the Entitlements module to enforce edition-based gating."
);
}
var permManager = app.Services.GetService<ITenantPermissionManager>();
if (permManager is NoOpTenantPermissionManager)
{
startupLogger.LogCritical(
"IDENTITY SAFETY: ITenantPermissionManager is NoOp. " +
"Subscription changes will NOT sync permissions. " +
"Load the Identity module in the same process as Entitlements."
);
}FeatureCheckBehavior — The Gating Point
FeatureCheckBehavior is an AstraFlow pipeline behavior that intercepts commands implementing IRequireFeature. It resolves the tenant ID from the current user context, calls IFeatureChecker.IsEnabledAsync, and either passes the request through or returns a 403 Forbidden result. Because it uses IFeatureChecker (not a concrete class), it works transparently whether Entitlements is deployed or not. When Entitlements is absent, NoOpFeatureChecker returns true for every check, making the behavior a transparent pass-through with zero overhead.
// ✅ This command requires the 'Communication.BulkEmail' feature to be enabled
public record SendBulkEmailCommand(Guid TenantId, List<string> Recipients, string Body)
: ICommand, IRequireFeature
{
public string RequiredFeatureName => "Communication.BulkEmail";
}
// Handler runs ONLY if the tenant's edition has BulkEmail enabled
public class SendBulkEmailCommandHandler : IRequestHandler<SendBulkEmailCommand, Result>
{
public async Task<Result> Handle(SendBulkEmailCommand request, CancellationToken ct)
{
// Feature gating already checked by FeatureCheckBehavior
// If we're here, the tenant IS licensed for BulkEmail
// ...
}
}IRequireFeature — The Opt-In Marker Interface
IRequireFeature is a zero-overhead marker interface. Commands that implement it opt into edition-based feature gating via FeatureCheckBehavior. Commands that don't implement it pass through the behavior with zero overhead. This design means feature gating is explicit and opt-in — existing commands are never accidentally gated, and new commands consciously declare their feature requirements.
/// <summary>
/// Opt-in marker interface for commands that require an edition feature to be active.
/// When a command implements this, FeatureCheckBehavior intercepts the pipeline
/// and checks IFeatureChecker before allowing the handler to execute.
/// </summary>
public interface IRequireFeature
{
/// <summary>
/// The feature name to check. Must match an edition feature key.
/// Convention: "{Module}.{FeatureName}" e.g. "Communication.BulkEmail"
/// </summary>
string RequiredFeatureName { get; }
}SubscriptionChangedEvent — The Permission Sync Backbone
SubscriptionChangedEvent is the most critical cross-module domain event in SCRIPE. Published by Entitlements, handled by Identity. It carries the complete effective feature set, the subscription status, and pre-expanded bundle data. Identity uses this event to rebuild the tenant's entire permission pool — adding permissions for newly enabled modules and removing permissions for disabled ones. This is how a billing change in Entitlements becomes a permission change in Identity without any direct module coupling.
/// <summary>
/// Domain event published when a tenant's subscription plan is created, changed, or revoked.
/// Carries subscription status so handlers can decide whether to grant or revoke permissions.
/// </summary>
public sealed record SubscriptionChangedEvent(
Guid TenantId,
Guid? EditionId,
string? EditionName,
DateTime? EndDate,
Dictionary<string, string> EffectiveFeatures,
string SubscriptionStatus = "Active",
Guid AssignedByAdminId = default,
List<BundleExpansionDto>? BundleExpansions = null
) : DomainEvent
{
// True when event represents revocation — handler removes permissions
public bool IsRevocation => SubscriptionStatus is "Canceled" or "Expired" or "PendingPayment" or "Suspended";
};All Commands That Publish SubscriptionChangedEvent
SubscriptionChangedEvent is published by any Entitlements command or service that changes a tenant's subscription state. The following table documents every trigger point in the system. Understanding this list is essential for debugging permission sync issues — if a tenant's permissions are wrong, one of these triggers is the source of the last sync.
| Trigger | Entitlements Command / Service | Notes |
|---|---|---|
| Edition Assigned | AssignEditionCommandHandler | Grants permissions for enabled modules |
| Edition Changed | ChangeEditionCommandHandler | Diffs old vs new — adds/removes permissions |
| Subscription Canceled | CancelSubscriptionCommandHandler | IsRevocation = true — permissions removed |
| Subscription Revoked | RevokeSubscriptionCommandHandler | Hard removal of all subscription permissions |
| Subscription Suspended | SuspendSubscriptionCommandHandler | IsRevocation = true |
| Subscription Resumed | ResumeSubscriptionCommandHandler | Re-grants permissions from current edition |
| Stripe Webhook (invoice.paid) | StripeWebhookHelper | Payment confirmed → activate subscription |
| Grace Period Expired | FallbackDowngradeService | Downgrades to Fallback edition |
| Edition Rollout Job | EditionRolloutJob | Scheduled batch rollout to all subscribed tenants |
| Manual Resync | ResyncTenantPermissionsCommandHandler | Admin triggers permission rebuild |
| Signup Checkout Recovery | DirectSignupCheckoutRecoveryService | Stripe webhook recovery |
| Reconciliation Sweep | SignupReconciliationSweepJob | Daily sweep for abandoned signups |
Permission Sync Lifecycle — Step by Step
When a tenant's subscription changes, a precise 5-step lifecycle executes to rebuild their permission pool. Understanding this lifecycle is essential for debugging permission issues and for designing new subscription-driven features.
Step 1 — Entitlements Resolves Edition Features
The Entitlements command handler (e.g., AssignEditionCommandHandler) resolves the full effective feature map for the tenant. This merges the Edition's base features, any TenantFeatureOverrides, and BundleExpansion feature additions. The result is a flat dictionary of feature name to value (e.g., 'Communication.Enabled' → 'true'). From this, it derives which module names are enabled.C#// AssignEditionCommandHandler resolves the full effective feature map: // Edition features → merged with TenantFeatureOverrides → BundleExpansions var effectiveFeatures = await _editionFeatureResolver .ResolveEffectiveFeaturesAsync(edition, tenantOverrides, bundles, ct); // Identifies which modules are enabled by checking boolean features: // e.g., "Communication.Enabled" == "true" → include Communication module var enabledModules = effectiveFeatures .Where(kvp => kvp.Key.EndsWith(".Enabled") && kvp.Value == "true") .Select(kvp => kvp.Key.Replace(".Enabled", "")) .ToList();Step 2 — Event Published via Outbox
The SubscriptionChangedEvent is raised on the entity as a domain event. The EF Core OutboxInterceptor captures it before SaveChangesAsync. The event is persisted to the OutboxMessages table in the same database transaction as the subscription change. After commit, the OutboxProcessor dispatches the event to all registered handlers. This guarantees exactly-once delivery even if the process crashes mid-execution.C#// SubscriptionChangedEvent is raised as a domain event, captured by OutboxInterceptor, // persisted in OutboxMessages table, then dispatched after SaveChangesAsync: entity.AddDomainEvent(new SubscriptionChangedEvent( TenantId: command.TenantId, EditionId: edition.Id, EditionName: edition.Name, EffectiveFeatures: effectiveFeatures, SubscriptionStatus: "Active", AssignedByAdminId: _currentUser.AdminId!.Value, BundleExpansions: bundleExpansions )); await _unitOfWork.SaveChangesAsync(ct); // OutboxProcessor dispatches the event after commitStep 3 — Identity Handler Processes the Event
Identity.Application's SubscriptionChangedEventHandler receives the event. If IsRevocation is true (subscription canceled/expired/suspended), it calls SyncPermissionsForModulesAsync with an empty module list — which removes all edition-based permissions. If not a revocation, it syncs permissions for all enabled modules and processes each BundleExpansion (grant and deny codes).C#// Identity.Application handles the SubscriptionChangedEvent: public class SubscriptionChangedEventHandler : INotificationHandler<DomainEventNotification<SubscriptionChangedEvent>> { public async Task Handle(...) { if (notification.DomainEvent.IsRevocation) { // Remove all edition-based permissions from tenant pool await _tenantPermissionManager.SyncPermissionsForModulesAsync( tenantId: e.TenantId, enabledModules: [], // Empty = revoke all module permissions effectiveFeatures: [], assignedByAdminId: e.AssignedByAdminId ); } else { // Grant permissions for enabled modules + bundle expansions await _tenantPermissionManager.SyncPermissionsForModulesAsync( tenantId: e.TenantId, enabledModules: enabledModules, effectiveFeatures: e.EffectiveFeatures, assignedByAdminId: e.AssignedByAdminId ); // Process bundle expansions (grant/deny permission codes) foreach (var bundle in e.BundleExpansions ?? []) { await _tenantPermissionManager.SyncBundlePermissionsAsync( tenantId: e.TenantId, grantCodes: bundle.GrantPermissionCodes.ToHashSet(), denyCodes: bundle.DenyPermissionCodes.ToHashSet(), assignedByAdminId: e.AssignedByAdminId ); } } } }Step 4 — ITenantPermissionManager Syncs the Pool
Identity.Infrastructure.TenantPermissionManager implements ITenantPermissionManager. It uses IPermissionReader to get all permission IDs for the enabled modules from the Identity database. It filters these by RequiredFeature (permissions gated on specific features are only granted if that feature is enabled). It diffs against the current TenantPermission records, adding missing and removing excess entries atomically.C#// Identity.Infrastructure.TenantPermissionManager: // 1. IPermissionReader.GetPermissionIdsByModulesAsync() — reads from Identity DB // 2. Filters by RequiredFeature (only grant if feature is enabled in effectiveFeatures) // 3. Gets current TenantPermission records from DB // 4. Diffs: adds missing, removes excess // 5. Result: (Added, Removed) count returned public async Task<(int Added, int Removed)> SyncPermissionsForModulesAsync( Guid tenantId, IEnumerable<string> enabledModules, IReadOnlyDictionary<string, string> effectiveFeatures, Guid assignedByAdminId, CancellationToken ct = default) { var permissionIds = await _permissionReader .GetPermissionIdsByModulesAsync(enabledModules, ct); // Filter out permissions whose RequiredFeature is not in effectiveFeatures var grantableIds = permissionIds .Where(id => IsFeatureEnabled(id, effectiveFeatures)) .ToHashSet(); var currentIds = await _tenantPermRepo.GetPermissionIdsAsync(tenantId, ct); var toAdd = grantableIds.Except(currentIds).ToList(); var toRemove = currentIds.Except(grantableIds).ToList(); await _tenantPermRepo.AddRangeAsync(toAdd.Select(id => new TenantPermission(tenantId, id))); await _tenantPermRepo.RemoveRangeAsync(tenantId, toRemove); await _unitOfWork.SaveChangesAsync(ct); return (toAdd.Count, toRemove.Count); }Step 5 — Admin Permission Cache Invalidated
After the permission pool is synced, IAdminPermissionCache.InvalidateAll() is called for the tenant. Every admin's cached permission set is cleared from the in-memory + Redis cache. On their next API request, the AuthorizationBehavior will reload their permissions from the database and re-populate the cache. The frontend receives updated permissions on the next token refresh cycle.C#// After sync, all admin caches for this tenant are invalidated: _adminPermissionCache.InvalidateAll(); // Nuclear option after bulk sync // Next request for any admin will reload permissions from DB into cache // Frontend receives updated permissions on next token refresh
Login Response Enrichment — ISubscriptionStatusProvider
Identity's login handler needs to return the tenant's subscription status so the frontend can display grace period warnings, upgrade prompts, and edition badges. But Identity cannot import Entitlements. The solution: ISubscriptionStatusProvider is injected into the login handler. Entitlements.Infrastructure implements this interface, returning TenantSubscriptionInfo with status, edition name, grace phase, and expiry date.
// Identity's LoginCommandHandler enriches the JWT response without importing Entitlements:
// ISubscriptionStatusProvider is injected — implemented by Entitlements.Infrastructure
public class LoginCommandHandler : IRequestHandler<LoginCommand, Result<LoginResponse>>
{
private readonly ISubscriptionStatusProvider _subscriptionStatus;
public async Task<Result<LoginResponse>> Handle(LoginCommand request, CancellationToken ct)
{
// ... auth logic ...
// Enrich response with subscription status (cross-module via Core interface)
var subscriptionInfo = await _subscriptionStatus
.GetTenantSubscriptionInfoAsync(tenant.Id, ct);
return Result<LoginResponse>.Success(new LoginResponse(
AccessToken: jwtToken,
RefreshToken: refreshToken,
SubscriptionStatus: subscriptionInfo?.Status,
EditionName: subscriptionInfo?.EditionName,
GracePhase: subscriptionInfo?.GracePhase,
GracePeriodEndsAt: subscriptionInfo?.GracePeriodEndsAt
));
}
}Deployment Topology Impact
The MODULE_NAME environment variable controls which modules are loaded. This fundamentally changes how cross-module communication works. Monolith mode supports all collaboration patterns. Microservice mode has critical limitations you must understand before extracting modules.
Monolith Mode (MODULE_NAME="")
- All 5 modules load in a single process
- Domain events dispatched in-process (synchronous, zero latency)
- SubscriptionChangedEvent → Identity handler runs in same request scope
- Permission sync completes before HTTP response returns
- Self-service signup fully supported (SignupCheckoutCompletedEvent in-process)
- NoOp implementations completely absent — overridden by real modules
Microservice Mode (MODULE_NAME="Identity")
- Only the specified module loads — others are absent
- NoOp implementations active for absent modules
- Domain events CANNOT cross process boundaries (silent loss)
- Self-service signup BLOCKED at startup (G15 guard in PostBuildInitialization.cs)
- Future: outbox + message bus will bridge the gap
- Gateway mode (MODULE_NAME="Gateway"): YARP proxy only, zero modules
MODULE_NAME Environment Variable Reference
The MODULE_NAME environment variable is set at container startup and determines which modules are loaded into the process. The ModuleRegistration.cs file in Host/API reads this variable and conditionally registers only the specified module's DI registrations and EF Core DbContext. When empty (the default), all modules are registered — this is the monolith mode that supports all cross-module collaboration patterns.
| MODULE_NAME Value | Modules Loaded | Use Case |
|---|---|---|
| "" (empty) | All 5 modules | Monolith — recommended for most deployments |
| "Gateway" | YARP proxy only | API gateway — routes to individual module services |
| "Identity" | Identity only | Microservice — NoOp for Entitlements features |
| "Entitlements" | Entitlements only | Microservice — NoOp for Identity permission sync |
| "Compliance" | Compliance only | Standalone compliance auditing service |
Module Co-Dependency Map
This table documents every formal dependency between modules, showing exactly what each module needs from another and how that need is satisfied via Core interfaces. Use this as a reference when planning microservice extraction or when debugging cross-module data flow issues.
| Module | Needs From | What It Gets | Via Interface |
|---|---|---|---|
| Identity | Entitlements | Subscription status for login response | ISubscriptionStatusProvider |
| Entitlements | Identity | Permission IDs by module name | IPermissionReader |
| Entitlements | Identity | Permission pool sync after edition change | ITenantPermissionManager |
| Entitlements | Identity | Deactivate excess admins/users on quota reduce | IOverflowResourceDeactivator |
| Entitlements | Identity | Quota counting for enforcement | IQuotaCounterService |
| All Modules | Core | Feature gating (edition-based) | IFeatureChecker |
| Signup Flow | Entitlements | Available public editions | IPublicEditionProvider |
| Compliance | Identity | Current user data for audit trails | ICurrentUser |
| All Modules | Core | Admin permission enforcement | IAdminPermissionCache (via AuthBehavior) |
The Self-Service Signup Cross-Module Saga
The self-service B2B2C tenant signup is the most complex cross-module saga in SCRIPE. It spans Identity (tenant provisioning), Entitlements (subscription linking and billing), and Stripe (payment processing). Understanding this flow is critical for supporting customers and for maintaining the signup infrastructure.
Phase 1 — Identity Provisions Tenant
RegisterTenantSelfServiceCommand (in Identity module) executes in a single atomic database transaction. It creates the Tenant entity, sets up the default subdomain and branding, provisions default security roles (Admin, User), creates the tenant owner Admin account, and verifies the email verification ticket. On success, it publishes SignupPhase1CompletedEvent.C#// RegisterTenantSelfServiceCommand (Identity module): // - Creates Tenant + Admin in Identity DB // - Configures default roles + permissions // - Publishes SignupPhase1CompletedEvent // All in a single DB transaction (atomic)Phase 2 — Entitlements Links Subscription
The SignupPhase1CompletedEventHandler (in Entitlements module) handles SignupPhase1CompletedEvent. It creates a TenantSubscription record for the chosen edition. If the edition is free, it immediately activates and publishes SubscriptionChangedEvent to grant permissions. If the edition is paid, it creates a Stripe Checkout session and returns the payment URL.C#// SignupPhase1CompletedEventHandler (Entitlements module): // - Creates TenantSubscription for chosen edition // - If free edition → activates immediately + publishes SubscriptionChangedEvent // - If paid edition → creates Stripe checkout session // - Returns checkout URL to frontend for payment redirectPhase 3 — Stripe Confirms, Entitlements Activates
When the user completes Stripe checkout, Stripe sends a checkout.session.completed webhook. StripeWebhookHelper (in Entitlements) processes this event, finds the matching SignupSession, activates the subscription, and publishes SubscriptionChangedEvent. Identity's handler grants all edition permissions to the new tenant's permission pool.C#// StripeWebhookHelper processes checkout.session.completed: // - Finds matching SignupSession // - Activates subscription → publishes SubscriptionChangedEvent // - Identity handler grants all edition permissions to new tenantCompensation — If Checkout Abandoned
If the user abandons the Stripe checkout (or Stripe fails to initialize), CompensatePhase1Async is called. This deletes the provisioned tenant and the tenant admin account, preventing orphaned accounts with no active subscription. A daily SignupReconciliationSweepJob also sweeps for stale incomplete signups and cleans them up.C#// CompensatePhase1Async (Identity module) called if Stripe checkout fails: // - Deletes the provisioned tenant // - Deletes the tenant admin account // - Prevents orphaned accounts with no active subscription // SignupReconciliationSweepJob runs daily to clean up stale incomplete signups
Signup Event Chain — Cross-Module Event Flow
The signup saga crosses module boundaries via three in-process domain events. SignupPhase1CompletedEvent flows from Identity to Entitlements. SignupCheckoutCompletedEvent flows within Entitlements (Stripe webhook to activation). SubscriptionChangedEvent flows from Entitlements back to Identity. This bidirectional event chain is why signup ONLY works in monolith mode — all three events require both modules in the same process.
Bundle Expansion — Fine-Grained Permission Grants
Bundle Expansion allows an edition to grant or deny specific permission codes beyond the module-level enablement that SubscriptionChangedEvent carries. When a subscription includes bundles, the SubscriptionChangedEvent carries pre-expanded BundleExpansionDto entries. Identity's event handler processes each bundle separately via ITenantPermissionManager.SyncBundlePermissionsAsync, which diffs grant and deny codes against the current tenant permission set.
/// <summary>
/// Represents a permission bundle expansion attached to a subscription event.
/// Bundles allow fine-grained permission grants/denials beyond module-level enablement.
/// For example: a "PowerUser" bundle might grant extra permissions within an enabled module.
/// </summary>
public record BundleExpansionDto(
Guid BundleId,
string BundleName,
IReadOnlyList<string> GrantPermissionCodes,
IReadOnlyList<string> DenyPermissionCodes
);IAdminPermissionCache — The Authorization Cache
IAdminPermissionCache is the server-side Redis cache that AuthorizationBehavior uses to check permissions without hitting the database on every request. It stores a denormalized snapshot of each admin's permissions, roles, and field projections. The cache entry is populated lazily on the first request after a cache miss. InvalidateAll() is called after bulk permission sync operations (SubscriptionChangedEvent handling) to force all admins to reload on their next request.
/// <summary>
/// Server-side Redis cache for admin permissions, roles, and field projections.
/// Populated lazily on first request per admin. Invalidated on role/permission changes.
/// Used by AuthorizationBehavior to enforce RBAC without hitting the DB on every request.
/// </summary>
public interface IAdminPermissionCache
{
Task<AdminPermissionCacheEntry?> GetAsync(Guid adminId, CancellationToken ct = default);
Task SetAsync(Guid adminId, AdminPermissionCacheEntry entry, CancellationToken ct = default);
Task InvalidateAsync(Guid adminId, CancellationToken ct = default);
Task InvalidateAll(CancellationToken ct = default); // Nuclear — used after bulk sync
}
public record AdminPermissionCacheEntry(
IReadOnlySet<string> PermissionCodes,
IReadOnlySet<string> RoleCodes,
IReadOnlyDictionary<string, IReadOnlySet<string>> FieldProjections,
DateTime CachedAt
);| Cache Operation | Trigger | Scope |
|---|---|---|
| SetAsync(adminId, entry) | First request after cache miss | Single admin |
| InvalidateAsync(adminId) | Admin role/permission changed | Single admin |
| InvalidateAll() | SubscriptionChangedEvent processed | All admins in tenant |
| InvalidateAll() | Edition changed for tenant | All admins in tenant |
| TTL expiry (Redis) | Automatic after 15 minutes | Single admin (lazy rebuild) |
ICurrentUser — The Ubiquitous Cross-Cutting Interface
ICurrentUser is the one interface that every module uses directly — it's not a cross-module bridge like the others, it's a fundamental cross-cutting concern available everywhere. It's populated by Identity's JWT middleware on every authenticated request and provides the current admin/user context to any handler in any module. Every module depends on Core.Application which defines ICurrentUser, so it's always available without any cross-module ceremony.
/// <summary>
/// Provides information about the currently authenticated user.
/// Available in ALL modules via DI — populated from JWT claims by Identity middleware.
/// This is the single cross-cutting interface that every module uses directly.
/// </summary>
public interface ICurrentUser
{
Guid? AdminId { get; }
Guid? UserId { get; }
Guid? TenantId { get; }
string? Email { get; }
bool IsAuthenticated { get; }
bool IsSuperAdmin { get; }
bool IsSystemProtectedAdmin { get; }
IReadOnlySet<string> Permissions { get; }
IReadOnlySet<string> Roles { get; }
string? Language { get; }
}Feature Value Resolution Chain
When IFeatureChecker.IsEnabledAsync() is called for a tenant and feature, Entitlements resolves the value through a priority chain. TenantFeatureOverride (per-tenant manual override) always wins. If no override exists, the EditionFeature value is used. If the edition doesn't define the feature, the Feature.DefaultValue is used. For numeric features with multiple active subscriptions (trial + base plan), the MAX value wins. For boolean features, true wins. For string features, the Base subscription wins.
| ValueType | Merge Rule (multi-subscription) | Example |
|---|---|---|
| Boolean | OR — true wins across all active subscriptions | BulkEmail: false + true = true |
| Numeric | MAX — highest value wins, -1 = unlimited | MaxAdmins: 5 + 20 = 20 |
| String | First subscription wins (Base > Trial > AddOn) | SupportTier: "Gold" + "Silver" = "Gold" |
Developer Checklist — Adding a New Cross-Module Dependency
When you need two modules to share data, follow this exact pattern. Never import one module from another. Always go through Core.Application.Abstractions.
1. Define the contract in Core.Application.Abstractions
Create a new interface file in Core.Application/Abstractions/. The interface should be minimal — only what the consuming module actually needs. Add XML documentation explaining which module implements it and which module consumes it.C#// Core.Application.Abstractions/IMyNewCrossModuleService.cs public interface IMyNewCrossModuleService { Task<SomeResult> DoSomethingAsync(Guid tenantId, CancellationToken ct = default); }2. Register a NoOp in Core.Infrastructure
Create a NoOp implementation in Core.Infrastructure/Services/ (or appropriate subfolder). Register it with TryAddScoped in Core.Infrastructure/DependencyInjection.cs. The NoOp should return a safe, neutral value (null, empty, false, -1 for unlimited). Never throw NotImplementedException in a NoOp.C#// Core.Infrastructure/Services/NoOpMyNewCrossModuleService.cs public class NoOpMyNewCrossModuleService : IMyNewCrossModuleService { public Task<SomeResult> DoSomethingAsync(Guid tenantId, CancellationToken ct = default) => Task.FromResult(SomeResult.Empty); // Safe fallback } // Register in Core.Infrastructure/DependencyInjection.cs: services.TryAddScoped<IMyNewCrossModuleService, NoOpMyNewCrossModuleService>();3. Implement in the target module's Infrastructure
Create the real implementation in {Module}.Infrastructure/CrossModule/ or {Module}.Infrastructure/Services/. Register it with AddScoped (NOT TryAddScoped) in the module's DependencyInjection.cs. Using AddScoped ensures the real implementation OVERRIDES the NoOp that Core registered first.C#// {Module}.Infrastructure/CrossModule/MyNewCrossModuleService.cs public class MyNewCrossModuleService : IMyNewCrossModuleService { private readonly IMyRepository _repo; public MyNewCrossModuleService(IMyRepository repo) => _repo = repo; public async Task<SomeResult> DoSomethingAsync(Guid tenantId, CancellationToken ct = default) { // Real implementation using module's own DB access return await _repo.GetSomethingAsync(tenantId, ct); } } // Override NoOp in the module's DependencyInjection.cs: services.AddScoped<IMyNewCrossModuleService, MyNewCrossModuleService>(); // Note: AddScoped (not TryAddScoped) to OVERRIDE the NoOp4. Add startup diagnostic in PostBuildInitialization.cs
Add a check in PostBuildInitialization.cs to detect if the interface is still resolved as the NoOp. Log a LogCritical warning if so. This is the safety net that alerts developers to misconfigured deployments in production without crashing the server.C#// Add a check in PostBuildInitialization.cs: var myService = app.Services.GetService<IMyNewCrossModuleService>(); if (myService is NoOpMyNewCrossModuleService) { startupLogger.LogCritical( "MY SERVICE SAFETY: IMyNewCrossModuleService is NoOp. " + "Load the {Module} module or register a custom implementation." ); }
Module Collaboration — Architecture Rules Summary
These are the binding rules for all cross-module communication in SCRIPE. They are enforced by scripe arch-check (30-rule deep scan), project reference constraints in the .sln file, and code review. Violations of these rules create circular dependencies, deployment coupling, and testing impossibility.
✅ Do This
- Define all cross-module contracts in Core.Application.Abstractions
- Register NoOp implementations in Core.Infrastructure using TryAddScoped
- Override NoOps in real modules using AddScoped (not Try)
- Add startup diagnostics for every new cross-module interface
- Use domain events (via Outbox) for cross-module reactions
- Keep Core.Application free of any module-specific references
- Test with NoOp implementations in unit tests for isolation
- Document every new interface in this catalog
❌ Never Do This
- Never import one module's Infrastructure from another module
- Never reference Entitlements.Infrastructure from Identity (or vice versa)
- Never use static calls or service locator pattern across modules
- Never dispatch domain events synchronously across module boundaries
- Never put cross-module logic in Core.Domain (domain must stay pure)
- Never assume a module is loaded — always handle NoOp gracefully
- Never skip startup diagnostics for critical cross-module services
- Never hardcode module-specific types in Core layer
Security Boundary Enforcement
The module isolation rules are not just an architectural preference — they are security boundaries. Cross-module isolation ensures that a bug or compromise in one module cannot directly access another module's data store. These rules are enforced at multiple levels: project reference constraints, architecture lint rules, and code review checklists.
| Boundary Rule | Why | Enforced By |
|---|---|---|
| Identity NEVER references Entitlements directly | Prevents circular dependency | Architecture lint rule (scripe arch-check) |
| Entitlements NEVER writes to Identity tables directly | Single source of truth per module's DB | No shared DbContext between modules |
| Tenant entity has NO EditionId field | TenantSubscription owns the mapping | Domain model — entity has no edition reference |
| Feature checks run as pipeline behavior, not in handlers | Consistent enforcement, handlers stay pure | AstraFlow pipeline registration |
| Core.Domain has zero module dependencies | Core must be deployable standalone | Project reference graph enforced by .sln |