Policy decides how a tenant treats the same signals. Assessment evidence records why a particular decision was made at that time. Policy may change; historical evidence must not be reinterpreted.
1. One atomic JSON override
RiskControlSettingDefinitions contributes tenant setting riskcontrol.policy. One JSON value contains the complete score ranges and challenge map.
{ "scoreRanges": { "Low": { "min": 0, "max": 30 }, "Medium": { "min": 31, "max": 50 }, "High": { "min": 51, "max": 75 }, "Critical": { "min": 76, "max": 90 }, "Block": { "min": 91, "max": 100 } }, "challengeMap": { "Low": "None", "Medium": "Captcha", "High": "MFA", "Critical": "StepUp", "Block": "Block" }}The Settings subsystem resolves tenant override, global value, and code default. RiskControl then performs JSON and domain validation.
2. Policy invariants
Both config and domain policy require:
- exactly five known levels and one challenge for each;
- score ranges inside 0–100 with Min no greater than Max;
- first range beginning at zero and last ending at one hundred;
- adjacent closed ranges satisfying previous Max + 1 = next Min;
- every challenge name resolving to a known strong enum.
public static class RiskControlErrors{ public static readonly Error PolicyInvalid = Error.Validation("RiskControl.Policy.Invalid", "Risk policy is invalid.");}
var config = JsonSerializer.Deserialize( command.Json, RiskControlApplicationJsonSerializerContext.Default.RiskPolicyConfig);
// The write path rejects invalid policy instead of relying on read-time fallback.if (config is null || !config.IsValid()){ return Result.Failure(RiskControlErrors.PolicyInvalid);}
// Domain construction provides a second invariant gate for programmatic callers._ = config.ToPolicy();return await settings.SetAsync( SettingsRiskPolicyProvider.PolicyKey, config.ToJson(), currentTenant.Tenant.EffectiveTenantId, cancellationToken);There is no RiskControl-specific policy-management endpoint. Settings administration can store the value, but a commercial surface should add preview, approval, and audit instead of raw JSON editing.
3. Default fallback
SettingsRiskPolicyProvider.ResolveAsync warns and returns RiskPolicy.Default on Settings failure, JSON null, invalid config, or conversion exception. Caller cancellation propagates.
This preserves a known control rather than disabling risk assessment. It can still weaken a tenant whose override was stricter. Production telemetry should include policy source, version or hash, fallback reason, and strict-tenant fallback behavior.
4. Immutable assessment fact
SysRiskAssessment is a tenant-owned, soft-deleted unified aggregate:
| Column | Meaning |
|---|---|
| TenantId | assessment owner |
| UserId | nullable before user resolution |
| RiskScore | total from 0 to 100 |
| Level | level at evaluation time |
| Challenge | required challenge at evaluation time |
| FactorsJson | AOT snapshot of factors and evidence |
| AssessedAt | UTC evaluation time |
Create deep-copies evidence and uses placeholder Id "0". The Store assigns the infrastructure Id. Restore rejects unknown enums, malformed or ambiguous JSON, nested evidence, and factor totals inconsistent with RiskScore.
5. Independent transaction
Risk persistence must wrap the business transaction:
CancellationToken.None lets evidence survive client disconnect. Database connection and command timeouts must still bound the independent operation.
6. Capture-once is not durable idempotency
RiskAssessmentExecutionContext rejects a second pending assessment in one scoped request and clears on Take. It does not deduplicate HTTP retry, message replay, or a commit whose response is lost.
If one login attempt must produce one fact, add AttemptId or CorrelationId and a TenantId + AttemptId unique constraint.
7. Persistence failure
Begin, Record, or Commit failure triggers rollback and error logging, then is swallowed so the original login result or exception survives.
8. QueryShape store
RiskAssessmentStore.SearchAsync normalizes page index, clamps size to 1–100, applies an explicit tenant/user/level/time predicate, orders by AssessedAt and AssessmentId descending, projects scalar columns, and restores validated aggregates. QueryShape keeps the implementation provider-neutral.
private static Expression<Func<RiskAssessment, bool>> BuildPredicate( string trustedTenantId, RiskAssessmentFilter filter){ var levelName = filter.Level?.Name; // TenantId comes from trusted runtime context and cannot be overridden by query input. // User, level, and time remain optional business filters inside that tenant baseline. return assessment => assessment.TenantId == trustedTenantId && (filter.UserId == null || assessment.UserId == filter.UserId) && (levelName == null || assessment.LevelName == levelName) && (filter.From == null || assessment.AssessedAt >= filter.From) && (filter.To == null || assessment.AssessedAt <= filter.To);}Damaged snapshots return RiskControl.Assessment.InvalidPersistenceState instead of silently dropping a row.
9. Privacy and retention
Current FactorsJson contains raw IP, device fingerprint, and full business time, and the query DTO returns all factors. Production needs:
- a documented purpose and retention period;
- IP truncation or hashing and device pseudonymization;
- factor-code summaries by default and a separately authorized evidence endpoint;
- purpose/audit controls for export;
- retention deletion and legal hold beyond soft delete.
10. Review commands
# Risk persistence must appear before the transaction behavior.rg -n "RiskAssessmentPersistencePipelineBehavior|TransactionPipelineBehavior" \ src/Hosts/BitzOrcas.Api/Composition/ApiPipelineRegistration.cs
# Review policy key, default, fallback, and source-generated JSON.rg -n "riskcontrol.policy|RiskPolicyConfig|RiskPolicy.Default|JsonSerializer" \ src/Platform/RiskControl -g '*.cs'
# The current model has no attempt/correlation idempotency field.rg -n "AttemptId|CorrelationId|TraceId" \ src/Platform/RiskControl -g '*.cs'