ConnectSoft SaaS — Aggregates, Entities, VOs & Enums Specification¶
This document is the canonical, implementation-ready specification of the ConnectSoft SaaS domain model. It defines aggregate boundaries, relationship semantics, property-level documentation, and ORM mapping guidance so teams can generate consistent code, migrations, and contracts across services.
Document conventions (DDD + ORM mapping)¶
Purpose¶
Set the rules of the road for how we model and document our domain. These conventions apply to every type that follows (aggregates, entities, value objects, enumerations) and to every relationship we declare (Reference, HasMany, HasManyThrough). They are intentionally domain-driven (behavior/invariants first) and ORM-aware (clear persistence shape without leaking database concerns into the model).
Aggregate boundaries¶
- Aggregate Root (AR): The only entry point for invariants and mutations. Commands target a single AR.
- Consistency: Strong inside an aggregate; eventual across aggregates via domain events and process managers.
- Cross-context references: Never navigate across bounded contexts. Use identifiers/keys and integration events.
Rule of thumb: “One command → one aggregate.” If you need to touch two aggregates, raise a domain event and coordinate with a policy/saga.
Relationships vocabulary¶
We use three first-class relationship types. Each type must be explicitly documented when it appears in a model.
-
Reference Single navigation to a peer within the same bounded context. Stored as a foreign key (FK) plus an optional navigation property. Example:
Editionhas a Reference to its owningProduct. -
HasMany Collection of child entities or peer aggregates within the same context. Ownership and cascade rules must be explicit. Example:
ProductHasManyEdition. -
HasManyThrough Many-to-many via an explicit link entity (join with behavior, invariants, effective-dating, etc.). Never implicit M:N. Example:
EditionHasManyThroughEditionFeature→Feature.
Across contexts: Only IDs/keys are allowed (no navigations). E.g., a Billing rule may carry a
FeatureKeystring, not anIFeaturenavigation.
When to use navigation vs. IDs¶
| Situation | Use | Why |
|---|---|---|
| Same bounded context, invariants span the relation | Navigation (+ FK) | Enables transactional invariants and expressive code |
| Same context, read-mostly, no invariant coupling | ID-only (+ explicit query) | Minimizes loading / N+1, keeps AR smaller |
| Different bounded context | ID/Key only | Prevents model leakage and accidental cross-context joins |
Naming:
- FK fields:
<TargetName>Id(e.g.,ProductId), keys:<TargetName>Key(e.g.,FeatureKey). - Collections: pluralized (e.g.,
Editions,EditionFeatures).
Property documentation style (XML comments)¶
Every property in this spec will be documented as if it were a C# interface/class member. Use this pattern:
/// <summary>
/// Human-friendly name shown in administration UIs.
/// </summary>
/// <remarks>
/// Must be 1–200 UTF-8 characters. Not required to be unique.
/// </remarks>
string DisplayName { get; }
/// <summary>
/// Canonical key (slug) that uniquely identifies the entity within its context.
/// </summary>
/// <value>Lowercase, 3–64 chars: [a–z0–9][a–z0–9._-]+</value>
/// <remarks>
/// Immutable after first <c>Published</c> status. Used by tokens, entitlements, and APIs.
/// </remarks>
string Key { get; }
Where to put invariants:
- If the invariant is property-level (e.g., “Key is immutable after publish”), include it in the property’s
<remarks>. - If it’s type-level (e.g., “Product cannot publish without at least one published Edition”), put it in the type’s
<remarks>block.
Value Objects (VOs)¶
- Definition: Immutable, equality-by-value, owned by an aggregate. No identity.
- ORM: Map as EF Core owned types. No separate tables unless the VO is large or repeated broadly.
- Construction: Validate invariants at creation (factory methods).
- Examples:
ProductKey,FeatureKey,Quota,TenantRegionResidency.
Template
/// <summary>Canonical feature key.</summary>
/// <remarks>Global uniqueness across Catalog. Immutable after publish.</remarks>
public sealed record FeatureKey
{
public string Value { get; init; }
// Validation rules documented in spec; enforced in factory at runtime.
}
Entities (non-root)¶
- Have identity (usually a
Guid), live inside an aggregate root. - Use for child collections that have lifecycle of their own (e.g.,
ContactinsideTenant). - ORM: Same table as AR by default; split table only when size/volatility warrants.
Enumerations¶
- Prefer smart enums or exhaustive static catalogs (no “magic ints”).
- Each member in this document is listed with:
Name,Value,ShortName, and clear semantics. - Versioning: Additive-first. Removing/renaming members requires a migration note and mapping guidance.
Event conventions (for reference in later cycles)¶
- Name:
<noun>.<pastParticiple>(e.g.,product.published,edition.changed). - Envelope fields:
eventId,occurredAtUtc,aggregateId,aggregateVersion,tenantId?,schemaVersion. - Evolution: Additive fields are allowed; breaking changes publish a new
schemaVersionor topic.
EF-friendly mapping guidance¶
- Owned VOs: Map with
OwnsOne/OwnsMany. No separate identity. - Link entities: Always explicit for M:N (e.g.,
EditionFeature). Use composite keys and effective-dating where applicable. - Shadow FKs: Allowed to reduce public API noise (e.g., keep
ProductIdas FK while exposingProductnavigation). - Loading strategy:
- Command handlers: load only what you need (explicit includes).
- Queries/reads: project to DTOs; prefer
AsNoTracking. - Avoid global lazy loading (risk of N+1 and hidden I/O); use explicit
Include/ThenIncludeor separate queries.
- Indexes & constraints:
- Unique index on
Key(per context) and on join pairs (e.g.,(EditionId, FeatureId)). - Add filtered unique indexes for effective-dated records if you allow overlap rules.
- Unique index on
- Concurrency: Prefer optimistic concurrency with a
rowversion/xmincolumn on aggregates. - Soft delete vs. lifecycle: Prefer lifecycle status (e.g.,
Deprecated,Retired) over hard deletes; actual data removal is a policy event (e.g., archival).
Relationship examples (mapping sketch)
// Product 1..* Edition
modelBuilder.Entity<Product>()
.HasMany(p => p.Editions)
.WithOne(e => e.Product)
.HasForeignKey(e => e.ProductId)
.IsRequired();
// Edition M..N Feature via EditionFeature
modelBuilder.Entity<EditionFeature>()
.HasKey(x => new { x.EditionId, x.FeatureId });
modelBuilder.Entity<EditionFeature>()
.HasOne(x => x.Edition)
.WithMany(e => e.EditionFeatures)
.HasForeignKey(x => x.EditionId);
modelBuilder.Entity<EditionFeature>()
.HasOne(x => x.Feature)
.WithMany(f => f.EditionLinks)
.HasForeignKey(x => x.FeatureId);
Naming & files¶
- Interface-first: We document interfaces that mirror aggregates/entities/VOs. Concrete classes may add behavior but must not change contract meanings.
- Namespaces by context:
ConnectSoft.Saas.Catalog,ConnectSoft.Saas.Billing,ConnectSoft.Saas.Identity,ConnectSoft.Saas.Tenants, etc. - File names: One type per file, matching type name. For link entities, use the pair name (e.g.,
EditionFeature).
Validation & invariants (how we’ll write them)¶
- Preconditions at factory/constructor: mandatory fields, key format, status defaults.
- State-change guards on AR methods: e.g., prohibit publishing a Product without at least one Published Edition.
- Cross-field invariants: e.g.,
MinimumPrice ≤ BasePrice ≤ MaximumPrice. - Temporal invariants: effective date ≤ expiry date; no overlapping active windows unless explicitly allowed.
Read/write separation (at the doc level)¶
- Write model: Aggregates, entities, VOs, invariants, and events (this spec).
- Read model: Projections/DTOs derived from events/queries. We will annotate places where a read model is recommended instead of exposing deep navigations.
Cross-cutting enumerations: Access & Authorization¶
AccessScopeEnumeration¶
Describes where a rule applies (the target scope for access decisions).
Per-member properties (documented for each row below):
- Name — Human-friendly label (shown in admin UIs).
- ShortName — Slug used in policies/tokens (lowercase,
_for spaces). - Value — Stable integer for storage/wire contracts.
- Description — Purpose, typical usage in policies, and scope boundaries.
| Name (ShortName) | Value | Description (purpose, typical usage, scope boundaries) |
|---|---|---|
Global (global) |
1 | Cross-platform scope affecting the entire system. Use for platform-wide kill switches, incident controls, and cross-tenant admin operations. Boundary: must not couple to tenant/edition specifics; apply only in platform admin planes. |
System (system) |
2 | System-level operations and infrastructure controls (e.g., key rotation, provider credentials). Typical in SRE/ops policies. Boundary: restricted to internal principals; never used to grant tenant-facing features. |
Feature (feature) |
3 | A single capability/module (e.g., “webhooks”). Use to gate visibility and actions within one feature. Boundary: do not leak to unrelated services; pair with FeatureKey. |
Service (service) |
4 | A product-aligned service or set of APIs. Use to toggle service endpoints or admin functions for that service. Boundary: stays within the service’s bounded context. |
Edition (edition) |
5 | Edition-bound access (Free/Standard/Enterprise). Use to enforce entitlements derived from Catalog. Boundary: never cross-edit ion—token/claims must carry the active edition. |
Tenant (tenant) |
6 | Per-tenant controls (suspension, support access). Use in ABAC where tenantId is present. Boundary: RLS/filters must apply; no cross-tenant effects. |
User (user) |
7 | Subject-specific overrides (e.g., preview access for a user). Boundary: scoped to the subject; do not imply group/project rights. |
Group (group) |
8 | Role/group-based grants (e.g., “SupportAgents”). Boundary: effective only through group membership resolution in Identity. |
Project (project) |
9 | Workspace/project resources. Use where tenants host multiple projects. Boundary: all checks must also satisfy tenant scoping. |
Application (application) |
10 | Client/app-level rights (first- or third-party apps). Use to scope OAuth clients/service principals. Boundary: never bypass user/tenant checks. |
Resource (resource) |
11 | Specific objects (files, records, datasets). Use for row/document-level ACLs. Boundary: must evaluate resource ownership and classification. |
Custom (custom) |
12 | Extension point for domain-specific scopes. Use sparingly with a well-defined published contract and audit trail. Boundary: must be registered and discoverable in policy catalogs. |
Linkage points
- Used by IAccessRule and IAccessExceptionRule to declare where a rule applies.
- Referenced from Edition/Feature rules (e.g., edition-scoped preview access).
- Propagated into tokens/claims only as short names to keep payloads compact.
AccessTypeEnumeration¶
Describes what an actor may do at a scope (the action/permission flavor).
Per-member properties (documented for each row below):
- Name — Human-friendly label (shown in admin UIs).
- ShortName — Slug used in policies/tokens (lowercase,
_for spaces). - Value — Stable integer for storage/wire contracts.
- Description — Purpose, typical usage in policies, and scope boundaries.
| Name (ShortName) | Value | Description (purpose, typical usage, scope boundaries) |
|---|---|---|
Read (read) |
1 | View-only. Typical for dashboards, audit log viewing, and GET APIs. Boundary: never implies modify or export; pair with data-class filters. |
Write (write) |
2 | Create/modify. Used for POST/PUT/PATCH in admin/user planes. Boundary: must pass ABAC checks (edition/residency/ownership). |
Execute (execute) |
3 | Run operations or jobs (e.g., replay webhooks, run exports). Boundary: side effects must be auditable and idempotent. |
Admin (admin) |
4 | Administrative actions within a scope (not platform-wide). Boundary: limited to the owning context; often requires MFA and stronger audit. |
Delete (delete) |
5 | Remove or hard-delete entities. Boundary: subject to retention/legal hold; prefer lifecycle states (Deprecated/Retired) in Catalog. |
Modify (modify) |
6 | Change configuration/settings without creating new resources. Boundary: gated by change management; often distinct from write on data. |
Share (share) |
7 | Grant/alter sharing of resources (invites, link generation). Boundary: must honor consent and preference policies. |
Manage (manage) |
8 | Operate non-destructive admin tasks (rotate keys, manage seats). Boundary: narrower than admin; no schema changes. |
Audit (audit) |
9 | Access to view/generate audit artifacts. Boundary: read-only; subject to data-class redaction and purpose-based access. |
Approve (approve) |
10 | Approve/reject requests (e.g., quota increase, PR-based changes). Boundary: requires reviewer policy and traceable sign-off. |
Configure (configure) |
11 | Configure options/experiments/flags. Boundary: cannot escalate identity roles or billing plans. |
Access Control (access_control) |
12 | Define/manage access policies and role bindings. Boundary: meta-privilege—must be isolated, logged, and often requires dual control. |
Linkage points
- Evaluated by Identity (RBAC) and Config (ABAC predicates) on each request.
- Bound into token claims (scopes/roles) as short names; downstream services evaluate against their published policy matrix.
- Edition-aware policies combine AccessType with AccessScope and entitlements to yield effective permissions.
Notes & guidance
- Additive-first: introduce new scopes/types as additions; existing consumers must ignore unknown values.
- Granularity: prefer fewer, well-defined AccessType values, and use AccessScope to localize where they apply.
- Auditing: every administrative AccessType (
admin,manage,access_control,approve,configure) should produce structured audit records with correlation IDs.
Cross-cutting enumerations: Limits & Windows¶
LimitTypeEnumeration¶
Defines what dimension is being limited or metered (the “what” of quotas/usage).
Per-member properties shown in the table:
- Name / ShortName — Display labels as defined in the enum.
- Value — Stable integer for storage/wire contracts.
- Unit (suggested) — Typical measurement unit used by metering/billing.
- Meter Key (suggested) — Canonical snake_case key for counters.
- Description — Purpose and typical usage.
The following members reflect the current code list. (Additions should be made additively to preserve compatibility.)
| Name (ShortName) | Value | Unit (suggested) | Meter Key (suggested) | Description |
|---|---|---|---|---|
| API Calls | 1 | count | api_calls |
Number of API invocations within a period; distinct from service-specific request tallies. |
| Storage | 2 | bytes/GB | storage_bytes |
Total stored data footprint; often surfaced in GB for UI, billed from bytes. |
| Users | 3 | count | users_count |
Number of provisioned identities/seats eligible to sign in. |
| Transactions | 4 | count | transactions_count |
Discrete business operations (e.g., financial ops, writes). |
| Bandwidth | 5 | bytes/GB | egress_bytes |
Network egress/transfer consumed. |
| Requests | 6 | count | requests_count |
Service/endpoint request count (if tracked separate from API Calls). |
| Messages | 7 | count | messages_count |
Communications/items processed (emails, chat messages, events). |
| Seats | 8 | count | seats_count |
Licensed seats allocated to a tenant. |
| Projects | 9 | count | projects_count |
Projects/workspaces under a tenant. |
| Computational Resources | 10 | CPU-sec/GB-hr | compute_seconds |
CPU/memory compute consumption (normalise to seconds or GB-hours). |
| Hours | 11 | hours | hours_used |
Time-bound use of a feature/service (lab time, agent time). |
| Data Processing | 12 | bytes/ops | bytes_processed |
Data processed by pipelines/AI/etc., measured by bytes or operation count. |
| Concurrent Users | 13 | count | concurrent_users |
Simultaneous active users allowed. |
| Features Activated | 14 | count | features_activated |
Number of modules/features switched on. |
| Custom Limit | 15 | varies | custom/<key> |
Extension point for domain-specific limits; define a concrete key & unit. |
Meter mapping hints
- Use one canonical meter key per dimension (snake_case) for counters and billing exports.
- Prefer the smallest atomic unit (e.g., bytes, seconds) in storage; convert for UI at query-time.
- Keep dimension→feature linkage via FeatureKey (not navigations) in metering/billing services.
ResetPeriodEnumeration¶
Defines when counters reset (the “when” of quotas/usage).
Per-member properties shown in the table:
- Name / ShortName — Display labels as defined in the enum.
- Value — Stable integer for storage/wire contracts.
- Cadence (ISO-8601) — Canonical duration for fixed periods (if applicable).
- Anchor — Calendar / Anniversary / Manual / Rolling / Per-Event.
- IsRolling — Whether the window is sliding rather than fixed.
- Notes — Operational guidance.
The following members reflect the current code list. (Semantics below clarify anchor and rolling behavior.)
| Name (ShortName) | Value | Cadence (ISO-8601) | Anchor | IsRolling | Notes |
|---|---|---|---|---|---|
| Daily | 1 | P1D |
Calendar | No | Resets every day at configured cutover (typically 00:00 UTC or tenant-local). |
| Weekly | 2 | P7D |
Calendar | No | Resets once a week on configured weekday (e.g., Monday 00:00). |
| Monthly | 3 | P1M |
Calendar | No | Resets on 1st of month or billing anchor date. |
| Quarterly | 4 | P3M |
Calendar | No | Resets at end of quarter (Mar/Jun/Sep/Dec) unless tenant-specified. |
| Annually | 5 | P1Y |
Anniversary | No | Resets yearly at contract anniversary or configured date. |
| Hourly | 6 | PT1H |
Calendar | No | Resets on the hour boundary. Useful for burst control. |
| Custom | 7 | varies | Manual | No | Tenant/model-defined cadence; document in policy. |
| On-Demand | 8 | n/a | Manual | No | Reset performed by admin action/API; audit required. |
| Bi-Weekly | 9 | P14D |
Calendar | No | Resets every 14 days; align with payroll-style schedules if needed. |
| Bi-Monthly | 10 | P2M |
Calendar | No | Resets every two months; ensure end-of-month handling for short months. |
| Per-Transaction | 11 | n/a | Per-Event | No | Resets after each usage event; behaves like “no accumulation.” |
| Rolling 24 Hours | 12 | PT24H |
Rolling | Yes | Sliding window of the last 24h from “now”; not tied to wall-clock cutovers. |
Meter mapping hints
- For fixed windows (Daily/Monthly/etc.), bucket usage by calendar anchors; for tenant-specific anchors, store the anchor in the subscription.
- For rolling windows, use timestamp-based sliding counters (ring buffers or decay queues).
- Per-Transaction is equivalent to enforcing at event time (no carry-over).
- On-Demand resets must be audited and should emit a
quota.reset_requested|appliedevent in Metering.
Cross-cutting enumerations: Commerce & Lifecycle¶
BillingCycleEnumeration¶
Represents the recurrence at which charges are applied or invoices are generated.
Per-member properties shown below:
- Name / ShortName — Display label (from code); recommended WireKey (slug) for tokens/exports.
- Value — Stable integer for storage/wire contracts.
- Cadence (ISO-8601) — Canonical interval for fixed cycles.
- Anchor — Calendar/Anniversary/Manual.
- Description — Billing & proration semantics.
| Name (ShortName) | WireKey | Value | Cadence (ISO-8601) | Anchor | Description |
|---|---|---|---|---|---|
| Monthly | monthly |
1 | P1M |
Calendar | Charges every month; proration anchored to the subscription start or tenant billing anchor. |
| Quarterly | quarterly |
2 | P3M |
Calendar | Charges every 3 months; align with fiscal quarters when configured. |
| Semi-Annually | semi_annually |
3 | P6M |
Calendar | Charges every 6 months; same proration rules as Monthly/Quarterly. |
| Annually | annually |
4 | P1Y |
Anniversary | Yearly charges; common for enterprise contracts and prepaid terms. |
| One-Time | one_time |
5 | n/a | Manual | Single, non-recurring charge (setup, migration). No proration. |
| Custom | custom |
6 | varies | Manual | Contract-specific cadence; include a required metadata field with the computed schedule. |
Notes
- Proration is applied for mid-period seat/plan changes for all fixed cadences; never for
One-Time. Customcycles must record the next run date to drive invoicing and reminders.
PricingTypeEnumeration¶
Describes the economic model applied by a pricing/rating engine.
Per-member properties shown below:
- Name / ShortName — Display label and WireKey (slug).
- Value — Stable integer for storage/wire contracts.
- Category — High-level bucket used in UIs and analytics.
- Description — When/why to use; how it combines with billing/usage.
| Name (ShortName) | WireKey | Value | Category | Description |
|---|---|---|---|---|
| Fixed Price | fixed_price |
1 | Flat | Single recurring price independent of seats/usage. |
| Tiered Pricing | tiered_pricing |
2 | Usage | Unit price changes by usage tiers (e.g., 0–1M, 1–10M). |
| Volume-Based Pricing | volume_pricing |
3 | Usage | Price per unit depends on total volume purchased/committed. |
| Usage-Based Pricing | usage_based |
4 | Usage | Pay strictly for what is consumed (e.g., tokens, API calls). |
| Per User Pricing | per_user |
5 | Seat | Base × seats; may include minimum seat policy. |
| Per Feature Pricing | per_feature |
6 | Add-on | Price varies by selected modules/add-ons. |
| Freemium | freemium |
7 | Promo | Free tier with feature/usage caps; upsell to paid tiers. |
| Subscription-Based | subscription |
8 | Flat | Recurring subscription (often combined with seats/usage). |
| Pay-As-You-Go | payg |
9 | Usage | Pure consumption billing with no commitment. |
| One-Time Purchase | one_time_purchase |
10 | One-off | Lifetime or timeboxed license; pairs with One-Time cycle. |
Notes
- Many real plans are hybrids (e.g., subscription base fee + tiered usage). Capture with a
PricingModel+BillingRulecomposition.
ProductStatusEnumeration (extended lifecycle)¶
Represents the publication lifecycle of a Product. We extend the current set to match DDD lifecycle semantics while preserving backward compatibility.
Per-member properties shown below:
- Name / ShortName — Display label and WireKey (slug).
- Value — Stable integer for storage/wire contracts.
- IsTerminal — Whether the state forbids further changes.
- Description — Business meaning and mutability rules.
| Name (ShortName) | WireKey | Value | IsTerminal | Description & mutability rules |
|---|---|---|---|---|
| Draft | draft |
0 | Work-in-progress. Keys editable; Editions/Features may be attached/removed freely. Not visible to customers. | |
| Published | published |
1 | Publicly available. Keys lock; only additive changes allowed (new Editions/metadata). | |
| Deprecated | deprecated |
2 | Sunsetting. No new tenants; existing tenants supported. Only security/compat fixes allowed. | |
| Retired | retired |
3 | ✅ | End-of-life. Hidden from catalogs; migrations complete. No further changes except archival/exports. |
Compatibility mapping (for existing code)
Active→ Published;Inactive→ Draft (or Deprecated based on usage);Deprecated→ Deprecated. Document the mapping in code comments and migration scripts.
EditionStatusEnumeration (extended lifecycle)¶
Same semantics as Product, but at the Edition level.
| Name (ShortName) | WireKey | Value | IsTerminal | Description & mutability rules |
|---|---|---|---|---|
| Draft | draft |
0 | Composition editable (add/remove features, quotas). Not selectable by tenants. | |
| Published | published |
1 | Selectable by tenants. Key locks; features must all be Published. Only additive changes permitted. |
|
| Deprecated | deprecated |
2 | Hidden for new sales; existing tenants remain. Only critical fixes. | |
| Retired | retired |
3 | ✅ | Not assignable; migrations completed; immutable except archival. |
Compatibility mapping (for existing code)
Active→ Published;Inactive→ Draft (or Deprecated);Deprecated→ Deprecated.
FeatureStatusEnumeration (new)¶
Completes the lifecycle symmetry for Feature entities.
| Name (ShortName) | WireKey | Value | IsTerminal | Description & mutability rules |
|---|---|---|---|---|
| Draft | draft |
0 | Spec under development; key editable; default quotas/rules can be defined. | |
| Published | published |
1 | Available for inclusion in editions. FeatureKey locks; only additive metadata allowed. | |
| Deprecated | deprecated |
2 | To be removed; cannot be added to new editions; existing editions may keep it until migration. | |
| Retired | retired |
3 | ✅ | Removed from catalogs; cannot be referenced by active editions; historical events remain. |
Lifecycle semantics & mutability (all three: Product, Edition, Feature)¶
- Key immutability:
Key/FeatureKeybecomes immutable at Published. - Additive-first: After Published, you may add metadata/links (e.g., new tags, optional descriptors) but not remove required ones that would break consumers.
- Deprecation discipline: Enter Deprecated with a sunset timeline, migration guidance, and event emission (
*.deprecated). - Retirement: Retired entities are immutable except archival and export operations. Remove from discovery UIs; keep in read models for historical queries.
- Eventing: Transitions emit canonical events (
product.published,edition.published,feature.published;*.deprecated;*.retired). - Policy checks: Publishing requires all linked entities to be Published and compatibility constraints satisfied (e.g., feature version requirements).
Migration note
The extended lifecycle is additive. Keep existing integer values for Active/Inactive/Deprecated where serialized, and introduce mapping functions to the new states in adapters. For new storage, adopt the extended set directly to prevent ambiguity going forward.
Shared value objects (Catalog)¶
These value objects are small, immutable pieces of the Catalog model that carry meaning and validation. They are owned by aggregates (e.g., Product, Feature, EditionFeature) and persisted inline. You won’t find them in your current interfaces—that’s expected; this section explains why we introduce them and how to use them.
Why VOs here
- They prevent accidental drift in identifiers/limits that show up in contracts (APIs, events, tokens).
- They centralize validation (one place to define allowed characters, ranges, and mutability rules).
- They are EF-friendly as owned types (no identity, embedded columns).
ProductKey (VO)¶
Purpose Canonical, URL-safe identifier for a Product. Used in URLs, events, and cross-service references. Immutable after the Product is first Published.
Properties (with XML-style intent)
| Property | Type | Description |
|---|---|---|
Value |
string |
Canonical slug for the product. Lowercase; a–z, 0–9, -, _, . only. Must start/end with alphanumeric. Length 3–64. Immutable after Publish. |
Creation & validation
- Normalize by: trim → lowercase → replace spaces with
-→ collapse repeated separators → validate against regex^[a-z0-9](?:[a-z0-9_.-]{1,62})[a-z0-9]$. - Uniqueness: globally unique within Catalog (not per tenant).
- Reserved examples:
default,all,system(document your reserved set if any).
Examples
crm-suite,analytics.core,config-flags
ORM notes
- Map as an owned type with a single
nvarchar(64)column (e.g.,ProductKey). - Add a unique index on
ProductKey.
Serialization
- Serialize as a string (not as an object) to keep wire contracts compact.
FeatureKey (VO)¶
Purpose Canonical identifier for a Feature, referenced by Editions, tokens/entitlements, and downstream services (Config, Billing, Metering). Immutable after the Feature is Published.
Properties
| Property | Type | Description |
|---|---|---|
Value |
string |
Canonical feature key. Lowercase slug; same character set as ProductKey. Global uniqueness across Catalog. Immutable after Publish. |
Creation & validation
- Same normalization and regex as
ProductKey. - Global uniqueness: a
FeatureKeymust be unique across all products/editions, because entitlements and meters key off it.
Examples
webhooks,feature-flags_advanced,ai.tokens
ORM
- Owned type; store as
nvarchar(64)column (e.g.,FeatureKey). - Index for fast lookup and join to link entities (e.g.,
EditionFeature).
Serialization
- As string in APIs/events/claims.
Quota (VO)¶
Purpose Declarative ceiling for a usage dimension. In Catalog, Quotas are advisory (describe editions/features); Metering enforces them at runtime.
Properties
| Property | Type | Description |
|---|---|---|
Limit |
decimal? |
Maximum allowed units for the period. null = Unlimited; 0 = Disabled (no usage). Non-negative. Supports fractional units (e.g., hours). |
LimitType |
LimitTypeEnumeration |
Dimension key (e.g., API Calls, Storage). Aligns with metering/billing dimension catalogs. |
ResetPeriod |
ResetPeriodEnumeration |
When counters reset (e.g., Monthly, Rolling 24 Hours). Required when Limit is not null. |
Semantics & invariants
- If
Limitis specified →ResetPeriodmust be specified. Limit = null→ unlimited; still publish theLimitTypefor clarity.Limit = 0→ blocked/disabled; used for Freemium feature placeholders.- The unit is implied by
LimitType(bytes, count, hours…); UI conversions (e.g., GB) happen at read time.
Examples
{ Limit: 1000000, LimitType: API Calls, ResetPeriod: Monthly }{ Limit: 10, LimitType: Projects, ResetPeriod: On-Demand }{ Limit: null, LimitType: Storage, ResetPeriod: Monthly } // Unlimited storage
ORM
- Owned type embedded in parent (e.g.,
Feature.DefaultQuota,EditionFeature.QuotaOverride). - Columns:
Quota_Limit(decimal(18,4)),Quota_LimitType(int),Quota_ResetPeriod(int). - Index
Quota_LimitTypeif you query by dimension often.
Serialization
- As an object with three fields; omit
Limitwhennullto indicate Unlimited.
KeyValueTag (VO)¶
Purpose Lightweight metadata for discovery, search, and telemetry. Never used to drive business invariants or authorization.
Properties
| Property | Type | Description |
|---|---|---|
Key |
string |
Tag name, 1–64 chars. Lowercase recommended; a–z, 0–9, -, _, .. No PII. |
Value |
string |
Tag value, up to 256 chars (or your chosen limit). May be free text; avoid secrets and PII. |
Guidelines
- No business logic: tags don’t alter entitlements or pricing.
- Reserved prefixes: consider
sys:for internal tags; reject unapproved internal prefixes at write time. - Duplicates: disallow duplicate keys within the same owner; last write wins if you must allow it.
Examples
{ Key: "category", Value: "analytics" }{ Key: "owner_team", Value: "growth" }{ Key: "region_hint", Value: "eu" }
ORM
- Either: (a) owned collection (separate table
*_Tagswith composite key{OwnerId, Key}), or (b) JSON column for low-cardinality tags. Choose based on query needs.
Serialization
- As array of
{ key, value }objects; order not significant.
How these VOs change your current interfaces (non-breaking adoption)¶
- Keep
Product.Key : stringandFeature.Key : stringin interfaces, but document that they representProductKey.Value/FeatureKey.Value. - In concrete aggregates, wrap the string in the VO and apply validation at creation and on publish transitions.
- For
Quota, start by using it where you already carry limits: default limits onFeature, overrides onEditionFeature. - Tags are optional; if you already carry free-form metadata, map it to
KeyValueTag[]for consistency.
Event impact
- Always include
key(string) for products/features in events (not the whole VO). - For quotas, include
{ limit, limitType, resetPeriod }fields directly in event payloads; omitlimitwhen unlimited.
Ownership
- These VOs belong to the Catalog context. Other contexts consume their scalar values (e.g.,
FeatureKeystrings, quota fields) via events/read models—no cross-context navigations.
Product (Aggregate Root, Core Metadata)¶
Relationships¶
- HasMany → IEdition A Product owns its lineup of Editions. The Edition model carries a Reference → IProduct back to its owner, and the Product model exposes a collection of editions.
- HasMany → IBusinessModel
Product is also included in zero or more Business Models; the Product model exposes a collection
IncludedInBusinessModelsfor that purpose.
Properties (interface contract)¶
The table lists each property on
IProduct(as implemented today) with DDD semantics. When a VO is referenced (e.g., ProductKey), the interface surface remainsstringfor compatibility; validation/mutability rules come from the VO spec.
| Property | Type | Description & rules |
|---|---|---|
ProductId |
Guid |
Aggregate identifier for storage and events; opaque and stable. |
Name |
string |
Internal product name (engineering/ops). Free text; 1–200 chars. Not necessarily unique globally. |
DisplayName |
string |
Human-friendly label shown in portals and marketing UIs. 1–200 chars; localized copies may exist in read models. |
Key |
string |
Canonical ProductKey slug. Lowercase [a–z0–9._-], 3–64 chars, unique within Catalog. Immutable after Published. Used in URLs, events and cross-service references. |
Description |
string |
Narrative description of the offering and scope. May include links to docs/SLAs (store links, not large HTML). |
ProductCategory |
string |
High-level grouping (e.g., CRM, ERP, Analytics) for discovery and reporting; does not affect entitlements. |
Status |
ProductStatusEnumeration |
Lifecycle state (Draft/Published/Deprecated/Retired). Keys lock at Published; only additive changes allowed thereafter. |
CreationDate |
DateTime |
UTC timestamp when the product was created. Used for audit and ordering. |
LatestVersion |
string? |
Optional catalog version label (e.g., 1.4). Bumped on significant publish events; semantic only. |
ReleaseNotes |
string? |
Optional human-written summary of changes included in the latest publish. |
DeactivationDate |
DateTime? |
Optional sunset anchor for deprecating/retiring the product; read models use it to hide from sales/UX after the date. |
Editions |
IList<IEdition> |
HasMany collection owned by Product. Each Edition composes features and pricing independently. Loaded explicitly in commands that need lineup invariants. |
IncludedInBusinessModels |
IList<IBusinessModel> |
HasMany collection of Business Models that include this product (organizational/commercial grouping). Often read-only from Product’s perspective. |
Recommended extension properties (DDD/HLD-aligned, optional to add)¶
These properties are not in the current interface but are part of the blueprint semantics. Add them if/when you extend the contract.
| Property | Type | Why it helps |
|---|---|---|
PublishedAtUtc |
DateTime? |
Trace the first time this Product reached Published; simplifies reporting and time-based policies. |
DeprecatedAtUtc |
DateTime? |
Start of deprecation window; downstream services can warn customers consistently. |
RetiredAtUtc |
DateTime? |
Actual EOL; used by cleanup/export jobs. |
Tags |
IReadOnlyList<KeyValueTag> |
Non-authoritative metadata for search/telemetry (owner team, category hints). |
DefaultEditionId |
Guid? |
Hint for provisioning flows to pick an edition when not specified explicitly (must refer to a Published edition). |
Invariants (type-level)¶
- A Product cannot transition to
Publishedunless it has at least onePublishedEdition inEditions. Keyis immutable once the Product reachesPublished.- When
Status = Deprecated, new tenants must not be assigned to any edition of this product via self-serve; enterprise assignments may require an override + audit. - When
Status = Retired, the product must be hidden from catalogs; only archival/export operations are allowed.
Domain events¶
product.published— Emitted when transitioning toPublished; includesproductId,key,publishedVersion, and minimal snapshot for catalogs.product.deprecated— Emitted when enteringDeprecated; include optionalsunsetDateand migration guidance ref.product.retired— Emitted at EOL; consumers should stop offering this product and preserve only historical records.
(Event envelopes follow the standard: eventId, occurredAtUtc, aggregateId, aggregateVersion, optional tenantId, and schemaVersion.)
ORM notes¶
- Editions navigation: map as
HasManywith FKProductIdonEdition. Load explicitly for commands that check publish invariants; avoid global lazy loading to prevent N+1. - Cascade rules: cascade only for owned value objects (e.g., tags if modeled as an owned collection). Do not cascade deletes across aggregate boundaries; use lifecycle states (
Deprecated/Retired) instead. - Indexes: unique index on
Key; nonclustered index onStatusand (Status,DeactivationDate) for catalog queries. - Concurrency: add an optimistic concurrency token (e.g.,
rowversion) to the concrete class; not part of the interface.
Notes for command handlers¶
- Create: validate
Keyformat (perProductKeyVO) and ensure uniqueness. - Publish: ensure at least one edition is
Published; emitproduct.published. - Deprecate/Retire: set deprecation/retirement timestamps and emit events; block new sales paths accordingly.
This anchors Product as the true aggregate root of the Catalog and sets the rules that upcoming sections (Edition, Feature, EditionFeature, SLA, Pricing/Business Models) build on.
Edition (Catalog)¶
Relationships¶
- Reference → IProduct (parent)
Each Edition belongs to exactly one Product; the Edition interface exposes a navigation to its owning
IProduct. - HasManyThrough → IEditionFeature (Edition↔Feature link)
An Edition’s feature set is composed via the explicit link entity
IEditionFeature(no implicit M:N). This allows behavior like effective dating, overrides, and custom rules per pairing. - HasMany → IServiceLevelAgreement (attached SLAs)
Editions may attach one or more SLA packages; the interface exposes a collection of
IServiceLevelAgreement. (In practice this is a many-to-many relationship modeled via a join table.) - Reference/HasOne → IEditionPricingModel (current shape)
Current interface models an Edition-scoped pricing object (
EditionPricing). See pricing/effective-dating guidance below.
Properties (interface contract)¶
| Property | Type | Description & rules |
|---|---|---|
EditionId |
Guid |
Stable identifier for storage/events. Opaque and immutable. |
Name |
string |
Internal name used by engineering/ops. 1–200 chars; not required to be unique globally. |
DisplayName |
string |
Human-friendly label shown in catalogs/UIs (e.g., “Enterprise”). May have localized copies in read models. |
Key |
string |
Edition key/slug, unique within its Product; lowercase [a–z0–9._-], 3–64 chars. Immutable after Publish. |
Description |
string |
Narrative of scope, included capabilities, and limits. Keep links/refs; avoid large HTML payloads. |
CreationDate |
DateTime |
UTC timestamp when the Edition was created. Used for audit and ordering. |
Status |
EditionStatusEnumeration |
Lifecycle (Draft/Published/Deprecated/Retired). Keys lock at Published; only additive changes allowed thereafter. |
Product |
IProduct |
Reference to the owning Product (same bounded context). Commands that change composition should load this for cross-aggregate checks only when required. |
EditionPricing |
IEditionPricingModel |
HasOne Edition-specific pricing (current shape). See invariants on effective windows and price bounds. |
EditionFeatures |
IList<IEditionFeature> |
HasManyThrough link entities defining composition (feature, effective/expiry, overrides). No implicit M:N. |
ServiceLevelAgreements |
IList<IServiceLevelAgreement> |
HasMany (implemented as M:N) SLA packages attached to this Edition. Typically exactly one active at a time. |
Recommended extension properties (DDD/HLD-aligned, optional to add)¶
| Property | Type | Why it helps |
|---|---|---|
Version |
string |
SemVer for Edition composition; increments on publish with material changes. Useful for cache keys & consumer diffs. |
PublishedAtUtc |
DateTime? |
First time this Edition reached Published; helps entitlement stamping and rollout analytics. |
DeprecatedAtUtc |
DateTime? |
Start of deprecation window; downstream can warn/upsell/migrate. |
RetiredAtUtc |
DateTime? |
EOL marker; used by cleanup/export processes. |
Tags |
IReadOnlyList<KeyValueTag> |
Non-authoritative metadata for discovery/telemetry (e.g., “vertical: healthcare”). |
Invariants (type-level)¶
-
Publish readiness
-
All
EditionFeatures.Featuremust be Published when the Edition transitions toPublished. Product.Statusmust bePublishedor change atomically toPublishedwith this Edition (policy choice; prefer product published first).-
Key immutability
-
Keybecomes immutable atPublished. -
Composition integrity
-
(EditionId, FeatureId)pairs are unique per effective window; no overlapping active windows for the same pair. - If
IEditionFeature.QuotaOverride(when modeled) orMaxUsageLimitis set, it must be ≥ 0 and align with the dimension semantics;ExpiryDate≥EffectiveDate. -
Pricing integrity
-
EditionPricingwindows must not overlap for the same Edition/PricingModel; enforceMinimumPrice ≤ BasePrice ≤ MaximumPrice. -
SLA attachment
-
At most one active SLA per Edition at a time; new versions create new rows, old ones become inactive with
ExpiryDate.
Domain events¶
edition.published— emitted on transition toPublished; payload includeseditionId,productId,key,compositionVersion, minimal snapshot of features (keys) and SLAs.edition.changed— emitted when composition/pricing/SLAs materially change (e.g., feature added/removed, pricing window updated). Include diffs or newcompositionVersion.edition.deprecated— emitted on enteringDeprecated; includesunsetDateand recommended migration path.
(Events follow the platform envelope: eventId, occurredAtUtc, aggregateId, aggregateVersion, optional tenantId, schemaVersion.)
ORM notes¶
-
Product reference
-
FK:
ProductIdonEdition; mapHasOne(e => e.Product).WithMany(p => p.Editions); require FK. (Same bounded context.) -
EditionFeatures (explicit join)
-
Map
IEditionFeaturewith composite key(EditionId, FeatureId, EffectiveDate)to support time-varied composition; unique filtered index to prevent overlapping windows. - Navigations:
Edition.HasMany(e => e.EditionFeatures);EditionFeature.HasOne(x => x.Edition)and.HasOne(x => x.Feature). -
SLAs (M:N via join table)
-
Join:
EditionSla(EditionId, ServiceLevelAgreementId, EffectiveDate, ExpiryDate?); enforce one active SLA per edition. -
EditionPricing (HasOne)
-
Edition.HasOne(e => e.EditionPricing).WithOne(p => p.Edition)(if modeled 1:1 active); orHasManywhen supporting historical windows. Enforce price bounds and non-overlap. -
Indexes
-
Unique index on
(ProductId, Key); nonclustered onStatus, and onEditionIdin the join tables for fast lookups. -
Loading
-
Command handlers: load minimal graph needed for invariants (e.g.,
EditionFeatureswhen publishing). - Reads: project to DTOs (
EditionView,EntitlementsView) instead of returning deep graphs.
This section fixes the Edition’s role as the composition anchor: it references its Product, composes Features through EditionFeature, binds an active SLA, and (in the current shape) carries an Edition-scoped pricing object for commercial behavior.
Feature (Catalog) & default limits¶
Relationships¶
- HasMany → IBillingRule A Feature can define one or more billing rules that describe how to charge for that capability (overage, thresholds, effective dating).
- HasManyThrough → IEditionFeature (to Editions)
Editions include Features via the explicit link entity
IEditionFeature(no implicit many-to-many). This enables effective/expiry windows and per-edition overrides. - HasMany → IAccessRule Feature-scoped access rules (and their exceptions) describe who can do what at this feature scope.
- HasMany → IUsageLimit
Feature-scoped default limits (dimension + cap + reset period). Editions can override through
IEditionFeature.
Properties (interface contract)¶
| Property | Type | Description & rules |
|---|---|---|
FeatureId |
Guid |
Aggregate identifier; opaque and stable for storage/events. |
Name |
string |
Internal feature name for engineering/ops; 1–200 chars. |
DisplayName |
string |
Human-friendly label for portals and docs; localized variants live in read models. |
Key |
string |
Canonical FeatureKey slug. Lowercase [a–z0–9._-], 3–64 chars. Globally unique in Catalog. Immutable after Published. Used by entitlements, metering, config, and billing. |
Description |
string |
Narrative description and scope; keep links rather than large HTML. |
CreationDate |
DateTime |
UTC timestamp when created. |
AccessRules |
IList<IAccessRule> |
Feature-scoped RBAC/ABAC rules; may carry AccessType, AccessScope, Condition, and effective windows. |
UsageLimits |
IList<IUsageLimit> |
Default per-feature limits (dimension, limit value, reset period). Editions may override. |
BillingRules |
IList<IBillingRule> |
Pricing/rating rules (cycle, thresholds, min/max, discount, effective/expiry). |
Recommended extension properties (DDD/HLD-aligned, optional to add)¶
| Property | Type | Why it helps |
|---|---|---|
Status |
FeatureStatusEnumeration |
Aligns lifecycle with Product/Edition (Draft/Published/Deprecated/Retired) and locks Key at Published. |
DefaultQuota |
Quota? (VO) |
Canonical single “most important” limit for this feature (dimension + cap + reset); mirrors entries in UsageLimits and simplifies entitlement stamping. |
PublishedAtUtc / DeprecatedAtUtc / RetiredAtUtc |
DateTime? |
Clear lifecycle anchors for rollout analytics, sunset comms, and archival. |
Tags |
IReadOnlyList<KeyValueTag> |
Non-authoritative metadata (search/telemetry). |
Note: these are additive to your current
IFeaturecontract and do not break existing callers. Validation and immutability are enforced in the concrete aggregate, not via the interface.
Invariants¶
- Key uniqueness & immutability
Keymust be globally unique across all features; oncePublished, it cannot change. (Tokens, meters, and flags depend on stability.) - Default quota validity
If
DefaultQuotais present (or anyUsageLimitsentry), it must specify a validLimitTypeandResetPeriod. For finite limits, value must be ≥ 0;nullmeans Unlimited. - Rule consistency
Billing rules must satisfy
MinimumPrice ≤ OverageRate×threshold ≤ MaximumPricewhere meaningful; effective windows must not overlap for the same rule. - Access rule scope
Feature-scoped access rules must use
AccessScope=Feature(or a narrower scope) and validAccessType; exception rules must reference their parent rule and share compatible scope/time windows.
Domain events¶
feature.published— on transition toPublished; includefeatureId,key, and optionaldefaultQuotasnapshot.feature.deprecated— on entering sunsetting; includesunsetDateand migration guidance reference.feature.retired— at EOL; hides feature from catalogs; leaves historical reads intact.
(Event envelope per platform standard: eventId, occurredAtUtc, aggregateId, aggregateVersion, optional tenantId, schemaVersion.)
ORM notes¶
- EditionFeatures inverse
Map
EditionFeatureas an explicit join entity: composite key such as(EditionId, FeatureId, EffectiveDate)to support effective-dating; ensure uniqueness per active window. Navigations fromFeatureto editions are viaEditionFeatureonly. - Rules collections
AccessRules,UsageLimits, andBillingRulesare separate tables with FKs back toFeature. Avoid cascading deletes across aggregates; prefer lifecycle statuses on rules. - Indexes
Unique index on
Key; nonclustered indexes on(FeatureId, EffectiveDate)in rules and joins for quick lookups by time window.
Guidance for use with Editions¶
- Use
IEditionFeatureto include a Feature in an Edition and optionally override limits or attach edition-specific rules (custom access/usage/billing). - When an Edition is Published, every included
Featuremust already be Published, and any overrides must be compatible with the feature’s dimensions and reset semantics.
EditionFeature (link/entity with behavior)¶
Relationships¶
- Reference → IEdition Connects the link to its owning edition (same bounded context).
- Reference → IFeature Connects the link to the included feature (same bounded context).
Properties (interface contract + DDD semantics)¶
| Property | Type | Description & rules |
|---|---|---|
EditionFeatureId |
Guid |
Surrogate identifier for the link entity (useful for event correlation and auditing). |
CreationDate |
DateTime |
When this link row was created (UTC). |
Edition |
IEdition |
Reference to owning Edition. The FK (EditionId) is persisted; navigation is same-context only. |
Feature |
IFeature |
Reference to Feature being included. Persist FeatureId as FK. |
EffectiveDate |
DateTime |
Start of validity (inclusive). Required. |
ExpiryDate |
DateTime? |
End of validity (exclusive). null = open-ended. |
IsIncluded |
bool |
Whether the feature is currently part of this edition (on/off switch). |
MaxUsageLimit |
int? |
Simple cap for this edition-feature pairing (nullable = no cap). For richer caps, use CustomUsageLimits. |
CustomAccessRules |
IList<IAccessRule> |
Access rules scoped to this edition-feature (override/extend feature defaults). |
CustomUsageLimits |
IList<IUsageLimit> |
Usage limits scoped to this edition-feature (override feature defaults). |
CustomBillingRules |
IList<IBillingRule> |
Billing/rating rules scoped to this edition-feature (override feature defaults). |
The HLD also documents these attributes (IDs/effective dates, per-edition overrides for access/usage/billing).
Recommended extension properties (additive, DDD/HLD-aligned)¶
| Property | Type | Why it helps | ||
|---|---|---|---|---|
Mode |
string (Enabled | Conditional | Preview) |
Clarifies inclusion intent beyond IsIncluded: Enabled = fully on; Conditional = gated by rule/flag; Preview = non-SLA early access. (Surfaces clearly in UIs and entitlements.) |
||
FeatureKey |
string |
Denormalized FeatureKey for events/claims; avoids loading Feature to emit downstream contracts while maintaining the navigation for commands. |
||
Constraints |
IReadOnlyList<string> |
Human/machine-readable compatibility or gating expressions (e.g., “requires ai.tokens ≥ 1M/month”). Helps validate composition pre-publish. | ||
QuotaOverride |
Quota? (VO) |
Rich cap model (dimension + limit + reset) when MaxUsageLimit is insufficient; aligns with Catalog VO semantics. |
||
CustomAccessRuleIds / CustomUsageLimitIds / CustomBillingRuleIds |
IReadOnlyList<Guid> |
Optional ID-only mirrors for projection/exports when navigations aren’t needed (keeps events/DTOs light). |
The HLD calls out per-edition overrides for limits/billing/access; these extensions make the intent explicit and wire-friendly.
Invariants¶
- Publish dependency
When an Edition transitions to
Published, every linkedFeaturemust already bePublished. (Edition cannot publish with Draft/Deprecated features.) -
Uniqueness A given
(EditionId, FeatureId)pair may not have overlapping active windows. Enforce either: -
single active row per pair (IsIncluded=true) at any time; or
- composite uniqueness on
(EditionId, FeatureId, EffectiveDate)plus a filtered index to prevent overlaps. - Override compatibility
QuotaOverride(orCustomUsageLimits) must use validLimitType/ResetPeriodcombinations; finite limits are ≥ 0. - Rule scope
CustomAccessRulesmust targetAccessScope = Feature(or narrower) and align with edition/feature IDs. Exceptions must reference their parent rule and share compatible windows.
Event implications (composition changes)¶
- Adding/removing or changing an
EditionFeatureshould raiseedition.changedwith a minimal diff (feature keys added/removed, overrides changed) so consumers (Identity, Config, Billing) can update entitlements and caches. (Edition emits; Feature remains unchanged.)
ORM notes¶
-
Keys & indexes
-
Keep
EditionFeatureIdas the surrogate PK (current interface). Add an alternate unique key on(EditionId, FeatureId, EffectiveDate)and a filtered unique to ensure no overlapping active windows. -
Joins
-
Map explicit many-to-many:
Edition (1..*) — EditionFeature (*..1) Feature. No implicit M:N; the link carries behavior (rules, limits, dates). -
Denormalized keys
-
Persist
FeatureKey(string) alongsideFeatureIdfor fast, event-friendly reads; keep it in sync via domain events or write-model guard. -
Loading strategy
-
Commands that publish or alter composition should load only the
EditionFeaturesneeded for the invariant checks (current and overlapping windows). Reads should project to entitlement DTOs (feature keys + overrides) rather than returning navigations.
Summary
EditionFeature is the behavioral join entity that makes Editions composable and expressive: it binds a Feature to an Edition over time, provides per-edition overrides (access, usage, billing), and is the source of truth for downstream entitlements. Its invariants protect publish quality, while explicit ORM mapping keeps the model DDD-pure and query-friendly.
ServiceLevelAgreement (Catalog)¶
Relationships¶
- HasMany ← IEdition (editions reference SLAs)
Editions attach SLA packages for contractual commitments; this is a many-to-many relationship realized via a join table (one Edition should have at most one active SLA at a time). The current interface exposes
Editions : IList<IEdition>. - Reference ← IBusinessModel (organizational linkage)
The current interface includes a
BusinessModelnavigation indicating the business model under which the SLA is defined.
Properties (interface contract + DDD semantics)¶
The table lists properties present today and DDD-aligned additions. For existing members, names map 1:1 to your interface. For additions, we note “(new)” and intended semantics.
| Property | Type | Description & rules |
|---|---|---|
ServiceLevelAgreementId |
Guid |
Stable identifier for storage/events. Consider aliasing as SlaId in DTOs for brevity. |
Name |
string |
Internal name for ops/engineering; 1–200 chars. |
Key |
string |
Canonical key/slug (lowercase [a–z0–9._-], 3–64). Used in catalogs/URLs. Immutable once published. |
DisplayName |
string |
Human-friendly label (e.g., “Enterprise SLA”). Localized copies live in read models. |
Description |
string |
Narrative of scope, targets, and exclusions. Prefer references to detailed docs over long HTML. |
CreationDate |
DateTime |
UTC timestamp when the SLA record was created. |
ServiceAvailability |
decimal? |
Target availability percentage (e.g., 99.9 = 99.9%). null when availability isn’t a stated metric. |
ResponseTime |
TimeSpan? |
Max initial response time for incidents/requests (e.g., PT1H). Optional when using severity matrices. |
ResolutionTime |
TimeSpan? |
Max resolution/restoration time for in-scope incidents (e.g., PT4H). Optional when using severity matrices. |
PenaltyClauses |
string? |
Text describing credits/penalties if targets are missed. Consider renaming to CreditsPolicy for clarity. |
EffectiveDate |
DateTime |
Start date/time when this SLA version becomes applicable (inclusive). |
ExpiryDate |
DateTime? |
End date/time when this SLA version ceases to apply (exclusive). null = open-ended. |
IsActive |
bool |
Convenience flag for current applicability. For effective-dated models this is derived; keep in sync with dates. |
BusinessModel |
IBusinessModel |
Organizational linkage; use for packaging/quoting. (Same bounded context.) |
Editions |
IList<IEdition> |
Editions that reference this SLA (many-to-many). Typically exactly one active per Edition. |
Recommended extension properties (additive; align with DDD/HLD)¶
| Property | Type | Why it helps |
|---|---|---|
Version (new) |
string |
SemVer for SLA content; increments on material changes. Useful for diffs and cache keys. |
Status (new) |
SlaStatus (Draft/Published/Deprecated/Retired) |
Lifecycle control consistent with Product/Edition/Feature; locks Key at Published. |
SupportWindow (new) |
string or VO (e.g., SupportWindowSpec) |
Human/structured availability of support (e.g., 24x7, 9x5 Mon–Fri, with timezone). |
ResponseTimes (new) |
IDictionary<string, TimeSpan> |
Severity-based targets (e.g., P1: PT1H, P2: PT4H), superseding single Response/Resolution when needed. |
CreditsPolicy (new) |
string |
Clear, positive naming for penalties/credits (maps from PenaltyClauses). |
PublishedAtUtc / DeprecatedAtUtc / RetiredAtUtc (new) |
DateTime? |
Lifecycle anchors for comms and archival. |
These are documentation-level additions; introduce incrementally without breaking existing contracts by keeping current fields and adding new ones.
Invariants¶
- One active SLA per Edition
For any Edition at a given time, there must be at most one SLA whose effective window covers “now”. Enforce through a filtered unique index on the Edition-SLA join (
EditionId) where(EffectiveDate ≤ now < ExpiryDate || ExpiryDate IS NULL). - Immutability of published versions
Once an SLA version is Published, its content is immutable. Changes require a new
Versionwith a new effective window (the prior version gets anExpiryDate). - Temporal coherence
EffectiveDatemust be ≤ExpiryDate(when present). Windows for the same SLAKeyshould not overlap—prefer versioned rows. - Lifecycle rules
Transitions to
Deprecatedshould include a sunset policy (how/when Editions move to a replacement).RetiredSLAs must not be attachable to Editions.
ORM notes¶
- Join mapping (Edition↔SLA)
Model an explicit join entity/table, e.g.,
EditionSla(EditionId, SlaId, EffectiveDate, ExpiryDate?). Add a filtered unique index to enforce “one active SLA per Edition”. - Owned VOs (optional)
If you adopt
SupportWindowSpecor structuredResponseTimes, map them as owned types (OwnsOne/OwnsMany). - Lifecycle vs delete
Prefer status transitions (
Deprecated,Retired) over hard delete. If you need soft delete, ensure joins ignore deleted rows and events are emitted on status change. -
Indexes
-
Unique index on
Key. - Nonclustered on
(IsActive, EffectiveDate)for quick queries. - For versioning, an index on
(Key, EffectiveDate DESC)helps “current version” lookups.
Notes for command handlers¶
- Publish: validate key uniqueness, set
PublishedAtUtc, and ensure no overlapping version window for the sameKey. - Attach to Edition: enforce “one active SLA per Edition” at write time; if switching, set the current one’s
ExpiryDatebefore attaching the new one. - Deprecate/Retire: update lifecycle timestamps and emit catalog events (if you introduce them) so downstream UIs and contracts update accordingly.
This frames SLAs as stable, versioned commitments that Editions attach at a point in time, with clear lifecycle and effective-dating rules—fully aligned with the rest of the Catalog model.
BusinessModel (Catalog)¶
Relationships¶
- HasMany → IProduct, IEdition, IFeature, IServiceLevelAgreement, IPricingModel A Business Model groups catalog objects for commercial packaging and go-to-market. The interface exposes collections for Products, Editions, Features, SLAs, and Pricing Models.
- Reference → IPricingModel (Default) One default pricing model defines the baseline commercial approach for this Business Model.
Properties (interface contract)¶
| Property | Type | Description & rules |
|---|---|---|
BusinessModelId |
Guid |
Stable identifier for storage/events; opaque and immutable. |
Name |
string |
Internal name for ops/engineering; 1–200 chars. |
Key |
string |
Canonical slug (lowercase [a–z0–9._-], 3–64). Used in admin UIs/exports. Prefer immutability after publish (see invariants). |
DisplayName |
string |
Human-friendly label for sales/portal surfaces. |
Description |
string |
Narrative of the commercial strategy (target segments, packaging notes). |
CreationDate |
DateTime |
UTC timestamp when the model was created. |
DefaultPricingModel |
IPricingModel |
Reference to the default pricing model used by this Business Model. Must also appear in PricingModels. |
Products |
IList<IProduct> |
HasMany collection of packaged products. Use for bundles or portfolios. |
Editions |
IList<IEdition> |
HasMany collection of editions included/offered under this model. |
Features |
IList<IFeature> |
HasMany collection of features included as add-ons or à la carte items. |
ServiceLevelAgreements |
IList<IServiceLevelAgreement> |
HasMany collection of SLAs this model can attach to offerings. |
PricingModels |
IList<IPricingModel> |
HasMany available pricing models (tiers/variants) under this business model. |
Recommended extension properties (DDD/HLD-aligned, optional to add)¶
| Property | Type | Why it helps |
|---|---|---|
Status |
BusinessModelStatus (Draft/Published/Deprecated/Retired) |
Align lifecycle with Catalog entities; lock Key at Published. |
Version |
string |
SemVer for packaging contents; bump when membership/pricing defaults materially change. |
PublishedAtUtc / DeprecatedAtUtc / RetiredAtUtc |
DateTime? |
Lifecycle anchors for sales enablement and archival. |
Tags |
IReadOnlyList<KeyValueTag> |
Non-authoritative metadata (e.g., “segment: smb”, “geo: eu”). |
Invariants¶
- Default pricing presence
DefaultPricingModelmust be an element ofPricingModels. (Reject writes otherwise.) - Coherent membership
Editionsmust belong toProductsthat are also included (or the BM declares a policy that allows cross-product editions—document explicitly). - Publish discipline
To mark a Business Model as
Published, all includedProducts/Ed itions/Features/SLAsshould be inPublished(or strictly compatible) states; otherwise stayDraft. - Key stability
Once
Status = Published,Keyis immutable; future changes are additive (new memberships/pricing) or versioned (Version++).
ORM notes¶
-
Explicit many-to-many Use link tables for each relationship to avoid implicit M:N and to allow effective-dating later if needed:
-
BusinessModelProduct(BusinessModelId, ProductId) BusinessModelEdition(BusinessModelId, EditionId)BusinessModelFeature(BusinessModelId, FeatureId)BusinessModelSla(BusinessModelId, SlaId)BusinessModelPricingModel(BusinessModelId, PricingModelId)- Default pricing
Store
DefaultPricingModelIdFK on the BM table; enforce presence in the join table (BusinessModelPricingModel) with a FK + unique constraint. - Indexes
Unique index on
Key; nonclustered indexes on each join table’s(BusinessModelId)and(RelatedId)for fast membership queries. - Loading strategy
Commands typically load only
DefaultPricingModel+ the specific collection being mutated (e.g., Products). Reads project to lightweight membership DTOs.
Notes on related contracts¶
IPricingModelsupplies pricing strategy, currency, base/min/max, billing cycle, and pricing type; it also owns the list of edition-specific prices viaIEditionPricingModel.- When a BM is
Published, prefer editions with activeIEditionPricingModelwindows for quoting; ensure min≤base≤max and non-overlap on effective dates at the pricing layer.
This positions BusinessModel as the organizational/commercial wrapper that cleanly references pricing defaults and collects eligible catalog items, with explicit invariants and ORM mappings that keep the model DDD-pure and evolution-friendly.
PricingModel (current Catalog location) & EditionPricingModel¶
Relationships¶
- HasMany → IEditionPricingModel (PricingModel→Edition prices)
A Pricing Model can define per-edition prices via
IEditionPricingModelentries (effective-dated). - Reference ← IBusinessModel (default/back-ref)
IPricingModelis referenced from a Business Model (as default and within its list of available models). - Reference ← IEdition (current EditionPricing nav)
Each
IEditionPricingModellinks back to both itsIEditionand itsIPricingModel.
IPricingModel — properties (interface contract)¶
| Property | Type | Description & rules |
|---|---|---|
PricingModelId |
Guid |
Stable identifier for storage/events. |
Name |
string |
Descriptive name (e.g., “Standard”, “Premium”). 1–200 chars. |
Key |
string |
Canonical slug ([a–z0–9._-], 3–64). Prefer immutability after publish. |
DisplayName |
string |
Human-friendly label for portals/quotes. |
Description |
string |
Narrative of strategy (e.g., seat+usage hybrid). |
CreationDate |
DateTime |
UTC creation timestamp. |
PricingType |
PricingTypeEnumeration |
Fixed / Per-Seat / Tiered / Volume / PAYG, etc. Drives rating logic. |
Currency |
string |
ISO 4217 code (e.g., USD). Must match edition prices’ currency. |
BasePrice |
decimal |
Baseline unit or recurring price before discounts. |
DiscountPercentage |
decimal? |
Optional percentage discount (0–100). |
BillingCycle |
BillingCycleEnumeration |
Monthly/Quarterly/Annually/etc. Used for subscription cadence. |
MinimumPrice |
decimal |
Floor amount—final rated price must be ≥ this. |
MaximumPrice |
decimal |
Cap amount—final rated price must be ≤ this. |
EditionPricingModels |
IList<IEditionPricingModel> |
Edition-specific prices under this model (effective-dated). |
BusinessModel |
IBusinessModel |
Back-reference to the owning/associated Business Model. |
Recommended extension (optional)
PublishedAtUtc/DeprecatedAtUtc(timestamps) — for lifecycle analytics.Tags : IReadOnlyList<KeyValueTag>— metadata (e.g., segment/geo).
IEditionPricingModel — properties (interface contract)¶
| Property | Type | Description & rules |
|---|---|---|
EditionPricingModelId |
Guid |
Stable identifier (useful for audit & event diffs). |
CreationDate |
DateTime |
UTC creation time. |
BasePrice |
decimal |
Edition-specific base price for this pricing model. |
DiscountPercentage |
decimal? |
Optional percentage discount (0–100). |
MinimumPrice |
decimal |
Floor amount (coherent with model). |
MaximumPrice |
decimal |
Cap amount. |
EffectiveDate |
DateTime |
Start of applicability (inclusive). |
ExpiryDate |
DateTime? |
End of applicability (exclusive); null = open-ended. |
IsActive |
bool |
Convenience flag; should reflect now ∈ [EffectiveDate, ExpiryDate). |
Edition |
IEdition |
Reference to the Edition being priced. |
PricingModel |
IPricingModel |
Reference to the Pricing Model defining rules/cadence/currency. |
Recommended extension (optional)
Currency : string— include explicitly if you anticipate model-local overrides or multi-currency (must match the parent model unless a policy permits exceptions).Notes : string?— justification / promotion code reference.
Invariants¶
- Bounds coherence:
For both
IPricingModelandIEditionPricingModel:MinimumPrice ≤ BasePrice ≤ MaximumPrice. Reject updates that violate. - Effective range:
IsActivemust betrueonly when “now” is within[EffectiveDate, ExpiryDate);EffectiveDate ≤ ExpiryDatewhen expiry is present. - Currency coherence:
EditionPricingModel.Currency(if added) must equal the parentPricingModel.Currency; otherwise, mandate an explicit conversion policy and FX source. - Non-overlap per (Edition, PricingModel):
For a given
(EditionId, PricingModelId), effective windows must not overlap. New windows start on or after the priorExpiryDate. - Business Model consistency:
If a Business Model declares a
DefaultPricingModel, it must also include that model in itsPricingModelslist (enforce at BusinessModel).
Domain events (pricing changes)¶
pricing.edition_price.added|updated|expired— emitted when an Edition’s pricing window is created/changed/ended. Payload:editionId,pricingModelId,effectiveDate,expiryDate?,basePrice,min,max,discount?.pricing.model.changed— emitted when a Pricing Model’s global properties change (e.g.,Currency,BillingCycle,PricingType).
(Events use the platform envelope: eventId, occurredAtUtc, aggregateId, aggregateVersion, optional tenantId, schemaVersion.)
ORM notes¶
- Explicit join
Map
IEditionPricingModelas a concrete table with PKEditionPricingModelId. Add a unique composite on(EditionId, PricingModelId, EffectiveDate)and a filtered unique to enforce non-overlapping active windows per(EditionId, PricingModelId). - FKs & indexes
FKs to
EditionandPricingModel. Nonclustered indexes on(EditionId),(PricingModelId), and(IsActive, EffectiveDate DESC)for “current price” queries. -
Loading strategy
-
Commands that compute quotes load only the current
IEditionPricingModelfor the targeted(Edition, PricingModel). - Reporting can project historical windows to DTOs; avoid returning deep navigations.
Usage guidance¶
- Quoting: select the active
IEditionPricingModelfor a chosenPricingModelandEdition; applyDiscountPercentagethen clamp to[MinimumPrice, MaximumPrice]. - Promotions: model as separate rules (at Billing context) or as additional
IEditionPricingModelwindows with explicit notes; keep historical windows immutable for audit. - Migration path: if you later move pricing under a dedicated Billing context, keep these interface shapes as read models (IDs/keys/currency/amounts/effective windows) and publish events from Billing that populate them.
BillingRule (as modeled today)¶
Relationships¶
- Reference → IFeature Each billing rule targets a specific Feature; charges are computed in the context of that capability (e.g., overage on API calls of that feature).
Properties (interface contract)¶
| Property | Type | Description & rules |
|---|---|---|
BillingRuleId |
Guid |
Stable identifier for storage/events; opaque and immutable. |
Name |
string |
Internal name for ops/engineering; 1–200 chars. |
DisplayName |
string |
Human-friendly label for admin UIs/quoting. |
Description |
string |
Narrative of how the rule applies (e.g., “$0.01 per API call beyond included 1M/month”). |
CreationDate |
DateTime |
UTC creation timestamp. |
BillingCycle |
BillingCycleEnumeration |
Frequency at which the rule is evaluated (Monthly/Quarterly/Semi-Annually/Annually/One-Time/Custom). Drives aggregation/proration. |
OverageRate |
decimal |
Unit price applied to usage beyond the threshold (e.g., 0.01 per call). Non-negative. |
MinimumPrice |
decimal |
Floor charge enforced by this rule, regardless of usage. Non-negative. |
MaximumPrice |
decimal |
Cap on charges produced by this rule. Must be ≥ MinimumPrice. |
Discount |
decimal? |
Optional discount applied by the rule (percentage or absolute—your current doc treats as numeric; specify policy in pricing engine). |
UsageThreshold |
decimal |
Level at which the rule starts to apply; measured in the feature’s metered dimension (e.g., calls, bytes). Must be ≥ 0. |
EffectiveDate |
DateTime |
Start of validity (inclusive). Required. |
ExpiryDate |
DateTime? |
End of validity (exclusive). null = open-ended. |
IsActive |
bool |
Convenience flag to toggle application without deleting. Should reflect current effective window. |
Feature |
IFeature |
Reference to the targeted Feature; determines the dimension and entitlement context for rating. |
Recommended extension properties (additive, optional)¶
| Property | Type | Why it helps |
|---|---|---|
Currency |
string |
Makes the rule self-contained when evaluated outside a PricingModel context; otherwise require engine to take currency from the Edition/PricingModel. |
DimensionKey |
string |
Explicit mapping to metering dimension (e.g., api_calls, egress_bytes) when a feature has multiple measurable aspects. |
Notes |
string? |
Reference to promotion code / contract clause justifying the rule. |
(These are not required by your current interface; they can be introduced in a v2 interface or a read-model.)
Invariants¶
- Price bounds coherence
MinimumPrice ≤ MaximumPrice. Any computed chargeCfrom the rule must satisfyC ∈ [MinimumPrice, MaximumPrice]. - Threshold & rate validity
UsageThreshold ≥ 0andOverageRate ≥ 0. Reject negative values. - Effective window
If
ExpiryDateis present, thenEffectiveDate ≤ ExpiryDate.IsActiveshould betrueiff “now” is within[EffectiveDate, ExpiryDate)(or open-ended). - Feature state
The associated
Featuremust be Published while the rule is active; do not activate a rule for Draft/Deprecated features (prevents selling unsupported capabilities). - Cycle consistency
When paired with subscription cycles, the
BillingCycleshould align with the subscriber’s cadence (or the pricing engine must prorate across cycles).
Examples (rating intent)¶
- Overage on API calls: After 1,000,000 calls/month (
UsageThreshold), chargeOverageRate = 0.01per call, clamped to[MinimumPrice, MaximumPrice]. - One-time setup fee:
BillingCycle = OneTime,OverageRate = 0, setMinimumPrice = MaximumPrice = 2500.
ORM notes¶
- Effective-dated indexing
Add a composite index on
(FeatureId, IsActive, EffectiveDate DESC)to efficiently resolve the current rule for a feature. Keep historical rows for audit. - Non-cascading relationship
Map
BillingRule.Featurewith a required FK toFeature. Do not cascade delete rules when features are retired; prefer deactivation or expiry. - Uniqueness & overlap
If you allow multiple rules per feature, constrain by type/priority or add a filtered uniqueness to prevent ambiguous overlaps within the same
BillingCycle. - Serialization
Expose IDs and scalar fields in events/DTOs; avoid shipping navigations. Use
FeatureKeyin read models for downstream systems.
This section reflects the current interface of IBillingRule and clarifies how to keep it effective-dated, bounded, and aligned with feature lifecycles and billing cycles.
Access Rules (if present in your model)¶
Relationships¶
- Reference → Feature or Edition targets A rule may target a Feature and/or an Edition (both are nullable in the current interface, enabling broad→narrow scoping).
- HasMany → Exceptions from rule A rule can include exception rules that carve out conditions where the primary rule does not apply. Each exception links back to its AssociatedAccessRule.
IAccessRule — properties (interface contract + DDD semantics)¶
| Property | Type | Description & rules |
|---|---|---|
AccessRuleId |
Guid |
Stable identifier for storage/events; opaque and immutable. |
Name |
string |
Internal name for ops/eng; 1–200 chars. |
Key |
string |
Canonical slug (policy catalog key). Treat as immutable once referenced externally. |
DisplayName |
string |
Human-friendly label shown in admin UIs. |
Description |
string |
Purpose and scope narrative; include examples of when it applies. |
CreationDate |
DateTime |
UTC creation timestamp. |
AccessType |
AccessTypeEnumeration |
What action is governed (Read/Write/Execute/Admin/…); bound to evaluation matrix. |
Condition |
string |
Expression/predicate evaluated at runtime (ABAC). Recommend treating as ConditionExpression (rename in v2) and documenting the expression language (e.g., CEL/OPA-like). |
EffectiveDate |
DateTime |
Start of applicability (inclusive). |
ExpiryDate |
DateTime? |
End of applicability (exclusive); null = open-ended. |
IsActive |
bool |
Operational toggle; should reflect whether “now” falls within the effective window. |
AccessScope |
AccessScopeEnumeration |
Where the rule applies (Global/System/Feature/Edition/Tenant/User/…); prefer the narrowest viable scope. |
Feature |
IFeature? |
Optional Reference to a feature target; when set, the rule applies only within this feature. |
Edition |
IEdition? |
Optional Reference to an edition target; when set, the rule applies only within this edition. |
ExceptionRules |
IList<IAccessExceptionRule> |
HasMany exceptions that carve out where the main rule should not apply. |
IAccessExceptionRule — properties (interface contract + DDD semantics)¶
| Property | Type | Description & rules |
|---|---|---|
AccessExceptionRuleId |
Guid |
Stable identifier for the exception rule. |
Name / DisplayName |
string |
Internal/UI labels; keep concise and specific. |
Description |
string |
What scenario this exception covers; include examples. |
CreationDate |
DateTime |
UTC creation timestamp. |
Condition |
string |
Predicate under which the exception negates/modifies the parent rule (recommend “ConditionExpression” in v2). |
EffectiveDate / ExpiryDate |
DateTime / DateTime? |
Validity window; ExpiryDate exclusive, null = open-ended. |
IsActive |
bool |
Toggle; should align with effective window. |
AccessScope |
AccessScopeEnumeration |
Scope of the exception; must be same or narrower than parent rule. |
AssociatedAccessRule |
IAccessRule |
Reference back to the primary rule this exception modifies. |
Invariants¶
- Scope narrowing
An exception’s
AccessScopemust be the same or narrower than the parent rule’s scope (e.g., parent=Edition → exception may be Feature/User/Resource within that edition). - Time coherence Exception windows must be within or equal to the rule’s window; do not allow exceptions to outlive the main rule. (Enforce at write-time.)
- No cycles Prevent cyclic references among rules/exceptions (A→B→A). Enforce via validation before persisting and by design (exception only references one parent).
- Target clarity
If both
FeatureandEditionare specified on a rule, the effective target is the intersection (that feature in that edition). If neither is specified, the rule applies at its declaredAccessScopeonly (e.g., Global/System/Tenant). - Activation semantics
IsActiveshould be true iff “now” ∈[EffectiveDate, ExpiryDate)(or open-ended), and the parent aggregate context (e.g., Feature/Edition) is in a state where such rules are allowed (e.g., Published).
Evaluation notes¶
- RBAC + ABAC
AccessType(what) andAccessScope(where) combine withCondition(when) to yield the decision. Typical predicates: edition tier, tenant residency, user role, feature flag, data classification. - Precedence Evaluate exceptions after the parent rule’s predicate; if an exception matches, it overrides the parent outcome (usually to deny or to further restrict). Document your exact precedence (deny-overrides-allow is common).
- Token enrichment Emit short names (from enumerations) into scopes/claims; services reconstruct policy checks using their published matrices.
ORM notes¶
- Separate tables
Persist
AccessRuleandAccessExceptionRulein distinct tables. FK:AccessExceptionRule.AssociatedAccessRuleId → AccessRule.AccessRuleId. -
Indexes Add composite indexes for typical evaluations:
-
(AccessScope, FeatureId, EditionId, IsActive, EffectiveDate)on rules. (AssociatedAccessRuleId, IsActive, EffectiveDate)on exceptions.-
Constraints
-
Check:
EffectiveDate <= ExpiryDate(when not null). - Check: exception
EffectiveDate/ExpiryDate⊆ parent window (enforce in app logic or via trigger). - Loading strategy Query only rules in scope plus their active exceptions; evaluate predicates in the service with the request context.
Suggested v2 refinements (non-breaking to current read models)¶
- Rename
Condition→ConditionExpressionin both rule and exception interfaces; document the expression grammar and allowed attributes (edition key, feature key, tenantId, user role, residency, etc.). - Add
Priority : inton rules to manage overlaps deterministically (lower = evaluated earlier). - Add
Effect : enum { Allow, Deny }if you want explicit allow/deny semantics on the rule itself (today it’s implied byAccessType+ policy matrix).
This section captures how Access Rules and their Exceptions shape effective permissions at Feature/Edition (and broader) scopes, grounded in your existing interfaces and enumeration catalogs.
Catalog events & projections¶
Event contracts (CloudEvents-aligned)¶
All events follow the canonical envelope (headers) and versioning rules used across the platform. Headers are required; payloads are additive and backwards-compatible.
Common envelope (headers)
| Header | Description |
|---|---|
type |
Semantic name with version suffix (e.g., product.published.v1). |
id |
Globally unique event id (ULID/GUID). |
source |
Emitting service/context (e.g., catalog-svc). |
specversion |
Always 1.0. |
time |
RFC3339 event time. |
traceId |
W3C correlation ID; ties to request/command trace. |
tenantId |
Authoritative tenant (omit for pure catalog-wide changes); consumers assume tenant-scoped by default. |
edition |
Snapshot of the tenant’s current edition (when tenant-scoped). |
schemaVersion |
Semantic version of data payload (e.g., 1.0.0). |
partitionKey |
Defaults to tenantId for ordered consumption. |
key |
Idempotency key for the business entity/sequence. |
Producers use outbox; consumers use inbox + idempotency keys; DLQs are immutable & observable.
product.published.v1¶
Minimal fact that a Product became available.
data
productId : guid— Aggregate id.key : string— CanonicalProductKey.displayName : string— Human label.version : string— Catalog version of the product definition at publish.editions : { editionId, key, status }[]— Keys only and lifecycle states for discoverability.
Notes: Emitted only when Product transitions to Published; keys are immutable after this point.
feature.published.v1¶
A Feature is available for inclusion.
data
featureId : guidkey : string— CanonicalFeatureKey(globally unique).displayName : stringdefaultQuota? : { limit, limitType, resetPeriod }— Snapshot for downstream caches.
Notes: Downstream services must not treat quotas in Catalog as enforcement; Metering is the source of truth.
edition.published.v1¶
An Edition composition is now active.
data
editionId : guidproductId : guidkey : stringcompositionVersion : string— Incremented on material composition changes.features : { key, mode, quotaOverride? }[]— Feature keys with per-edition flags/limits.sla? : { key, version }— Attached SLA pointer.
Notes: Only Published features may appear; event carries a minimal snapshot to build read models.
edition.changed.v1¶
Edition composition/pricing adjusted post-publish.
data
editionId : guidproductId : guidcompositionVersion : string— New value.diff : { addedFeatures: string[], removedFeatures: string[], changed: { key, from, to }[] }pricing? : { pricingModelId, effectiveDate, expiryDate?, basePrice, min, max, discount? }slaChanged? : { from?: { key, version }, to?: { key, version } }
Notes: Consumers (Config, Identity, Billing) update caches using the diff; pricing windows must be non-overlapping.
entitlements.changed.v1¶
Effective entitlements for a tenant+edition changed (Catalog → Config → Identity flow).
data
tenantId : string— Required; event is tenant-scoped.edition : string— Edition key at time of emission.features : { key, state: "enabled|conditional|preview", quota? }[]— Evaluated pack (post-edition overrides).reason : string— e.g.,edition.published,edition.changed,config.override.updated.digest : string— Hash of the entitlement set (for cache-busting).
Notes: Tokens carry snapshots of entitlements; Config is runtime source of truth. This event enables Identity claim enrichment and cache invalidation.
Projections¶
CatalogView (Product → Edition → Feature)¶
Denormalized read model to power discovery, quoting UIs, and claim enrichment. Built from product.*, feature.*, and edition.* events.
| Field | Description |
|---|---|
productId, productKey, productDisplayName, productStatus |
Identification + lifecycle for product. |
editions[] |
Array of: editionId, editionKey, editionDisplayName, status, compositionVersion, publishedAtUtc. |
editions[].features[] |
Array of: featureKey, mode, quotaOverride? { limit, limitType, resetPeriod }. |
editions[].sla? |
key, version of attached SLA (current). |
tags? : { key, value }[] |
Non-authoritative metadata for search. |
Indexes: (productKey), (editions.editionKey), and compound on (productStatus, editions.status) for catalog filters. Never exposes navigations—keys and scalars only.
EntitlementsView (Edition → Descriptors)¶
Tenant-facing projection used to stamp tokens and prime Config caches.
| Field | Description |
|---|---|
tenantId |
Projection partition key. |
editionKey |
Edition currently assigned. |
features[] |
{ key, state, quota? } after applying edition pack + tenant overrides. |
digest |
Stable hash (included in tokens) to detect drift. |
updatedAtUtc |
Last re-computation time. |
Sources: edition.published|changed, config.flag.updated|override.updated, subscription/billing events (when edition changes).
Mapping guidance (navigations → DTOs)¶
- Catalog to events: Emit IDs and keys, not graphs. For example, Edition publishes
feature keysand optionalquotaOverridescalars; consumers reconstruct their local models. - Events to projections: Consumers build
CatalogView/EntitlementsViewvia idempotent upserts keyed by natural keys (productKey, editionKey, tenantId+digest). Use tolerant readers for additive fields. - No rich graphs across contexts: Cross-context DTOs use keys + minimal snapshots; never leak EF navigations or internal invariants over the wire.
- Security & tenancy: All events carry
tenantIdwhere relevant; consumers reject missing/foreign tenant headers and scope processing by partition.
Topic & subscription conventions¶
- Topic-per-domain:
catalog-events,billing-events,config-events, etc. - Subscription-per-consumer with SQL filters on
type,tenantId,edition. - DLQ per subscription; operator replay via isolation workers w/ circuit breakers.
Versioning & evolution¶
typesuffix (.v1,.v2) marks major payload versions; additive fields do not bump major.- Deprecations follow a contract governance workflow; producers may dual-publish until consumers confirm adoption.
Example flows¶
- Edition updated → entitlements changed:
edition.changed.v1(diff) →EntitlementsViewrecompute → emitentitlements.changed.v1→ Identity stamps newdigestinto short-lived tokens; services still consult Config for real-time decisions.
This section standardizes the event-first contracts and the two canonical projections—clear, minimal, and tenant-safe—so downstream services can react, cache, and enforce without ever coupling to Catalog’s internal graphs.
Tenant (Aggregate Root, Tenant Management)¶
Relationships¶
- Reference → IEdition (default seed for provisioning) A Tenant may carry a default Edition reference that seeds configuration/entitlements at activation (optional).
- HasMany → Contact A Tenant owns its contact book (Owner/Billing/Technical/Support). Contacts live and die with the Tenant.
Types in this context¶
- ITenant — aggregate root.
- ITenantProfile — value object (owned by Tenant).
- ITenantRegionResidency — value object (owned by Tenant).
- IContact — child entity.
Interfaces are suggested names; you don’t have these yet—this cycle specifies their contracts for implementation later.
ITenant — properties (contract + DDD semantics)¶
| Property | Type | Description & rules |
|---|---|---|
TenantId |
Guid |
Authoritative identifier for the tenant. Opaque and stable; used as RLS/partition key across services. |
LifecycleStatus |
TenantLifecycleStatus (Provisioning, Active, Suspended, PendingDeletion, Deleted) |
Current lifecycle stage. Only Active tenants receive service. Deleted is terminal (data may be archived). |
Profile |
ITenantProfile |
Owned VO. Legal/display identity and commercial/account metadata. |
RegionResidency |
ITenantRegionResidency |
Owned VO. Data-region policy and physical/logic partition (silo) selection. |
Contacts |
IList<IContact> |
HasMany child entities with roles (Owner/Billing/Technical/Support/Security). At least one Owner required to activate. |
EditionDefaultsRef |
Guid? |
Optional default EditionId used to seed feature flags/entitlements at activation (must reference a Published edition). |
CreatedAtUtc |
DateTime |
Creation timestamp (UTC). |
ActivatedAtUtc |
DateTime? |
First activation timestamp (UTC). Set when transitioning to Active. |
LockedAtUtc |
DateTime? |
Residency lock time (UTC) if/when residency becomes immutable by policy. |
ITenantProfile — properties (owned VO)¶
| Property | Type | Description & rules |
|---|---|---|
LegalName |
string |
Registered legal entity name (1–256 chars). Required for invoicing/contracts. |
DisplayName |
string |
End-user display name (1–200 chars). Shown in portals, emails, and headers. |
BillingAccountRef |
string? |
External billing account/customer id (PSP/ERP/CRM). Do not encode PII beyond the provider’s id. |
Domain |
string? |
Vanity domain or subdomain used for routing/branding (e.g., acme.example.com). Globally unique if used. |
Tags |
IReadOnlyList<KeyValueTag> |
Non-authoritative metadata (segment, vertical, owner team). No secrets/PII. |
ITenantRegionResidency — properties (owned VO)¶
| Property | Type | Description & rules |
|---|---|---|
RegionCode |
string |
Jurisdiction/region key (US, EU, IL, …). Drives data locality and processor selection. |
DataSiloId |
string |
Physical/logical partition id (e.g., shard/cluster/tenant db). Used by routers and ODS. |
ResidencyLocked |
bool |
When true, region/silo are immutable except via regulated migration flow; set on activation if required by policy. |
IContact — properties (child entity)¶
| Property | Type | Description & rules |
|---|---|---|
ContactId |
Guid |
Identifier for updates/audit. |
Name |
string |
Full name (1–200 chars). |
Email |
string |
RFC-compliant email. Recommended to verify before activation when Role=Owner. |
Phone |
string? |
E.164 phone (optional). |
Role |
ContactRole (Owner, Billing, Technical, Support, Security) |
Functional responsibility used for notifications/approvals. |
Invariants¶
- Residency before activation
RegionResidency.RegionCodeandDataSiloIdmust be set (and if policy requires,ResidencyLocked=true) before transitioning to Active. - Owner contact
Must have ≥ 1
Contact.Role = Owner(with verified email if policy) to activate. - Domain uniqueness
If
Profile.Domainis provided, it must be globally unique across tenants and conform to DNS label rules. - Edition defaults
If
EditionDefaultsRefis set, it must reference a Published Edition. On activation, emittenant.edition_defaults_appliedand seed Config/Identity. -
Lifecycle transitions
-
Provisioning → Active: only if the above invariants hold. Active → Suspended: service access blocked; data retained.Suspended → Active: allowed after compliance/billing clearance.Active/Suspended → PendingDeletion: requires explicit confirmation/retention checks.PendingDeletion → Deleted: terminal; data archiving/anonymization completed.
Domain events (tenant stream)¶
tenant.created— New tenant record created. Payload:tenantId,profile.displayName,regionResidency.regionCode, timestamps.tenant.activated— Tenant became active. Payload includeseditionDefaultsRef?and residency lock info.tenant.suspended/tenant.reinstated— Operational suspension toggled; include reason code.tenant.residency_locked— Residency lock engaged; include region/silo.tenant.deletion_requested— Tenant requested deletion; include earliest deletion date and confirmation channel.tenant.deleted— Terminal; include archival/reference ids.tenant.contact_added|updated— Contact changes; include contact role/email.tenant.edition_defaults_applied— Edition defaults used to seed entitlements/config for this tenant.
All events use standard envelopes (id/time/traceId/schemaVersion), and are partitioned by
tenantId.
ORM notes¶
- Owned types
Map
ProfileandRegionResidencyas owned (OwnsOne) under Tenant. They have no identity and are persisted inline (columns prefixedProfile_*,Residency_*). - Contacts
Map as a child table with FK
TenantId. Add uniqueness constraint on(TenantId, Role, Email)to avoid duplicates. Consider an index on(TenantId, Role). - Edition reference
Store
EditionDefaultsRefas nullableGuid. No navigation across contexts at runtime; read models can join by key when needed. - Indexes
Unique index on
Profile_Domain(when not null). Nonclustered indexes onLifecycleStatus,Residency_RegionCode, andResidency_DataSiloId. - Row-level security
Use
TenantIdas the RLS key in shared stores; ensure every multi-tenant table carries it and is enforced in queries. - Concurrency
Add an optimistic concurrency token (e.g.,
rowversion) to the concrete Tenant class.
Notes for command handlers¶
- Provision
Create Tenant with
LifecycleStatus=Provisioning, setProfile,RegionResidency(region/silo). Optionally setEditionDefaultsRef. Emittenant.created. - Activate
Validate invariants (residency set, owner contact, domain uniqueness). If policy requires, set
ResidencyLocked=trueandLockedAtUtc=now. Emittenant.activatedandtenant.edition_defaults_applied(if defaults set). - Suspend/Reinstate
Flip status and emit events; ensure services consult status (tokens/requests denied for
Suspended). - Request Deletion / Delete
Transition to
PendingDeletion, run retention/export workflows, then finalize toDeletedwith archival references. Emittenant.deletion_requestedandtenant.deleted.
Suggested supporting enumerations (this context)¶
TenantLifecycleStatus { Provisioning = 0, Active = 1, Suspended = 2, PendingDeletion = 3, Deleted = 4 }ContactRole { Owner = 1, Billing = 2, Technical = 3, Support = 4, Security = 5 }
These contracts establish Tenant as the authoritative issuer of tenantId, define owned identity/residency VOs, and set clear lifecycle, invariants, and events to coordinate Config, Identity, Billing, and Observability—without leaking cross-context navigations.
Relationship diagrams & repository specs (Catalog & Tenant)¶
Relationship map (navigations)¶
erDiagram
Product ||--o{ Edition : "HasMany"
Edition }o--o{ Feature : "HasManyThrough via EditionFeature"
Edition ||--o{ EditionSla : "HasMany (M:N via join)"
ServiceLevelAgreement ||--o{ EditionSla : "Inverse"
PricingModel ||--o{ EditionPricingModel : "HasMany"
Edition ||--o{ EditionPricingModel : "Inverse"
BusinessModel }o--o{ Product : "M:N"
BusinessModel }o--o{ Edition : "M:N"
BusinessModel }o--o{ Feature : "M:N"
BusinessModel }o--o{ ServiceLevelAgreement : "M:N"
BusinessModel }o--o{ PricingModel : "M:N"
Feature ||--o{ BillingRule : "HasMany"
Tenant ||--o{ Contact : "HasMany"
Tenant }o--|| Edition : "Default Edition (optional Reference)"
- Product 1→* Editions (Edition carries
Productreference). - Edition M↔N Feature via EditionFeature (explicit link entity).
- Edition 1→* SLA (modeled as M:N join; only one active at a time).
- PricingModel 1→* EditionPricingModel → Edition (effective-dated per-edition price).
- BusinessModel M↔N Products/ Editions/ Features/ SLAs/ PricingModels; Reference → DefaultPricingModel.
- Feature 1→* BillingRule (feature-scoped rating).
- Tenant 1→* Contact, and Tenant (optional) ref Default Edition used to seed config. (Specified in prior cycle.)
Repository interfaces (spec-only)¶
Repositories return aggregate roots or thin projections. They avoid leaking ORMs to callers, and expose identity-only and key-based loaders. Navigation graphs are loaded explicitly via well-named methods to avoid N+1.
Catalog — ProductRepository¶
Task<IProduct?> GetByIdAsync(Guid productId, CancellationToken ct)Task<IProduct?> GetByKeyAsync(string productKey, CancellationToken ct)Task<bool> KeyExistsAsync(string productKey, CancellationToken ct)Task<IReadOnlyList<IProduct>> ListPublishedAsync(CancellationToken ct)Task<IReadOnlyList<IEdition>> LoadEditionsAsync(Guid productId, CancellationToken ct)Task PublishAsync(Guid productId, string version, CancellationToken ct)(enforces “≥1 published edition” invariant)
Catalog — EditionRepository¶
Task<IEdition?> GetByIdAsync(Guid editionId, CancellationToken ct)Task<IEdition?> GetByKeyAsync(Guid productId, string editionKey, CancellationToken ct)Task<IReadOnlyList<IEditionFeature>> LoadEditionFeaturesAsync(Guid editionId, DateTime? atUtc, CancellationToken ct)Task<IServiceLevelAgreement?> GetActiveSlaAsync(Guid editionId, DateTime atUtc, CancellationToken ct)Task<IEditionPricingModel?> GetActivePriceAsync(Guid editionId, Guid pricingModelId, DateTime atUtc, CancellationToken ct)Task PublishAsync(Guid editionId, string compositionVersion, CancellationToken ct)
Catalog — FeatureRepository¶
Task<IFeature?> GetByIdAsync(Guid featureId, CancellationToken ct)Task<IFeature?> GetByKeyAsync(string featureKey, CancellationToken ct)Task<IReadOnlyList<IBillingRule>> LoadBillingRulesAsync(Guid featureId, DateTime? atUtc, CancellationToken ct)Task<IReadOnlyList<IEditionFeature>> LoadEditionLinksAsync(Guid featureId, CancellationToken ct)
Catalog — EditionFeatureRepository (link entity)¶
Task<IReadOnlyList<IEditionFeature>> ListByEditionAsync(Guid editionId, DateTime? atUtc, CancellationToken ct)Task<bool> HasOverlapAsync(Guid editionId, Guid featureId, DateTime effective, DateTime? expiry, CancellationToken ct)(prevent overlapping windows)
Catalog — ServiceLevelAgreementRepository¶
Task<IServiceLevelAgreement?> GetByIdAsync(Guid slaId, CancellationToken ct)Task<IServiceLevelAgreement?> GetByKeyAsync(string slaKey, CancellationToken ct)Task<IServiceLevelAgreement?> GetActiveForEditionAsync(Guid editionId, DateTime atUtc, CancellationToken ct)Task<bool> SetActiveForEditionAsync(Guid editionId, Guid slaId, DateTime effective, DateTime? expiry, CancellationToken ct)(enforce “one active”)
Catalog — BusinessModelRepository¶
Task<IBusinessModel?> GetByIdAsync(Guid businessModelId, CancellationToken ct)Task<IReadOnlyList<IProduct>> LoadProductsAsync(Guid businessModelId, CancellationToken ct)Task<IReadOnlyList<IEdition>> LoadEditionsAsync(Guid businessModelId, CancellationToken ct)Task<IReadOnlyList<IFeature>> LoadFeaturesAsync(Guid businessModelId, CancellationToken ct)Task<IReadOnlyList<IServiceLevelAgreement>> LoadSlasAsync(Guid businessModelId, CancellationToken ct)Task<IReadOnlyList<IPricingModel>> LoadPricingModelsAsync(Guid businessModelId, CancellationToken ct)Task<IPricingModel?> GetDefaultPricingModelAsync(Guid businessModelId, CancellationToken ct)
Catalog — PricingModelRepository¶
Task<IPricingModel?> GetByIdAsync(Guid pricingModelId, CancellationToken ct)Task<IReadOnlyList<IEditionPricingModel>> ListEditionPricesAsync(Guid pricingModelId, DateTime? atUtc, CancellationToken ct)
Catalog — EditionPricingModelRepository¶
Task<IEditionPricingModel?> GetActiveAsync(Guid editionId, Guid pricingModelId, DateTime atUtc, CancellationToken ct)Task<bool> HasOverlapAsync(Guid editionId, Guid pricingModelId, DateTime effective, DateTime? expiry, CancellationToken ct)
Catalog — BillingRuleRepository¶
Task<IBillingRule?> GetByIdAsync(Guid billingRuleId, CancellationToken ct)Task<IReadOnlyList<IBillingRule>> ListActiveForFeatureAsync(Guid featureId, DateTime atUtc, CancellationToken ct)Task<bool> HasOverlapAsync(Guid featureId, BillingCycleEnumeration cycle, DateTime effective, DateTime? expiry, CancellationToken ct)
Tenant — TenantRepository¶
Task<ITenant?> GetByIdAsync(Guid tenantId, CancellationToken ct)Task<bool> DomainExistsAsync(string domain, CancellationToken ct)Task<IReadOnlyList<IContact>> LoadContactsAsync(Guid tenantId, CancellationToken ct)Task SetDefaultEditionAsync(Guid tenantId, Guid? editionId, CancellationToken ct)Task<bool> ActivateAsync(Guid tenantId, DateTime atUtc, CancellationToken ct)(enforces residency + owner contact)Task SuspendAsync(Guid tenantId, string reason, CancellationToken ct)/Task ReinstateAsync(Guid tenantId, CancellationToken ct)Task RequestDeletionAsync(Guid tenantId, DateTime earliestDelete, CancellationToken ct)/Task FinalizeDeletionAsync(Guid tenantId, CancellationToken ct)
Loading guidance (eager vs. lazy)¶
- Identity-only loads by default: methods named
GetById*/GetByKey*return just the aggregate; callers explicitly request navigations. - Targeted includes: use specific methods like
LoadEditionFeaturesAsync,LoadBillingRulesAsync,GetActivePriceAsyncto avoid accidental deep graphs (prevents N+1 and large payloads). - Time-aware queries: where effective dating exists (EditionFeature, EditionPricingModel, BillingRule, SLAs), provide
atUtcto resolve current data efficiently via indexed predicates. - No cross-context navigations: IDs/keys only across bounded contexts; build projections for read scenarios that span contexts (see previous cycle on projections).
Consistency notes¶
- One command → one aggregate: mutations touch a single aggregate root; cross-aggregate work is coordinated via domain events and process managers.
- Navigations are read conveniences: collections like
Product.Editionsexist for readability inside Catalog; write flows still validate invariants locally. - Event-first integration: publish
product.*,edition.*,feature.*,pricing.*events; downstream contexts (Identity, Config, Billing) maintain projections rather than calling into Catalog synchronously. - Effective-dated correctness: all joins with temporal semantics enforce non-overlap and expose helpers like
HasOverlapAsyncto keep invariants honest.
This gives you a precise, DDD- and ORM-aware map of how the Catalog and Tenant aggregates relate, plus repository contracts that encourage identity-first loads, explicit navigation fetching, and clean cross-context boundaries.
Real examples: applying the model end-to-end¶
Below are concrete, production-style examples that exercise the Catalog (Product/Edition/Feature), Pricing, SLAs, Billing Rules, Access Rules, and Tenant Management. Values (keys, quotas, windows) are chosen to align with the enumerations and invariants we defined earlier. These are domain objects, not database rows; effective-dating and joins are implied by the relationships we’ve modeled.
Example A — “ConnectSoft CRM” (subscription + per-feature usage)¶
Product¶
| Field | Example |
|---|---|
| Product | Key: crm-suite • DisplayName: “ConnectSoft CRM” • Status: Published |
| Notes | Product key immutable; at least one published edition required for publish. |
Editions¶
| Edition (Key) | Status | CompositionVersion | Notes |
|---|---|---|---|
Free (free) |
Published | 1.0.0 | Lead capture & basic contacts only. |
Standard (standard) |
Published | 1.3.0 | Adds campaigns, API, and webhooks. |
Enterprise (enterprise) |
Published | 2.1.0 | SSO, audit trails, higher limits, premium SLA. |
Features (selected)¶
| Feature (Key) | DisplayName | DefaultQuota (VO) |
|---|---|---|
contacts.core |
Contacts | Unlimited (LimitType: Users/Seats not applicable; functional) |
campaigns.email |
Email Campaigns | 50,000 messages / Monthly |
api.core |
Public API | 1,000,000 API calls / Monthly |
webhooks.outbound |
Outbound Webhooks | 500,000 requests / Monthly |
sso.saml |
SSO (SAML) | N/A (gated by access rules, not counters) |
audit.trail |
Audit Trail | Retention bound by SLA, not quota |
EditionFeature composition (effective today)¶
| Edition | FeatureKey | Mode | QuotaOverride? |
|---|---|---|---|
| Free | contacts.core |
Enabled | — |
| Free | campaigns.email |
Enabled | 5,000 messages / Monthly |
| Standard | campaigns.email |
Enabled | 200,000 messages / Monthly |
| Standard | api.core |
Enabled | 2,000,000 API calls / Monthly |
| Standard | webhooks.outbound |
Enabled | 200,000 requests / Monthly |
| Enterprise | api.core |
Enabled | 10,000,000 API calls / Monthly |
| Enterprise | webhooks.outbound |
Enabled | 2,000,000 requests / Monthly |
| Enterprise | sso.saml |
Enabled | — |
| Enterprise | audit.trail |
Enabled | — |
SLAs¶
| Edition | SLA | Availability | SupportWindow | Notes |
|---|---|---|---|---|
| Free | sla.standard v1 |
99.5% | Community/Email (9×5) | Single queue |
| Standard | sla.standard v2 |
99.9% | 9×5 | P2 4h response |
| Enterprise | sla.enterprise v3 |
99.95% | 24×7 | P1 1h response, credits policy v3 |
Pricing¶
- BusinessModel:
bm.crm.default(Published) -
PricingModel (default):
pm.crm.subscription -
PricingType:
subscription• Currency:USD• BillingCycle:monthly - BasePrice: 39 • Min: 39 • Max: 39 (Free has 0 via edition price)
-
EditionPricingModel (effective now):
-
Free: Base 0, Min 0, Max 0
- Standard: Base 39
- Enterprise: Base 99
Billing Rules (feature-level, effective now)¶
| FeatureKey | Rule | Cycle | Threshold | OverageRate | Bounds |
|---|---|---|---|---|---|
campaigns.email |
“Email Overage” | Monthly | 200,000 msgs | 0.0008/msg | Min 0, Max 5,000 |
api.core |
“API Overage” | Monthly | 2,000,000 calls | 0.00001/call | Min 0, Max 2,000 |
webhooks.outbound |
“Webhook Overage” | Monthly | 200,000 req | 0.00002/req | Min 0, Max 1,000 |
Access rules (examples)¶
- Rule:
feature.api.manage_keys— AccessScope=Feature, AccessType=Manage, Condition:role in ["Admin","Owner"]. - Exception: narrow to tenant
security.tier="strict"→ denyManageunless MFA present (ABAC predicate).
Example B — “Doc Intelligence” (AI usage, rolling windows, PAYG hybrid)¶
Product & Editions¶
- Product:
ai-doc-intel(Published) - Editions:
starter(Published),pro(Published)
Features & quotas¶
| FeatureKey | DefaultQuota |
|---|---|
ai.tokens |
5,000,000 tokens / Rolling 24 Hours |
ai.batch_extract |
100,000 pages / Monthly |
ai.private_models |
N/A (gated by edition) |
EditionFeature overrides
starter:ai.tokens→ 1,000,000 / Rolling 24h;ai.batch_extract→ 10,000 / Monthlypro: inherits defaults;ai.private_models→ Mode=Preview (no SLA)
Pricing (hybrid)¶
-
PricingModel:
pm.ai.hybrid— PricingType:hybrid(subscription base + usage) -
Base (Starter/Pro): 0 / 299 (monthly)
-
Billing Rules
-
ai.tokens: threshold 5M / Rolling 24h equivalent (rated against monthly bill), overage 0.000002 per token, cap 10,000 ai.batch_extract: threshold 100k / Monthly, overage 0.01 per page, cap 5,000
SLA¶
- Starter:
sla.ai.standard99.5% (no credits for preview) - Pro:
sla.ai.premium99.9% (credits apply)
Example C — Tenant lifecycle using these products¶
Tenant at a glance¶
| Field | Value |
|---|---|
TenantId |
c7a0…-acme-eu |
Profile |
LegalName=Acme GmbH, DisplayName=Acme EU, Domain=acme-eu.connectsoft.cloud |
RegionResidency |
RegionCode=EU, DataSiloId=eu-shard-03, ResidencyLocked=true (on activation) |
EditionDefaultsRef |
Edition( crm-suite / enterprise ) |
Contacts |
Owner=sara@acme.eu, Billing=ap@acme.eu, Technical=ops@acme.eu |
LifecycleStatus |
Active (since 2025-02-10T09:00Z) |
Activation flow (events)¶
tenant.created→ payload includes profile + region.tenant.activated(+tenant.residency_locked) → emits withEditionDefaultsRef.- Catalog emits
edition.published/edition.changedas needed; Config composes tenant-level overrides. - Config/Identity emit
entitlements.changedwithdigest→ short-lived tokens enriched with edition + feature claims.
Example D — From navigations to read models (DTOs)¶
CatalogView (excerpt)
{
"productKey": "crm-suite",
"editions": [
{
"editionKey": "standard",
"compositionVersion": "1.3.0",
"features": [
{ "key": "campaigns.email", "mode": "enabled", "quota": { "limit": 200000, "limitType": "messages", "resetPeriod": "monthly" } },
{ "key": "api.core", "mode": "enabled", "quota": { "limit": 2000000, "limitType": "api_calls", "resetPeriod": "monthly" } }
],
"sla": { "key": "sla.standard", "version": "2" }
}
]
}
EntitlementsView (tenant-scoped)
{
"tenantId": "c7a0…-acme-eu",
"editionKey": "enterprise",
"features": [
{ "key": "api.core", "state": "enabled", "quota": { "limit": 10000000, "limitType": "api_calls", "resetPeriod": "monthly" } },
{ "key": "webhooks.outbound", "state": "enabled", "quota": { "limit": 2000000, "limitType": "requests", "resetPeriod": "monthly" } },
{ "key": "sso.saml", "state": "enabled" },
{ "key": "audit.trail", "state": "enabled" }
],
"digest": "9b6b9e80…",
"updatedAtUtc": "2025-09-29T10:12:00Z"
}
What these examples demonstrate¶
- Aggregate boundaries: Product→Editions; Edition→EditionFeature links; Feature-centric rules; SLA attached via join with “one active” invariant.
- Enumerations in action:
BillingCycle=monthly,PricingType=subscription/hybrid,ResetPeriod=monthly/rolling_24h,LimitType=api_calls/messages. - Invariants upheld: Only Published features included in published editions; non-overlapping effective windows for EditionFeature and pricing; min≤base≤max for prices.
- Event-first: Minimal, key-oriented events flow into CatalogView and EntitlementsView; tokens carry snapshots by short names.
- Tenant alignment: Residency locked before activation; default edition seeding; contacts for approvals and notifications.
Subscription & Licensing Context¶
Relationships¶
- Reference → Edition (Catalog)
Each subscription targets a single edition at a time (by
EditionIdorEditionKey). No cross-context navigations—store the ID/key. - HasMany → SeatAssignments Seats are assigned to users or service principals in Identity; this context only tracks the assignment facts (principal IDs + dates).
- HasOne → LicensePool (inside the Subscription aggregate) Capacity (total seats, grace, over-allocation policy) lives with the subscription to keep seat invariants strongly consistent.
- HasMany → AddOnPacks Purchased add-ons bound to the subscription; each has quantity and effective dating.
Note: You can later “promote”
LicensePoolorAddOnPackto separate aggregates if you need cross-subscription pooling or independent lifecycles. Default design keeps them insideSubscriptionto honor “one command → one aggregate”.
Types (aggregates / entities / VOs)¶
- Aggregates:
ISubscription - Entities (child):
ILicensePool,ISeatAssignment,IAddOnPack - VOs:
SubscriptionTerm,RenewalPolicy,BillingAnchor,AddOnRef - Enums:
SubscriptionStatus,PaymentState,PrincipalType
ISubscription — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
SubscriptionId |
Guid |
Aggregate identifier; stable and opaque. Used as partition key in this context. |
TenantId |
Guid |
Owning tenant. Required on every command and event for RLS. |
EditionRef |
Guid (EditionId) or string (EditionKey) |
Target edition from Catalog at this point in time. Edition changes are governed by RenewalPolicy. |
Status |
SubscriptionStatus (Trial, Active, PastDue, Suspended, Canceled, Expired) |
Commercial lifecycle. Canceled and Expired are terminal for service; reactivation opens a new term. |
PaymentState |
PaymentState (Trialing, Paid, PastDue, Dunning, Failed, Free) |
Payment processor-facing state; drives grace periods and webhooks to Billing. |
Term |
SubscriptionTerm (VO) |
Contract window and edition entitlement window. |
Renewal |
RenewalPolicy (VO) |
Whether/how the subscription renews (auto-renew, alignment, proration). |
BillingAnchor |
BillingAnchor (VO) |
Cadence alignment for invoicing (e.g., monthly on the 1st, 09:00 UTC). |
LicensePool |
ILicensePool |
Seats capacity & policy inside the aggregate to keep seat invariants strong. |
SeatAssignments |
IList<ISeatAssignment> |
Occupied seats (users/service principals). Derived count must not exceed LicensePool.TotalSeats (unless over-allocation grace is enabled). |
AddOnPacks |
IList<IAddOnPack> |
Purchased add-ons with quantities and effective windows (e.g., extra storage, API pack). |
CreatedAtUtc |
DateTime |
Creation time. |
ActivatedAtUtc |
DateTime? |
Transition to Active. Set only once. |
CanceledAtUtc |
DateTime? |
Transition to Canceled. |
NextRenewalAtUtc |
DateTime? |
Next renewal timestamp (if Renewal.AutoRenew=true). |
TrialEndsAtUtc |
DateTime? |
Trial cutoff (if Status=Trial). |
SubscriptionTerm (VO)¶
| Property | Type | Description & rules |
|---|---|---|
StartAtUtc |
DateTime |
Start of the contract/entitlement window (inclusive). |
EndAtUtc |
DateTime? |
End (exclusive). null for open-ended (renewing) terms. |
BillingCycle |
BillingCycleEnumeration |
Recurrence cadence (monthly/annual/…). |
TrialDays |
int? |
Optional trial length; TrialEndsAtUtc = StartAtUtc + TrialDays. Must be ≥ 0. |
RenewalPolicy (VO)¶
| Property | Type | Description & rules |
|---|---|---|
AutoRenew |
bool |
If true, extend term at NextRenewalAtUtc automatically. |
ProrateOnUpgrade |
bool |
When upgrading edition or increasing seats mid-cycle, compute proration. |
ChangeEditionAtRenewalOnly |
bool |
If true, edition changes are queued and applied at next renewal. |
GracePeriodDays |
int |
Allowed grace post PastDue before suspension. Non-negative. |
BillingAnchor (VO)¶
| Property | Type | Description & rules |
|---|---|---|
AnchorDay |
int |
Day-of-month for renewal (1–28, or 31 with “last-day” semantics). |
AnchorHourUtc |
int |
Hour of day (0–23) to cut over. |
ProrationBasis |
string (calendar |
anniversary) |
ILicensePool (entity inside Subscription)¶
| Property | Type | Description & rules |
|---|---|---|
TotalSeats |
int |
Maximum assignable seats for this subscription. Must be ≥ 0. |
OverAllocationGrace |
int |
Temporary excess seats allowed (e.g., 5) before enforcement; used during bulk changes. |
UsedSeats |
int (derived) |
Count of active seat assignments. Cannot exceed TotalSeats + OverAllocationGrace. |
ReservedSeats |
int |
Seats reserved for pending invites/provisioning. Must be ≥ 0 and Used + Reserved ≤ Total + Grace. |
ISeatAssignment (entity)¶
| Property | Type | Description & rules |
|---|---|---|
SeatAssignmentId |
Guid |
Identifier for audit/updates. |
PrincipalId |
Guid |
User or Service Principal Id from Identity. |
PrincipalType |
PrincipalType (User, ServicePrincipal) |
Distinguishes who consumes the seat. |
RoleKey |
string? |
Optional role shortcut granted with the seat (e.g., app.user). Binding to actual roles happens in Identity. |
AssignedAtUtc |
DateTime |
When the seat became occupied. |
UnassignedAtUtc |
DateTime? |
When released; null means currently occupying. |
IAddOnPack (entity)¶
| Property | Type | Description & rules |
|---|---|---|
AddOnPackId |
Guid |
Identifier. |
AddOn |
AddOnRef (VO) |
Points to the add-on definition (by key) in Catalog (or an AddOn catalog). |
Quantity |
int |
Purchased units; must be ≥ 1. |
EffectiveDate |
DateTime |
Start (inclusive). |
ExpiryDate |
DateTime? |
End (exclusive); null = open-ended while subscription active. |
IsActive |
bool |
Convenience flag; true iff now within window and subscription is Active. |
AddOnRef (VO)¶
| Property | Type | Description & rules |
|---|---|---|
Key |
string |
Canonical add-on key (slug). |
DisplayName |
string |
Human label; snapshot for reads. |
FeatureKey? |
string? |
If the add-on maps to a Feature, include the feature key for entitlements/metering. |
Enums¶
| Enum | Members |
|---|---|
SubscriptionStatus |
Trial, Active, PastDue, Suspended, Canceled, Expired |
PaymentState |
Trialing, Paid, PastDue, Dunning, Failed, Free |
PrincipalType |
User, ServicePrincipal |
Invariants¶
- Seat capacity:
UsedSeats + ReservedSeats ≤ TotalSeats + OverAllocationGrace. Assigning a seat must fail if capacity would be exceeded (unless a grace policy explicitly allows it). - Edition change discipline:
If
Renewal.ChangeEditionAtRenewalOnly=true, any edition change command records a pending change and applies it atNextRenewalAtUtc. If false, apply immediately with proration ifRenewal.ProrateOnUpgrade=true. - Trial → Active:
Requires: verified Owner contact on tenant, and a valid payment method if the plan is paid (
PaymentState=Paid). - Temporal coherence:
EffectiveDate ≤ ExpiryDateon add-ons;AssignedAtUtc ≤ UnassignedAtUtcfor seat history. - Tenant/Edition existence:
TenantIdmust reference an existing, non-deleted tenant;EditionRefmust reference a Published edition (by id/key) at activation time.
Domain events¶
subscription.created—{ subscriptionId, tenantId, editionKey|id, status, term, renewal, billingAnchor }subscription.activated—{ subscriptionId, activatedAtUtc }subscription.renewed—{ subscriptionId, previousTermEnd, newTermEnd, editionKey|id }subscription.changed—{ subscriptionId, changes: { seats?, edition?, renewal? } }subscription.canceled—{ subscriptionId, canceledAtUtc, reason }seat.assigned/seat.unassigned—{ subscriptionId, seatAssignmentId, principalId, principalType }addon.added/addon.removed—{ subscriptionId, addOnPackId, addOnKey, quantity, effectiveDate, expiryDate? }
All events follow the platform envelope (id, time, traceId, schemaVersion) and are partitioned by
tenantId.
APIs / projections¶
Commands
CreateSubscription(tenantId, editionRef, term, renewal, billingAnchor, totalSeats, trialDays?)ActivateSubscription(subscriptionId)ChangeEdition(subscriptionId, newEditionRef, applyNow|AtRenewal)AdjustSeats(subscriptionId, newTotalSeats)AssignSeat(subscriptionId, principalId, principalType, roleKey?)UnassignSeat(subscriptionId, seatAssignmentId)AddAddOn(subscriptionId, addOnKey, quantity, effectiveDate, expiryDate?)RemoveAddOn(subscriptionId, addOnPackId)CancelSubscription(subscriptionId, when, reason)
Reads / Projections
SubscriptionView:{ subscriptionId, tenantId, editionKey, status, paymentState, term {start,end}, renewal {autoRenew,…}, license { total, used, reserved }, addons[], nextRenewalAtUtc }SeatRoster: list of active seat assignments with principal types.PendingChangesView: queued edition/seat changes for next renewal.
Integration
- Emit events to Billing (rating/invoicing), Identity (grant/revoke role bundles on seat assign/unassign), and Config/Entitlements (apply add-on feature keys).
ORM notes¶
- Aggregate mapping:
Subscriptionroot table (PK =SubscriptionId), with owned VO columns forTerm,Renewal,BillingAnchor. -
Child tables:
-
SeatAssignment(SeatAssignmentId PK, SubscriptionId FK, PrincipalId, PrincipalType, AssignedAtUtc, UnassignedAtUtc?) AddOnPack(AddOnPackId PK, SubscriptionId FK, AddOnKey, Quantity, EffectiveDate, ExpiryDate?, IsActive)-
Indexes:
-
SeatAssignmenton(SubscriptionId, PrincipalId, UnassignedAtUtc IS NULL)for “currently assigned” queries. AddOnPackon(SubscriptionId, IsActive, EffectiveDate DESC)for current add-ons.- Checks: DB constraints for non-negative seats/quantities; application-enforced invariant for capacity and edition change discipline.
- No cross-context navigation: Store
EditionRefas scalar key/ID; join via projections if needed.
Handler guidance (consistency)¶
- One command → one aggregate: adjust seats and assign/unassign through
Subscription. - Seat assignment flow: load
SubscriptionwithLicensePooland currentSeatAssignments(active) → validate capacity → append assignment → emitseat.assigned. - Edition change flow: if apply-now, emit
subscription.changed(+entitlements.changeddownstream); if at-renewal, record pending change and schedule atNextRenewalAtUtc. - PastDue → Suspended: consumer of Billing events updates
PaymentState; a policy transitionsStatusafterRenewal.GracePeriodDays.
This context keeps licensing decisions and seat capacity inside the Subscription aggregate, ensuring strong invariants, clean events to Billing/Identity, and crystal-clear separation from Catalog (keys/ids only, never navigations).
Provisioning & Environment Context¶
Relationships¶
- ProvisioningRun HasMany Steps A run is an execution plan made of ordered, dependency-aware steps.
- ProvisioningRun HasMany ResourceBindings Each successful step can register one or more resource bindings (what was created/updated/deleted).
- ProvisioningRequest → ProvisioningRun
A request may spawn exactly one authoritative run (idempotent by
ProvisioningRequestId). Retries resume/replace the same run depending on policy.
Types in this context¶
- Aggregates:
IProvisioningRequest,IProvisioningRun,IResourceBinding - Entities/VOs:
IStep(entity),IStepLog(entity),TemplateRef(VO) - Enums:
RunStatus,StepStatus,ResourceState
Interface names are provided to standardize contracts; concrete classes can add behavior but must not alter the semantics below.
IProvisioningRequest — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
ProvisioningRequestId |
Guid |
Idempotency key supplied by the caller (or generated and echoed). Exactly one authoritative run is allowed per id. |
TenantId |
Guid |
Target tenant. Must refer to an existing, non-deleted tenant. |
Template |
TemplateRef (VO) |
Which provisioning template/version to execute (golden image, infra recipe). |
Variables |
IDictionary<string,string> |
Name-value inputs to parameterize the template (region, sku, capacity). Secrets must be provided as references only. |
RequestedRegion |
string |
Requested data region (e.g., EU, US). Must be compatible with Tenant residency policy. |
RequestedSiloId? |
string? |
Optional explicit silo/shard. If omitted, placement engine selects one that satisfies capacity & policy. |
DryRun |
bool |
When true, perform validation/plan only (no side effects). |
RequestedAtUtc |
DateTime |
Submission timestamp. |
CorrelationId |
string |
Cross-service correlation/tracing id. |
Notes
- A
ProvisioningRequestis immutable after acceptance; later corrections produce a new request id.
IProvisioningRun — properties¶
| Property | Type | Description & rules |
|---|---|---|
ProvisioningRunId |
Guid |
Aggregate id for the run. |
ProvisioningRequestId |
Guid |
Back-reference to the request that created this run; enforces idempotency. |
TenantId |
Guid |
Redundant for partition/RLS. |
Status |
RunStatus (Planned, Running, Succeeded, Failed, PartiallyCompensated, Compensated, Aborted) |
Current lifecycle state. |
PlannedAtUtc / StartedAtUtc / CompletedAtUtc? |
DateTime |
Timestamps for lifecycle transitions. |
Template |
TemplateRef (VO) |
Snapshot of template key/version used. |
ResolvedRegion |
string |
Final region after policy/placement resolution. Must match Tenant residency. |
ResolvedSiloId |
string |
Final silo/shard chosen. |
Steps |
IList<IStep> |
Ordered plan with dependencies and retries. |
ResourceBindings |
IList<IResourceBinding> |
Produced resources (ids, providers, types, states). |
FailureSummary? |
string? |
Compact description of the failing step(s) and error categories. |
CompensationPlan? |
IList<IStep> |
Reverse-ordered steps to undo side effects (generated when needed). |
IStep — properties (child entity)¶
| Property | Type | Description & rules |
|---|---|---|
StepId |
Guid |
Identity for audit/resume. |
Index |
int |
Order within the plan. Combined with DependsOn for scheduling. |
Name |
string |
Human-friendly identifier (e.g., CreateTenantDb). |
ActionKey |
string |
Handler/action slug (e.g., sql.create_database, kv.put_secret_ref). |
Parameters |
IDictionary<string,string> |
Materialized inputs for this step (secrets by reference only). |
DependsOn |
IReadOnlyList<Guid> |
Step ids that must succeed before this step is eligible. |
Attempt |
int |
Current attempt count (starts at 0). |
MaxAttempts |
int |
Maximum retry attempts (e.g., 3 with backoff). |
IdempotencyKey |
string |
Deterministic id for the side effect; ensures exactly-once at the provider level. |
Status |
StepStatus (Pending, Running, Succeeded, Failed, Compensating, Compensated, Skipped) |
Current state. |
StartedAtUtc? / EndedAtUtc? |
DateTime? |
Timing for the current/last attempt. |
Logs |
IList<IStepLog> |
Structured logs & important outputs (resource ids, endpoints). |
IStepLog — properties (child entity)¶
| Property | Type | Description & rules |
|---|---|---|
TimestampUtc |
DateTime |
When the log was recorded. |
Level |
string (Info,Warn,Error,Debug) |
Severity. |
Message |
string |
Human-readable message. |
Data? |
IDictionary<string,string>? |
Structured fields (provider request ids, latency ms). No secrets. |
TemplateRef (VO)¶
| Property | Type | Description |
|---|---|---|
Key |
string |
Template identifier (e.g., tenant.basic.v2). |
Version |
string |
SemVer of the template logic. |
Checksum |
string |
Hash of the compiled/template plan to guarantee reproducibility. |
IResourceBinding — properties (aggregate)¶
| Property | Type | Description & rules |
|---|---|---|
ResourceBindingId |
Guid |
Aggregate id. |
ProvisioningRunId |
Guid |
Run that created/updated this resource. |
TenantId |
Guid |
Owning tenant (partition/RLS). |
Provider |
string |
Provider slug (azure, aws, gcp, internal-svc). |
ResourceType |
string |
Type slug (sql-db, kv-secret, blob-bucket, routing-entry). |
ResourceId |
string |
Provider-native id or fully qualified name. |
Region |
string |
Physical region where resource resides. |
SiloId |
string |
Local shard/cluster identifier. |
State |
ResourceState (Active, Updating, Compensating, Decommissioned) |
Lifecycle state. |
Tags |
IReadOnlyList<KeyValueTag> |
Metadata for discovery/ops. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Lifecycle timestamps. |
Enums¶
| Enum | Members |
|---|---|
RunStatus |
Planned, Running, Succeeded, Failed, PartiallyCompensated, Compensated, Aborted |
StepStatus |
Pending, Running, Succeeded, Failed, Compensating, Compensated, Skipped |
ResourceState |
Active, Updating, Compensating, Decommissioned |
Invariants & policies¶
- Exactly-once per request
A
ProvisioningRequestIdmay have at most one authoritativeProvisioningRunin non-terminal state. Replays with the same id resume the existing run. - Tenant residency match
ResolvedRegionmust equal the tenant’sRegionResidency.RegionCode;ResolvedSiloIdmust be a registered silo for that region. Reject otherwise. - Step dependency & retries
A step may run only when all
DependsOnareSucceeded. Retries incrementAttemptup toMaxAttemptswith backoff; after that the run entersFailed(and may generate a compensation plan). - Idempotent effects
Every external call uses
IdempotencyKey(e.g., provider idempotency token) to guarantee at-least-once engine → exactly-once side effects. - Secrets handling Steps only receive references (URIs/ids) to secrets; retrieval happens via the Secrets context with short-lived tokens. No raw secrets are persisted in steps/logs/events.
- Compensation
If any step fails irrecoverably, generate reverse-order compensation steps for already-succeeded steps that registered side effects, transitioning the run to
PartiallyCompensatedorCompensated.
Domain events¶
provisioning.started—{ runId, requestId, tenantId, template: { key, version }, region, siloId }provisioning.step_succeeded—{ runId, stepId, name, actionKey, attempt, durationMs }provisioning.step_failed—{ runId, stepId, name, actionKey, attempt, errorCode, errorMessage, retryable }provisioning.completed—{ runId, status: "Succeeded"|"Failed"|"Compensated", resourceCount }provisioning.compensated—{ runId, compensatedSteps, status: "Compensated" }resource.binding.created|updated|decommissioned—{ bindingId, runId, tenantId, provider, type, resourceId, region, siloId, state }
(All events use the platform envelope, are partitioned by tenantId, and correlate via runId and requestId.)
APIs / projections¶
Commands
StartProvisioning(requestId, tenantId, templateRef, variables, requestedRegion, requestedSiloId?, dryRun=false)AbortRun(runId, reason)— attempts graceful stop; marks remaining stepsSkipped.RetryRun(runId)— restarts failed steps if retryable; preserves succeeded steps.RetryStep(runId, stepId)— targeted retry (ops workflow).ApplyCompensation(runId)— executes generated compensation plan.ReconcileBindings(tenantId)— scans provider state vs. bindings and fixes drift.
Reads
ProvisioningStatusView
{
runId, requestId, tenantId, status, template:{key,version},
region, siloId, startedAtUtc, completedAtUtc?,
steps:[{ stepId,index,name,actionKey,status,attempt,maxAttempts,startedAtUtc?,endedAtUtc?,dependsOn[] }],
resourceBindings:[{ bindingId, provider, resourceType, resourceId, region, siloId, state }],
failureSummary?
}
RecentRunsView(tenantId) — last N runs with status rollups.
* ResourceBindingsView(tenantId) — current active bindings per tenant.
ORM notes¶
-
Aggregates & ownership
-
ProvisioningRunas root;StepsandStepLogsas child tables (FKRunId). ResourceBindingas a separate aggregate (own table) with FK toRunIdfor provenance; can be mutated independently (e.g., decommission).-
Uniqueness & indexes
-
Unique index on
ProvisioningRequestIdinProvisioningRun(enforces idempotency). - Composite indexes:
Steps(RunId, Status, Index),ResourceBinding(TenantId, State, ResourceType). - Optional unique on
(Provider, ResourceId)to prevent duplicate bindings. -
Concurrency
-
Use optimistic concurrency on
ProvisioningRunand per-step rows to avoid double-execution. - A lightweight lease (e.g.,
LockOwner,LockExpiresAt) can coordinate workers for the same run. -
Retention
-
Keep
StepLogfor 30–90 days (configurable); keepResourceBindinguntil decommission + grace.
Handler guidance (engine behavior)¶
- Planner builds the DAG of
StepsfromTemplateRef+Variables, validates tenancy/residency/capacity, and persists the plan (RunStatus=Planned). - Runner picks ready steps (no unmet deps, not running), acquires a lease, sets
Status=Running, executes with idempotency, writesStepLog, and transitions toSucceededorFailed. - Retry logic respects
MaxAttemptswith exponential backoff and error classification (retryablevsfatal). - Compensator generates reverse operations for steps that registered side effects (e.g., drop DB, delete bucket, remove routing).
- Auditing: every state transition emits events and a structured audit record with
traceIdand step outputs (without secrets).
Integration touchpoints¶
- Tenant: on
tenant.activated, start a provisioning request (if not already provisioned). On successful completion, update routing (region/silo). - Data Residency & Routing: consume
resource.binding.*to build the RoutingTable for gateways. - Secrets: resolve secret references at step execution time (short-lived credentials).
- Notifications: post progress to Owners/Tech contacts; attach
ProvisioningStatusViewdeep-link.
This context gives you a deterministic, idempotent, and compensating engine to stand up (and tear down) tenant environments while staying faithful to residency policies and auditability requirements.
Data Residency & Routing Context¶
Relationships¶
- RegionPolicy HasMany DataSilo
A region (e.g.,
EU,US) owns its set of silos/clusters and the rules for placing tenants within it. - DataSilo HasMany ShardAssignment Each silo carries assignments (by tenant, hash range, or sub-shard) subject to capacity.
- MigrationPlan References (from→to) DataSilo A plan describes a tenant’s move between silos (same region unless policy allows cross-region with approvals).
Types in this context¶
- Aggregates:
IRegionPolicy,IDataSilo,IShardAssignment - Entities/VOs:
IMigrationPlan(entity),CapacityPlan(VO),EndpointSet(VO),ResidencyRule(VO) - Enums (suggested):
SiloState { Active, Draining, ReadOnly, Decommissioned },MigrationStatus { Planned, Approved, Running, Completed, Failed, Canceled }
Names are interface-oriented; implement as aggregates with behavior. VOs are owned types in ORM.
IRegionPolicy — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
RegionCode |
string |
Key for the jurisdiction (e.g., EU, US, IL). Immutable identifier. |
DisplayName |
string |
Human-friendly label (e.g., “European Union”). |
ResidencyRules |
IReadOnlyList<ResidencyRule> |
Set of predicates/constraints (data locality, allowed providers, cross-border transfer rules). |
Silos |
IList<IDataSilo> |
HasMany silos governed by this policy. |
DefaultPlacementStrategy |
string |
Slug of placement algorithm (balanced, least_connections, hash_tenantId). |
FailoverRegionCodes |
IReadOnlyList<string> |
Ordered list of regions allowed for failover (can be empty/none). |
UpdatedAtUtc |
DateTime |
Last time policy changed (triggers rebalancing checks). |
ResidencyRule (VO)¶
| Property | Type | Description |
|---|---|---|
Key |
string |
Identifier of the rule (slug). |
Expression |
string |
Policy expression (e.g., CEL/OPA-like) evaluated on tenant.profile, subscription, provider. |
Effect |
string (Allow |
Deny |
Reason |
string |
Operator-facing rationale for audits and error details. |
IDataSilo — properties¶
| Property | Type | Description & rules | |||
|---|---|---|---|---|---|
SiloId |
string |
Unique identifier (e.g., eu-shard-03). Immutable. |
|||
RegionCode |
string |
Back-reference to owning region. | |||
Provider |
string |
Infra provider slug (azure | aws | gcp | onprem). |
|||
State |
SiloState |
Operational state (Active, Draining, …); affects placement and migration. |
|||
Capacity |
CapacityPlan (VO) |
Max tenants, storage/IO ceilings, connection budget; hard caps. | |||
Endpoints |
EndpointSet (VO) |
Public/priv endpoints (read/write/data plane), health probe URIs. | |||
Tags |
IReadOnlyList<KeyValueTag> |
Ops metadata (sku, zone, cost-center). | |||
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Lifecycle timestamps. |
CapacityPlan (VO)¶
| Property | Type | Description & rules |
|---|---|---|
MaxTenants |
int |
Hard cap for tenant count. Must be ≥ 1. |
MaxStorageGb |
int |
Aggregate storage budget (advisory unless enforced by provider). |
MaxIops |
int |
Aggregate IOPS budget (advisory). |
WarnAtTenants |
int |
Soft threshold for alerts/rebalancing. Must be ≤ MaxTenants. |
EndpointSet (VO)¶
| Property | Type | Description |
|---|---|---|
ReadWrite |
Uri |
Primary read/write endpoint for the tenant data plane. |
ReadOnly |
Uri? |
Optional RO endpoint (reporting/BI). |
Admin |
Uri? |
Control-plane/admin endpoint used by internal services. |
Metrics |
Uri? |
Observability endpoint for health/telemetry. |
DnsZone |
string? |
Optional DNS zone suffix (for vanity domains). |
IShardAssignment — properties¶
| Property | Type | Description & rules |
|---|---|---|
AssignmentId |
Guid |
Aggregate id. |
TenantId |
Guid |
Tenant assigned (partition/RLS). |
SiloId |
string |
Target silo. Must belong to the tenant’s region policy. |
RegionCode |
string |
Redundant for consistency checks and routing joins. |
PlacedAtUtc |
DateTime |
When assigned. |
PlacementReason |
string |
“initial”, “rebalance”, “migration”. |
IsPrimary |
bool |
Primary placement vs read replica. |
ReplicaSiloIds |
IReadOnlyList<string> |
Optional read replica silos (within region or policy-approved cross-region). |
IMigrationPlan — properties (entity)¶
| Property | Type | Description & rules |
|---|---|---|
MigrationPlanId |
Guid |
Identity for approvals and audit. |
TenantId |
Guid |
Tenant to move. |
FromSiloId |
string |
Current silo. |
ToSiloId |
string |
Target silo. Must be in same RegionCode unless a cross-region policy is explicitly allowed. |
Status |
MigrationStatus |
Lifecycle of the migration. |
PlannedAtUtc / ApprovedAtUtc? / StartedAtUtc? / CompletedAtUtc? |
DateTime |
Timeline anchors. |
WindowStartUtc / WindowEndUtc |
DateTime |
Maintenance window for disruption-minimal cutover. |
Method |
string (online-replication, snapshot-restore, dual-write) |
Technical approach. |
ApprovalBy |
string? |
Who approved (user/service) with role. |
Notes |
string? |
Rationale, risk, rollback plan. |
Invariants & policies¶
- Silo capacity (hard cap)
New
ShardAssignmentinto a silo requires:currentTenants < Capacity.MaxTenants. Reject otherwise unless a formal override is recorded (for emergencies) with audit. - Residency lock
A tenant with
ResidencyLocked=true(from Tenant context) may not change RegionCode. Migrations must remain within the same region, unless an approved cross-region rule exists (and legal basis documented). - State-aware placement
Placement is allowed only into
SiloState=Active.Drainingprevents new placements;ReadOnlycan serve reads but not new write primaries. - Endpoint consistency
EndpointSet.ReadWritemust be reachable/healthy before marking an assignment active. Changing endpoints emitsendpoint.changedand refreshesRoutingTable. - Primary uniqueness
A tenant must have exactly one
IsPrimary=trueassignment at a time. Replicas are optional and cannot be marked primary unless a controlled failover/migration occurs.
Domain events¶
silo.created—{ siloId, regionCode, provider, capacity, endpoints }capacity.changed—{ siloId, old, @new }(caps and/or warnings)endpoint.changed—{ siloId, endpoints }tenant.placement.changed—{ tenantId, primary: { siloId, regionCode }, replicas?: [siloId…], reason }migration.planned—{ migrationPlanId, tenantId, fromSiloId, toSiloId, window }migration.approved/migration.started/migration.completed/migration.failed— with timestamps and error summaries
(All events use the platform envelope and are partitioned by tenantId where applicable.)
APIs / projections¶
Commands
CreateSilo(regionCode, provider, capacityPlan, endpoints, tags?)UpdateSiloCapacity(siloId, capacityPlan)UpdateSiloEndpoints(siloId, endpoints)AssignTenant(tenantId, regionCode, preferredSiloId?)→ computes placement perRegionPolicy.DefaultPlacementStrategyPlanMigration(tenantId, toSiloId, windowStartUtc, windowEndUtc, method, notes?)ApproveMigration(migrationPlanId, approver)StartMigration(migrationPlanId)CompleteMigration(migrationPlanId)FailMigration(migrationPlanId, error)
Reads / Projections
RoutingTable(authoritative; cached at edge):
{
tenantId: "...",
regionCode: "EU",
primary: { siloId: "eu-shard-03", endpoints: { readWrite: "...", readOnly: "..." } },
replicas: [{ siloId: "eu-shard-01", endpoints: {...} }],
updatedAtUtc: "..."
}
SiloCatalog: by region/provider/state with capacity utilization (tenants, % used, warn/ok).
* MigrationQueue: upcoming/active migrations with windows and status.
Lookup APIs
ResolveTenant(tenantId)→RoutingTableentry (fast path for gateways/services).FailoverHints(tenantId)→ candidate replicas/regions allowed by policy (for read routing or emergency).
ORM notes¶
- Aggregates
RegionPolicyroot table with ownedResidencyRules(JSON or child rows).DataSilotable linked toRegionPolicyviaRegionCodeFK; owned VOs forCapacityPlan,EndpointSet.ShardAssignmenttable keyed byAssignmentIdwith unique(TenantId, IsPrimary WHERE IsPrimary=1)to enforce single-primary.
- Indexes
DataSilo(RegionCode, State)for placement queries.ShardAssignment(TenantId)for routing lookups;ShardAssignment(SiloId, IsPrimary)for capacity tallies.
- Consistency
- Use transactional updates when flipping primary during migration: write new primary, swap flags atomically, update
RoutingTable, then emittenant.placement.changed.
- Use transactional updates when flipping primary during migration: write new primary, swap flags atomically, update
Integration touchpoints¶
- Tenant: on activation, call
AssignTenantwithRegionResidency.RegionCode; storeSiloIdin routing projection (not in the Tenant aggregate). - Provisioning: consumes
RoutingTableto know where to create resources; emitsresource.binding.*which can updateEndpointSetif endpoints are provisioned dynamically. - Observability: health checks update
SiloState; failing silos triggerDrainingand influence placement. - Notifications: send migration window notifications to Tenant Owner/Tech contacts.
Operational guidance¶
- Rebalancing: when
WarnAtTenantsis exceeded or skew > threshold, compute candidate migrations within region; generateMigrationPlans for approval. - Disaster readiness: if
FailoverRegionCodesare populated, keep cold/warm replicas and publish read-failover hints while ensuring legal compliance. - Cache discipline:
RoutingTableis aggressively cached (edge/CDN) with short TTL and event-driven invalidation ontenant.placement.changedandendpoint.changed.
This context keeps policy (RegionPolicy), capacity (DataSilo), and placement (ShardAssignment) cleanly separated, while producing a crisp RoutingTable projection that every service can trust—fast to read, simple to cache, and governed by explicit, auditable events.
Secrets & Provider Credentials Context¶
Relationships¶
- SecretBundle HasMany SecretVersion A bundle is the logical container for one secret across time (versions).
- ProviderCredential Reference SecretBundle Credentials for external providers (email/SMS/payments/etc.) point to the bundle that holds their current/next secrets.
- RotationPolicy Owned by SecretBundle or ProviderCredential Policies travel with what they govern to keep rotation SLOs enforceable.
Types in this context¶
- Aggregates:
ISecretBundle,IProviderCredential,IRotationPolicy - Entities/VOs:
ISecretVersion(entity),Grant(VO),SecretRef(VO),ScopeRef(VO) - Enums:
SecretScope,SecretVersionState,GrantPermission,CredentialStatus
Secrets are never stored in cleartext here. Only metadata, KMS pointers, hashes/fingerprints, and access grants live in the write model. Retrieval returns a short-lived materialization or a vault URI after authn/z.
ISecretBundle — properties (contract + semantics)¶
| Property | Type | Description & rules | |||
|---|---|---|---|---|---|
SecretBundleId |
Guid |
Aggregate id. | |||
Key |
string |
Canonical slug (e.g., smtp.sendgrid.api_key). Unique per (Environment,Region,Scope). |
|||
Environment |
string |
prod | staging | dev | … for separation of duties. |
|||
Region |
string? |
Optional regionalization (EU, US, …). |
|||
Scope |
SecretScope |
Where this secret applies (Global, Region, Tenant, Provider, Connector, Service). |
|||
OwnerRef |
ScopeRef |
Who owns it (e.g., { type:"Service", id:"notifications-svc" } or { type:"Tenant", id:tenantId }). |
|||
ActiveVersionId |
Guid? |
The version currently in use. Only one version can be active. | |||
NextVersionId |
Guid? |
Optional pre-staged version for zero-downtime rotation. | |||
RotationPolicy |
IRotationPolicy |
Min/max age, overlap/grace, verification hooks. | |||
Grants |
IReadOnlyList<Grant> |
Who can use/rotate/read metadata (least privilege). | |||
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Lifecycle timestamps. | |||
Tags |
IReadOnlyList<KeyValueTag> |
Metadata (do not include secrets). |
ISecretVersion — properties (child entity)¶
| Property | Type | Description & rules |
|---|---|---|
SecretVersionId |
Guid |
Identity for audit/activation. |
BundleId |
Guid |
Parent link. |
State |
SecretVersionState (Staged, Active, Revoked, Compromised, Superseded) |
State machine; only one Active. |
KmsKeyId |
string |
KMS key alias/id used for envelope encryption. |
CiphertextRef |
string |
Vault URI or storage pointer (e.g., vault://…/versions/…). No cleartext. |
Fingerprint |
string |
Hash (HMAC/KEK) of the plaintext for integrity checks (non-reversible). |
CreatedAtUtc |
DateTime |
When version was created. |
ActivatedAtUtc? / RevokedAtUtc? |
DateTime? |
Lifecycle anchors. |
ExpiresAtUtc? |
DateTime? |
Optional expiry; rotation jobs act before this. |
IProviderCredential — properties (aggregate)¶
| Property | Type | Description & rules | ||
|---|---|---|---|---|
ProviderCredentialId |
Guid |
Aggregate id. | ||
Provider |
string |
Slug (sendgrid, twilio, stripe, smtp, …). |
||
AccountId |
string |
Provider account/project id. | ||
Environment |
string |
prod | staging | …. |
||
Region? |
string? |
Optional region if provider keys are regional. | ||
Status |
CredentialStatus (Active, Disabled, Rotating, Revoked) |
Operational state. | ||
SecretBundle |
ISecretBundle |
Reference to the bundle that contains the secret material. | ||
AllowedActions |
IReadOnlyList<string> |
Provider-specific scopes (e.g., email.send, sms.send, payments.charge). |
||
LastRotatedAtUtc? / NextRotationDueAtUtc? |
DateTime? |
For SLO tracking. | ||
Tags |
IReadOnlyList<KeyValueTag> |
Metadata for ops (team, system, purpose). |
IRotationPolicy — properties (aggregate or VO)¶
| Property | Type | Description & rules |
|---|---|---|
MinAgeDays |
int |
Earliest allowed rotation (to avoid thrash). |
MaxAgeDays |
int |
SLA for rotation; schedulers alert/rotate before this. |
OverlapMinutes |
int |
Window where current and next are both valid to allow smooth cutover. |
VerificationWebhook? |
Uri? |
Optional endpoint to verify new version before promotion. |
ActivationStrategy |
string (dual-write, flip-pointer, rollout-traffic) |
How consumers transition. |
GraceOnRevokeMinutes |
int |
Grace period for consumers to refresh before hard revoke. |
Grant (VO)¶
| Property | Type | Description & rules |
|---|---|---|
SubjectType |
string (Service, Worker, User, Job) |
Who can act. |
SubjectId |
string |
Identity of subject (e.g., workload identity, group id). |
Permissions |
IReadOnlyList<GrantPermission> |
Use, Rotate, ReadMetadata. Never ReadPlaintext. |
Scope |
ScopeRef |
Where permission applies (tenant/region/service). |
ExpiresAtUtc? |
DateTime? |
Optional temporal bound. |
SecretRef (VO) & ScopeRef (VO)¶
| VO | Fields | Notes |
|---|---|---|
SecretRef |
{ bundleKey, versionId? } |
Pointer used by consumers. Usually returned as vault://bundleKey#versionId?. |
ScopeRef |
{ type, id } |
Identifies owner/tenant/service; used in grants and audit. |
Enums¶
| Enum | Members |
|---|---|
SecretScope |
Global, Region, Environment, Tenant, Provider, Connector, Service |
SecretVersionState |
Staged, Active, Revoked, Compromised, Superseded |
GrantPermission |
Use, Rotate, ReadMetadata |
CredentialStatus |
Active, Disabled, Rotating, Revoked |
Invariants & policies¶
- No plaintext at rest: only
CiphertextRefand non-reversibleFingerprintare stored; logs/events never include secret material. - Single active version: exactly one
Activeversion per bundle.NextVersionIdmay exist for overlap, but not markedActivesimultaneously. - Rotation SLO: if
ActiveVersionage ≥MaxAgeDays(policy), rotation must be initiated; schedulers raise alerts prior to breach. - Least privilege: grants must use the narrowest feasible
Scopeand minimalPermissions. Rotation requiresRotate; consumers only haveUse. - Tenant isolation: bundles with
Scope=Tenantcan only be granted to subjects scoped to that tenant. - Promotion gate: to promote a
Stagedversion toActive, verification must pass (optional webhook/health checks) withinOverlapMinutes. - Revocation window: after
Revoked, consumers have at mostGraceOnRevokeMinutesto refresh before access is denied.
Domain events¶
secret.created—{ bundleId, key, environment, region?, scope, activeVersionId? }secret.rotated—{ bundleId, previousVersionId?, newVersionId, method, overlappedForMinutes }secret.revoked—{ bundleId, versionId, reason }provider.credential.bound—{ credentialId, provider, accountId, bundleKey }provider.credential.updated—{ credentialId, status, nextRotationDueAtUtc? }
(Events contain identifiers, not plaintext; correlation via bundleId/credentialId, partitioned by tenantId when scope is tenant.)
Retrieval & rotation flows¶
Retrieve (for a service):
- Service authenticates (workload identity) →
GetAccessToken(subjectId, scopeRef). - Calls
GetSecret(ref)→ gets either a vault URI it can dereference or a short-lived materialization (TTL seconds). - Audit record: subject, bundleKey, versionId, scopeRef, traceId.
Rotate (operator or job):
PutVersion(bundleKey, ciphertextRef, fingerprint)(state=Staged).- Optional verification hook (smoke send, sandbox charge…).
PromoteVersion(bundleKey, versionId)→ flipsActiveVersionId(dual valid if overlap configured).- Notify dependents (change events or out-of-band secrets manager signals).
RevokeVersion(bundleKey, previousVersionId)after overlap/grace window.
Zero-downtime rotation for providers often uses dual credentials: current+next both authorized at the provider for the overlap window.
APIs / projections¶
Commands
CreateSecretBundle(key, environment, region?, scope, ownerRef, rotationPolicy, tags?)PutSecretVersion(bundleKey, ciphertextRef, fingerprint)→ returnsversionIdPromoteSecretVersion(bundleKey, versionId)RevokeSecretVersion(bundleKey, versionId, reason)IssueGrant(bundleKey, grant)/RevokeGrant(bundleKey, subjectId)CreateProviderCredential(provider, accountId, environment, region?, bundleKey, allowedActions, tags?)UpdateCredentialStatus(credentialId, status)/ScheduleRotation(credentialId, when)
Reads / Projections
CredentialCatalog(redacted):
{ credentialId, provider, accountId, environment, region?, status,
bundleKey, lastRotatedAtUtc?, nextRotationDueAtUtc?, allowedActions[], tags[] }
SecretBundleView: bundle metadata, no ciphertexts.
* RotationQueue: bundles/credentials due for rotation (SLO-aware).
ORM notes¶
- Aggregates
SecretBundleroot with ownedRotationPolicyandGrants;SecretVersionchild table keyed bySecretVersionId(FKBundleId).ProviderCredentialroot referencingSecretBundleby key/id (no navigation to secret material).
- Uniqueness & indexes
- Unique
(Key, Environment, Region, Scope)on bundles. - Unique filtered index
SecretVersion(BundleId, State WHERE State='Active'). - Index
ProviderCredential(provider, environment, region, status).
- Unique
- Hard deletes: avoid. Use
Revoked/Disabledstates; retain history for audit. - Encryption: envelope encryption via KMS; rotate KEKs per policy; store only
KmsKeyIdandCiphertextRef.
Integration touchpoints¶
- Provisioning: steps carry secret references; engine resolves at runtime with a short-lived token from this context.
- Notifications/Billing/Integrations: their provider credentials are bound here; rotation is coordinated centrally (downstream services reload).
- Audit: every retrieve/rotate/revoke produces an immutable audit record correlated by
traceId. - Access Rules: evaluation ensures caller has a
GrantwithUseorRotatefor the specificScopeRef.
Operator guidance¶
- Prefer service-to-service identities over long-lived API keys. Where unavoidable, enforce aggressive rotation and overlap with dual credentials.
- Maintain runbooks for compromised credentials: mark
Compromised, generate new version, immediate revoke after overlap 0, notify incident mgmt. - Periodically validate
Fingerprintagainst live material to detect drift between vault and consumers.
This context gives you a single source of truth for secret metadata and provider credentials, with clear rotation SLOs, least-privilege grants, event-driven updates, and zero-downtime cutovers—without ever leaking secret material into your domain model, events, or logs.
Observability & Incident Management Context¶
Relationships¶
- AlertRule HasMany Incidents An alert rule can generate many incidents over time (deduped by correlation keys).
- Incident Reference SLA (by key+version) Each incident optionally links to the SLA package in effect for the affected tenants/editions at the time of breach (to drive credits).
- MaintenanceWindow affects AlertRule & Incident evaluations Active maintenance can silence/suppress alerts and pause SLA clocks.
Types in this context¶
- Aggregates:
IAlertRule,IIncident,IMaintenanceWindow - Entities/VOs:
SloTarget(VO),RunbookRef(VO),OwnerRef(VO),ScopeRef(VO) - Enums:
Severity,IncidentStatus,AlertState,DetectionSource,SlaClockState
All cross-context references (Tenant, Region, Edition, SLA) are IDs/keys only. No navigations.
IAlertRule — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
AlertRuleId |
Guid |
Aggregate id. |
Key |
string |
Canonical slug (e.g., api-latency-p99). Unique per environment. |
DisplayName |
string |
Human-friendly title. |
Description |
string |
What’s being monitored and why. |
Severity |
Severity (P1,P2,P3,P4) |
Default severity for incidents opened by this rule. |
Slo |
SloTarget (VO) |
Target objective (SLO) tied to this alert (e.g., latency_p99 ≤ 300ms over 99.9% req). |
Query |
string |
Metric/log query or detector expression (PromQL/SQL/CEL-like). |
Threshold |
decimal |
Numeric threshold (or use Query to embed it). |
EvaluationWindow |
TimeSpan |
Window over which to evaluate (e.g., 5m). |
ForDuration |
TimeSpan |
Breach must persist for this long before firing (debounce). |
EvaluateEvery |
TimeSpan |
Evaluation cadence (e.g., 1m). |
Scope |
ScopeRef (VO) |
Where this rule applies (Region/Silo/Service/Tenant/Product/Edition). |
SilencedUntilUtc? |
DateTime? |
Temporary silence (e.g., during maintenance). |
Runbook |
RunbookRef (VO) |
Primary remediation instructions. |
Labels |
IDictionary<string,string> |
Arbitrary labels (team, component, env). |
State |
AlertState (Enabled,Disabled) |
Operational toggle. |
Owner |
OwnerRef (VO) |
Owning team/service for on-call routing. |
LastEvaluatedAtUtc? |
DateTime? |
Last evaluation timestamp. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Lifecycle timestamps. |
SloTarget (VO)¶
| Property | Type | Description |
|---|---|---|
Indicator |
string |
SLI key (e.g., availability, latency_p99, error_rate). |
Target |
decimal |
Target value (e.g., 99.9 for availability, 300 for ms). |
Operator |
string (>=,<=) |
Comparison direction. |
Measurement |
string |
How measured (e.g., “HTTP 2xx/total”, “p99 over 5m”). |
IIncident — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
IncidentId |
Guid |
Aggregate id. |
Key |
string |
Correlation key (e.g., eu:api-latency-p99); dedupes repeat fires. |
Title |
string |
Short human-readable summary. |
Description |
string |
Detailed context/symptoms. |
Status |
IncidentStatus (Open,Acknowledged,Mitigated,Resolved,Canceled) |
Lifecycle; “Mitigated” = user impact addressed while root cause ongoing. |
Severity |
Severity |
Initial severity; may be reclassified. |
DetectionSource |
DetectionSource (Alert,Manual,External) |
Who/what opened it. |
AlertRuleId? |
Guid? |
When opened by an alert. |
TenantIds |
IReadOnlyList<Guid> |
Affected tenants (non-empty for tenant-impacting incidents). |
RegionCodes |
IReadOnlyList<string> |
Impacted regions. |
SiloIds |
IReadOnlyList<string> |
Impacted silos/clusters. |
ProductKeys / EditionKeys |
IReadOnlyList<string> |
Impacted catalog surfaces (optional). |
SlaRef? |
{ key:string, version:string }? |
SLA reference for credits assessment. |
SlaClock |
SlaClockState (Running,Paused,Stopped) |
Whether SLA timers are counting (paused during approved maintenance). |
OpenedAtUtc |
DateTime |
When created. |
AcknowledgedAtUtc? |
DateTime? |
When an on-call acked. |
MitigatedAtUtc? |
DateTime? |
When user-facing impact mitigated. |
ResolvedAtUtc? |
DateTime? |
When fully resolved. |
Owner |
OwnerRef (VO) |
Current incident commander/team. |
Runbooks |
IReadOnlyList<RunbookRef> |
Contextual remediation docs. |
RelatedTraces |
IReadOnlyList<string> |
Trace/session IDs for root cause analysis. |
RelatedTickets |
IReadOnlyList<string> |
Links to Jira/ServiceNow/etc. |
Updates |
IList<IncidentUpdate> |
Timeline (see below). |
Tags |
IDictionary<string,string> |
Labels (component, customer-tier). |
IncidentUpdate (child entity)
{ WhenUtc: DateTime, Author: string, Message: string, Visibility: string("internal"|"public"), Attachments?: string[] }
IMaintenanceWindow — properties¶
| Property | Type | Description & rules |
|---|---|---|
MaintenanceWindowId |
Guid |
Aggregate id. |
Title |
string |
Purpose (“EU DB patching”). |
Scope |
ScopeRef (VO) |
Region/Silo/Service/Tenant scope. |
StartsAtUtc / EndsAtUtc |
DateTime |
Window (Ends exclusive). |
ApprovedBy |
string |
Approver (user/service). |
SilenceAlerts |
bool |
Whether to auto-silence rules in scope. |
PauseSlaClocks |
bool |
Whether incidents’ SLA clocks pause in scope. |
Status |
string (Scheduled,Active,Completed,Canceled) |
Lifecycle. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
RunbookRef (VO) & OwnerRef (VO) & ScopeRef (VO)¶
| VO | Fields | Notes | |||||
|---|---|---|---|---|---|---|---|
RunbookRef |
{ key:string, url:string } |
Operational doc pointer. | |||||
OwnerRef |
{ team:string, onCallRotation?:string } |
Owning team & rotation id. | |||||
ScopeRef |
{ kind:"Region" | "Silo" | "Service" | "Tenant" | "Product" | "Edition", id:string } |
Where rule/window/incident applies. |
Enums¶
| Enum | Members |
|---|---|
Severity |
P1,P2,P3,P4 (P1 = highest user impact) |
IncidentStatus |
Open,Acknowledged,Mitigated,Resolved,Canceled |
AlertState |
Enabled,Disabled |
DetectionSource |
Alert,Manual,External |
SlaClockState |
Running,Paused,Stopped |
Invariants & policies¶
- Incident scoping
Every incident must reference at least one Region or Tenant (or both). For tenant-impacting incidents,
TenantIdsmust not be empty. - SLA breach workflow
When an incident violates
SloTargetfor an Edition/SLA in scope, emitincident.sla_breachand startcredits.assessin Billing with the affected tenant set and breach window. - Ack & escalation P1 incidents must be Acknowledged within, e.g., 5 minutes; auto-escalate owner rotation if breached.
- Maintenance behavior
Active
MaintenanceWindowwithPauseSlaClocks=truepauses theSlaClockfor matching incidents; withSilenceAlerts=truethe system suppresses or auto-silences alert fires in scope. - Dedup & correlation
New alerts create or attach to an open incident with the same
Key(matching rule+scope). Reopen only if last resolution was within a cool-off window (configurable).
Domain events¶
alert.fired—{ alertRuleId, key, severity, scope, evaluationWindow, forDuration, value, threshold, time }alert.resolved—{ alertRuleId, key, time }incident.opened—{ incidentId, key, title, severity, scope, openedAtUtc, detectionSource, tenantIds?, regionCodes? }incident.acknowledged—{ incidentId, acknowledgedAtUtc, owner }incident.mitigated—{ incidentId, mitigatedAtUtc }incident.resolved—{ incidentId, resolvedAtUtc, rootCause?, followUps? }incident.sla_breach—{ incidentId, sla:{key,version}, tenants:[…], fromUtc, toUtc, slo:{indicator,target,operator,measurement} }maintenance.started|completed|canceled—{ maintenanceWindowId, scope, startsAtUtc, endsAtUtc }
Events carry IDs/keys and scope; no PII beyond tenant IDs. Correlate via
incidentIdandalertRuleId, plustraceId/spanIdin headers.
APIs / projections¶
Commands
- Alert rules:
CreateRule,UpdateRule,EnableRule,DisableRule,SilenceRuleUntil - Incidents:
OpenIncident,AcknowledgeIncident,MitigateIncident,ResolveIncident,ReclassifySeverity,AddIncidentUpdate - Maintenance:
ScheduleMaintenance,ActivateMaintenance,CompleteMaintenance,CancelMaintenance
Reads / Projections
IncidentTimelineView
{
incidentId, key, title, status, severity, detectionSource,
tenants:[...], regions:[...], siloIds:[...],
slaRef?, slaClock, openedAtUtc, acknowledgedAtUtc?, mitigatedAtUtc?, resolvedAtUtc?,
updates:[{ whenUtc, author, message, visibility }],
relatedTraces:[...], relatedTickets:[...]
}
StatusPageView (public/tenant-scoped): incidents affecting a region/tenant with filtered updates.
* AlertRuleCatalog: rules by scope, severity, owner.
* SlaBreachReport: incidents with breach windows and affected tenants (feeds Billing).
Evaluation engine (operational notes)¶
- Scheduler evaluates enabled rules every
EvaluateEvery, fetching metrics via adapters (Prometheus, CloudWatch, etc.); appliesEvaluationWindowandForDuration. - Silence/maintenance is checked prior to opening/attaching incidents.
- Correlation: hash
(alertRuleId, scope)to formIncident.Key; attach subsequent fires until resolved. - Escalation uses
OwnerRef.onCallRotationto page/notify. - SLA breach detector computes SLI against
SloTargetover the incident window; if target missed, emitincident.sla_breach.
ORM notes¶
- Aggregates
AlertRuleroot with ownedSloTarget,ScopeRef,RunbookRef,OwnerRef, and label dictionary (JSON).Incidentroot with childIncidentUpdatetable; arrays for scope fields stored as join tables or JSON (depending on DB).MaintenanceWindowroot with ownedScopeRef.
- Indexes
AlertRule(State, Scope.kind, Scope.id)for evaluation selection.Incident(Status, Severity, OpenedAtUtc DESC)for on-call dashboards.- GIN/JSON indexes on labels/scope fields if using JSON.
- Constraints
- Check
EndsAtUtc > StartsAtUtcon maintenance;AcknowledgedAtUtc ≥ OpenedAtUtcon incidents. - Foreign keys only inside the context; external references are keys/ids without FKs.
- Check
Integration touchpoints¶
- Audit & Compliance: emit audit records for rule changes and every incident state transition (with
traceId). - Billing: consume
incident.sla_breachto assess service credits for affected tenants within the breach window. - Notifications: send pages/emails/SMS based on severity and owner; publish sanitized
StatusPageViewupdates. - Data Platform: export incidents/alerts for analytics; compute MTTA/MTTR and SLO performance reports.
Operator guidance¶
- Keep rule runbooks current and link to automated fix scripts when possible.
- Regularly review false positives/negatives to tune thresholds and
ForDuration. - Enforce post-incident reviews (P1/P2) with action items tracked in
RelatedTickets. - Ensure maintenance windows are scheduled with sufficient notice and have
PauseSlaClocksconfigured when appropriate.
This context makes reliability first-class: clear alerting semantics tied to SLOs, rich incident timelines with tenant/region scoping, and a direct path from validated SLA breaches to Billing credits—all without crossing DDD boundaries.
Search & Discovery Context¶
Relationships¶
- IndexDefinition HasMany IngestPipelines Each index is fed by one or more pipelines (e.g., Catalog events, Tenant updates, Docs).
- IndexDefinition HasMany IndexShards For capacity and regional isolation; shard metadata lives in this context (search engine keeps its own state too).
- IndexDefinition References SynonymSets (by locale/key) Indexes can attach multiple synonym lists per language/locale.
Types in this context¶
- Aggregates:
IIndexDefinition,IIngestPipeline,ISynonymSet - Entities/VOs:
IIndexShard(entity),AnalyzerSpec(VO),FieldMapping(VO),ScopeRef(VO) - Enums:
IndexState,PipelineState,ShardState
Cross-context data is copied into the index via pipelines; never navigated live. All tenant-scoped documents carry
tenantIdand are RLS-enforced at query time.
IIndexDefinition — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
IndexId |
Guid |
Aggregate id. |
Key |
string |
Canonical slug (e.g., catalog.search.v1). Unique per environment/region. |
DisplayName |
string |
Human label. |
Description |
string |
Purpose & scope of content (e.g., “Products/Edt/Features and docs”). |
Scope |
ScopeRef (VO) |
Global or Region; some indexes are region-local for residency. |
Analyzer |
AnalyzerSpec (VO) |
Tokenization, stemming, stopwords, language(s). |
FieldMappings |
IReadOnlyList<FieldMapping> |
Schema: field name, type (text, keyword, numeric, date, vector), stored/indexed flags. |
SynonymSetKeys |
IReadOnlyList<string> |
Attached synonym sets by key/locale. |
RefreshInterval |
TimeSpan |
Target refresh for near-real-time search. |
ShardCount |
int |
Planned primary shards. |
ReplicaCount |
int |
Replicas per shard. |
State |
IndexState (Draft,Built,Active,ReadOnly,Deleting,Deleted) |
Lifecycle state. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Lifecycle timestamps. |
Tags |
IReadOnlyList<KeyValueTag> |
Metadata (team, dataset). |
FieldMapping (VO)
{ Name:string, Type:string, Indexed:bool, Stored:bool, Searchable:bool, Aggregatable:bool, AnalyzerKey?:string, VectorDims?:int, Pii?:bool, TenantScoped?:bool }
AnalyzerSpec (VO)¶
| Property | Type | Description |
|---|---|---|
Key |
string |
Analyzer identifier (e.g., standard-en, icu-multi). |
Languages |
IReadOnlyList<string> |
ISO codes (en, de, …). |
Stopwords? |
IReadOnlyList<string>? |
Optional per-language stopwords. |
Stemming |
string? |
Algorithm or none. |
SynonymMode |
string (off, expand, replace) |
How synonyms apply. |
IIngestPipeline — properties (aggregate)¶
| Property | Type | Description & rules |
|---|---|---|
IngestPipelineId |
Guid |
Aggregate id. |
IndexKey |
string |
Target index by key (no navigation). |
Key |
string |
Pipeline slug (e.g., catalog-events-v2). |
DisplayName |
string |
Human label. |
State |
PipelineState (Disabled,Enabled,Draining) |
Operational state. |
Source |
string |
Source type (event:catalog, db:readmodel, blob:docs). |
Filters |
IDictionary<string,string> |
Event types, regions, tenants (for backfills). |
Transforms |
IReadOnlyList<string> |
Ordered transform keys (pii.scrub, text.normalize, vector.embed:all-MiniLM). |
Mappings |
IReadOnlyList<string> |
Mapping templates to target fields (e.g., catalog.product → product.*). |
DlqRef? |
string? |
Dead-letter queue reference. |
BatchSize |
int |
Indexing batch size. |
MaxInFlight |
int |
Parallelism. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
ISynonymSet — properties (aggregate)¶
| Property | Type | Description & rules |
|---|---|---|
SynonymSetId |
Guid |
Aggregate id. |
Key |
string |
Slug (catalog-en, acronyms-global). |
Locale |
string |
BCP-47 (en-US, de-DE, und for locale-agnostic). |
Entries |
IReadOnlyList<string> |
Lines like "sso, single sign-on, saml". |
Status |
string (Draft,Active,Deprecated) |
Lifecycle. |
UpdatedAtUtc |
DateTime |
Timestamp for cache busting. |
IIndexShard — properties (entity)¶
| Property | Type | Description & rules |
|---|---|---|
ShardId |
string |
Engine shard id. |
RegionCode |
string |
Region where this shard resides. |
PrimaryNode |
string |
Node id. |
ReplicaNodes |
IReadOnlyList<string> |
Replica node ids. |
State |
ShardState (Active,Initializing,Relocating,ReadOnly) |
Operational state. |
DocCount |
long |
Approx document count. |
StoreGb |
double |
Store size. |
UpdatedAtUtc |
DateTime |
Last sync time from engine. |
Document shapes (examples)¶
- Catalog document
{
type: "catalog",
productKey, productDisplayName,
editionKey?, featureKey?,
status, tags[],
tenantId?: null, // catalog is global
text: "...", // concatenated + normalized fields
facets: { product, edition, feature, status },
updatedAtUtc
}
{
type: "tenant",
tenantId, displayName, domain, regionCode,
editionKey?, features[], tags[],
text: "...",
facets: { region:"EU", edition:"enterprise" },
updatedAtUtc
}
All tenant-scoped docs must include
tenantIdand are filtered by RLS at query time.
Events¶
index.built—{ indexKey, shardCount, replicaCount, analyzerKey }index.updated—{ indexKey, changes: { mappings?, analyzer?, synonyms? } }index.deleted—{ indexKey }document.indexed—{ indexKey, docType, docId, tenantId?, updatedAtUtc }document.reindexed—{ indexKey, docType, range|criteria, count }
(Events are additive, idempotent; no document bodies are emitted.)
Invariants & policies¶
- PII minimization
Fields marked
Pii=trueinFieldMappingsare either excluded, hashed, or tokenized without storing raw values. Default = deny unless explicitly allowlisted. - Tenant isolation
Tenant docs require
tenantId. Queries must supplytenantIdor a privileged scope; engine-side filters enforce RLS. - Residency compliance Region-scoped indexes store/serve only from that region; cross-region queries are disabled unless explicitly allowed. Synonym sets can be global but cached per region.
- Schema evolution Additive changes permitted; destructive changes require a reindex plan (build new index, swap alias).
- Synonym sanity
Reject cycles/degenerate synonym rules (e.g.,
a ↔ a); limit expansion fan-out to prevent query explosion. - Capacity
ShardCount × (ReplicaCount+1)within engine cluster limits; warn whenDocCount/Shardexceeds thresholds.
APIs / projections¶
Commands
CreateIndex(key, analyzer, fieldMappings, shardCount, replicaCount, refreshInterval, scope)UpdateIndex(key, analyzer?, fieldMappings?, synonymSetKeys?)DeleteIndex(key)(transitions toReadOnlythenDeleting)CreatePipeline(key, indexKey, source, transforms, mappings, batchSize, maxInFlight)EnablePipeline(pipelineId)/DisablePipeline(pipelineId)/DrainPipeline(pipelineId)PutSynonymSet(key, locale, entries[], status)Reindex(indexKey, fromAlias?, where?)(build-new & swap through alias)
Reads
Search(query, tenantId, filters?, page?, pageSize?, sort?, vector? )Returns{ total, hits:[{ id, score, highlights, facets, … }] }(RLS bytenantId).Suggest(prefix, tenantId, limit)— typeahead.SearchFacetView(indexKey)— aggregated facets for UIs (e.g., products/editions/features/status).IndexCatalog— definitions, pipelines, shard health.PipelineLagView(pipelineId)— event offsets, DLQ size.
Ranking & retrieval¶
- Keyword: BM25 with field boosts (title > displayName > text).
- Semantic: optional dual retrieval using
vectorfields (VectorDimspresent) and ANN search; hybrid rank:α·BM25 + (1−α)·Cosine. - Faceting: use
Aggregatable=truefields (productKey,editionKey,status,regionCode) for counts. - Highlighting: pre/post tags applied to
textsnippets; strip PII.
Pipelines (operational notes)¶
- Sources:
catalog.*andtenant.*events, plus batched backfills from read models. - Transforms: normalize text (lowercase, ASCII fold), apply synonyms, scrub PII, derive composite
text, compute embeddings (optional), map to fields. - Failures: validation errors → DLQ; transient errors → retry with backoff.
- Idempotency: document
_idconstructed from natural key (e.g.,catalog:product:{productKey}ortenant:{tenantId}).
ORM & engine notes¶
- Meta store (relational):
IndexDefinition,IngestPipeline,SynonymSet,IndexShard(read-through). - Engine: Elastic/OpenSearch/Equiv keeps documents and shard state. We mirror minimal shard stats for ops dashboards.
- Aliases: read alias
key@read, write aliaskey@writeto support zero-downtime reindex. - Indexes & constraints (meta store): unique
(Key, Scope.Region?); FK-less references by key for loose coupling.
Integration touchpoints¶
- Catalog: consumes
product/edition/featureevents; transforms toCatalogdocs for discovery. - Tenants: consumes
tenant.*(created/activated/suspended) to index tenant directory (RLS). - Observability: exposes pipeline lag and shard health to alerting.
- Notifications/Portal: uses
SearchFacetViewfor filters and typeahead across products/features and tenant resources.
Example: SearchFacetView (Catalog)¶
{
"indexKey": "catalog.search.v1",
"facets": {
"productKey": [{"key":"crm-suite","count":124},{"key":"ai-doc-intel","count":52}],
"editionKey": [{"key":"standard","count":98},{"key":"enterprise","count":78}],
"featureKey": [{"key":"api.core","count":65},{"key":"webhooks.outbound","count":44}],
"status": [{"key":"published","count":160},{"key":"deprecated","count":16}]
},
"updatedAtUtc": "2025-09-30T10:12:00Z"
}
Security & compliance¶
- Enforce RLS with a mandatory
tenantIdfilter for tenant-scoped indexes; admin queries require elevated scopes. - Maintain a PII field registry; pipelines must scrub or hash these before indexing.
- Support right-to-erasure by tracking document sources/keys → targeted delete/reindex plans.
This context gives your platform a robust, compliant search layer: index schemas and analyzers as first-class domain objects, event-driven pipelines that copy data from authoritative contexts, strict tenant isolation, and flexible ranking (keyword + semantic) to power discovery and in-app search.
Data Platform & Reporting Context¶
Relationships¶
- Dataset HasMany IngestJobs A dataset is produced by one or more jobs (batch/stream) that load or transform data into its storage.
- Report Reference Dataset Each report or dashboard is defined against a single semantic dataset (can join others internally via the semantic layer), version-pinned for reproducibility.
- DataContract Governs Dataset The contract specifies schema, semantics, and privacy/quality rules for a dataset version.
Types in this context¶
- Aggregates:
IDataset,IDataContract,IReport - Entities/VOs:
IIngestJob(entity),SchemaVersion(VO),RetentionPolicy(VO),PartitionSpec(VO),LineageRef(VO),QualityRule(VO) - Enums (suggested):
DatasetType,StorageFormat,JobKind,JobStatus,ContractStatus,ReportStatus,DataClassification
Authoritative data lives in storage engines (data lake/warehouse). This context governs metadata, contracts, versions, lineage, retention, and exposed read endpoints. All cross-context references are IDs/keys only.
IDataset — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
DatasetId |
Guid |
Aggregate id. |
Key |
string |
Canonical slug (e.g., catalog.products.v1, usage.events.daily). Unique per environment/region. |
DisplayName |
string |
Human label for catalogs/BI. |
Description |
string |
Purpose, grain, and primary keys (at a glance). |
Type |
DatasetType (Raw, Curated, Mart, Semantic) |
Lifecycle stage in the lakehouse. |
Storage |
{ Catalog:string, Schema:string, Table:string, Format:StorageFormat } |
Physical mapping (warehouse/lake). |
Partitioning |
PartitionSpec (VO) |
How data is partitioned (e.g., by ingestion_date, tenantId, region). |
Contract |
IDataContract |
Governing contract (current version). |
SchemaVersion |
SchemaVersion (VO) |
Version pin (SemVer). Breaking changes bump major. |
Classification |
DataClassification (Public, Internal, Confidential, Restricted) |
Highest classification present. |
Retention |
RetentionPolicy (VO) |
How long data is retained (legal/compliance aligned). |
Lineage |
IReadOnlyList<LineageRef> |
Upstream sources (keys) and transforms. |
TenantScoped |
bool |
If true, every row must include tenantId and be RLS-filterable. |
AccessRoles |
IReadOnlyList<string> |
RBAC groups allowed to query. Additional ABAC checks may apply. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Lifecycle timestamps. |
Tags |
IReadOnlyList<KeyValueTag> |
Metadata (domain, steward, SLA). |
IDataContract — properties (aggregate)¶
| Property | Type | Description & rules |
|---|---|---|
DataContractId |
Guid |
Aggregate id. |
Key |
string |
Contract slug (e.g., usage.events.contract). |
Version |
SchemaVersion (VO) |
SemVer for the contract. |
Status |
ContractStatus (Draft,Published,Deprecated) |
Lifecycle. |
OwnerTeam |
string |
Stewardship team for approvals and change control. |
Schema |
IReadOnlyList<{ Name:string, Type:string, Nullable:bool, Pii:bool, Description?:string }> |
Logical schema with PII flags. |
PrimaryKey |
IReadOnlyList<string> |
Column set for row identity (immutable). |
NaturalKey? |
IReadOnlyList<string>? |
Optional business key(s). |
SCDMode |
string (None,SCD1,SCD2) |
Change handling strategy (for dims). |
QualityRules |
IReadOnlyList<QualityRule> |
Declarative data quality checks (e.g., %null(tenantId)=0). |
BreakingChangeNotes? |
string? |
Rationale when major version changes. |
PublishedAtUtc? |
DateTime? |
Publication timestamp. |
QualityRule (VO)
{ Key:string, Expression:string, Severity:"Error"|"Warn", Description?:string }
IReport — properties (aggregate)¶
| Property | Type | Description & rules |
|---|---|---|
ReportId |
Guid |
Aggregate id. |
Key |
string |
Canonical slug (e.g., billing.mrr.trend). |
DisplayName |
string |
Human label. |
Description |
string |
Business purpose and target audience. |
DatasetKey |
string |
Reference to the semantic dataset powering this report. |
SchemaVersion |
SchemaVersion (VO) |
Version pin to ensure consistent visuals/queries. |
Parameters |
IReadOnlyList<{ Name:string, Type:string, Required:bool, Default?:string }> |
Runtime parameters (e.g., tenantId, region, dateRange). |
AccessRoles |
IReadOnlyList<string> |
Who may render/download. |
Status |
ReportStatus (Draft,Published,Deprecated) |
Lifecycle. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Lifecycle timestamps. |
Tags |
IReadOnlyList<KeyValueTag> |
Metadata (KPI, department). |
IIngestJob — properties (entity)¶
| Property | Type | Description & rules |
|---|---|---|
IngestJobId |
Guid |
Identity for monitoring/audit. |
DatasetKey |
string |
Target dataset (by key). |
Kind |
JobKind (Batch,Stream,Backfill) |
Execution model. |
Status |
JobStatus (Idle,Running,Succeeded,Failed,Degraded,Disabled) |
Current state. |
Source |
{ Type:string, Ref:string } |
e.g., event:metering, db:catalog, blob://…. |
Schedule? |
string? |
CRON or schedule definition for batch. |
WatermarkColumn? |
string? |
For incremental loads (e.g., updated_at). |
LastWatermark? |
string? |
Last processed value (ISO datetime/ULID). |
LateArrivalWindow |
TimeSpan |
Period to accept late data (re-processing rules apply). |
Retries |
{ Max:int, Backoff:string } |
Retry policy. |
OwnerTeam |
string |
On-call owner for failures. |
LastRunAtUtc? / NextRunAtUtc? |
DateTime? |
Planner/monitoring. |
DlqRef? |
string? |
Dead-letter sink for bad records. |
Value objects¶
SchemaVersion (VO) — { Major:int, Minor:int, Patch:int } (compare rules: any change to PrimaryKey/types → Major; additive columns → Minor; doc-only tweaks → Patch).
RetentionPolicy (VO) — { Mode:"Time"|"Rows"|"Hybrid", Duration?:TimeSpan, Rows?:long, LegalHold?:bool }
PartitionSpec (VO) — { Keys:string[], Strategy:"Range"|"Hash"|"Composite" }
LineageRef (VO) — { UpstreamKey:string, Transform:string, Columns?:string[] }
Enums¶
| Enum | Members |
|---|---|
DatasetType |
Raw, Curated, Mart, Semantic |
StorageFormat |
Parquet, Iceberg, Delta, BigQuery, View |
JobKind |
Batch, Stream, Backfill |
JobStatus |
Idle, Running, Succeeded, Failed, Degraded, Disabled |
ContractStatus |
Draft, Published, Deprecated |
ReportStatus |
Draft, Published, Deprecated |
DataClassification |
Public, Internal, Confidential, Restricted |
Invariants & policies¶
- Contracts govern schemas
Breaking schema changes (type/primary key changes, column removal/rename) require
SchemaVersion.Major++and a new dataset key or table with dual-write/swap. - Retention enforcement
Apply
RetentionPolicyat storage (e.g., Iceberg/Delta VACUUM) and at query endpoints; align with Audit/Compliance retention. - Tenant isolation
If
TenantScoped=true, the dataset MUST include atenantIdfield, and endpoints must enforce RLS (filter by caller’s tenant). - PII minimization
Columns marked
Pii=truerequire hashing/tokenization atRawor removal fromCurated/Martunless explicitly allowed by policy. - Idempotent ingest
Batch jobs use
Watermark+ upsert/merge keyed byPrimaryKey. Stream jobs dedupe by event id to achieve at-least-once → exactly-once semantics. - Late data
Records within
LateArrivalWindowtrigger re-aggregation/re-merging; outside the window go to DLQ for manual handling. - Reproducibility
Reports pin
SchemaVersion; rendering must fail fast if the dataset contract advances a major.
Domain events¶
dataset.published—{ datasetKey, schemaVersion, type, classification, retention }dataset.schema.changed—{ datasetKey, from:version, to:version, breaking:boolean, notes }dataset.deprecated—{ datasetKey, sunsetAfterUtc }report.published—{ reportKey, datasetKey, schemaVersion, parameters[] }report.deprecated—{ reportKey }ingest.job.started|succeeded|failed—{ jobId, datasetKey, kind, watermark?, counts, error? }
Events are minimal; payloads avoid large schemas, referring to keys/versions instead.
APIs / projections¶
Read endpoints
- SQL/Presto/Trino gateway (read-only): enforces RLS; exposes views
{env}.{domain}_{datasetKey}_v{major}with access control. - Semantic API:
Query(reportKey, parameters)returns typed frames for BI and programmatic use. - Metrics API: job health, lag, DLQ counts.
Commands
PublishDataset(datasetKey, contractKey@version, storage, partitioning, retention, tenantScoped, tags)UpdateContract(contractKey, nextVersion, schema diff, qualityRules)(validations auto-derivebreaking)RegisterIngestJob(datasetKey, source, kind, schedule?, watermark?, retries, lateWindow, ownerTeam)SetDatasetClassification(datasetKey, classification)PublishReport(reportKey, datasetKey, schemaVersion, parameters, accessRoles)
Projections
DatasetCatalog
{ datasetKey, displayName, type, schemaVersion, storage:{catalog,schema,table,format},
classification, retention, tenantScoped, ownerTeam, tags }
JobRunView(jobId) — last N runs, status, watermarks, row counts, errors.
* ReportCatalog — reports by dataset, with parameter metadata.
ORM & storage notes (metadata store)¶
- Aggregates
Datasetroot referencesDataContractby key/version; ownsPartitionSpec,RetentionPolicy.DataContractroot owns schema columns and quality rules (child rows or JSON).Reportroot pins dataset key and schema version.
- Indexes
- Unique on
Dataset(Key)and(DataContract.Key, SchemaVersion); filtered unique onReport(Key). - Secondary indexes on
Dataset(Type, Classification),Job(DatasetKey, Status).
- Unique on
- Change control
- Contract updates require owner approval; publishing emits
dataset.published. - Migrations (major bumps) create new physical tables or versions and optionally backfill.
- Contract updates require owner approval; publishing emits
Lineage & quality¶
- Capture upstream
LineageRefon datasets (e.g.,metering.events→billing.usage_daily). - Quality rules run in jobs; failures fail the job (for
Error) or log/warn (forWarn) and push to DLQ. - Expose Data Lineage View for traceability (edges: contract key/version → dataset key → report key).
Security & compliance¶
- Enforce RLS for tenant datasets; only internal roles may query cross-tenant aggregated marts, with anonymization where required.
- Mask or tokenize PII at rest; audit all query access to
Restricteddatasets. - Support right-to-erasure workflows: locate rows by tenant/user key and delete across Raw/Curated/Mart with proofs.
Example: version bump workflow¶
- Propose schema change →
UpdateContract(createsv2.0.0, marks breaking=true). - Create
datasetKey_v2(or same key withSchemaVersion.Major=2and new physical table). - Dual-write ingest (v1 & v2) until confidence threshold.
- Republish dependent Reports pinned to
v2. - Deprecate
v1withsunsetAfterUtc, then archive perRetentionPolicy.
This context provides the governance backbone for analytical data: explicit contracts and versions, idempotent ingest with late data handling, strict retention and classification, and version-pinned reports—so your platform can deliver trustworthy metrics and dashboards without coupling execution to any single storage engine.
Marketplace & Integrations Context¶
Relationships¶
- IntegrationApp HasMany Connectors One App (a vendor offering) can expose multiple Connectors (e.g., “CRM Sync”, “Slack Notifier”).
- Installation Reference Tenant + Connector An Installation is the tenant-scoped binding of a Connector, including auth state and config. Uniqueness: (tenantId, connectorId).
- Connector References OAuthClient + WebhookConfig Connector may require OAuth and/or webhooks; secrets live in Secrets & Provider Credentials via references.
Types in this context¶
- Aggregates:
IIntegrationApp,IConnector,IInstallation - Entities/VOs:
IOAuthClient(entity),PermissionGrant(VO),WebhookConfig(VO),EndpointRef(VO) - Enums (suggested):
AppStatus,ConnectorStatus,InstallationStatus,ConnectorAuthType,WebhookSignatureAlgorithm,GrantEffect
Cross-context refs to Tenant and Access enumerations use IDs/keys only. All sensitive material (client secrets, webhook signing keys) is stored indirectly via SecretBundle refs.
IIntegrationApp — properties (contract)¶
| Property | Type | Description & rules |
|---|---|---|
IntegrationAppId |
Guid |
Aggregate id. |
Key |
string |
Canonical slug (acme.crm). Unique per environment. |
DisplayName |
string |
Marketplace card title. |
Description |
string |
What the app does, data flow summary (ingress/egress). |
VendorName |
string |
Publisher shown in marketplace. |
HomepageUrl |
Uri? |
Vendor site. |
PrivacyUrl |
Uri? |
Policy link (required for publish). |
SupportUrl |
Uri? |
Support link. |
LogoAssetRef |
string? |
Asset id from Content/Branding. |
Categories |
IReadOnlyList<string> |
e.g., “CRM”, “Messaging”. |
RequiredScopes |
IReadOnlyList<PermissionGrant> |
Must map to AccessScope/Type; least privilege. |
Status |
AppStatus (Draft,Published,Deprecated,Retired) |
Lifecycle; publish allowed only with policy checks. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
Tags |
IReadOnlyList<KeyValueTag> |
Search facets. |
IConnector — properties (aggregate)¶
| Property | Type | Description & rules |
|---|---|---|
ConnectorId |
Guid |
Aggregate id. |
AppKey |
string |
Reference to parent App by key. |
Key |
string |
Connector slug (crm.sync, slack.notify). |
DisplayName |
string |
Human label. |
Description |
string |
Feature description & required permissions. |
Status |
ConnectorStatus (Draft,Published,Deprecated) |
Lifecycle. |
AuthType |
ConnectorAuthType (OAuth2,APIKey,None) |
Required authorization to bind tenant. |
OAuthClient? |
IOAuthClient? |
Present when AuthType=OAuth2. Secrets via SecretBundle key. |
Webhook |
WebhookConfig (VO) |
Outbound webhooks (to tenant or vendor) including signing. |
Permissions |
IReadOnlyList<PermissionGrant> |
Minimal permissions this connector needs (AccessScope/Type mapping). |
DataEgressDomains |
IReadOnlyList<string> |
Allowed external domains; enforced by residency/provider policy. |
Endpoints |
IReadOnlyList<EndpointRef> |
Connector APIs/base URLs by region. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
IOAuthClient (entity)
{ ClientId:string, SecretBundleKey:string, AuthUrl:Uri, TokenUrl:Uri, Scopes:string[], RedirectUris:Uri[], ExtraParams?:IDictionary<string,string> }
(SecretBundleKey points to Secrets context; no plaintext stored.)
WebhookConfig (VO)
{ CallbackUrl:Uri, SignatureAlgorithm:WebhookSignatureAlgorithm (HmacSha256|HmacSha512), SecretBundleKey:string, ReplayWindowSeconds:int (e.g., 300), RetryPolicy:{ Max:int, Backoff:string } }
EndpointRef (VO)
{ RegionCode:string, BaseUrl:Uri }
PermissionGrant (VO)
{ AccessScope:string, AccessType:string, Effect:GrantEffect (Allow|Deny), ConditionExpression?:string }
(AccessScope/Type map to your enumerations.)
IInstallation — properties (aggregate)¶
| Property | Type | Description & rules |
|---|---|---|
InstallationId |
Guid |
Aggregate id. |
TenantId |
Guid |
Owning tenant. |
ConnectorId |
Guid |
Connector being installed. |
Status |
InstallationStatus (Pending,Active,Suspended,Revoked,Uninstalled) |
Lifecycle. |
RegionCode |
string |
Must comply with tenant residency and connector endpoints. |
AuthorizedAtUtc? |
DateTime? |
When OAuth/API key validated. |
RevokedAtUtc? |
DateTime? |
When deauthorized. |
Config |
IDictionary<string,string> |
Non-secret config (e.g., channel id). |
SecretRefs |
IReadOnlyList<string> |
Secret bundle keys (e.g., per-tenant API keys). |
ScopesGranted |
IReadOnlyList<PermissionGrant> |
Effective grants at installation time. |
WebhookDeliveryState |
{ LastDeliveryAtUtc?:DateTime, Failures:int } |
Telemetry for reliability. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
Enums¶
| Enum | Members |
|---|---|
AppStatus |
Draft,Published,Deprecated,Retired |
ConnectorStatus |
Draft,Published,Deprecated |
InstallationStatus |
Pending,Active,Suspended,Revoked,Uninstalled |
ConnectorAuthType |
OAuth2,APIKey,None |
WebhookSignatureAlgorithm |
HmacSha256,HmacSha512 |
GrantEffect |
Allow,Deny |
Invariants & policies¶
- Scope mapping
PermissionGrant.AccessScope/Typemust map to the enumeration set; deny publish if any unknown/over-broad scopes are requested. - Residency & provider allowlist
Connector
DataEgressDomainsmust pass the region’s Residency/Provider policy; installations must use endpoints allowed for the tenant’sRegionCode. - Secret hygiene OAuth client secrets and webhook signing keys are held by SecretBundle only; events/logs carry no secrets. Rotation via Secrets context.
- Unique installation
Enforce uniqueness
(TenantId, ConnectorId)for Active/Pending; allow historicalUninstalledrecords for audit. - Webhook integrity
Validate signatures using
HMACover a canonical string{timestamp}.{eventId}.{body}; enforceReplayWindowSecondsto reject replays. - Deauthorization
Revokedinstallations must stop sending/receiving data immediately; purge tokens/secrets and emitconnector.revoked. - Least privilege Granted permissions are the intersection of Connector permissions and App-required scopes and may be further reduced by tenant policy.
Domain events¶
app.published—{ appKey, displayName, categories, requiredScopes[] }app.deprecated—{ appKey, sunsetAfterUtc }connector.published|deprecated—{ connectorId, appKey, key, authType }installation.created—{ installationId, tenantId, connectorId, regionCode, status }connector.authorized—{ installationId, authorizedAtUtc, authType }connector.revoked—{ installationId, revokedAtUtc, reason }installation.deleted—{ installationId, tenantId }webhook.delivered|failed—{ installationId, deliveryId, code, durationMs }
(Events include ids/keys and timestamps; no secrets or PII besides tenantId.)
Flows¶
Install (OAuth2)
- Admin selects Connector →
CreateInstallation(tenantId, connectorId)→Status=Pending. - Redirect to provider auth (
OAuthClient), receivecodeon redirect URI. - Exchange
codeusing SecretBundle client secret → store token SecretBundleKey inInstallation.SecretRefs; setAuthorizedAtUtc,Status=Active. - Emit
connector.authorizedandinstallation.created.
Install (API Key)
- Store API key in SecretBundle; keep only the reference in Installation.
Uninstall / Revoke
- Revoke tokens/keys, rotate webhook secret if shared, set
Status=UninstalledorRevoked; emitconnector.revoked.
Webhook delivery
- Sign with
WebhookConfigsecret; include headers:X-Signature,X-Timestamp,X-Event-Id. Retries with exponential backoff; stop afterMaxattempts and emitwebhook.failed.
APIs / projections¶
Commands
RegisterApp(appKey, displayName, description, vendor, links…, requiredScopes[])PublishApp(appKey)/DeprecateApp(appKey, sunsetAfterUtc)RegisterConnector(appKey, key, authType, permissions[], endpoints[], webhook?, oauthClient?)PublishConnector(connectorId)/DeprecateConnector(connectorId)CreateInstallation(tenantId, connectorId, regionCode, config?)AuthorizeConnector(installationId, authPayload)(OAuth code/API key ref)RevokeConnector(installationId, reason)UpdateInstallationConfig(installationId, config)RotateWebhookSecret(connectorId)(roll signing key with overlap window)DeleteInstallation(installationId)(soft-delete/archive)
Reads / Projections
MarketplaceCatalog(cards)
{ appKey, displayName, vendor, categories[], logoAssetRef?,
connectors:[{ connectorId, key, displayName, authType, status, categories? }],
rating?: { average, count }, tags[] }
ConnectorDetails(connectorId) — permissions, endpoints, webhook info (redacted), install pre-checks (residency/providers).
* InstallationView(installationId) — status, config (non-secret), scopes granted, last webhook delivery stats.
* TenantInstallations(tenantId) — list + health.
ORM notes¶
- Aggregates
IntegrationApproot;Connectorroot referencingAppKey.Installationroot linking toTenantId&ConnectorId; do not FK to Tenant table across contexts (store scalar ID).OAuthClientas child entity underConnector;WebhookConfigas owned VO.
- Uniqueness & indexes
- Unique
(Key)perIntegrationApp. - Unique
(AppKey, Key)perConnector. - Unique filtered
(TenantId, ConnectorId)for active/pending installations. - Index
Installation(Status, UpdatedAtUtc DESC)for admin views.
- Unique
- Secrets
- Only store
SecretBundleKeystrings for OAuth/webhook; never store plaintext tokens.
- Only store
Integration touchpoints¶
- Secrets: client secrets, access tokens, webhook signing secrets rotate centrally; connectors listen for rotation and refresh.
- Data Residency & Routing: validate
RegionCodeandDataEgressDomainsbefore activating an installation; block if policy denies provider endpoints. - Notifications: surface install success/failure to tenant Owner/Tech contacts; send webhook delivery failures to on-call.
- Audit: every install/authorize/revoke event produces an audit record with
traceId; webhook deliveries produce append-only audit entries (sans payload, with hashes).
Security & compliance¶
- Enforce least privilege: permissions presented to the tenant admin explicitly; default-deny.
- Require signed webhooks with short replay windows and idempotency keys; verify timestamps drift.
- Support right-to-revocation: tenant admin can revoke installations at any time; system purges tokens/refresh tokens immediately.
- Implement scoped rate limits per installation for webhook/API interactions to protect the platform and vendors.
This context enables a safe, governable marketplace with explicit permissions, strong residency controls, secret hygiene, and operationally solid webhook/auth flows—cleanly integrated with your existing Access, Secrets, Tenant, and Routing contexts.
Workflow Orchestration (Deterministic) Context¶
Relationships¶
- WorkflowDefinition HasMany Triggers & Tasks
A definition is a versioned, deterministic DAG of
Tasks, plus one or moreTriggers(CRON/HTTP/Event/Manual). - RunInstance HasMany TaskExecutions Each run materializes the DAG with task-state, attempts, outputs, and approvals.
- Tasks Reference CompensationSpec / ApprovalGate Side-effecting tasks declare compensations; gated tasks declare approvals.
Types in this context¶
- Aggregates:
IWorkflowDefinition,IRunInstance,ITrigger - Entities/VOs:
IDefinitionTask(entity),ITaskExecution(entity),ApprovalGate(VO),CompensationSpec(VO),RetryPolicy(VO),TimeoutPolicy(VO),OwnerRef(VO) - Enums:
RunState,TaskStatus,TaskKind,TriggerType,ApprovalOutcome
Cross-context calls are executed by activities that must be idempotent and authorized via Access Rules. No AI tools here—this is the deterministic engine.
IWorkflowDefinition — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
WorkflowDefinitionId |
Guid |
Aggregate id. |
Key |
string |
Slug (e.g., provision.tenant.v3). Unique per environment. |
Version |
int |
Monotonic version. Publishing creates a new immutable version. |
DisplayName |
string |
Human label. |
Description |
string |
Purpose and business guarantees. |
Owner |
OwnerRef (VO) |
Team/rotation responsible for the workflow. |
Tasks |
IList<IDefinitionTask> |
Deterministic DAG (topologically valid). |
Triggers |
IList<ITrigger> |
CRON/HTTP/Event triggers (see below). |
DefaultRetry |
RetryPolicy (VO) |
Fallback for tasks without explicit retry. |
DefaultTimeout |
TimeoutPolicy (VO) |
Global activity timeout bounds. |
Parameters |
IReadOnlyList<{Name:string,Type:string,Required:bool,Default?:string}> |
Run-time inputs. |
State |
string (Draft,Published,Deprecated) |
Lifecycle; only Published can start new runs. |
CreatedAtUtc/UpdatedAtUtc |
DateTime |
Timestamps. |
IDefinitionTask (entity)
| Field | Type | Notes |
|---|---|---|
TaskKey |
string |
Unique within definition. |
Kind |
TaskKind (Activity,Timer,Gate,SubWorkflow) |
Execution semantics. |
Action |
string |
Activity slug (e.g., http.post, kv.put, billing.prorate). Empty for Gate/Timer. |
Parameters |
IDictionary<string,string> |
Templated with inputs/outputs ({{run.input.tenantId}}). |
DependsOn |
IReadOnlyList<string> |
Upstream TaskKeys. Must not create cycles. |
Retry? |
RetryPolicy? |
Overrides default retry. |
Timeout? |
TimeoutPolicy? |
Overrides default timeout. |
Approval? |
ApprovalGate? |
Required when Kind=Gate. |
Compensation? |
CompensationSpec? |
How to undo this task’s side effects. |
IdempotencyKeyExpr? |
string? |
Deterministic expression for activity idempotency (e.g., {{run.id}}:{{TaskKey}}). |
ITrigger — properties¶
| Property | Type | Description |
|---|---|---|
TriggerId |
Guid |
Identifier. |
Type |
TriggerType (Cron,Http,Event,Manual) |
Trigger kind. |
Cron? |
string? |
CRON expression (Cron only). |
Path? |
string? |
HTTP path (Http only). |
EventKey? |
string? |
Event routing key (Event only). |
AuthScope |
string |
Access scope required to invoke (Http/Manual). |
Enabled |
bool |
Toggle. |
IRunInstance — properties¶
| Property | Type | Description & rules |
|---|---|---|
RunInstanceId |
Guid |
Aggregate id. |
DefinitionKey / Version |
string / int |
The published definition snapshot. |
CorrelationId |
string |
For dedupe and cross-system tracing. |
State |
RunState (Pending,Running,Completed,Failed,Canceled,Compensating,Compensated) |
Run lifecycle. |
Input |
IDictionary<string,string> |
Immutable inputs, validated against schema. |
Outputs |
IDictionary<string,string> |
Final run outputs (composed from tasks). |
StartedAtUtc / CompletedAtUtc? |
DateTime |
Timeline. |
TaskExecutions |
IList<ITaskExecution> |
Materialized DAG state. |
Owner |
OwnerRef (VO) |
Current operator/queue (for approvals, manual actions). |
ITaskExecution (entity)
| Field | Type | Notes |
|---|---|---|
TaskKey |
string |
Link to definition. |
Status |
TaskStatus (Pending,Running,Succeeded,Failed,WaitingApproval,TimedOut,Skipped,Compensated) |
State machine. |
Attempt |
int |
Attempt counter. |
StartedAtUtc? / EndedAtUtc? |
DateTime? |
Timing. |
Output |
IDictionary<string,string> |
Small structured outputs (ids, refs). |
Error? |
{ Code:string, Message:string, Retryable:bool }? |
Last error. |
ApprovalOutcome? |
ApprovalOutcome? |
Set for gates. |
ChildRunId? |
Guid? |
For SubWorkflow tasks. |
IdempotencyKey |
string |
Effective key used by the executor. |
Value objects¶
ApprovalGate (VO) — { Approvers:[OwnerRef], Rule:"any|all|quorum:n", Timeout:TimeSpan, EscalateTo?:OwnerRef }
CompensationSpec (VO) — { Action:string, Parameters:IDictionary<string,string>, When:"on-failure"|"always" }
RetryPolicy (VO) — { MaxAttempts:int, Backoff:"exponential|fixed", InitialDelay:TimeSpan, Jitter:boolean }
TimeoutPolicy (VO) — { Soft:TimeSpan, Hard:TimeSpan }
OwnerRef (VO) — { Team:string, OnCallRotation?:string, AgentId?:string }
Enums¶
| Enum | Members |
|---|---|
RunState |
Pending,Running,Completed,Failed,Canceled,Compensating,Compensated |
TaskStatus |
Pending,Running,Succeeded,Failed,WaitingApproval,TimedOut,Skipped,Compensated |
TaskKind |
Activity,Timer,Gate,SubWorkflow |
TriggerType |
Cron,Http,Event,Manual |
ApprovalOutcome |
Approved,Rejected,Expired |
Invariants & policies¶
- Deterministic DAG:
Tasksmust be cycle-free;Parametersare pure functions of inputs/previous outputs. No network time or randomness in definitions. - Exactly-once activities: Every
Activityresolves an idempotency key; executors must use provider idempotency (e.g.,Idempotency-Keyheader) and handle at-least-once delivery. - Authorization: Triggers and activities evaluate Access Rules (scope/type).
Httptriggers require signed requests;Eventtriggers verify origin. - Time discipline:
Softtimeouts mark tasks for cancellation;Hardenforces termination. Timeouts generate compensations when configured. - Approvals:
Gatetasks block untilApproved/Rejected/Expired; SLA on approvals can raise incidents or escalate perApprovalGate. - Compensation: If the run fails and a task has
CompensationSpec, the engine executes compensations in reverse topological order for succeeded side-effects.
Domain events¶
workflow.published—{ key, version, taskCount, triggerCount }workflow.deprecated—{ key, version }run.started—{ runId, key, version, correlationId }task.started|succeeded|failed|timed_out—{ runId, taskKey, attempt, idempotencyKey, timings }gate.waiting—{ runId, taskKey, approvers[], deadlineUtc }gate.approved|rejected|expired—{ runId, taskKey, outcome, who?, whenUtc }run.completed|failed|canceled—{ runId, key, version, outputs? }run.compensating|compensated—{ runId, compensatedTasks }
Events include IDs, keys, and small metadata only—no secret payloads. Correlate with
correlationIdandtraceId.
APIs / projections¶
Commands
PublishWorkflow(defKey, version, tasks[], triggers[], defaults)DeprecateWorkflow(defKey, version)StartRun(defKey@version, input, correlationId?)CancelRun(runId, reason)Approve(runId, taskKey, approver)/Reject(runId, taskKey, approver)Signal(runId, taskKey, payload)(for event-driven tasks)RetryTask(runId, taskKey)(if retryable and within limits)
Reads
RunTimelineView(runId)
{ runId, key, version, state, startedAtUtc, completedAtUtc?,
tasks:[{ taskKey,status,attempt,startedAtUtc?,endedAtUtc?,error?,approvalOutcome?,childRunId? }],
outputs, correlationId }
WorkflowCatalog — definitions, versions, triggers, owners.
* PendingApprovalsView(owner/team) — gates awaiting action with deadlines.
* ExecutionMetrics — success rates, durations, retries per task.
Executor design (handler guidance)¶
- Planner loads
Definition+ validates inputs → materializesTaskExecutionsinPending. - Scheduler picks runnable tasks (all
DependsOnsucceeded), acquires a short lease, setsRunning, computesIdempotencyKey, executes with retry policy, writes outputs, and transitions on success/failure/timeout. - Gatekeeper manages approvals (emails/Slack/portal), updates status on decision/expiry.
- Compensator builds reverse plan when failing and executes
CompensationSpecactivities with their own idempotency keys. - Idempotency: executor must be restart-safe; replays use the same
IdempotencyKeyand become no-ops at providers.
ORM notes¶
- Aggregates
WorkflowDefinitionroot;DefinitionTaskandTriggeras child tables (FKDefinitionId), immutable oncePublished.RunInstanceroot;TaskExecutionchild table with(RunId, TaskKey)unique.
- Indexes
RunInstance(State, StartedAtUtc DESC)for dashboards.TaskExecution(RunId, Status)for picking next work.Trigger(Type, Enabled)for schedulers;Http(Path)unique.
- Constraints
- Check DAG acyclicity on publish (server-side validation).
- Enforce
Versionimmutability after publish.
Integration touchpoints¶
- Access Rules: triggers and activities check
AccessScope/Typebefore executing. - Secrets: activities receive secret references only; engine resolves via Secrets context with short-lived tokens.
- Provisioning / Billing / Notifications: common activity adapters (
provisioning.start,billing.create_invoice,notify.send) executed idempotently. - Observability: emit metrics for
task.duration,task.retries,run.state; integrate with Incident Mgmt for chronic failures/timeouts.
Security & compliance¶
- Signed triggers: HTTP requires HMAC signatures + timestamp; Event triggers validate origin & schema.
- Audit-everything: publish audit records for run/approval/compensation transitions with
traceId. - Least privilege: service principals executing activities have scoped, time-bound grants.
- PII-safety: inputs/outputs are metadata; large or sensitive payloads move through content stores by reference.
This context gives you a deterministic, idempotent workflow engine with first-class approvals and compensations, clean DDD boundaries, and operational guardrails that make automations safe and auditable across the platform.
Feature Flagging & Experimentation (Runtime) Context¶
Relationships¶
- FlagDefinition HasMany TargetingRules Rules implement audience targeting, percentage rollouts, and variant allocation.
- Experiment References FlagDefinition Experiments attach to a flag (boolean or multivariate) and control who gets which variant for a period.
- Experiment HasMany Exposures (append-only) Each exposure records the resolved assignment at evaluation time for analytics.
Types in this context¶
- Aggregates:
IFlagDefinition,IExperiment,ITargetingRule - Entities/VOs:
IExposure(entity),EvaluationContext(VO),BucketingKey(VO),Variant(VO),OwnerRef(VO),Guardrail(VO) - Enums:
FlagStatus,FlagType,ExperimentStatus,RunMode,UnitType
This context is runtime-first: sub-millisecond evaluation, edge-cache friendly, and entitlement/residency aware. Cross-context references are keys/ids only.
IFlagDefinition — properties (contract + semantics)¶
| Property | Type | Description & rules |
|---|---|---|
FlagId |
Guid |
Aggregate id. |
Key |
string |
Canonical slug ("billing.api.rate_limit.new_eval"). Immutable once Active. |
DisplayName |
string |
Human label. |
Description |
string |
What this flag controls and safety notes. |
Type |
FlagType (Boolean,Multivariate,JSONConfig) |
Evaluation output domain. |
DefaultVariant |
Variant (VO) |
Fallback when no rules/experiments match. Must be one of Variants. |
Variants |
IReadOnlyList<Variant> |
Allowed outputs (e.g., on/off, A/B, or JSON blobs for config). |
Rules |
IList<ITargetingRule> |
Ordered top-to-bottom. First match wins unless ContinueIf specified. |
KillSwitch |
bool |
If true, force DefaultVariant for everyone (used during incidents). |
Status |
FlagStatus (Draft,Active,Deprecated,Killed) |
Lifecycle. |
Owner |
OwnerRef (VO) |
Team/rotation accountable for the flag. |
Tags |
IReadOnlyList<KeyValueTag> |
Domain, product, residency risk level. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
Variant (VO) — { Key:string, Value?:string }
(For JSONConfig, Value is a small JSON string validated by a stored schema.)
ITargetingRule — properties (aggregate)¶
| Property | Type | Description & rules |
|---|---|---|
RuleId |
Guid |
Aggregate id. |
Priority |
int |
Evaluation order (lower = earlier). |
Name |
string |
Human label ("EU tenants 10% rollout"). |
Condition |
string |
CEL/OPA-like expression over EvaluationContext (see below). |
UnitType |
UnitType (User,Tenant,Session) |
Bucketing unit for stickiness. |
Allocation |
IReadOnlyList<{ VariantKey:string, Percent:int }> |
Must sum to 100 when used with bucketing. |
BucketSalt |
string |
Salt for deterministic hashing. Changing it re-buckets the population. |
ContinueIf |
bool |
When true, continue evaluating next rules (multi-hit use cases); default false. |
EffectiveDate / ExpiryDate? |
DateTime / DateTime? |
Temporal window (UTC). |
ResidencyConstraint? |
{ RegionCodes:string[] }? |
Optional additional residency guard. |
EntitlementConstraint? |
{ EditionKeys?:string[], FeatureKeys?:string[] }? |
Ensure rule never grants beyond entitlements. |
Status |
string (Draft,Active,Paused) |
Operational toggle. |
UpdatedAtUtc |
DateTime |
For cache invalidation. |
IExperiment — properties (aggregate)¶
| Property | Type | Description & rules | ||
|---|---|---|---|---|
ExperimentId |
Guid |
Aggregate id. | ||
Key |
string |
Slug ("checkout-new-flow-ab-v4"). |
||
FlagKey |
string |
Target flag (by key). Flag Type and experiment Variants must be compatible. |
||
DisplayName |
string |
Human label. | ||
Hypothesis |
string |
Short statement of expected outcome. | ||
UnitType |
UnitType |
Bucketing unit—must match the main targeting rule for consistency. | ||
Namespace |
string |
Optional global namespace to avoid overlap with other experiments. | ||
Allocation |
IReadOnlyList<{ VariantKey:string, Percent:int }> |
A/B(/n) split. Sums to 100. | ||
SampleFraction |
double (0–1] |
Fraction of eligible population to include (e.g., 0.5 for 50%). | ||
BucketingKey |
BucketingKey (VO) |
Deterministic key derivation expression. | ||
Metrics |
IReadOnlyList<{ Key:string, Kind:"Primary" | "Secondary", Direction:"Increase" | "Decrease" }> |
For reporting only (no stats engine here). | ||
Guardrails |
IReadOnlyList<Guardrail> |
Stop conditions (e.g., error rate > x%). | ||
ResidencyConstraint? |
{ RegionCodes:string[] }? |
Additional constraint; default inherits from flag/rule. | ||
EntitlementConstraint? |
{ EditionKeys?:string[], FeatureKeys?:string[] }? |
Tenant must be entitled or excluded. | ||
RunMode |
RunMode (Shadow,Live) |
Shadow evaluates but does not expose variant to client. |
||
Status |
ExperimentStatus (Draft,Running,Paused,Stopped,Completed) |
Lifecycle. | ||
StartAtUtc / EndAtUtc? |
DateTime / DateTime? |
Schedule. | ||
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
BucketingKey (VO) — { Expr:string, Salt:string, Algo:"murmur3"|"xxhash" }
(Example Expr: "tenantId" for tenant stickiness or "tenantId:userId" for user stickiness under tenant.)
Guardrail (VO) — { MetricKey:string, Threshold:double, Window:TimeSpan, Action:"Pause"|"Stop" }
EvaluationContext (VO)¶
| Field | Type | Description |
|---|---|---|
TenantId |
Guid? |
Optional for anonymous flows; required for tenant-scoped flags. |
UserId |
Guid? |
Optional; use when UnitType=User. |
SessionId |
string? |
For session bucketing. |
RegionCode |
string? |
Used for residency constraints. |
EditionKey? |
string? |
Current tenant edition snapshot. |
EntitledFeatureKeys? |
IReadOnlyList<string>? |
Effective entitlements (from Config/Identity projection). |
Attributes |
IDictionary<string,string> |
Arbitrary attributes (e.g., appVersion, device, locale). |
RequestTimeUtc |
DateTime |
Server-evaluated current time (no client manipulation). |
Security note: No raw PII is required for evaluation; stickiness uses opaque ids.
IExposure — properties (entity, append-only)¶
| Property | Type | Description & rules |
|---|---|---|
ExposureId |
string (ULID) |
Row id; ensures global ordering per time. |
ExperimentId |
Guid |
What experiment this exposure belongs to. |
FlagKey |
string |
For convenience in analytics. |
UnitType |
UnitType |
Bucketing unit used. |
UnitId |
string |
Hashed unit identifier (tenantId, userId, or sessionId). |
TenantId? |
Guid? |
For tenant-scoped analytics. |
VariantKey |
string |
Assigned variant. |
ContextHash |
string |
Hash of attributes used in decision (tamper defense). |
EvaluatedAtUtc |
DateTime |
Timestamp. |
RequestId? |
string? |
Optional correlation id. |
Enums¶
| Enum | Members |
|---|---|
FlagStatus |
Draft,Active,Deprecated,Killed |
FlagType |
Boolean,Multivariate,JSONConfig |
ExperimentStatus |
Draft,Running,Paused,Stopped,Completed |
RunMode |
Shadow,Live |
UnitType |
User,Tenant,Session |
Invariants & policies¶
- Precedence (highest → lowest):
KillSwitch→ Tenant override → Edition default → Product default → Experiment allocation →DefaultVariant. (Tenant overrides live in Config context; the evaluator consumes the projection.) - Entitlement guard
A rule/experiment must not assign a variant that enables a Feature the tenant/user is not entitled to. If violated, evaluator must fallback to an entitled variant or
DefaultVariant. - Residency guard
Evaluations must respect regional constraints: if
RegionCodenot in allowed set, fallback (no exposure recorded). - Determinism & stickiness
Same
(UnitId, FlagKey, Salt)→ same variant across evaluations until allocation changes. - Allocation validity
Percentages in an
Allocationsum to 100; no negative or zero for all variants. - Temporal windows
Rules and experiments apply only within
[EffectiveDate, ExpiryDate)/[StartAtUtc, EndAtUtc). - Killed flags
Status=KilledforcesDefaultVariantand bypasses experiments—used during incidents. - Privacy
Do not store raw
UserId/SessionIdin exposures; store hashedUnitId. PII minimization by default.
Domain events¶
flag.created—{ flagId, key, type, variants[], defaultVariant }flag.changed—{ flagId, key, changes:{ rules?, variants?, status? } }flag.killed—{ flagId, key, reason }experiment.started—{ experimentId, key, flagKey, unitType, allocation, sampleFraction, startAtUtc }experiment.paused|stopped|completed—{ experimentId, key, atUtc, reason? }exposure.recorded—{ experimentId, flagKey, unitType, unitIdHash, variantKey, evaluatedAtUtc }
Events include metadata only; no raw context attributes beyond hashed unit keys.
APIs / projections¶
Low-latency Evaluate
Evaluate(flagKey, evaluationContext)→
{ variant:{ key, value? }, source:"Override|Rule|Experiment|Default",
debug?:{ matchedRuleId?, bucket?:{ unitType, unitIdHash, salt, pct } } }
EvaluateMany(flagKeys[], evaluationContext) → batch for edge efficiency.
Management
CreateFlag(key, type, variants[], defaultVariant, owner, tags?)PublishFlag(flagId)/KillFlag(flagId, reason)UpsertRule(flagKey, rule)/PauseRule(flagKey, ruleId)/DeleteRule(flagKey, ruleId)StartExperiment(flagKey, experiment)/PauseExperiment(experimentId)/StopExperiment(experimentId)/CompleteExperiment(experimentId)
Reads / Projections
FlagSnapshot(region)— compact, signed snapshot for edge nodes/CDN.ExperimentView(experimentId)— status, allocations, exposures, guardrail status.ExposureSummary(experimentId, window)— counts by variant, by region/edition/tenant segment.DecisionLog(flagKey, window)— sampled debug decisions (off by default).
Evaluator design (operational notes)¶
- Critical path: pure, allocation-compatible function:
- If
KillSwitch, return default. - Apply tenant overrides (from Config projection).
- Apply rules in priority order (predicate on
EvaluationContext). - If experiment
Runningand unit in sample, perform allocation viahash(bucketingKey + salt) % 100and pick variant. - Enforce entitlement/residency guards before returning.
- If
- Caching:
- Edge nodes keep
FlagSnapshot(ETag’ed, signed) with short TTL (e.g., 30s) and event-driven invalidation onflag.changed. - Decision results for hot keys can be memoized per
(flagKey, unitIdHash)for a few seconds.
- Edge nodes keep
- Exposures:
- Recorded asynchronously (fire-and-forget) to a write-optimized log (e.g., Kafka) → ingested to the Data Platform.
- Deduplicate on
(experimentId, unitIdHash)per window to avoid double counting.
ORM notes¶
- Aggregates
FlagDefinitionroot with childTargetingRulerows (ordered byPriority).Experimentroot referencingFlagKey.Exposureas append-only time-series table/stream (partition byexperimentIdortenantId).
- Indexes
FlagDefinition(Key)unique;TargetingRule(FlagId, Status, Priority);Experiment(FlagKey, Status);Exposure(ExperimentId, EvaluatedAtUtc)and optionally(ExperimentId, UnitId)for dedupe.
- Migrations
- Flag
Keyimmutable afterActive; variant set can only grow (removal requires deprecation cycle).
- Flag
Integration touchpoints¶
- Config & Entitlements: evaluator consumes tenant overrides and entitlement projection; never mutates them.
- Identity:
EvaluationContextmay include roles/claims for ABAC-style rules (as attributes). - Data Residency: region code from Routing; evaluator blocks cross-region activations where disallowed.
- Notifications: optional announcement when experiments start/stop (internal visibility).
- Data Platform: exposures stream into datasets for analysis; reports compute lift/guardrail breaches.
Security & compliance¶
- No PII in evaluation/exposures; use hashed/stable ids.
- Signed snapshots to prevent edge tampering; clients cannot set server-sourced attributes (e.g.,
RegionCode). - Audit changes to flags/rules/experiments with
traceId, author, and diffs. - Kill switch available to Incident Mgmt to instantly revert unsafe flags globally.
This context gives you a fast, safe, and compliant runtime for feature gating and experimentation—coexisting with Config flags, honoring entitlements and residency, and producing clean exposure data for trustworthy analysis.
Content & Branding Context¶
Relationships¶
- BrandPack HasMany Assets A brand pack is a versioned set of visual assets and theming VOs.
- ContentPack HasMany Localized entries A content pack holds localized strings and templates (email/UI copy).
- Tenant → BrandPack / ContentPack Packs can be tenant-scoped (override) or global defaults; lookup resolves with fallback (Tenant → Global) and locale fallback.
Types in this context¶
- Aggregates:
IBrandPack,IContentPack - Entities/VOs:
IAsset(entity),ILocalizedContent(entity),Locale(VO),Checksum(VO),ColorPalette(VO),TypographySpec(VO),TemplateRef(VO) - Enums:
PackStatus,AssetKind,TemplateKind,ContentFormat
Binary content lives in object storage; the domain stores metadata + pointers. Signed URLs are issued at read time.
IBrandPack — properties¶
| Property | Type | Description & rules |
|---|---|---|
BrandPackId |
Guid |
Aggregate id. |
Key |
string |
Slug (e.g., default.light.v1, tenant-acme.v2). Immutable after publish. |
TenantId? |
Guid? |
null for global; value for tenant-scoped override. |
DisplayName |
string |
Human label for admins/designers. |
Version |
int |
Monotonic; publishing increments; immutable once Published. |
Status |
PackStatus (Draft,Published,Deprecated) |
Lifecycle. |
DefaultLocale |
Locale (VO) |
Fallback locale (e.g., en-US). Must be in SupportedLocales. |
SupportedLocales |
IReadOnlyList<Locale> |
Locales this pack provides. |
Theme |
ColorPalette + TypographySpec |
Visual theme (owned VOs). |
Assets |
IList<IAsset> |
Logos/icons/illustrations/email partials, etc. |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
Tags |
IReadOnlyList<KeyValueTag> |
Metadata (brand, dark-mode, product area). |
ColorPalette (VO) — { Primary:string, OnPrimary:string, Secondary:string, OnSecondary:string, Background:string, Surface:string, Success:string, Warning:string, Danger:string } (hex #RRGGBB validated; contrast responsibility on UI layer).
TypographySpec (VO) — { FontFamily:string, FontUrls?:string[], BaseSizePx:int, Scale:{ H1:int, H2:int, H3:int, H4:int, H5:int, H6:int } }
IAsset — properties (child entity)¶
| Property | Type | Description & rules |
|---|---|---|
AssetId |
Guid |
Identity for updates/rotation. |
Kind |
AssetKind (LogoLight,LogoDark,Icon,Illustration,EmailPartial,Stylesheet) |
Controls placement/usage. |
Name |
string |
Human label. |
Locale? |
Locale? |
Optional locale for localized imagery (e.g., RTL logos). |
MimeType |
string |
Whitelist (e.g., image/svg+xml, image/png, text/css). |
SizeBytes |
long |
Hard-capped (e.g., ≤ 2 MB images, ≤ 200 KB CSS). |
Width? / Height? |
int? |
For raster assets; aids responsive layout. |
StorageUrl |
string |
Object storage URI (not directly public). |
Checksum |
Checksum (VO) |
Content hash for cache busting/integrity. |
CreatedAtUtc |
DateTime |
Upload time. |
Active |
bool |
Deactivate without deleting (for rollbacks). |
Checksum (VO) — { Algo:"sha256", Value:string }
Locale (VO) — { Code:string } (BCP-47; validated)
IContentPack — properties¶
| Property | Type | Description & rules |
|---|---|---|
ContentPackId |
Guid |
Aggregate id. |
Key |
string |
Slug (e.g., emails.core.v3, portal.copy.v2). Immutable after publish. |
TenantId? |
Guid? |
null for global; value for tenant-scoped overrides. |
DisplayName |
string |
Human label. |
Version |
int |
Monotonic; immutable once Published. |
Status |
PackStatus (Draft,Published,Deprecated) |
Lifecycle. |
DefaultLocale |
Locale |
Fallback locale. |
SupportedLocales |
IReadOnlyList<Locale> |
Locales present. |
Entries |
IList<ILocalizedContent> |
Localized content items (strings/templates). |
CreatedAtUtc / UpdatedAtUtc |
DateTime |
Timestamps. |
Tags |
IReadOnlyList<KeyValueTag> |
Metadata (channel, product). |
ILocalizedContent (entity)
| Field | Type | Notes |
|---|---|---|
ContentId |
Guid |
Identity. |
ContentKey |
string |
Stable key (e.g., email.welcome.subject). Unique per (Key, Locale). |
Locale |
Locale |
Language/region. |
Format |
ContentFormat (PlainText,Markdown,Html,Mjml,Json) |
Rendering pipeline. |
BodyRef |
TemplateRef (VO) |
Pointer to body (object storage) or inline string for small content. |
VariablesSchema? |
string? (JSON Schema) |
Required variables and types for rendering (for safety). |
Checksum |
Checksum |
Integrity/caching. |
CreatedAtUtc |
DateTime |
Timestamp. |
Active |
bool |
If false, entry ignored at resolve time. |
TemplateRef (VO) — { StorageUrl?:string, Inline?:string } (only one populated)
Enums¶
| Enum | Members |
|---|---|
PackStatus |
Draft,Published,Deprecated |
AssetKind |
LogoLight,LogoDark,Icon,Illustration,EmailPartial,Stylesheet |
TemplateKind |
Email,Notification,UiCopy |
ContentFormat |
PlainText,Markdown,Html,Mjml,Json |
Invariants & policies¶
- Tenant isolation
A tenant-scoped pack (
TenantId!=null) may only be resolved/served for that tenant; RLS enforced on reads. - Version immutability
Once
Status=Published, entries/assets in that version are immutable; changes require a new version. - Locale discipline
DefaultLocale ∈ SupportedLocales; missing locale resolution uses fallback:TenantPack(locale)→TenantPack(default)→GlobalPack(locale)→GlobalPack(default). - Size & type limits Enforce MIME allowlist and maximum sizes; raster dimensions must be within configured bounds.
- Sanitization
Html/Mjmlbodies are sanitized at render time; disallow inline scripts and remote JS. Images served via signed URLs. - Residency
Assets/content stored in a region aligned with
Tenant.RegionResidency; signed URLs are region-bound. - Variables safety
Rendering requires values conforming to
VariablesSchema; unknown variables ignored, missing required → render fails fast.
Domain events¶
brandpack.published—{ brandPackId, key, version, tenantId?, locales[], theme:{…} }brandpack.deprecated—{ brandPackId, key, version, sunsetAfterUtc }contentpack.published—{ contentPackId, key, version, tenantId?, locales[], entryCount }contentpack.updated—{ contentPackId, key, version, changes:{ added:int, updated:int, deactivated:int } }
(Events contain metadata only—no binaries or template bodies.)
APIs / projections¶
Commands
CreateBrandPack(key, tenantId?, defaultLocale, supportedLocales[], theme)AddAsset(brandPackId, kind, name, locale?, mimeType, sizeBytes, storageUrl, checksum, width?, height?)PublishBrandPack(brandPackId)/DeprecateBrandPack(brandPackId, sunsetAfterUtc)CreateContentPack(key, tenantId?, defaultLocale, supportedLocales[])PutContentEntry(contentPackId, contentKey, locale, format, bodyRef, variablesSchema?)PublishContentPack(contentPackId)/DeprecateContentPack(contentPackId)RotateAsset(brandPackId, assetId, storageUrl, checksum)(creates new pack version)
Reads / Projections
BrandingView(tenantId, locale)
{ theme:{ colors:{...}, typography:{...} },
logos:{ light:signedUrl, dark:signedUrl, icon:signedUrl },
locale:"en-US", source:{ tenantPackKey?, globalPackKey } }
ContentResolve(contentPackKey, contentKey, locale, variables) → { format, bodyRendered, subject? }
* SignedAssetUrl(assetId, ttl) → short-lived URL
* PackCatalog(tenantId?) — visible packs (tenant + global), versions, statuses.
ORM notes¶
- Aggregates
BrandPackroot with owned VOs (Theme,Locales);Assetchild table (FKBrandPackId).ContentPackroot withLocalizedContentchild table (FKContentPackId).
- Constraints & indexes
- Unique
(Key, Version, TenantId?)for packs. - Unique
(ContentPackId, ContentKey, Locale.Code)for entries. - Index
Asset(BrandPackId, Kind, Locale.Code)for fast logo/icon lookup. - Store
TemplateRef.Inlineonly for small payloads; otherwiseStorageUrlto object store.
- Unique
- No cross-context FKs
- Only scalar
TenantIdstored; other contexts consume projections.
- Only scalar
Integration touchpoints¶
- Notifications: requests
ContentResolvefor email subject/body (MJML → HTML), andBrandingViewfor correct logos/colors in templates. - Web Portal: initializes theme from
BrandingView; caches signed URLs (short TTL). - Marketplace: app/connector cards pull logos/icons via signed URLs.
- Secrets: signing keys for URL generation managed in Secrets & Provider Credentials.
- Data Residency & Routing: content delivery endpoints are region-aware; CDN caches are partitioned by region and tenant.
Security & compliance¶
- Signed URLs only (short-lived, single-use where needed); no public buckets for tenant assets.
- Sanitize HTML templates; block script tags/inline event handlers.
- Audit pack publishes/deplications with diffs and
traceId. - Right-to-erasure: avoid PII in templates/assets; if present, provide redaction/rotation paths in object storage.
Operator guidance¶
- Keep global default packs lean and neutral; push heavy assets (illustrations) behind feature flags in the portal.
- For high-contrast/dark mode support, ensure both
LogoLightandLogoDarkexist; portals switch based on media queries. - Version frequently and never mutate published packs; rely on cache-busting via
Checksum.
This context gives Notifications and Web a consistent, secure source of brand and content truth, with tenant isolation, regional compliance, robust versioning, and fast resolution APIs.