From d4071bb7fad5c6ea644ed1cda1c0b912fc5eacb7 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 18:51:21 -0400 Subject: [PATCH 1/2] Propose checks from a rule on save, confirmed, edited or rejected by its author --- CONTRIBUTING.md | 4 +- ...tem-initiator-for-engine-driven-actions.md | 2 +- .../Builtin/Checks/ModelJudgement.cs | 2 +- .../Builtin/Jobs/RuleCompileJob.cs | 47 + src/FluentCMS.Core/Checks/ICheckDirectory.cs | 3 +- .../CoreServiceCollectionExtensions.cs | 10 +- src/FluentCMS.Core/Rules/IRuleChecks.cs | 34 + src/FluentCMS.Core/Rules/Rule.cs | 4 + src/FluentCMS.Core/Rules/RuleCompilation.cs | 52 + src/FluentCMS.Core/Rules/RuleCompiler.cs | 264 +++ .../Checks/CheckDirectory.cs | 57 +- ...frastructureServiceCollectionExtensions.cs | 5 +- .../Persistence/FluentCmsDbContext.cs | 7 + ...0261019223914_RuleCompilations.Designer.cs | 1864 +++++++++++++++++ .../20261019223914_RuleCompilations.cs | 103 + ...stgreSqlFluentCmsDbContextModelSnapshot.cs | 106 + ...0261019223901_RuleCompilations.Designer.cs | 1854 ++++++++++++++++ .../Sqlite/20261019223901_RuleCompilations.cs | 103 + .../SqliteFluentCmsDbContextModelSnapshot.cs | 106 + .../Rules/RuleChecks.cs | 400 ++++ .../Rules/RuleDirectory.cs | 7 +- .../Rules/RuleRows.cs | 82 + .../ChangeSets/ChangeSetScenario.cs | 18 +- .../Rules/RuleCheckTests.cs | 455 ++++ tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs | 10 + .../Rules/RuleCompilerTests.cs | 183 ++ 26 files changed, 5764 insertions(+), 18 deletions(-) create mode 100644 src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs create mode 100644 src/FluentCMS.Core/Rules/IRuleChecks.cs create mode 100644 src/FluentCMS.Core/Rules/RuleCompilation.cs create mode 100644 src/FluentCMS.Core/Rules/RuleCompiler.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019223914_RuleCompilations.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019223914_RuleCompilations.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019223901_RuleCompilations.Designer.cs create mode 100644 src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019223901_RuleCompilations.cs create mode 100644 src/FluentCMS.Infrastructure/Rules/RuleChecks.cs create mode 100644 tests/FluentCMS.IntegrationTests/Rules/RuleCheckTests.cs create mode 100644 tests/FluentCMS.Tests/Rules/RuleCompilerTests.cs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 07fbb06b4..501042cb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,7 +94,7 @@ Migrations apply automatically when the host starts. Every migration needs a wor ## Units of work and the audit log -Every operation that changes data runs inside a unit of work and appends the audit event that describes it from inside the same scope, so the two commit together or not at all (AUD-N1, ADR-0005). `IUnitOfWork.BeginAsync()` returns a scope; `CommitAsync()` saves and commits, and disposing a scope without committing rolls everything back, including saves that Identity makes on its own inside it. Inner scopes join the outer transaction, and an inner scope disposed without committing makes the outer commit fail. `IAuditLog.AppendAsync(new NewAuditEvent("entry.updated") { ... })` stamps the current initiator and time itself, and throws outside a unit of work or when no initiator is known, so a change without provenance cannot exist. The initiator is a person, an agent, or a `SystemInitiator` naming a part of the product that acts on its own (ADR-0012): the check engine (`checks`) moving a set out of checks running, or a retention purge (`retention`). A system initiator never opens a change set, reads but never writes content, and makes only the moves `AgentPermissions` lists for its component. +Every operation that changes data runs inside a unit of work and appends the audit event that describes it from inside the same scope, so the two commit together or not at all (AUD-N1, ADR-0005). `IUnitOfWork.BeginAsync()` returns a scope; `CommitAsync()` saves and commits, and disposing a scope without committing rolls everything back, including saves that Identity makes on its own inside it. Inner scopes join the outer transaction, and an inner scope disposed without committing makes the outer commit fail. `IAuditLog.AppendAsync(new NewAuditEvent("entry.updated") { ... })` stamps the current initiator and time itself, and throws outside a unit of work or when no initiator is known, so a change without provenance cannot exist. The initiator is a person, an agent, or a `SystemInitiator` naming a part of the product that acts on its own (ADR-0012): the check engine (`checks`) moving a set out of checks running or proposing checks for a saved rule, or a retention purge (`retention`). A system initiator never opens a change set, reads but never writes content, and makes only the moves `AgentPermissions` lists for its component. Nothing in the code updates or deletes an audit row. On PostgreSQL the `AuditEvents` migration also revokes `UPDATE`, `DELETE` and `TRUNCATE` from `PUBLIC` and from the role that applies it, which owns the table. A superuser bypasses privilege checks, so run the host, which applies migrations at startup, as an ordinary role that owns the database. SQLite has no roles; there the code is the guarantee. @@ -153,6 +153,8 @@ A submitted change set is checked as the check engine (`SystemInitiator.Checks`, The rule book (`IRuleDirectory` in `FluentCMS.Core.Rules`, RUL-01 to RUL-03) holds the team's rules as versioned content. A rule has a slug and a definition: its kind (voice and style, factual, structural, or permission and routing), its text, a severity (block or warn) and a scope of content types, fields and locales; nothing reads the locales in M1. Each version is one JSON document. Every write goes into a `ChangeSetTarget`, as content type writes do: creating and revising store a version, and retiring proposes taking the active version out, each recorded as an operation of the set with its audit event in one unit of work. A rule is a draft until a published set makes one of its versions active, and retired once a published set retires it; reverting that set makes it active again, and reverting the set that created it makes it a draft again. The change set diff shows a rule's kind, text, severity and scope before and after. Only a person writes rules; an agent or an engine is refused. +Saving a rule version proposes checks for it (`IRuleChecks`, RUL-04). With a model profile named `rules` configured, a `rules.compile` job asks the model, as the check engine on behalf of the author, for up to three checks of the built-in types, each with a passing and a failing example. The rule's text and scope go in the prompt's data sections, never its instructions. A proposal that does not validate against the installed check types is left out. Without a model, or without a usable answer, the version is advisory, with a message that says why (MDL-03). The author confirms a proposal, as proposed or edited, which creates the check and activates it once its examples get their verdicts, or rejects it. Proposing again marks the undecided proposals replaced. A confirmed check guards content only while its rule is active at the version it was proposed for. Only a person confirms, rejects or proposes again, and only the check engine records a compile. + Search (`ISearchIndex` in `FluentCMS.Core.Search`, SRC-01, ADR-0010) indexes each entry's latest version in the same unit of work as the write, as the lowercase words of every string and number in its fields (`SearchText`), so a result appears the moment its write commits. PostgreSQL keeps them in a `tsvector` with a GIN index and SQLite in an FTS5 table, both outside the EF model and created by their migrations; an external engine implements the same interface, registered with `AddFluentCmsExtension(ServiceLifetime.Scoped)` when it needs the request's scope. `ISearch` combines the matches with the entry rows, so deleting, restoring and publishing change results without touching the index, follows a match to the entries that reference it, and returns only types the caller may read. It reads the matches a page at a time, `Search__PageSize` (1,000 by default), and drops what the scope, deletions and filters leave out, until enough are left, the index has no more, or it has read `Search__MaxMatches` (10,000). A result that stopped there says it is not complete. Each hit has the state the entry tools show, in review included. A string that is a GUID, such as a reference, is not indexed. Field filters (`SearchQuery.Fields`) compare one type's latest field values, an entity's by its fact's value, and `SearchQuery.References` keeps entries whose latest version references an id; both are checked against the type before anything is read and applied to each page's candidates. With one type and either filter the text can be left out, and the candidates are then that type's entries. ## Code style diff --git a/docs/adr/0012-system-initiator-for-engine-driven-actions.md b/docs/adr/0012-system-initiator-for-engine-driven-actions.md index 2ef11dc0c..db7e68ac3 100644 --- a/docs/adr/0012-system-initiator-for-engine-driven-actions.md +++ b/docs/adr/0012-system-initiator-for-engine-driven-actions.md @@ -8,7 +8,7 @@ The audit log names who caused every change, a person or an agent (AUD-01, ADR-0009), and every change set has one of them as its initiator (CHG-01). Some actions have neither behind them: -- The check engine moves a change set from checks running to checks failed or awaiting review once its checks have run (CHK-03, CHG-04). +- The check engine moves a change set from checks running to checks failed or awaiting review once its checks have run (CHK-03, CHG-04). It also proposes checks for a saved rule, which its author then confirms (RUL-04). - Routing auto-publishes a set that passes on checks (RTE-01, the one exception in MCP-06). - Maintenance runs on its own, such as clearing stored prompts past their retention (AUD-04, AUD-N2). diff --git a/src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs b/src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs index 65000580b..5a215b3e6 100644 --- a/src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs +++ b/src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs @@ -147,7 +147,7 @@ private static IEnumerable Findings(JsonElement findings, CheckSub // Models often wrap JSON in a Markdown code fence even when asked for JSON alone. Only a fence around the whole answer is taken // off; anything said after it leaves the answer as it is, which then does not read as JSON. - private static string Unfenced(string text) + internal static string Unfenced(string text) { var trimmed = text.Trim(); if (!trimmed.StartsWith("```", StringComparison.Ordinal) || !trimmed.EndsWith("```", StringComparison.Ordinal)) diff --git a/src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs b/src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs new file mode 100644 index 000000000..6cc53e539 --- /dev/null +++ b/src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs @@ -0,0 +1,47 @@ +using System.Text.Json; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using FluentCMS.Core.Rules; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Core.Builtin.Jobs; + +// Proposes checks for a saved version of a rule (RUL-04), as the check engine on behalf of the person who saved it (ADR-0012). The +// payload only names the version; whether this job is still its compile, and what the rule says, come from what is stored. +internal sealed class RuleCompileJob : IJobHandler +{ + public string Type => RuleCompiler.JobType; + + public SystemInitiator RunAs => SystemInitiator.Checks; + + public async Task RunAsync(JobContext context, CancellationToken cancellationToken) + { + var payload = context.Job.Payload; + if (payload.ValueKind != JsonValueKind.Object + || !payload.TryGetProperty("ruleId", out var id) + || id.ValueKind != JsonValueKind.String + || !id.TryGetGuid(out var ruleId) + || !payload.TryGetProperty("version", out var number) + || !number.TryGetInt32(out var version)) + { + return Result.Failure("payload_invalid", "A rule compile names the rule as ruleId and its version as version."); + } + + var rules = context.Services.GetRequiredService(); + var compiling = await rules.CompilingAsync(ruleId, version, context.Job.Id, cancellationToken); + if (!compiling.IsSuccess) + { + return Replaced(compiling.Errors); + } + + var outcome = await context.Services.GetRequiredService().ProposeAsync(compiling.Value, context.Job.Initiator, cancellationToken); + return Replaced((await rules.RecordAsync(ruleId, version, context.Job.Id, outcome, cancellationToken)).Errors); + } + + // A compile that a later request replaced has nothing left to do. + private static Result Replaced(IReadOnlyList errors) + { + return errors.Count == 0 || errors.All(error => error.Code == RuleErrorCodes.CompileReplaced) ? Result.Success() : Result.Failure(errors); + } +} diff --git a/src/FluentCMS.Core/Checks/ICheckDirectory.cs b/src/FluentCMS.Core/Checks/ICheckDirectory.cs index 004c0aafd..a1b2f4538 100644 --- a/src/FluentCMS.Core/Checks/ICheckDirectory.cs +++ b/src/FluentCMS.Core/Checks/ICheckDirectory.cs @@ -12,7 +12,8 @@ public interface ICheckDirectory Task> GetVersionAsync(string name, int version, CancellationToken cancellationToken = default); - // Every active check at the version that runs, by name. + // Every active check at the version that runs, by name. A check confirmed from a rule's proposal is left out while the rule is not + // active at the version it was proposed for (RUL-04). Task> ListActiveAsync(CancellationToken cancellationToken = default); // Creates version 1, not active. diff --git a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs index b44b33737..93d74bb48 100644 --- a/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs +++ b/src/FluentCMS.Core/CoreServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ using FluentCMS.Core.Jobs; using FluentCMS.Core.Media; using FluentCMS.Core.Models; +using FluentCMS.Core.Rules; using FluentCMS.Core.Schema; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -51,8 +52,15 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic // Writes through IEntryDirectory, IContentTypeDirectory and IChangeSetDirectory in one unit of work, which Infrastructure provides. services.AddScoped(); + // Asks the model through IModelGateway; the compile job records what it proposes through IRuleChecks, which Infrastructure provides. + services.AddScoped(); + // The handlers that run as an engine; a job worker runs any other handler as whoever enqueued its job (ADR-0012). - services.AddSingleton(new EngineJobs(new Dictionary { [typeof(CheckRunJob)] = SystemInitiator.Checks })); + services.AddSingleton(new EngineJobs(new Dictionary + { + [typeof(CheckRunJob)] = SystemInitiator.Checks, + [typeof(RuleCompileJob)] = SystemInitiator.Checks, + })); return services; } diff --git a/src/FluentCMS.Core/Rules/IRuleChecks.cs b/src/FluentCMS.Core/Rules/IRuleChecks.cs new file mode 100644 index 000000000..1803f8b71 --- /dev/null +++ b/src/FluentCMS.Core/Rules/IRuleChecks.cs @@ -0,0 +1,34 @@ +using FluentCMS.Core.Checks; +using FluentCMS.Core.Results; + +namespace FluentCMS.Core.Rules; + +// The checks proposed for a rule, and the ones its author confirmed (RUL-04). Saving a version starts its compile; the author then +// confirms, edits or rejects each proposal. Only a person decides, since a confirmed check guards content (D17), and only the check +// engine records what a compile proposed. Every write appends its audit event in the same unit of work. +public interface IRuleChecks +{ + Task> GetAsync(string slug, int version, CancellationToken cancellationToken = default); + + // Proposes checks for the version again, such as once a model is configured. The proposals not yet decided are marked replaced, + // and the new ones are numbered after every earlier one. + Task> CompileAsync(string slug, int version, CancellationToken cancellationToken = default); + + // Creates the proposed check, or the definition the author edited it to, under its proposed name or checkName, and activates it + // once its examples get their verdicts, in one unit of work. The check guards content only while the rule is active at the + // version it was proposed for. + Task> ConfirmAsync( + string slug, + int number, + CheckDefinition? definition = null, + string? checkName = null, + CancellationToken cancellationToken = default); + + Task> RejectAsync(string slug, int number, CancellationToken cancellationToken = default); + + // For the compile job: the version's definition while the job is still that version's compile. + Task> CompilingAsync(Guid ruleId, int version, Guid jobId, CancellationToken cancellationToken = default); + + // Stores what the job's compile found. A compile that a later request replaced records nothing and gets rule_compile_replaced. + Task RecordAsync(Guid ruleId, int version, Guid jobId, RuleCompileOutcome outcome, CancellationToken cancellationToken = default); +} diff --git a/src/FluentCMS.Core/Rules/Rule.cs b/src/FluentCMS.Core/Rules/Rule.cs index 77f952646..223cef1ff 100644 --- a/src/FluentCMS.Core/Rules/Rule.cs +++ b/src/FluentCMS.Core/Rules/Rule.cs @@ -110,4 +110,8 @@ public static class RuleErrorCodes public const string VersionConflict = "rule_version_conflict"; public const string NotActive = "rule_not_active"; public const string PeopleOnly = "rule_people_only"; + public const string EngineOnly = "rule_engine_only"; + public const string ProposalNotFound = "rule_proposal_not_found"; + public const string ProposalDecided = "rule_proposal_decided"; + public const string CompileReplaced = "rule_compile_replaced"; } diff --git a/src/FluentCMS.Core/Rules/RuleCompilation.cs b/src/FluentCMS.Core/Rules/RuleCompilation.cs new file mode 100644 index 000000000..26f84746f --- /dev/null +++ b/src/FluentCMS.Core/Rules/RuleCompilation.cs @@ -0,0 +1,52 @@ +using FluentCMS.Core.Checks; + +namespace FluentCMS.Core.Rules; + +// Where proposing checks for one version of a rule stands (RUL-04). +public enum RuleCompileStatus +{ + // A job is asking the model for checks. + Compiling, + + // Checks were proposed; the author confirms, edits or rejects each. + Proposed, + + // No check was proposed, and the message says why. The rule is guidance until one of its checks is confirmed (MDL-03). + Advisory, +} + +public enum RuleProposalState +{ + Proposed, + Confirmed, + Rejected, + + // Still undecided when checks were proposed for the version again; kept so a number always names the same proposal. + Replaced, +} + +// A check the system proposed for a version of a rule, numbered within the rule. Once confirmed, CheckName is the check it became. +public sealed record RuleCheckProposal(int Number, int RuleVersion, string CheckName, CheckDefinition Definition, RuleProposalState State); + +// The compile of one version and the checks it proposed, in number order. +public sealed record RuleCompilation( + string Slug, + int Version, + RuleCompileStatus Status, + string? Message, + Guid? JobId, + IReadOnlyList Proposals); + +// What a compile found, which the check engine records for the version it compiled. +public sealed record RuleCompileOutcome(RuleCompileStatus Status, string? Message, IReadOnlyList Proposals) +{ + // The model and prompt the proposals came from, which the audit event cites (MDL-04). + public string? Model { get; init; } + + public string? PromptHash { get; init; } + + public static RuleCompileOutcome Advisory(string message) + { + return new RuleCompileOutcome(RuleCompileStatus.Advisory, message, []); + } +} diff --git a/src/FluentCMS.Core/Rules/RuleCompiler.cs b/src/FluentCMS.Core/Rules/RuleCompiler.cs new file mode 100644 index 000000000..12e19e9f1 --- /dev/null +++ b/src/FluentCMS.Core/Rules/RuleCompiler.cs @@ -0,0 +1,264 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using FluentCMS.Core.Builtin.Checks; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Models; + +namespace FluentCMS.Core.Rules; + +// Proposes checks that enforce a rule (RUL-04), asking the model on the 'rules' profile. The instructions are written here; the rule's +// text and scope go in data sections, since a person wrote them (SEC-03, RUL-07). A proposal is kept only if it validates against the +// installed check types. Without a model, or without a usable answer, the rule is advisory rather than in error (MDL-03). +public sealed class RuleCompiler(IModelGateway gateway, CheckTypeRegistry checkTypes) +{ + public const string Profile = "rules"; + public const string JobType = "rules.compile"; + public const int MaxProposals = 3; + + public const string NoModel = + "No model is configured to propose checks, so the rule is advisory: agents read it as guidance and it blocks nothing. " + + "Configure the 'rules' model profile, then propose checks again."; + + private const string Unreadable = "The model's answer could not be read, so the rule is advisory until checks are proposed again."; + + // An answer is one small object; a duplicate key could hide a second proposal behind the first. + private static readonly JsonDocumentOptions AnswerOptions = new() { AllowDuplicateProperties = false, MaxDepth = 32 }; + + // The model reads the scope as written, not as \u escapes. + private static readonly JsonSerializerOptions ScopeOptions = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; + + // The built-in types a rule can compile to, with what each does and the settings it takes, in the words the model reads. + private static readonly (string Type, string Use)[] Catalog = + [ + ("terms", """fails content that uses any of the listed words or phrases, matched as whole words ignoring case. Settings: {"terms": ["world-class", "revolutionary"]}."""), + ("pattern", """fails text a regular expression matches, or with "mode": "require" text it does not match. Settings: {"pattern": "[0-9]+ ?%", "mode": "forbid", "ignoreCase": true}; no backreferences, lookarounds or atomic groups."""), + ("length", """fails text shorter than min or longer than max, in characters or words. Settings: {"max": 60, "unit": "characters"}, with min, max or both."""), + ("range", """fails a number below min or above max. Settings: {"min": 0, "max": 10000}, with min, max or both."""), + ("tone", """a model judges whether content keeps a voice. Settings: {"voice": "calm and factual, no hype"}."""), + ("claims", """a model judges whether factual, comparative, statistical, health, safety and legal claims are supported by a cited source or an attribution. Settings: {"focus": "price comparisons"}, where focus is optional."""), + ("readingLevel", """a model judges whether content reads at a level. Settings: {"level": "plain English a twelve-year-old can follow"}."""), + ]; + + private static readonly JsonElement AnswerSchema = JsonSerializer.Deserialize(""" + { + "type": "object", + "properties": { + "checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": {"type": "string"}, + "description": {"type": "string"}, + "settings": {"type": "string"}, + "examples": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "expect": {"type": "string", "enum": ["pass", "fail"]}, + "contentType": {"type": "string"}, + "fields": {"type": "string"} + }, + "required": ["name", "expect", "contentType", "fields"], + "additionalProperties": false + } + } + }, + "required": ["type", "description", "settings", "examples"], + "additionalProperties": false + } + } + }, + "required": ["checks"], + "additionalProperties": false + } + """); + + public bool IsAvailable => gateway.IsAvailable(Profile); + + // onBehalfOf is the rule's author, whose work the check engine asks the model for. + public async Task ProposeAsync(RuleDefinition rule, Initiator? onBehalfOf, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(rule); + + if (!IsAvailable) + { + return RuleCompileOutcome.Advisory(NoModel); + } + + var offered = Catalog.Where(entry => checkTypes.Find(entry.Type) is not null).ToList(); + var scope = JsonSerializer.Serialize( + new { kind = RuleDefinition.KindName(rule.Kind), contentTypes = rule.Scope.ContentTypes, fields = rule.Scope.Fields }, + ScopeOptions); + + var asked = await gateway.AskAsync( + new ModelRequest(Profile, "rule:compile", Instructions(offered), [new ModelData("rule", rule.Text), new ModelData("scope", scope)]) + { + AnswerSchema = AnswerSchema, + OnBehalfOf = onBehalfOf, + }, + cancellationToken); + if (!asked.IsSuccess) + { + return RuleCompileOutcome.Advisory( + $"The model could not propose checks, so the rule is advisory until checks are proposed again: {asked.Errors[0].Message}"); + } + + return Read(asked.Value.Text, rule, offered.Select(entry => entry.Type).ToHashSet(StringComparer.Ordinal)) with + { + Model = asked.Value.Model, + PromptHash = asked.Value.PromptHash, + }; + } + + private static string Instructions(IReadOnlyList<(string Type, string Use)> offered) + { + var types = string.Join("\n", offered.Select(entry => $"- {entry.Type}: {entry.Use}")); + return $""" + You turn a content rule a team wrote into automated checks that enforce it. The "rule" section is the rule in its author's + words. The "scope" section is JSON: the rule's kind, and the content types and fields it applies to, each null for all. + + Propose at most {MaxProposals} checks that together enforce the rule, each of one of these types: + {types} + + Prefer a type that decides without a model (terms, pattern, length, range) when it enforces the rule exactly, and a + model-judged type only when none can. Propose no check when no type can enforce the rule, such as a rule about who may + publish; the rule is then guidance for writers. + + Give each check: "type"; "description", one sentence saying what it enforces; "settings", its settings as a JSON object + written as a string; and "examples", at least one piece of content the check must pass and one it must fail. Give each + example: "name", a few words saying what it shows; "expect", "pass" or "fail"; "contentType", one of the scope's content + types, or "" when the scope names none; and "fields", the content as a JSON object with one property per field, written as + a string, using the scope's fields when it names some. Answer only with JSON in the given shape. + """; + } + + // Only the types the prompt offered count, so an answer cannot reach a type the compile does not describe. + private RuleCompileOutcome Read(string text, RuleDefinition rule, HashSet offered) + { + JsonElement root; + try + { + using var document = JsonDocument.Parse(ModelJudgement.Unfenced(text), AnswerOptions); + root = document.RootElement.Clone(); + } + catch (JsonException) + { + return RuleCompileOutcome.Advisory(Unreadable); + } + + // Only the shape that was asked for counts. + if (root.ValueKind != JsonValueKind.Object + || root.EnumerateObject().Any(property => property.Name != "checks") + || !root.TryGetProperty("checks", out var listed) + || listed.ValueKind != JsonValueKind.Array) + { + return RuleCompileOutcome.Advisory(Unreadable); + } + + var proposals = new List(); + var unusable = 0; + foreach (var item in listed.EnumerateArray().Take(MaxProposals)) + { + if (Proposal(item, rule) is { } proposal && offered.Contains(proposal.Type) && proposal.Validate(checkTypes).IsSuccess) + { + proposals.Add(proposal); + } + else + { + unusable++; + } + } + + if (proposals.Count > 0) + { + return new RuleCompileOutcome( + RuleCompileStatus.Proposed, + unusable == 0 ? null : $"{Checks(unusable)} more that the model proposed could not be used and {(unusable == 1 ? "was" : "were")} left out.", + proposals); + } + + return RuleCompileOutcome.Advisory(unusable == 0 + ? "The model found no check that can enforce this rule, so it is advisory: agents read it as guidance and it blocks nothing." + : $"The model proposed {Checks(unusable)} that could not be used, so the rule is advisory until checks are proposed again."); + } + + // A proposal takes the rule's severity and scope, whatever the model says; an example that names no content type is of the + // first type the scope names. + private static CheckDefinition? Proposal(JsonElement item, RuleDefinition rule) + { + if (item.ValueKind != JsonValueKind.Object + || item.EnumerateObject().Any(property => property.Name is not ("type" or "description" or "settings" or "examples")) + || Text(item, "type") is not { } type + || Text(item, "description") is not { } description + || ObjectIn(Text(item, "settings")) is not { } settings + || !item.TryGetProperty("examples", out var listed) + || listed.ValueKind != JsonValueKind.Array) + { + return null; + } + + var examples = new List(); + foreach (var example in listed.EnumerateArray()) + { + if (example.ValueKind != JsonValueKind.Object + || Text(example, "name") is not { } name + || Text(example, "expect") is not ("pass" or "fail") + || ObjectIn(Text(example, "fields")) is not { } fields) + { + return null; + } + + var contentType = Text(example, "contentType") is { Length: > 0 } named ? named : rule.Scope.ContentTypes?[0]; + var expected = Text(example, "expect") == "pass" ? CheckVerdict.Pass : CheckVerdict.Fail; + examples.Add(new CheckExample(name, expected, fields, contentType: contentType)); + } + + return new CheckDefinition(type, description, settings, examples, rule.Severity, new CheckScope(rule.Scope.ContentTypes, rule.Scope.Fields)); + } + + // A string that escapes half of a surrogate pair holds no .NET string, so it reads as missing. + private static string? Text(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String) + { + return null; + } + + try + { + return value.GetString(); + } + catch (InvalidOperationException) + { + return null; + } + } + + // Settings and fields come as JSON written in a string, which a provider's strict answer schema can carry. + private static JsonElement? ObjectIn(string? json) + { + if (json is null) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(json, AnswerOptions); + return document.RootElement.ValueKind == JsonValueKind.Object ? document.RootElement.Clone() : null; + } + catch (JsonException) + { + return null; + } + } + + private static string Checks(int count) + { + return count == 1 ? "1 check" : $"{count} checks"; + } +} diff --git a/src/FluentCMS.Infrastructure/Checks/CheckDirectory.cs b/src/FluentCMS.Infrastructure/Checks/CheckDirectory.cs index 4c17cf9e8..11d3e00cd 100644 --- a/src/FluentCMS.Infrastructure/Checks/CheckDirectory.cs +++ b/src/FluentCMS.Infrastructure/Checks/CheckDirectory.cs @@ -3,6 +3,7 @@ using FluentCMS.Core.Identity; using FluentCMS.Core.Persistence; using FluentCMS.Core.Results; +using FluentCMS.Core.Rules; using FluentCMS.Core.Schema; using FluentCMS.Core.Sites; using FluentCMS.Infrastructure.Persistence; @@ -53,6 +54,9 @@ public async Task> ListActiveAsync(CancellationToken cancel from check in context.Checks.AsNoTracking() join version in context.CheckVersions.AsNoTracking() on check.Id equals version.CheckId where check.SiteId == SiteId && check.ActiveVersion == version.Version + // A check confirmed from a rule's proposal guards content only while the rule is active at that version (RUL-04). + where !context.RuleCheckProposals.Any(proposal => proposal.CheckId == check.Id + && !context.Rules.Any(rule => rule.Id == proposal.RuleId && rule.State == RuleState.Active && rule.ActiveVersion == proposal.RuleVersion)) orderby check.Name select new { Check = check, Version = version }) .ToListAsync(cancellationToken); @@ -244,6 +248,54 @@ await auditLog.AppendAsync( return Result.Success(check); } + // A check a person confirms from a rule's proposal (RUL-04) is created and activated at once. Every expected failure is found + // here, before the caller's unit of work: the definition, the name, and the examples, which must get their verdicts. + internal async Task> PrepareActiveAsync(NewCheck check, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(check); + + if (RefuseAgent("activate") is { } refused) + { + return refused; + } + + var created = Check.Create(SiteId, check.Name, check.Definition, checkTypes); + if (!created.IsSuccess) + { + return created; + } + + if (await context.Checks.AnyAsync(row => row.SiteId == SiteId && row.Name == check.Name, cancellationToken)) + { + return Result.Failure([NameTaken(check.Name)]); + } + + var definitions = await ExampleContentTypesAsync(check.Definition, cancellationToken); + if (!definitions.IsSuccess) + { + return Result.Failure(definitions.Errors); + } + + var examples = await check.Definition.RunExamplesAsync(checkTypes, definitions.Value, services, cancellationToken); + return examples.IsSuccess ? Result.Success(created.Value.WithActiveVersion(created.Value.Version)) : Result.Failure(examples.Errors); + } + + // Writes a check PrepareActiveAsync returned, with its events, inside the caller's unit of work. + internal async Task AddActiveAsync(Check check, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(check); + + context.Checks.Add(new CheckRow { Id = check.Id, SiteId = check.SiteId, Name = check.Name, Version = check.Version, ActiveVersion = check.Version }); + context.CheckVersions.Add(VersionRow(check)); + await auditLog.AppendAsync(new NewAuditEvent("check.created") { SubjectType = SubjectType, SubjectId = check.Id }, cancellationToken); + await auditLog.AppendAsync(new NewAuditEvent("check.activated") { SubjectType = SubjectType, SubjectId = check.Id }, cancellationToken); + } + + internal static Error NameTaken(string name) + { + return new Error(CheckErrorCodes.NameTaken, $"There is already a check named '{name}' in this site; revise it or choose another name."); + } + // The current definition of each content type the examples name, so types that read the definition see the one entries use. private async Task>> ExampleContentTypesAsync( CheckDefinition definition, @@ -373,11 +425,6 @@ private static Result VersionNotFound(CheckRow row, int version) $"The check '{row.Name}' has no version {version}; its latest version is {row.Version}."); } - private static Error NameTaken(string name) - { - return new Error(CheckErrorCodes.NameTaken, $"There is already a check named '{name}' in this site; revise it or choose another name."); - } - private static Error VersionConflict(string name, int baseVersion) { return new Error( diff --git a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs index 1232a1133..e1cc70c12 100644 --- a/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/FluentCMS.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -118,6 +118,8 @@ private static void AddSchema(IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); } private static void AddChangeSets(IServiceCollection services) @@ -167,7 +169,8 @@ private static void AddContent(IServiceCollection services, IConfiguration confi // CheckTypeRegistry comes from AddFluentCmsCore, so built-in and plugin check types reach the directory the same way. private static void AddChecks(IServiceCollection services) { - services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); services.AddScoped(); } diff --git a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs index 108cda4d1..b54dbd7ea 100644 --- a/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs +++ b/src/FluentCMS.Infrastructure/Persistence/FluentCmsDbContext.cs @@ -74,6 +74,10 @@ public abstract class FluentCmsDbContext(DbContextOptions options) : IdentityDbC public DbSet RuleVersions => Set(); + public DbSet RuleCompilations => Set(); + + public DbSet RuleCheckProposals => Set(); + public DbSet WebhookDeliveries => Set(); protected override void OnModelCreating(ModelBuilder builder) @@ -106,6 +110,8 @@ protected override void OnModelCreating(ModelBuilder builder) builder.ApplyConfiguration(new PreviewTokenConfiguration()); builder.ApplyConfiguration(new RuleConfiguration()); builder.ApplyConfiguration(new RuleVersionConfiguration()); + builder.ApplyConfiguration(new RuleCompilationConfiguration()); + builder.ApplyConfiguration(new RuleCheckProposalConfiguration()); builder.ApplyConfiguration(new WebhookDeliveryConfiguration()); builder.Entity(user => @@ -135,6 +141,7 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity().Property(version => version.Fields).HasColumnType("jsonb"); builder.Entity().Property(version => version.Definition).HasColumnType("jsonb"); builder.Entity().Property(version => version.Definition).HasColumnType("jsonb"); + builder.Entity().Property(proposal => proposal.Definition).HasColumnType("jsonb"); builder.Entity().Property(version => version.Definition).HasColumnType("jsonb"); } } diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019223914_RuleCompilations.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019223914_RuleCompilations.Designer.cs new file mode 100644 index 000000000..45440e463 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019223914_RuleCompilations.Designer.cs @@ -0,0 +1,1864 @@ +// +using System; +using System.Collections.Generic; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + [DbContext(typeof(PostgreSqlFluentCmsDbContext))] + [Migration("20261019223914_RuleCompilations")] + partial class RuleCompilations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.13") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CallPeriod") + .HasColumnType("interval"); + + b.Property("CallsPerPeriod") + .HasColumnType("integer"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("integer"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Fields") + .HasColumnType("text[]"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("integer"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("numeric"); + + b.Property("LlmSpendPeriod") + .HasColumnType("interval"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Sites") + .IsRequired() + .HasColumnType("uuid[]"); + + b.PrimitiveCollection>("Types") + .HasColumnType("text[]"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PreviousVersionId") + .HasColumnType("uuid"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BaseVersion") + .HasColumnType("integer"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ProposedVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("text"); + + b.Property("RejectionReason") + .HasColumnType("text"); + + b.Property("RevertsChangeSetId") + .HasColumnType("uuid"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Sources") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CheckVersion") + .HasColumnType("integer"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("text"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("ItemVersion") + .HasColumnType("integer"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelCallId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("RunId") + .HasColumnType("uuid"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("TargetId") + .HasColumnType("uuid"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("EntryId") + .HasColumnType("uuid"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastVerified") + .HasColumnType("timestamp with time zone"); + + b.Property("Owner") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceReference") + .HasColumnType("text"); + + b.Property("Unsourced") + .HasColumnType("boolean"); + + b.Property("ValidFrom") + .HasColumnType("timestamp with time zone"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FinishedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LeaseUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxAttempts") + .HasColumnType("integer"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProgressDone") + .HasColumnType("integer"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProgressTotal") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SubjectId") + .HasColumnType("uuid"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("LatestVersion") + .HasColumnType("integer"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("AssetId") + .HasColumnType("uuid"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("Length") + .HasColumnType("bigint"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.PrimitiveCollection>("Tags") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AgentCredentialId") + .HasColumnType("uuid"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("numeric(18,6)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasColumnType("text"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("InputTokens") + .HasColumnType("bigint"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OutputTokens") + .HasColumnType("bigint"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Prompt") + .HasColumnType("text"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("PurgedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Response") + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("RoutingRules", null, t => + { + t.HasCheckConstraint("CK_RoutingRules_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_RoutingRules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RoutingRuleId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoutingRuleId", "Version") + .IsUnique(); + + b.ToTable("RoutingRuleVersions", null, t => + { + t.HasCheckConstraint("CK_RoutingRuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCheckProposalRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("RuleVersion") + .HasColumnType("integer"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("CheckId") + .IsUnique(); + + b.HasIndex("RuleId", "Number") + .IsUnique(); + + b.HasIndex("RuleId", "RuleVersion"); + + b.ToTable("RuleCheckProposals", null, t => + { + t.HasCheckConstraint("CK_RuleCheckProposals_Number", "\"Number\" >= 1"); + + t.HasCheckConstraint("CK_RuleCheckProposals_RuleVersion", "\"RuleVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCompilationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleCompilations", null, t => + { + t.HasCheckConstraint("CK_RuleCompilations_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ActiveVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("Rules", null, t => + { + t.HasCheckConstraint("CK_Rules_ActiveVersion", "\"ActiveVersion\" IS NULL OR \"ActiveVersion\" >= 1"); + + t.HasCheckConstraint("CK_Rules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleVersions", null, t => + { + t.HasCheckConstraint("CK_RuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("PublishedVersion") + .HasColumnType("integer"); + + b.Property("SiteId") + .HasColumnType("uuid"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentTypeId") + .HasColumnType("uuid"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookDeliveryRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Attempt") + .HasColumnType("integer"); + + b.Property("AttemptedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ChangeSetId") + .HasColumnType("uuid"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Event") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("StatusCode") + .HasColumnType("integer"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Id"); + + b.ToTable("WebhookDeliveries", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddedById") + .HasColumnType("uuid"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("DisabledAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Events") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Routing.RoutingRuleRow", null) + .WithMany() + .HasForeignKey("RoutingRuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCheckProposalRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCompilationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019223914_RuleCompilations.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019223914_RuleCompilations.cs new file mode 100644 index 000000000..55611e722 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/20261019223914_RuleCompilations.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.PostgreSql +{ + /// + public partial class RuleCompilations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RuleCheckProposals", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RuleId = table.Column(type: "uuid", nullable: false), + RuleVersion = table.Column(type: "integer", nullable: false), + Number = table.Column(type: "integer", nullable: false), + CheckName = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + Definition = table.Column(type: "jsonb", nullable: false), + State = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + CheckId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RuleCheckProposals", x => x.Id); + table.CheckConstraint("CK_RuleCheckProposals_Number", "\"Number\" >= 1"); + table.CheckConstraint("CK_RuleCheckProposals_RuleVersion", "\"RuleVersion\" >= 1"); + table.ForeignKey( + name: "FK_RuleCheckProposals_Checks_CheckId", + column: x => x.CheckId, + principalTable: "Checks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_RuleCheckProposals_Rules_RuleId", + column: x => x.RuleId, + principalTable: "Rules", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RuleCompilations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RuleId = table.Column(type: "uuid", nullable: false), + Version = table.Column(type: "integer", nullable: false), + Status = table.Column(type: "character varying(16)", maxLength: 16, nullable: false), + Message = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + JobId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RuleCompilations", x => x.Id); + table.CheckConstraint("CK_RuleCompilations_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_RuleCompilations_Rules_RuleId", + column: x => x.RuleId, + principalTable: "Rules", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_RuleCheckProposals_CheckId", + table: "RuleCheckProposals", + column: "CheckId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RuleCheckProposals_RuleId_Number", + table: "RuleCheckProposals", + columns: new[] { "RuleId", "Number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RuleCheckProposals_RuleId_RuleVersion", + table: "RuleCheckProposals", + columns: new[] { "RuleId", "RuleVersion" }); + + migrationBuilder.CreateIndex( + name: "IX_RuleCompilations_RuleId_Version", + table: "RuleCompilations", + columns: new[] { "RuleId", "Version" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RuleCheckProposals"); + + migrationBuilder.DropTable( + name: "RuleCompilations"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs index 2b899b84b..fe9e376d7 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/PostgreSql/PostgreSqlFluentCmsDbContextModelSnapshot.cs @@ -1162,6 +1162,89 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCheckProposalRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CheckId") + .HasColumnType("uuid"); + + b.Property("CheckName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("RuleVersion") + .HasColumnType("integer"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("CheckId") + .IsUnique(); + + b.HasIndex("RuleId", "Number") + .IsUnique(); + + b.HasIndex("RuleId", "RuleVersion"); + + b.ToTable("RuleCheckProposals", null, t => + { + t.HasCheckConstraint("CK_RuleCheckProposals_Number", "\"Number\" >= 1"); + + t.HasCheckConstraint("CK_RuleCheckProposals_RuleVersion", "\"RuleVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCompilationRow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("JobId") + .HasColumnType("uuid"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("RuleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleCompilations", null, t => + { + t.HasCheckConstraint("CK_RuleCompilations_Version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => { b.Property("Id") @@ -1663,6 +1746,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCheckProposalRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCompilationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => { b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019223901_RuleCompilations.Designer.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019223901_RuleCompilations.Designer.cs new file mode 100644 index 000000000..88d55dc8c --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019223901_RuleCompilations.Designer.cs @@ -0,0 +1,1854 @@ +// +using System; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + [DbContext(typeof(SqliteFluentCmsDbContext))] + [Migration("20261019223901_RuleCompilations")] + partial class RuleCompilations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.13"); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.AgentCredentialRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CallPeriod") + .HasColumnType("TEXT"); + + b.Property("CallsPerPeriod") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetsPerDay") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Fields") + .HasColumnType("TEXT"); + + b.Property("IssuedAt") + .HasColumnType("TEXT"); + + b.Property("ItemsPerChangeSet") + .HasColumnType("INTEGER"); + + b.Property("KeyHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("LlmSpendPerPeriod") + .HasColumnType("TEXT"); + + b.Property("LlmSpendPeriod") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Operations") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sites") + .IsRequired() + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Types") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("KeyHash") + .IsUnique(); + + b.ToTable("AgentCredentials", null, t => + { + t.HasCheckConstraint("CK_AgentCredentials_Caps", "\"CallsPerPeriod\" >= 1 AND \"ItemsPerChangeSet\" >= 1 AND \"ChangeSetsPerDay\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("AgentName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("TaskId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("Tool") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ToolCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Audit.AuditEventRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Operation") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PreviousVersionId") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AgentCredentialId"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("Timestamp"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("AuditEvents", null, t => + { + t.HasCheckConstraint("CK_AuditEvents_AgentCredentialId", "(\"InitiatorKind\" = 'agent') = (\"AgentCredentialId\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("BaseVersion") + .HasColumnType("INTEGER"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("ProposedVersion") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Position") + .IsUnique(); + + b.HasIndex("ItemKind", "ItemId"); + + b.HasIndex("ChangeSetId", "ItemKind", "ItemId") + .IsUnique(); + + b.ToTable("ChangeSetOperations", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Rationale") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RejectionReason") + .HasColumnType("TEXT"); + + b.Property("RevertsChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Sources") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RevertsChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("State"); + + b.HasIndex("InitiatorKind", "InitiatorId", "SessionId") + .IsUnique() + .HasFilter("\"State\" = 'Open' AND \"SessionId\" IS NOT NULL"); + + b.ToTable("ChangeSets", null, t => + { + t.HasCheckConstraint("CK_ChangeSets_RejectionReason", "(\"State\" = 'Rejected') = (\"RejectionReason\" IS NOT NULL)"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Check") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CheckVersion") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Findings") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ItemKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ItemVersion") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelCallId") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("PromptHash") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("RunId") + .HasColumnType("TEXT"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RunId", "Position") + .IsUnique(); + + b.ToTable("CheckResults", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("Checks", null, t => + { + t.HasCheckConstraint("CK_Checks_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_Checks_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("TEXT"); + + b.Property("Mode") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Problems") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId", "Number") + .IsUnique(); + + b.ToTable("CheckRuns", null, t => + { + t.HasCheckConstraint("CK_CheckRuns_Number", "\"Number\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CheckId", "Version") + .IsUnique(); + + b.ToTable("CheckVersions", null, t => + { + t.HasCheckConstraint("CK_CheckVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TargetField") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TargetId") + .HasColumnType("TEXT"); + + b.Property("TargetKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version"); + + b.HasIndex("TargetKind", "TargetId", "TargetField"); + + b.ToTable("EntryReferences", null, t => + { + t.HasCheckConstraint("CK_EntryReferences_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId"); + + b.HasIndex("SiteId", "ContentTypeId"); + + b.ToTable("Entries", null, t => + { + t.HasCheckConstraint("CK_Entries_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_Entries_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + + t.HasCheckConstraint("CK_Entries_State", "\"State\" IN ('Draft', 'Published', 'Reverted', 'Archived')"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeVersion") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Fields") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EntryId", "Version") + .IsUnique(); + + b.ToTable("EntryVersions", null, t => + { + t.HasTrigger("TR_EntryVersions_NoUpdate"); + + t.HasCheckConstraint("CK_EntryVersions_ContentTypeVersion", "\"ContentTypeVersion\" >= 1"); + + t.HasCheckConstraint("CK_EntryVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.HasKey("Id"); + + b.HasIndex("ChangeSetId"); + + b.HasIndex("SiteId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("PreviewTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("EntryId") + .HasColumnType("TEXT"); + + b.Property("Field") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastVerified") + .HasColumnType("TEXT"); + + b.Property("Owner") + .HasColumnType("TEXT"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("SourceKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SourceReference") + .HasColumnType("TEXT"); + + b.Property("Unsourced") + .HasColumnType("INTEGER"); + + b.Property("ValidFrom") + .HasColumnType("TEXT"); + + b.Property("ValidUntil") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LastVerified"); + + b.HasIndex("Unsourced"); + + b.HasIndex("ValidUntil"); + + b.HasIndex("EntryId", "Version", "Field") + .IsUnique(); + + b.ToTable("EntityFacts", null, t => + { + t.HasCheckConstraint("CK_EntityFacts_Unsourced", "\"Unsourced\" = (\"SourceKind\" IS NULL)"); + + t.HasCheckConstraint("CK_EntityFacts_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Jobs.JobRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempts") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FinishedAt") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InitiatorName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LastError") + .HasColumnType("TEXT"); + + b.Property("LeaseOwner") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LeaseUntil") + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProgressDone") + .HasColumnType("INTEGER"); + + b.Property("ProgressMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("ProgressTotal") + .HasColumnType("INTEGER"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("ScheduledAt") + .HasColumnType("TEXT"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("State", "ScheduledAt"); + + b.HasIndex("SubjectType", "SubjectId"); + + b.ToTable("Jobs", null, t => + { + t.HasCheckConstraint("CK_Jobs_Attempts", "\"Attempts\" >= 0 AND \"MaxAttempts\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("INTEGER"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SiteId"); + + b.ToTable("MediaAssets", null, t => + { + t.HasCheckConstraint("CK_MediaAssets_LatestVersion", "\"LatestVersion\" >= 1"); + + t.HasCheckConstraint("CK_MediaAssets_PublishedVersion", "\"PublishedVersion\" IS NULL OR (\"PublishedVersion\" >= 1 AND \"PublishedVersion\" <= \"LatestVersion\")"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AltText") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AssetId") + .HasColumnType("TEXT"); + + b.Property("Caption") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Folder") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Length") + .HasColumnType("INTEGER"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Storage") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StorageKey") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Tags") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Folder"); + + b.HasIndex("AssetId", "Version") + .IsUnique(); + + b.ToTable("MediaAssetVersions", null, t => + { + t.HasTrigger("TR_MediaAssetVersions_NoUpdate"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Length", "\"Length\" > 0"); + + t.HasCheckConstraint("CK_MediaAssetVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AgentCredentialId") + .HasColumnType("TEXT"); + + b.Property("Cost") + .HasPrecision(18, 6) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasColumnType("TEXT"); + + b.Property("InitiatorId") + .HasColumnType("TEXT"); + + b.Property("InitiatorKind") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("InputTokens") + .HasColumnType("INTEGER"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ModelVersion") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Outcome") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OutputTokens") + .HasColumnType("INTEGER"); + + b.Property("Profile") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Prompt") + .HasColumnType("TEXT"); + + b.Property("PromptHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("PurgedAt") + .HasColumnType("TEXT"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Response") + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PromptHash"); + + b.HasIndex("Timestamp"); + + b.HasIndex("AgentCredentialId", "Timestamp"); + + b.ToTable("ModelCalls", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("Roles", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Persistence.FluentCmsUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Name") + .IsUnique(); + + b.ToTable("RoutingRules", null, t => + { + t.HasCheckConstraint("CK_RoutingRules_ActiveVersion", "\"ActiveVersion\" IS NULL OR (\"ActiveVersion\" >= 1 AND \"ActiveVersion\" <= \"Version\")"); + + t.HasCheckConstraint("CK_RoutingRules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RoutingRuleId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoutingRuleId", "Version") + .IsUnique(); + + b.ToTable("RoutingRuleVersions", null, t => + { + t.HasCheckConstraint("CK_RoutingRuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCheckProposalRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("RuleId") + .HasColumnType("TEXT"); + + b.Property("RuleVersion") + .HasColumnType("INTEGER"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheckId") + .IsUnique(); + + b.HasIndex("RuleId", "Number") + .IsUnique(); + + b.HasIndex("RuleId", "RuleVersion"); + + b.ToTable("RuleCheckProposals", null, t => + { + t.HasCheckConstraint("CK_RuleCheckProposals_Number", "\"Number\" >= 1"); + + t.HasCheckConstraint("CK_RuleCheckProposals_RuleVersion", "\"RuleVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCompilationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleCompilations", null, t => + { + t.HasCheckConstraint("CK_RuleCompilations_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ActiveVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("Rules", null, t => + { + t.HasCheckConstraint("CK_Rules_ActiveVersion", "\"ActiveVersion\" IS NULL OR \"ActiveVersion\" >= 1"); + + t.HasCheckConstraint("CK_Rules_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleVersions", null, t => + { + t.HasCheckConstraint("CK_RuleVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("PublishedVersion") + .HasColumnType("INTEGER"); + + b.Property("SiteId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SiteId", "Slug") + .IsUnique(); + + b.ToTable("ContentTypes", null, t => + { + t.HasCheckConstraint("CK_ContentTypes_PublishedVersion", "\"PublishedVersion\" IS NULL OR \"PublishedVersion\" >= 1"); + + t.HasCheckConstraint("CK_ContentTypes_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ContentTypeId") + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ContentTypeId", "Version") + .IsUnique(); + + b.ToTable("ContentTypeVersions", null, t => + { + t.HasCheckConstraint("CK_ContentTypeVersions_Version", "\"Version\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Sites.SiteRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Sites", (string)null); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + Name = "Default" + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookDeliveryRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Attempt") + .HasColumnType("INTEGER"); + + b.Property("AttemptedAt") + .HasColumnType("TEXT"); + + b.Property("ChangeSetId") + .HasColumnType("TEXT"); + + b.Property("DeliveryId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("TEXT"); + + b.Property("Error") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("Event") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("StatusCode") + .HasColumnType("INTEGER"); + + b.Property("SubscriptionId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SubscriptionId", "Id"); + + b.ToTable("WebhookDeliveries", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Webhooks.WebhookSubscriptionRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("TEXT"); + + b.Property("AddedById") + .HasColumnType("TEXT"); + + b.Property("AddedByName") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DisabledAt") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Events") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProtectedSecret") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebhookSubscriptions", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("UserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("UserTokens", (string)null); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Agents.ToolCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetOperationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("RevertsChangeSetId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckResultRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRunRow", null) + .WithMany() + .HasForeignKey("RunId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckRunRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Checks.CheckVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryReferenceRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Content.EntryVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Delivery.PreviewTokenRow", b => + { + b.HasOne("FluentCMS.Infrastructure.ChangeSets.ChangeSetRow", null) + .WithMany() + .HasForeignKey("ChangeSetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Entities.EntityFactRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Content.EntryRow", null) + .WithMany() + .HasForeignKey("EntryId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Media.MediaAssetVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Media.MediaAssetRow", null) + .WithMany() + .HasForeignKey("AssetId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Models.ModelCallRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Agents.AgentCredentialRow", null) + .WithMany() + .HasForeignKey("AgentCredentialId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Routing.RoutingRuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Routing.RoutingRuleRow", null) + .WithMany() + .HasForeignKey("RoutingRuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCheckProposalRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCompilationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) + .WithMany() + .HasForeignKey("SiteId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Schema.ContentTypeVersionRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Schema.ContentTypeRow", null) + .WithMany() + .HasForeignKey("ContentTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("FluentCMS.Infrastructure.Persistence.FluentCmsUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019223901_RuleCompilations.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019223901_RuleCompilations.cs new file mode 100644 index 000000000..c68e8e337 --- /dev/null +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/20261019223901_RuleCompilations.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FluentCMS.Infrastructure.Persistence.Migrations.Sqlite +{ + /// + public partial class RuleCompilations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "RuleCheckProposals", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RuleId = table.Column(type: "TEXT", nullable: false), + RuleVersion = table.Column(type: "INTEGER", nullable: false), + Number = table.Column(type: "INTEGER", nullable: false), + CheckName = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Definition = table.Column(type: "TEXT", nullable: false), + State = table.Column(type: "TEXT", maxLength: 16, nullable: false), + CheckId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RuleCheckProposals", x => x.Id); + table.CheckConstraint("CK_RuleCheckProposals_Number", "\"Number\" >= 1"); + table.CheckConstraint("CK_RuleCheckProposals_RuleVersion", "\"RuleVersion\" >= 1"); + table.ForeignKey( + name: "FK_RuleCheckProposals_Checks_CheckId", + column: x => x.CheckId, + principalTable: "Checks", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_RuleCheckProposals_Rules_RuleId", + column: x => x.RuleId, + principalTable: "Rules", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "RuleCompilations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + RuleId = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 16, nullable: false), + Message = table.Column(type: "TEXT", maxLength: 2000, nullable: true), + JobId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RuleCompilations", x => x.Id); + table.CheckConstraint("CK_RuleCompilations_Version", "\"Version\" >= 1"); + table.ForeignKey( + name: "FK_RuleCompilations_Rules_RuleId", + column: x => x.RuleId, + principalTable: "Rules", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_RuleCheckProposals_CheckId", + table: "RuleCheckProposals", + column: "CheckId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RuleCheckProposals_RuleId_Number", + table: "RuleCheckProposals", + columns: new[] { "RuleId", "Number" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_RuleCheckProposals_RuleId_RuleVersion", + table: "RuleCheckProposals", + columns: new[] { "RuleId", "RuleVersion" }); + + migrationBuilder.CreateIndex( + name: "IX_RuleCompilations_RuleId_Version", + table: "RuleCompilations", + columns: new[] { "RuleId", "Version" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "RuleCheckProposals"); + + migrationBuilder.DropTable( + name: "RuleCompilations"); + } + } +} diff --git a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs index b4f2b4abc..29fe51c87 100644 --- a/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs +++ b/src/FluentCMS.Infrastructure/Persistence/Migrations/Sqlite/SqliteFluentCmsDbContextModelSnapshot.cs @@ -1156,6 +1156,89 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCheckProposalRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CheckId") + .HasColumnType("TEXT"); + + b.Property("CheckName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Definition") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("RuleId") + .HasColumnType("TEXT"); + + b.Property("RuleVersion") + .HasColumnType("INTEGER"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CheckId") + .IsUnique(); + + b.HasIndex("RuleId", "Number") + .IsUnique(); + + b.HasIndex("RuleId", "RuleVersion"); + + b.ToTable("RuleCheckProposals", null, t => + { + t.HasCheckConstraint("CK_RuleCheckProposals_Number", "\"Number\" >= 1"); + + t.HasCheckConstraint("CK_RuleCheckProposals_RuleVersion", "\"RuleVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCompilationRow", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("JobId") + .HasColumnType("TEXT"); + + b.Property("Message") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("RuleId") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RuleId", "Version") + .IsUnique(); + + b.ToTable("RuleCompilations", null, t => + { + t.HasCheckConstraint("CK_RuleCompilations_Version", "\"Version\" >= 1"); + }); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => { b.Property("Id") @@ -1653,6 +1736,29 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCheckProposalRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Checks.CheckRow", null) + .WithMany() + .HasForeignKey("CheckId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleCompilationRow", b => + { + b.HasOne("FluentCMS.Infrastructure.Rules.RuleRow", null) + .WithMany() + .HasForeignKey("RuleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + modelBuilder.Entity("FluentCMS.Infrastructure.Rules.RuleRow", b => { b.HasOne("FluentCMS.Infrastructure.Sites.SiteRow", null) diff --git a/src/FluentCMS.Infrastructure/Rules/RuleChecks.cs b/src/FluentCMS.Infrastructure/Rules/RuleChecks.cs new file mode 100644 index 000000000..973d4c85e --- /dev/null +++ b/src/FluentCMS.Infrastructure/Rules/RuleChecks.cs @@ -0,0 +1,400 @@ +using System.Text.Json; +using FluentCMS.Core.Audit; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Persistence; +using FluentCMS.Core.Results; +using FluentCMS.Core.Rules; +using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; +using FluentCMS.Infrastructure.Checks; +using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace FluentCMS.Infrastructure.Rules; + +// Every expected failure is found before the unit of work begins. Reads never track rows, so a long-lived context sees what is stored. +internal sealed class RuleChecks( + FluentCmsDbContext context, + IUnitOfWork unitOfWork, + IAuditLog auditLog, + ICurrentInitiator currentInitiator, + IJobQueue jobs, + RuleCompiler compiler, + CheckDirectory checks) : IRuleChecks +{ + private const string SubjectType = "rule"; + + private const string NotCompiled = "No checks have been proposed for this version, so the rule is advisory; propose checks for it."; + + // Until multi-site (MST-01), every rule belongs to the one site. + private static readonly Guid SiteId = Site.Default.Id; + + public async Task> GetAsync(string slug, int version, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(slug); + + var rule = await FindAsync(slug, cancellationToken); + if (rule is null) + { + return Result.Failure([NotFound(slug)]); + } + + if (!await context.RuleVersions.AnyAsync(stored => stored.RuleId == rule.Id && stored.Version == version, cancellationToken)) + { + return Result.Failure([VersionNotFound(rule, version)]); + } + + var compilation = await context.RuleCompilations.AsNoTracking() + .SingleOrDefaultAsync(row => row.RuleId == rule.Id && row.Version == version, cancellationToken); + var proposals = await context.RuleCheckProposals.AsNoTracking() + .Where(row => row.RuleId == rule.Id && row.RuleVersion == version) + .OrderBy(row => row.Number) + .ToListAsync(cancellationToken); + + return Result.Success(new RuleCompilation( + rule.Slug, + version, + compilation?.Status ?? RuleCompileStatus.Advisory, + compilation is null ? NotCompiled : compilation.Message, + compilation?.JobId, + [.. proposals.Select(ToProposal)])); + } + + public async Task> CompileAsync(string slug, int version, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(slug); + + if (PeopleOnly("propose checks for") is { } refused) + { + return Result.Failure([refused]); + } + + var rule = await FindAsync(slug, cancellationToken); + if (rule is null) + { + return Result.Failure([NotFound(slug)]); + } + + if (!await context.RuleVersions.AnyAsync(stored => stored.RuleId == rule.Id && stored.Version == version, cancellationToken)) + { + return Result.Failure([VersionNotFound(rule, version)]); + } + + await using (var scope = await unitOfWork.BeginAsync(cancellationToken)) + { + await context.RuleCheckProposals + .Where(row => row.RuleId == rule.Id && row.RuleVersion == version && row.State == RuleProposalState.Proposed) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.State, RuleProposalState.Replaced), cancellationToken); + await ScheduleAsync(rule.Id, version, replace: true, cancellationToken); + await auditLog.AppendAsync(new NewAuditEvent("rule.compile_requested") { SubjectType = SubjectType, SubjectId = rule.Id }, cancellationToken); + + try + { + await scope.CommitAsync(cancellationToken); + } + catch (DbUpdateException exception) when (DatabaseErrors.IsUniqueViolation(exception)) + { + return Result.Failure( + RuleErrorCodes.VersionConflict, + $"Checks for version {version} of the rule '{slug}' were proposed again at the same time; read it again."); + } + } + + return await GetAsync(slug, version, cancellationToken); + } + + public async Task> ConfirmAsync( + string slug, + int number, + CheckDefinition? definition = null, + string? checkName = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(slug); + + if (PeopleOnly("confirm a check for") is { } refused) + { + return Result.Failure([refused]); + } + + var found = await UndecidedAsync(slug, number, cancellationToken); + if (!found.IsSuccess) + { + return Result.Failure(found.Errors); + } + + var (rule, proposal) = found.Value; + var prepared = await checks.PrepareActiveAsync(new NewCheck(checkName ?? proposal.CheckName, definition ?? ToProposal(proposal).Definition), cancellationToken); + if (!prepared.IsSuccess) + { + return Result.Failure(prepared.Errors); + } + + var check = prepared.Value; + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + await checks.AddActiveAsync(check, cancellationToken); + + // The check is stored first, so the proposal can name it. + try + { + await context.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (DatabaseErrors.IsUniqueViolation(exception)) + { + return Result.Failure([CheckDirectory.NameTaken(check.Name)]); + } + + // Decides the proposal only from where it was read, so of two concurrent decisions the second changes nothing. + var decided = await context.RuleCheckProposals + .Where(row => row.Id == proposal.Id && row.State == RuleProposalState.Proposed) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(row => row.State, RuleProposalState.Confirmed) + .SetProperty(row => row.CheckId, check.Id) + .SetProperty(row => row.CheckName, check.Name), + cancellationToken); + if (decided == 0) + { + return Result.Failure([Decided(slug, number)]); + } + + await auditLog.AppendAsync(new NewAuditEvent("rule.check_confirmed") { SubjectType = SubjectType, SubjectId = rule.Id }, cancellationToken); + await scope.CommitAsync(cancellationToken); + + return Result.Success(ToProposal(proposal) with { CheckName = check.Name, State = RuleProposalState.Confirmed }); + } + + public async Task> RejectAsync(string slug, int number, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(slug); + + if (PeopleOnly("reject a check for") is { } refused) + { + return Result.Failure([refused]); + } + + var found = await UndecidedAsync(slug, number, cancellationToken); + if (!found.IsSuccess) + { + return Result.Failure(found.Errors); + } + + var (rule, proposal) = found.Value; + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + var decided = await context.RuleCheckProposals + .Where(row => row.Id == proposal.Id && row.State == RuleProposalState.Proposed) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.State, RuleProposalState.Rejected), cancellationToken); + if (decided == 0) + { + return Result.Failure([Decided(slug, number)]); + } + + await auditLog.AppendAsync(new NewAuditEvent("rule.check_rejected") { SubjectType = SubjectType, SubjectId = rule.Id }, cancellationToken); + await scope.CommitAsync(cancellationToken); + + return Result.Success(ToProposal(proposal) with { State = RuleProposalState.Rejected }); + } + + public async Task> CompilingAsync(Guid ruleId, int version, Guid jobId, CancellationToken cancellationToken = default) + { + if (EngineOnly() is { } refused) + { + return Result.Failure([refused]); + } + + var stored = await ( + from compilation in context.RuleCompilations.AsNoTracking() + join definition in context.RuleVersions.AsNoTracking() + on new { compilation.RuleId, compilation.Version } equals new { definition.RuleId, definition.Version } + where compilation.RuleId == ruleId && compilation.Version == version + select new { compilation.Status, compilation.JobId, definition.Definition }) + .SingleOrDefaultAsync(cancellationToken); + + return stored is { Status: RuleCompileStatus.Compiling } && stored.JobId == jobId + ? RuleDefinition.Parse(stored.Definition) + : Result.Failure([Replaced(version)]); + } + + public async Task RecordAsync(Guid ruleId, int version, Guid jobId, RuleCompileOutcome outcome, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(outcome); + + if (EngineOnly() is { } refused) + { + return Result.Failure([refused]); + } + + var rule = await context.Rules.AsNoTracking().SingleOrDefaultAsync(row => row.Id == ruleId, cancellationToken); + if (rule is null || !await CompilingQuery(ruleId, version, jobId).AnyAsync(cancellationToken)) + { + return Result.Failure([Replaced(version)]); + } + + var next = (await context.RuleCheckProposals.Where(row => row.RuleId == ruleId).MaxAsync(row => (int?)row.Number, cancellationToken) ?? 0) + 1; + var status = outcome.Status == RuleCompileStatus.Proposed && outcome.Proposals.Count > 0 ? RuleCompileStatus.Proposed : RuleCompileStatus.Advisory; + var message = outcome.Message is { Length: > RuleCompilationRow.MaxMessageLength } text ? text[..RuleCompilationRow.MaxMessageLength] : outcome.Message; + + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + + // Moves the compile on only while this job is still the version's; a request to propose again replaces it. + var moved = await CompilingQuery(ruleId, version, jobId) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.Status, status).SetProperty(row => row.Message, message), cancellationToken); + if (moved == 0) + { + return Result.Failure([Replaced(version)]); + } + + if (status == RuleCompileStatus.Proposed) + { + foreach (var proposal in outcome.Proposals) + { + context.RuleCheckProposals.Add(new RuleCheckProposalRow + { + Id = Guid.CreateVersion7(), + RuleId = ruleId, + RuleVersion = version, + Number = next, + CheckName = CheckName(rule.Slug, next), + Definition = proposal.ToJson(), + State = RuleProposalState.Proposed, + }); + next++; + } + } + + await auditLog.AppendAsync( + new NewAuditEvent("rule.compiled") { SubjectType = SubjectType, SubjectId = ruleId, Model = outcome.Model, PromptHash = outcome.PromptHash }, + cancellationToken); + + // Two compiles of one rule that number their proposals alike conflict here, and the job's next attempt numbers them again. + await scope.CommitAsync(cancellationToken); + return Result.Success(); + } + + // Starts proposing checks for a version inside the caller's unit of work: a compile job when a model is configured, and otherwise + // the version is advisory at once (MDL-03). replace is false for a version just written, which has no compile yet. + internal async Task ScheduleAsync(Guid ruleId, int version, bool replace, CancellationToken cancellationToken) + { + Guid? jobId = null; + if (compiler.IsAvailable) + { + var job = await jobs.EnqueueAsync( + new NewJob(RuleCompiler.JobType, JsonSerializer.SerializeToElement(new { ruleId, version })) { Subject = new JobSubject(SubjectType, ruleId) }, + cancellationToken); + jobId = job.Id; + } + + var status = jobId is null ? RuleCompileStatus.Advisory : RuleCompileStatus.Compiling; + var message = jobId is null ? RuleCompiler.NoModel : null; + var replaced = replace + ? await context.RuleCompilations + .Where(row => row.RuleId == ruleId && row.Version == version) + .ExecuteUpdateAsync( + setters => setters.SetProperty(row => row.Status, status).SetProperty(row => row.Message, message).SetProperty(row => row.JobId, jobId), + cancellationToken) + : 0; + + if (replaced == 0) + { + context.RuleCompilations.Add(new RuleCompilationRow + { + Id = Guid.CreateVersion7(), + RuleId = ruleId, + Version = version, + Status = status, + Message = message, + JobId = jobId, + }); + } + } + + // A rule's proposals are named after it, numbered so each rule's are distinct, and cut to fit a check name. + internal static string CheckName(string slug, int number) + { + var suffix = $"-{number}"; + var head = slug.Length + suffix.Length > SchemaNames.MaxLength ? slug[..(SchemaNames.MaxLength - suffix.Length)].TrimEnd('-') : slug; + return head + suffix; + } + + private IQueryable CompilingQuery(Guid ruleId, int version, Guid jobId) + { + return context.RuleCompilations.Where(row => + row.RuleId == ruleId && row.Version == version && row.JobId == jobId && row.Status == RuleCompileStatus.Compiling); + } + + private async Task> UndecidedAsync(string slug, int number, CancellationToken cancellationToken) + { + var rule = await FindAsync(slug, cancellationToken); + if (rule is null) + { + return Result<(RuleRow, RuleCheckProposalRow)>.Failure([NotFound(slug)]); + } + + var proposal = await context.RuleCheckProposals.AsNoTracking() + .SingleOrDefaultAsync(row => row.RuleId == rule.Id && row.Number == number, cancellationToken); + if (proposal is null) + { + return Result<(RuleRow, RuleCheckProposalRow)>.Failure( + RuleErrorCodes.ProposalNotFound, + $"The rule '{slug}' has no proposed check {number}; read the rule's proposals for the numbers it has."); + } + + return proposal.State == RuleProposalState.Proposed + ? Result<(RuleRow, RuleCheckProposalRow)>.Success((rule, proposal)) + : Result<(RuleRow, RuleCheckProposalRow)>.Failure([Decided(slug, number)]); + } + + // Deciding which checks guard content is a person's (D17): no agent, whatever its scope, and no engine. + private Error? PeopleOnly(string action) + { + return currentInitiator.Current switch + { + AgentInitiator => new Error(RuleErrorCodes.PeopleOnly, $"Only a person can {action} a rule; an agent reads the rule book and cannot."), + SystemInitiator system => new Error(RuleErrorCodes.PeopleOnly, $"The '{system.Component}' engine cannot {action} a rule; a person does."), + _ => null, + }; + } + + // What a compile proposed is recorded only by the check engine's job, never by a caller who could make proposals up. + private Error? EngineOnly() + { + return currentInitiator.Current == SystemInitiator.Checks + ? null + : new Error(RuleErrorCodes.EngineOnly, "Only the check engine records what a rule's compile proposed; propose checks for the rule instead."); + } + + private Task FindAsync(string slug, CancellationToken cancellationToken) + { + return context.Rules.AsNoTracking().SingleOrDefaultAsync(row => row.SiteId == SiteId && row.Slug == slug, cancellationToken); + } + + private static RuleCheckProposal ToProposal(RuleCheckProposalRow row) + { + var definition = CheckDefinition.Parse(row.Definition); + return definition.IsSuccess + ? new RuleCheckProposal(row.Number, row.RuleVersion, row.CheckName, definition.Value, row.State) + : throw new InvalidOperationException($"Proposed check {row.Number} cannot be read: {definition.Errors[0].Message}"); + } + + private static Error NotFound(string slug) + { + return new Error(RuleErrorCodes.NotFound, $"There is no rule '{slug}'; list the rule book for the rules it holds."); + } + + private static Error VersionNotFound(RuleRow rule, int version) + { + return new Error(RuleErrorCodes.VersionNotFound, $"The rule '{rule.Slug}' has no version {version}; its latest version is {rule.Version}."); + } + + private static Error Decided(string slug, int number) + { + return new Error(RuleErrorCodes.ProposalDecided, $"Proposed check {number} of the rule '{slug}' has already been confirmed, rejected or replaced."); + } + + private static Error Replaced(int version) + { + return new Error(RuleErrorCodes.CompileReplaced, $"Checks for version {version} were proposed again since this compile began, so it records nothing."); + } +} diff --git a/src/FluentCMS.Infrastructure/Rules/RuleDirectory.cs b/src/FluentCMS.Infrastructure/Rules/RuleDirectory.cs index 7a3bc057f..e504dfe79 100644 --- a/src/FluentCMS.Infrastructure/Rules/RuleDirectory.cs +++ b/src/FluentCMS.Infrastructure/Rules/RuleDirectory.cs @@ -12,13 +12,14 @@ namespace FluentCMS.Infrastructure.Rules; // Each write joins its change set as content types do (SCH-05): every expected failure is found before the unit of work, and the -// rule's rows, its event and its operation commit together. +// rule's rows, its event and its operation commit together. Saving a version also starts proposing checks for it (RUL-04). internal sealed class RuleDirectory( FluentCmsDbContext context, IUnitOfWork unitOfWork, IAuditLog auditLog, ICurrentInitiator currentInitiator, - ChangeSetDirectory changeSets) : IRuleDirectory + ChangeSetDirectory changeSets, + RuleChecks ruleChecks) : IRuleDirectory { private const string SubjectType = "rule"; @@ -105,6 +106,7 @@ public async Task> CreateAsync(NewRule rule, ChangeSetTarget change context.Rules.Add(new RuleRow { Id = written.Id, SiteId = written.SiteId, Slug = written.Slug, Version = written.Version, State = RuleState.Draft }); context.RuleVersions.Add(VersionRow(written)); + await ruleChecks.ScheduleAsync(written.Id, written.Version, replace: false, cancellationToken); await auditLog.AppendAsync( new NewAuditEvent("rule.created") { SubjectType = SubjectType, SubjectId = written.Id, ChangeSetId = pending.Value.ChangeSet.Id }, cancellationToken); @@ -194,6 +196,7 @@ public async Task> ReviseAsync( } context.RuleVersions.Add(VersionRow(revised)); + await ruleChecks.ScheduleAsync(revised.Id, revised.Version, replace: false, cancellationToken); await auditLog.AppendAsync( new NewAuditEvent("rule.revised") { SubjectType = SubjectType, SubjectId = row.Id, ChangeSetId = pending.Value.ChangeSet.Id, PreviousVersionId = based.Id }, cancellationToken); diff --git a/src/FluentCMS.Infrastructure/Rules/RuleRows.cs b/src/FluentCMS.Infrastructure/Rules/RuleRows.cs index a07253120..281af1896 100644 --- a/src/FluentCMS.Infrastructure/Rules/RuleRows.cs +++ b/src/FluentCMS.Infrastructure/Rules/RuleRows.cs @@ -1,5 +1,6 @@ using FluentCMS.Core.Rules; using FluentCMS.Core.Schema; +using FluentCMS.Infrastructure.Checks; using FluentCMS.Infrastructure.Sites; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -35,6 +36,45 @@ public sealed class RuleVersionRow public string Definition { get; set; } = string.Empty; } +// How proposing checks for one version of a rule went (RUL-04); a later request to propose again replaces it. +public sealed class RuleCompilationRow +{ + public const int MaxMessageLength = 2000; + + public Guid Id { get; set; } + + public Guid RuleId { get; set; } + + public int Version { get; set; } + + public RuleCompileStatus Status { get; set; } + + public string? Message { get; set; } + + // The job that is or was proposing; only that job records what it found. + public Guid? JobId { get; set; } +} + +// A check proposed for a version of a rule, numbered within the rule. CheckId is the check it became once confirmed. +public sealed class RuleCheckProposalRow +{ + public Guid Id { get; set; } + + public Guid RuleId { get; set; } + + public int RuleVersion { get; set; } + + public int Number { get; set; } + + public string CheckName { get; set; } = string.Empty; + + public string Definition { get; set; } = string.Empty; + + public RuleProposalState State { get; set; } + + public Guid? CheckId { get; set; } +} + internal sealed class RuleConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder row) @@ -71,3 +111,45 @@ public void Configure(EntityTypeBuilder row) row.HasIndex(version => new { version.RuleId, version.Version }).IsUnique(); } } + +internal sealed class RuleCompilationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("RuleCompilations", table => table.HasCheckConstraint("CK_RuleCompilations_Version", "\"Version\" >= 1")); + row.HasKey(compilation => compilation.Id); + row.Property(compilation => compilation.Id).ValueGeneratedNever(); + row.Property(compilation => compilation.Status).HasConversion().HasMaxLength(16); + row.Property(compilation => compilation.Message).HasMaxLength(RuleCompilationRow.MaxMessageLength); + + row.HasOne().WithMany().HasForeignKey(compilation => compilation.RuleId).OnDelete(DeleteBehavior.Restrict); + row.HasIndex(compilation => new { compilation.RuleId, compilation.Version }).IsUnique(); + } +} + +internal sealed class RuleCheckProposalConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder row) + { + row.ToTable("RuleCheckProposals", table => + { + table.HasCheckConstraint("CK_RuleCheckProposals_RuleVersion", "\"RuleVersion\" >= 1"); + table.HasCheckConstraint("CK_RuleCheckProposals_Number", "\"Number\" >= 1"); + }); + row.HasKey(proposal => proposal.Id); + row.Property(proposal => proposal.Id).ValueGeneratedNever(); + row.Property(proposal => proposal.CheckName).HasMaxLength(SchemaNames.MaxLength).IsRequired(); + row.Property(proposal => proposal.Definition).IsRequired(); + row.Property(proposal => proposal.State).HasConversion().HasMaxLength(16); + + row.HasOne().WithMany().HasForeignKey(proposal => proposal.RuleId).OnDelete(DeleteBehavior.Restrict); + row.HasOne().WithMany().HasForeignKey(proposal => proposal.CheckId).OnDelete(DeleteBehavior.Restrict); + + // Also what stops two compiles of one rule from numbering proposals alike. + row.HasIndex(proposal => new { proposal.RuleId, proposal.Number }).IsUnique(); + row.HasIndex(proposal => new { proposal.RuleId, proposal.RuleVersion }); + + // A check is confirmed from one proposal at most, which the check run reads to find the rule it serves. + row.HasIndex(proposal => proposal.CheckId).IsUnique(); + } +} diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs index fc115b682..604f4eeb3 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs @@ -54,17 +54,25 @@ public Initiator? Initiator set => _initiator.Current = value; } - public static async Task CreateAsync(string provider) + // settings and configure add to the database's configuration and the test doubles, such as a model profile and its transport. + public static async Task CreateAsync( + string provider, + IReadOnlyDictionary? settings = null, + Action? configure = null) { var database = await TemporaryDatabase.CreateAsync(provider); var initiator = new SwitchableInitiator { Current = Agent }; var clock = new TickingClock(); var auditFailure = new AuditFailure(); var services = TestServices.Build( - database.Configuration, - collection => collection - .AddSingleton(initiator) - .AddSingleton(clock), + database.Configuration.Concat(settings ?? new Dictionary()), + collection => + { + collection + .AddSingleton(initiator) + .AddSingleton(clock); + configure?.Invoke(collection); + }, collection => FailingAuditLog.Wrap(collection, auditFailure)); await TestServices.MigrateAsync(services); diff --git a/tests/FluentCMS.IntegrationTests/Rules/RuleCheckTests.cs b/tests/FluentCMS.IntegrationTests/Rules/RuleCheckTests.cs new file mode 100644 index 000000000..08de03d60 --- /dev/null +++ b/tests/FluentCMS.IntegrationTests/Rules/RuleCheckTests.cs @@ -0,0 +1,455 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using FluentCMS.Core.ChangeSets; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Results; +using FluentCMS.Core.Rules; +using FluentCMS.Core.Security; +using FluentCMS.Infrastructure.Models; +using FluentCMS.Infrastructure.Persistence; +using FluentCMS.IntegrationTests.ChangeSets; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using static FluentCMS.IntegrationTests.ChangeSets.ChangeSetScenario; + +namespace FluentCMS.IntegrationTests.Rules; + +// RUL-04, MDL-03: saving a rule proposes checks for it, which its author confirms, edits or rejects; a confirmed check guards content +// only while the rule is active at the version it was proposed for. +public sealed class RuleCheckTests +{ + private const string Slug = "no-superlatives"; + + private static readonly RuleDefinition NoSuperlatives = new( + RuleKind.VoiceAndStyle, + "Never claim best in class.", + CheckSeverity.Block, + new RuleScope(["product-page"], ["headline"])); + + private static readonly Dictionary Model = new() + { + ["Models:Profiles:rules:Provider"] = "openai", + ["Models:Profiles:rules:Model"] = "gpt-fake", + ["Models:Profiles:rules:ApiKey"] = "sk-test", + ["Models:Profiles:rules:InputCostPerMillionTokens"] = "1", + ["Models:Profiles:rules:OutputCostPerMillionTokens"] = "4", + ["Outbound:AllowedHosts:0"] = "api.openai.com", + }; + + private const string Terms = """ + {"type": "terms", "description": "Product copy never claims best in class.", "settings": "{\"terms\": [\"best in class\"]}", + "examples": [ + {"name": "plain claim", "expect": "pass", "contentType": "product-page", "fields": "{\"headline\": \"A kettle that boils in two minutes\"}"}, + {"name": "superlative", "expect": "fail", "contentType": "product-page", "fields": "{\"headline\": \"The best in class kettle\"}"}]} + """; + + private const string Pattern = """ + {"type": "pattern", "description": "Product copy never says best-in-class in any spelling.", "settings": "{\"pattern\": \"best.in.class\", \"ignoreCase\": true}", + "examples": [ + {"name": "plain claim", "expect": "pass", "contentType": "product-page", "fields": "{\"headline\": \"A kettle that boils in two minutes\"}"}, + {"name": "hyphenated", "expect": "fail", "contentType": "product-page", "fields": "{\"headline\": \"Best-in-class kettle\"}"}]} + """; + + private static readonly string TwoChecks = $$"""{"checks": [{{Terms}}, {{Pattern}}]}"""; + + private static readonly CheckSubject Superlative = new(JsonDocument.Parse("""{"headline": "The best in class kettle"}""").RootElement.Clone(), [], "product-page"); + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Without_a_model_a_saved_rule_is_advisory_and_no_compile_is_queued(string provider) + { + await using var scenario = await ScenarioAsync(provider, model: null); + + var saved = await SavedAsync(scenario); + var compilation = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value; + + Assert.True(saved.Rule.IsSuccess); + Assert.Equal((RuleCompileStatus.Advisory, RuleCompiler.NoModel, (Guid?)null), (compilation.Status, compilation.Message, compilation.JobId)); + Assert.Empty(compilation.Proposals); + Assert.False(await scenario.QueryAsync(context => context.Jobs.AnyAsync(row => row.Type == RuleCompiler.JobType))); + } + + // The compile job runs as the check engine for the person who saved the rule; the rule reaches the model only inside a data + // section of the prompt (SEC-03, RUL-07), and the call and its prompt are recorded (MDL-04). + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task The_compile_proposes_checks_as_the_check_engine_with_the_rule_only_in_the_prompts_data(string provider) + { + var model = new CompilingProvider(TwoChecks); + await using var scenario = await ScenarioAsync(provider, model); + var saved = await SavedAsync(scenario); + + var compiling = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value; + var job = await scenario.QueryAsync(context => context.Jobs.AsNoTracking().SingleAsync(row => row.Type == RuleCompiler.JobType)); + var ran = await RunCompileAsync(scenario, compiling.JobId!.Value); + var compiled = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value; + + Assert.Equal((RuleCompileStatus.Compiling, (string?)null, (Guid?)job.Id), (compiling.Status, compiling.Message, compiling.JobId)); + Assert.Equal(("user", Amir.UserId, "rule", saved.Rule.Value.Id), (job.InitiatorKind, job.InitiatorId!.Value, job.SubjectType, job.SubjectId!.Value)); + Assert.True(ran.IsSuccess, string.Join("; ", ran.Errors.Select(error => error.Message))); + Assert.Equal((RuleCompileStatus.Proposed, (string?)null), (compiled.Status, compiled.Message)); + Assert.Equal( + [(1, 1, "no-superlatives-1", "terms", RuleProposalState.Proposed), (2, 1, "no-superlatives-2", "pattern", RuleProposalState.Proposed)], + compiled.Proposals.Select(proposal => (proposal.Number, proposal.RuleVersion, proposal.CheckName, proposal.Definition.Type, proposal.State))); + Assert.Equal(["product-page"], compiled.Proposals[0].Definition.Scope.ContentTypes!); + + var call = await scenario.QueryAsync(context => context.ModelCalls.AsNoTracking().SingleAsync()); + Assert.Equal(("rules", "rule:compile"), (call.Profile, call.Purpose)); + var messages = JsonSerializer.Deserialize(call.Prompt!).EnumerateArray().Select(message => message.GetProperty("text").GetString()!).ToList(); + Assert.DoesNotContain("Never claim best in class.", messages[0], StringComparison.Ordinal); + Assert.Matches(@"<<>>\nNever claim best in class.\n<<>>", messages[1]); + + var compiledEvent = await scenario.QueryAsync(context => context.AuditEvents.AsNoTracking().SingleAsync(row => row.Operation == "rule.compiled")); + Assert.Equal( + ("system", "checks", saved.Rule.Value.Id, call.PromptHash), + (compiledEvent.InitiatorKind, compiledEvent.InitiatorName, compiledEvent.SubjectId!.Value, compiledEvent.PromptHash)); + Assert.NotNull(compiledEvent.Model); + } + + // UX-01, S2: the author confirms the proposed terms check; it guards content once the set publishes the rule, and stops once the + // rule is retired. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_confirmed_check_guards_content_only_while_its_rule_is_active(string provider) + { + await using var scenario = await ScenarioAsync(provider, new CompilingProvider(TwoChecks)); + var saved = await SavedAsync(scenario); + await CompiledAsync(scenario, 1); + + var confirmed = await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 1)); + + Assert.True(confirmed.IsSuccess, string.Join("; ", confirmed.Errors.Select(error => error.Message))); + Assert.Equal(("no-superlatives-1", RuleProposalState.Confirmed), (confirmed.Value.CheckName, confirmed.Value.State)); + var check = (await ChecksAsync(scenario, checks => checks.GetAsync("no-superlatives-1"))).Value; + Assert.Equal((1, (int?)1, "terms"), (check.Version, check.ActiveVersion, check.Definition.Type)); + var events = await scenario.QueryAsync(context => context.AuditEvents.AsNoTracking() + .Where(row => row.Operation == "check.created" || row.Operation == "check.activated" || row.Operation == "rule.check_confirmed") + .OrderBy(row => row.Id) + .ToListAsync()); + Assert.Equal( + [("check.created", check.Id), ("check.activated", check.Id), ("rule.check_confirmed", saved.Rule.Value.Id)], + events.Select(row => (row.Operation, row.SubjectId!.Value))); + Assert.All(events, row => Assert.Equal(("user", "Amir"), (row.InitiatorKind, row.InitiatorName))); + + Assert.DoesNotContain("no-superlatives-1", await ActiveChecksAsync(scenario)); + Assert.Empty((await RunChecksAsync(scenario)).Results); + + await scenario.PublishAsync(saved.ChangeSetId); + + Assert.Contains("no-superlatives-1", await ActiveChecksAsync(scenario)); + var failed = Assert.Single((await RunChecksAsync(scenario)).Results); + Assert.Equal(("no-superlatives-1", CheckStatus.Fail), (failed.Check, failed.Status)); + + var retiring = await scenario.OpenAsync(); + Assert.True((await RulesAsync(scenario, rules => rules.RetireAsync(Slug, ChangeSetTarget.Existing(retiring.Id)))).IsSuccess); + await scenario.PublishAsync(retiring.Id); + + Assert.DoesNotContain("no-superlatives-1", await ActiveChecksAsync(scenario)); + } + + // The checks confirmed for a revision take over once its set publishes it; until then the active version's checks guard content. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_revisions_checks_take_over_when_the_revision_is_published(string provider) + { + await using var scenario = await ScenarioAsync(provider, new CompilingProvider(TwoChecks)); + var saved = await SavedAsync(scenario); + await CompiledAsync(scenario, 1); + Assert.True((await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 1))).IsSuccess); + await scenario.PublishAsync(saved.ChangeSetId); + + var revising = await scenario.OpenAsync(); + var revised = new RuleDefinition(RuleKind.VoiceAndStyle, "Never claim best in class, in any spelling.", CheckSeverity.Block, NoSuperlatives.Scope); + Assert.True((await RulesAsync(scenario, rules => rules.ReviseAsync(Slug, 1, revised, ChangeSetTarget.Existing(revising.Id)))).IsSuccess); + var compiled = await CompiledAsync(scenario, 2); + Assert.Equal([3, 4], compiled.Proposals.Select(proposal => proposal.Number)); + Assert.True((await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 4))).IsSuccess); + + Assert.Equal(["no-superlatives-1"], await ActiveChecksAsync(scenario)); + + await scenario.PublishAsync(revising.Id); + + Assert.Equal(["no-superlatives-4"], await ActiveChecksAsync(scenario)); + } + + // The author can edit a proposal as they confirm it; a confirmation whose examples do not get their verdicts stores nothing. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_confirmation_can_edit_the_check_and_one_whose_examples_fail_stores_nothing(string provider) + { + await using var scenario = await ScenarioAsync(provider, new CompilingProvider(TwoChecks)); + await SavedAsync(scenario); + var proposals = (await CompiledAsync(scenario, 1)).Proposals; + var widened = Edited(proposals[0].Definition, """{"terms": ["best in class", "world-class"]}""", "A world-class kettle"); + var wrong = Edited(proposals[1].Definition, proposals[1].Definition.Settings.GetRawText(), "A calm kettle"); + + var refused = await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 2, wrong)); + var edited = await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 1, widened, "superlatives")); + var taken = await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 2, checkName: "superlatives")); + + Assert.Equal(CheckErrorCodes.ExampleFailed, Assert.Single(refused.Errors).Code); + Assert.Equal(CheckErrorCodes.NameTaken, Assert.Single(taken.Errors).Code); + Assert.Equal(("superlatives", RuleProposalState.Confirmed), (edited.Value.CheckName, edited.Value.State)); + var check = (await ChecksAsync(scenario, checks => checks.GetAsync("superlatives"))).Value; + Assert.Equal(["best in class", "world-class"], check.Definition.Settings.GetProperty("terms").EnumerateArray().Select(term => term.GetString())); + Assert.Equal(["superlatives"], (await ChecksAsync(scenario, checks => checks.ListAsync())).Select(summary => summary.Name)); + + var after = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value.Proposals; + Assert.Equal([RuleProposalState.Confirmed, RuleProposalState.Proposed], after.Select(proposal => proposal.State)); + Assert.Equal( + ["best in class"], + after[0].Definition.Settings.GetProperty("terms").EnumerateArray().Select(term => term.GetString())); + Assert.Single(await scenario.QueryAsync(context => context.AuditEvents.AsNoTracking().Where(row => row.Operation == "rule.check_confirmed").ToListAsync())); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_decided_proposal_cannot_be_decided_again(string provider) + { + await using var scenario = await ScenarioAsync(provider, new CompilingProvider(TwoChecks)); + await SavedAsync(scenario); + await CompiledAsync(scenario, 1); + + var confirmed = await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 1)); + var rejected = await RuleChecksAsync(scenario, rules => rules.RejectAsync(Slug, 2)); + var again = new List> + { + await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 1)), + await RuleChecksAsync(scenario, rules => rules.RejectAsync(Slug, 1)), + await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 2)), + await RuleChecksAsync(scenario, rules => rules.RejectAsync(Slug, 2)), + }; + var missing = await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 9)); + + Assert.True(confirmed.IsSuccess); + Assert.Equal(RuleProposalState.Rejected, rejected.Value.State); + Assert.All(again, decided => Assert.Equal(RuleErrorCodes.ProposalDecided, Assert.Single(decided.Errors).Code)); + Assert.Equal(RuleErrorCodes.ProposalNotFound, Assert.Single(missing.Errors).Code); + Assert.Equal( + ["rule.check_confirmed", "rule.check_rejected"], + (await scenario.QueryAsync(context => context.AuditEvents.AsNoTracking().Where(row => row.Operation.StartsWith("rule.check_")).OrderBy(row => row.Id).ToListAsync())) + .Select(row => row.Operation)); + } + + // Proposing again, such as after rejecting what the model proposed, replaces the undecided proposals; a compile it replaced records + // nothing when it ends. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Proposing_again_replaces_the_undecided_proposals_and_the_replaced_compile_records_nothing(string provider) + { + await using var scenario = await ScenarioAsync(provider, new CompilingProvider(TwoChecks)); + var saved = await SavedAsync(scenario); + var first = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value.JobId!.Value; + + var again = (await RuleChecksAsync(scenario, rules => rules.CompileAsync(Slug, 1))).Value; + var replaced = await RunCompileAsync(scenario, first); + var stillCompiling = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value; + + Assert.Equal(RuleCompileStatus.Compiling, again.Status); + Assert.NotEqual(first, again.JobId); + Assert.True(replaced.IsSuccess); + Assert.Equal((RuleCompileStatus.Compiling, again.JobId), (stillCompiling.Status, stillCompiling.JobId)); + Assert.Empty(stillCompiling.Proposals); + + Assert.True((await RunCompileAsync(scenario, again.JobId!.Value)).IsSuccess); + Assert.True((await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 1))).IsSuccess); + var third = (await RuleChecksAsync(scenario, rules => rules.CompileAsync(Slug, 1))).Value; + Assert.Equal([(1, RuleProposalState.Confirmed), (2, RuleProposalState.Replaced)], third.Proposals.Select(proposal => (proposal.Number, proposal.State))); + Assert.Equal(RuleErrorCodes.ProposalDecided, Assert.Single((await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 2))).Errors).Code); + Assert.True((await RunCompileAsync(scenario, third.JobId!.Value)).IsSuccess); + + var compiled = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value; + Assert.Equal( + [(1, RuleProposalState.Confirmed), (2, RuleProposalState.Replaced), (3, RuleProposalState.Proposed), (4, RuleProposalState.Proposed)], + compiled.Proposals.Select(proposal => (proposal.Number, proposal.State))); + scenario.Initiator = SystemInitiator.Checks; + var late = await RuleChecksAsync(scenario, rules => rules.RecordAsync(saved.Rule.Value.Id, 1, first, RuleCompileOutcome.Advisory("late"))); + Assert.Equal(RuleErrorCodes.CompileReplaced, Assert.Single(late.Errors).Code); + Assert.Equal(2, await scenario.QueryAsync(context => context.AuditEvents.CountAsync(row => row.Operation == "rule.compile_requested"))); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_answer_that_cannot_be_read_leaves_the_version_advisory(string provider) + { + await using var scenario = await ScenarioAsync(provider, new CompilingProvider("I would suggest a terms check.")); + await SavedAsync(scenario); + + var compiled = await CompiledAsync(scenario, 1); + + Assert.Equal( + (RuleCompileStatus.Advisory, "The model's answer could not be read, so the rule is advisory until checks are proposed again."), + (compiled.Status, compiled.Message)); + Assert.Empty(compiled.Proposals); + } + + // D17: only a person decides which checks guard content; only the check engine records what a compile proposed. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Only_a_person_decides_and_only_the_check_engine_records(string provider) + { + await using var scenario = await ScenarioAsync(provider, new CompilingProvider(TwoChecks)); + var saved = await SavedAsync(scenario); + var jobId = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value.JobId!.Value; + await CompiledAsync(scenario, 1); + var refusals = new List>(); + var recordings = new List>(); + + foreach (var initiator in new Initiator[] { Agent, SystemInitiator.Checks }) + { + scenario.Initiator = initiator; + refusals.Add((await RuleChecksAsync(scenario, rules => rules.ConfirmAsync(Slug, 1))).Errors); + refusals.Add((await RuleChecksAsync(scenario, rules => rules.RejectAsync(Slug, 1))).Errors); + refusals.Add((await RuleChecksAsync(scenario, rules => rules.CompileAsync(Slug, 1))).Errors); + } + + foreach (var initiator in new Initiator[] { Amir, Agent }) + { + scenario.Initiator = initiator; + recordings.Add((await RuleChecksAsync(scenario, rules => rules.CompilingAsync(saved.Rule.Value.Id, 1, jobId))).Errors); + recordings.Add((await RuleChecksAsync(scenario, rules => rules.RecordAsync(saved.Rule.Value.Id, 1, jobId, RuleCompileOutcome.Advisory("made up")))).Errors); + } + + Assert.All(refusals, errors => Assert.Equal(RuleErrorCodes.PeopleOnly, Assert.Single(errors).Code)); + Assert.All(recordings, errors => Assert.Equal(RuleErrorCodes.EngineOnly, Assert.Single(errors).Code)); + scenario.Initiator = Amir; + Assert.All((await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, 1))).Value.Proposals, proposal => Assert.Equal(RuleProposalState.Proposed, proposal.State)); + Assert.Empty(await ChecksAsync(scenario, checks => checks.ListAsync())); + } + + // The handlers are resolved together, as the worker does, and the webhook handler among them needs the host's secret protector. + private static async Task ScenarioAsync(string provider, CompilingProvider? model) + { + var scenario = await ChangeSetScenario.CreateAsync(provider, model is null ? null : Model, services => + { + services.AddSingleton(); + if (model is not null) + { + services.AddSingleton(new ModelTransport(() => model)); + } + }); + scenario.Initiator = Amir; + await scenario.ContentTypeAsync("product-page"); + return scenario; + } + + private static async Task<(Result Rule, Guid ChangeSetId)> SavedAsync(ChangeSetScenario scenario) + { + var changeSet = await scenario.OpenAsync(); + var created = await RulesAsync(scenario, rules => rules.CreateAsync(new NewRule(Slug, NoSuperlatives), ChangeSetTarget.Existing(changeSet.Id))); + Assert.True(created.IsSuccess, string.Join("; ", created.Errors.Select(error => error.Message))); + return (created, changeSet.Id); + } + + // Runs the version's compile job and reads what it recorded. + private static async Task CompiledAsync(ChangeSetScenario scenario, int version) + { + var jobId = (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, version))).Value.JobId!.Value; + var ran = await RunCompileAsync(scenario, jobId); + Assert.True(ran.IsSuccess, string.Join("; ", ran.Errors.Select(error => error.Message))); + return (await RuleChecksAsync(scenario, rules => rules.GetAsync(Slug, version))).Value; + } + + // As the job worker runs it: the handler as the engine its type is listed for, with the job as stored. + private static async Task RunCompileAsync(ChangeSetScenario scenario, Guid jobId) + { + var job = await RunAsync(scenario, services => services.GetRequiredService().GetAsync(jobId)); + var person = scenario.Initiator; + scenario.Initiator = SystemInitiator.Checks; + try + { + return await RunAsync(scenario, services => services.GetServices() + .Single(handler => handler.Type == RuleCompiler.JobType) + .RunAsync(new JobContext(job.Value, services, (_, _) => Task.FromResult(true)), CancellationToken.None)); + } + finally + { + scenario.Initiator = person; + } + } + + private static Task> ActiveChecksAsync(ChangeSetScenario scenario) + { + return RunAsync(scenario, async services => (await services.GetRequiredService().ListActiveAsync()).Select(check => check.Name).ToList()); + } + + private static Task RunChecksAsync(ChangeSetScenario scenario) + { + return RunAsync(scenario, services => services.GetRequiredService().RunAsync(Superlative)); + } + + // The proposal with other settings and its failing example replaced by one with the headline given. + private static CheckDefinition Edited(CheckDefinition proposed, string settings, string failing) + { + return new CheckDefinition( + proposed.Type, + proposed.Description, + JsonDocument.Parse(settings).RootElement.Clone(), + [ + proposed.Examples.First(example => example.Expected == CheckVerdict.Pass), + new CheckExample("edited", CheckVerdict.Fail, JsonDocument.Parse(JsonSerializer.Serialize(new { headline = failing })).RootElement.Clone(), contentType: "product-page"), + ], + proposed.Severity, + proposed.Scope); + } + + private static Task RulesAsync(ChangeSetScenario scenario, Func> action) + { + return RunAsync(scenario, services => action(services.GetRequiredService())); + } + + private static Task RuleChecksAsync(ChangeSetScenario scenario, Func> action) + { + return RunAsync(scenario, services => action(services.GetRequiredService())); + } + + private static Task ChecksAsync(ChangeSetScenario scenario, Func> action) + { + return RunAsync(scenario, services => action(services.GetRequiredService())); + } + + // In a scope of its own, as whoever Initiator names. + private static async Task RunAsync(ChangeSetScenario scenario, Func> action) + { + await using var scope = scenario.Services.CreateAsyncScope(); + return await action(scope.ServiceProvider); + } + + private sealed class NoSecrets : ISecretProtector + { + public string Protect(string purpose, string secret) => throw new NotSupportedException("Rule tests protect no secrets."); + + public string Unprotect(string purpose, string protectedSecret) => throw new NotSupportedException("Rule tests protect no secrets."); + } + + // Answers like the OpenAI chat API, with the same answer to every request. + private sealed class CompilingProvider(string answer) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(new + { + id = "chatcmpl-1", + @object = "chat.completion", + created = 1760000000, + model = "gpt-fake-2026-01-01", + choices = new[] { new { index = 0, message = new { role = "assistant", content = answer }, finish_reason = "stop" } }, + usage = new { prompt_tokens = 900, completion_tokens = 300, total_tokens = 1200 }, + }); + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json, Encoding.UTF8, "application/json") }); + } + } +} diff --git a/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs index 6d41dceb8..d5f853c74 100644 --- a/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs +++ b/tests/FluentCMS.Tests/Jobs/EngineJobsTests.cs @@ -24,6 +24,16 @@ public void The_check_run_runs_as_the_check_engine() Assert.Equal(SystemInitiator.Checks, provider.GetRequiredService().RunAs(checkRun, new AgentInitiator(Guid.NewGuid(), "agent")).Value); } + // RUL-04: a rule's compile proposes checks as the check engine, on behalf of the person who saved the rule. + [Fact] + public void The_rule_compile_runs_as_the_check_engine() + { + using var provider = new ServiceCollection().AddFluentCmsCore().BuildServiceProvider(); + var compile = provider.GetServices().Single(handler => handler.Type == "rules.compile"); + + Assert.Equal(SystemInitiator.Checks, provider.GetRequiredService().RunAs(compile, new UserInitiator(Guid.NewGuid(), "Amir")).Value); + } + [Fact] public void A_handler_Core_does_not_list_cannot_claim_an_engine() { diff --git a/tests/FluentCMS.Tests/Rules/RuleCompilerTests.cs b/tests/FluentCMS.Tests/Rules/RuleCompilerTests.cs new file mode 100644 index 000000000..aeb646310 --- /dev/null +++ b/tests/FluentCMS.Tests/Rules/RuleCompilerTests.cs @@ -0,0 +1,183 @@ +using FluentCMS.Core; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Identity; +using FluentCMS.Core.Models; +using FluentCMS.Core.Results; +using FluentCMS.Core.Rules; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Rules; + +// RUL-04, MDL-03: a rule is compiled to proposed checks through the gateway, and without a usable answer it is advisory, not an error. +public sealed class RuleCompilerTests +{ + private static readonly UserInitiator Amir = new(Guid.NewGuid(), "Amir"); + + private static readonly RuleDefinition NoSuperlatives = new( + RuleKind.VoiceAndStyle, + "Never claim best in class.", + CheckSeverity.Warn, + new RuleScope(["product-page"], ["headline"], ["en"])); + + private const string TermsCheck = """ + {"type": "terms", "description": "Product copy never claims best in class.", "settings": "{\"terms\": [\"best in class\"]}", + "examples": [ + {"name": "plain claim", "expect": "pass", "contentType": "", "fields": "{\"headline\": \"A kettle that boils in two minutes\"}"}, + {"name": "superlative", "expect": "fail", "contentType": "product-page", "fields": "{\"headline\": \"The best in class kettle\"}"}]} + """; + + // The rule's text and scope reach the model only as data; the proposal takes the rule's severity and scope. + [Fact] + public async Task A_voice_and_style_rule_is_compiled_to_a_terms_check_with_its_examples() + { + var gateway = new FakeGateway($$"""{"checks": [{{TermsCheck}}]}"""); + + var outcome = await CompileAsync(gateway, NoSuperlatives); + + Assert.Equal((RuleCompileStatus.Proposed, (string?)null, "gpt-fake", "hash-1"), (outcome.Status, outcome.Message, outcome.Model, outcome.PromptHash)); + var proposal = Assert.Single(outcome.Proposals); + Assert.Equal(("terms", "Product copy never claims best in class.", CheckSeverity.Warn), (proposal.Type, proposal.Description, proposal.Severity)); + Assert.Equal(["product-page"], proposal.Scope.ContentTypes!); + Assert.Equal(["headline"], proposal.Scope.Fields!); + Assert.Equal("""{"terms": ["best in class"]}""", proposal.Settings.GetRawText()); + Assert.Equal( + [("plain claim", CheckVerdict.Pass, "product-page"), ("superlative", CheckVerdict.Fail, "product-page")], + proposal.Examples.Select(example => (example.Name, example.Expected, example.ContentType))); + + var request = Assert.Single(gateway.Requests); + Assert.Equal((RuleCompiler.Profile, "rule:compile", (Initiator?)Amir), (request.Profile, request.Purpose, request.OnBehalfOf)); + Assert.Equal( + [("rule", "Never claim best in class."), ("scope", """{"kind":"voice_and_style","contentTypes":["product-page"],"fields":["headline"]}""")], + request.Data.Select(section => (section.Name, section.Text))); + Assert.DoesNotContain("best in class", request.Instructions, StringComparison.OrdinalIgnoreCase); + Assert.NotNull(request.AnswerSchema); + } + + [Fact] + public async Task Without_a_model_the_rule_is_advisory_and_no_call_is_made() + { + var gateway = new FakeGateway("{}") { Available = false }; + + var outcome = await CompileAsync(gateway, NoSuperlatives); + + Assert.Equal((RuleCompileStatus.Advisory, RuleCompiler.NoModel), (outcome.Status, outcome.Message)); + Assert.Empty(outcome.Proposals); + Assert.Empty(gateway.Requests); + } + + [Fact] + public async Task A_failed_call_leaves_the_rule_advisory_with_the_reason() + { + var gateway = new FakeGateway(null); + + var outcome = await CompileAsync(gateway, NoSuperlatives); + + Assert.Equal(RuleCompileStatus.Advisory, outcome.Status); + Assert.Equal("The model could not propose checks, so the rule is advisory until checks are proposed again: The provider did not answer.", outcome.Message); + } + + [Theory] + [InlineData("Here are the checks.")] + [InlineData("""{"checks": "terms"}""")] + [InlineData("""{"checks": [], "advice": "none"}""")] + [InlineData("""{"checks": [], "checks": []}""")] + public async Task An_answer_that_cannot_be_read_leaves_the_rule_advisory(string answer) + { + var outcome = await CompileAsync(new FakeGateway(answer), NoSuperlatives); + + Assert.Equal( + (RuleCompileStatus.Advisory, "The model's answer could not be read, so the rule is advisory until checks are proposed again."), + (outcome.Status, outcome.Message)); + } + + [Fact] + public async Task An_answer_in_a_code_fence_is_read() + { + var outcome = await CompileAsync(new FakeGateway($$""" + ```json + {"checks": [{{TermsCheck}}]} + ``` + """), NoSuperlatives); + + Assert.Equal("terms", Assert.Single(outcome.Proposals).Type); + } + + // A proposal of a type that is not installed or not offered, without a failing example, or with settings that are not an object is + // left out. + [Fact] + public async Task A_proposal_the_installed_check_types_cannot_use_is_left_out() + { + const string unknownType = """{"type": "sentiment", "description": "Upbeat.", "settings": "{}", "examples": []}"""; + const string notOffered = """ + {"type": "links", "description": "Links work.", "settings": "{}", + "examples": [ + {"name": "no link", "expect": "pass", "contentType": "", "fields": "{\"headline\": \"A kettle\"}"}, + {"name": "bad link", "expect": "fail", "contentType": "", "fields": "{\"headline\": \"http://\"}"}]} + """; + const string onlyPasses = """ + {"type": "terms", "description": "No hype.", "settings": "{\"terms\": [\"hype\"]}", + "examples": [{"name": "calm", "expect": "pass", "contentType": "", "fields": "{\"headline\": \"A kettle\"}"}]} + """; + const string settingsList = """{"type": "terms", "description": "No hype.", "settings": "[\"hype\"]", "examples": []}"""; + + var some = await CompileAsync(new FakeGateway($$"""{"checks": [{{unknownType}}, {{TermsCheck}}, {{onlyPasses}}]}"""), NoSuperlatives); + var none = await CompileAsync(new FakeGateway($$"""{"checks": [{{unknownType}}, {{settingsList}}, {{notOffered}}]}"""), NoSuperlatives); + + Assert.Equal((RuleCompileStatus.Proposed, "2 checks more that the model proposed could not be used and were left out."), (some.Status, some.Message)); + Assert.Equal("terms", Assert.Single(some.Proposals).Type); + Assert.Equal( + (RuleCompileStatus.Advisory, "The model proposed 3 checks that could not be used, so the rule is advisory until checks are proposed again."), + (none.Status, none.Message)); + } + + [Fact] + public async Task A_rule_no_check_can_enforce_is_advisory() + { + var rule = new RuleDefinition(RuleKind.PermissionAndRouting, "Legal reviews every claim about safety."); + + var outcome = await CompileAsync(new FakeGateway("""{"checks": []}"""), rule); + + Assert.Equal( + (RuleCompileStatus.Advisory, "The model found no check that can enforce this rule, so it is advisory: agents read it as guidance and it blocks nothing."), + (outcome.Status, outcome.Message)); + } + + [Fact] + public async Task At_most_three_checks_are_proposed() + { + var outcome = await CompileAsync(new FakeGateway($$"""{"checks": [{{TermsCheck}}, {{TermsCheck}}, {{TermsCheck}}, {{TermsCheck}}]}"""), NoSuperlatives); + + Assert.Equal((RuleCompiler.MaxProposals, (string?)null), (outcome.Proposals.Count, outcome.Message)); + } + + private static async Task CompileAsync(FakeGateway gateway, RuleDefinition rule) + { + var services = new ServiceCollection().AddFluentCmsCore(); + services.AddScoped(_ => gateway); + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + + return await scope.ServiceProvider.GetRequiredService().ProposeAsync(rule, Amir); + } + + // Answers every request with the text it was given, or fails when given none. + private sealed class FakeGateway(string? answer) : IModelGateway + { + public bool Available { get; init; } = true; + + public List Requests { get; } = []; + + public bool IsAvailable(string profile) + { + return Available && profile == RuleCompiler.Profile; + } + + public Task> AskAsync(ModelRequest request, CancellationToken cancellationToken = default) + { + Requests.Add(request); + return Task.FromResult(answer is null + ? Result.Failure(ModelErrorCodes.CallFailed, "The provider did not answer.") + : Result.Success(new ModelAnswer(Guid.NewGuid(), "openai", "gpt-fake", answer, "hash-1", 0.01m))); + } + } +} From e534cb6d437af01d6bf18759b1dcb5748569b8f7 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 26 Oct 2026 09:33:49 -0400 Subject: [PATCH 2/2] Replace a recording compile's proposals, settle a compile's last attempt and check its version --- .../Builtin/Jobs/RuleCompileJob.cs | 17 ++- .../Rules/RuleChecks.cs | 4 +- .../Rules/RuleCompileJobTests.cs | 112 ++++++++++++++++++ 3 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 tests/FluentCMS.Tests/Rules/RuleCompileJobTests.cs diff --git a/src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs b/src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs index 6cc53e539..5ebfa2de1 100644 --- a/src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs +++ b/src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs @@ -11,6 +11,9 @@ namespace FluentCMS.Core.Builtin.Jobs; // payload only names the version; whether this job is still its compile, and what the rule says, come from what is stored. internal sealed class RuleCompileJob : IJobHandler { + private const string Abandoned = + "Checks could not be proposed after every attempt failed, so the rule is advisory until checks are proposed again."; + public string Type => RuleCompiler.JobType; public SystemInitiator RunAs => SystemInitiator.Checks; @@ -23,6 +26,7 @@ public async Task RunAsync(JobContext context, CancellationToken cancell || id.ValueKind != JsonValueKind.String || !id.TryGetGuid(out var ruleId) || !payload.TryGetProperty("version", out var number) + || number.ValueKind != JsonValueKind.Number || !number.TryGetInt32(out var version)) { return Result.Failure("payload_invalid", "A rule compile names the rule as ruleId and its version as version."); @@ -35,8 +39,17 @@ public async Task RunAsync(JobContext context, CancellationToken cancell return Replaced(compiling.Errors); } - var outcome = await context.Services.GetRequiredService().ProposeAsync(compiling.Value, context.Job.Initiator, cancellationToken); - return Replaced((await rules.RecordAsync(ruleId, version, context.Job.Id, outcome, cancellationToken)).Errors); + try + { + var outcome = await context.Services.GetRequiredService().ProposeAsync(compiling.Value, context.Job.Initiator, cancellationToken); + return Replaced((await rules.RecordAsync(ruleId, version, context.Job.Id, outcome, cancellationToken)).Errors); + } + catch (Exception exception) when (exception is not OperationCanceledException && context.Job.Attempts >= context.Job.MaxAttempts) + { + // The last attempt leaves the version advisory, so it does not stay compiling for good; proposing again starts over. + var abandoned = await rules.RecordAsync(ruleId, version, context.Job.Id, RuleCompileOutcome.Advisory(Abandoned), cancellationToken); + return Result.Failure(abandoned.IsSuccess ? [new Error("rule_compile_abandoned", exception.Message)] : abandoned.Errors); + } } // A compile that a later request replaced has nothing left to do. diff --git a/src/FluentCMS.Infrastructure/Rules/RuleChecks.cs b/src/FluentCMS.Infrastructure/Rules/RuleChecks.cs index 973d4c85e..d21f77aea 100644 --- a/src/FluentCMS.Infrastructure/Rules/RuleChecks.cs +++ b/src/FluentCMS.Infrastructure/Rules/RuleChecks.cs @@ -84,10 +84,12 @@ public async Task> CompileAsync(string slug, int version await using (var scope = await unitOfWork.BeginAsync(cancellationToken)) { + // The compile moves to the new job first: that waits for a compile recording at the same time, whose proposals are then + // committed and replaced with the rest. + await ScheduleAsync(rule.Id, version, replace: true, cancellationToken); await context.RuleCheckProposals .Where(row => row.RuleId == rule.Id && row.RuleVersion == version && row.State == RuleProposalState.Proposed) .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.State, RuleProposalState.Replaced), cancellationToken); - await ScheduleAsync(rule.Id, version, replace: true, cancellationToken); await auditLog.AppendAsync(new NewAuditEvent("rule.compile_requested") { SubjectType = SubjectType, SubjectId = rule.Id }, cancellationToken); try diff --git a/tests/FluentCMS.Tests/Rules/RuleCompileJobTests.cs b/tests/FluentCMS.Tests/Rules/RuleCompileJobTests.cs new file mode 100644 index 000000000..e3d3f3591 --- /dev/null +++ b/tests/FluentCMS.Tests/Rules/RuleCompileJobTests.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using FluentCMS.Core; +using FluentCMS.Core.Checks; +using FluentCMS.Core.Jobs; +using FluentCMS.Core.Models; +using FluentCMS.Core.Results; +using FluentCMS.Core.Rules; +using Microsoft.Extensions.DependencyInjection; + +namespace FluentCMS.Tests.Rules; + +// RUL-04: the compile job reads only the version its payload names, and its last attempt never leaves the version compiling. +public sealed class RuleCompileJobTests +{ + private static readonly Guid RuleId = Guid.NewGuid(); + + [Theory] + [InlineData("\"1\"")] + [InlineData("1.5")] + [InlineData("null")] + [InlineData(null)] + public async Task A_version_that_is_not_a_whole_number_is_a_payload_failure(string? version) + { + var json = version is null ? $$"""{"ruleId": "{{RuleId}}"}""" : $$"""{"ruleId": "{{RuleId}}", "version": {{version}}}"""; + + var result = await RunAsync(new RecordingRuleChecks(), attempt: 1, JsonDocument.Parse(json).RootElement.Clone()); + + Assert.Equal("payload_invalid", Assert.Single(result.Errors).Code); + } + + // An attempt that throws is retried; the last one records the version advisory, so proposing again is what starts it over. + [Fact] + public async Task The_last_attempt_of_a_compile_that_throws_leaves_the_version_advisory() + { + var rules = new RecordingRuleChecks(); + var payload = JsonSerializer.SerializeToElement(new { ruleId = RuleId, version = 2 }); + + await Assert.ThrowsAsync(() => RunAsync(rules, attempt: 1, payload)); + var abandoned = await RunAsync(rules, attempt: NewJob.DefaultMaxAttempts, payload); + + Assert.Equal(("rule_compile_abandoned", "The model client broke."), (Assert.Single(abandoned.Errors).Code, abandoned.Errors[0].Message)); + var recorded = Assert.Single(rules.Recorded); + Assert.Equal((RuleId, 2, RuleCompileStatus.Advisory), (recorded.RuleId, recorded.Version, recorded.Outcome.Status)); + Assert.Equal( + "Checks could not be proposed after every attempt failed, so the rule is advisory until checks are proposed again.", + recorded.Outcome.Message); + } + + private static async Task RunAsync(RecordingRuleChecks rules, int attempt, JsonElement payload) + { + var services = new ServiceCollection().AddFluentCmsCore(); + services.AddScoped(); + services.AddScoped(_ => rules); + await using var provider = services.BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + var job = new Job + { + Id = Guid.CreateVersion7(), + Type = RuleCompiler.JobType, + Payload = payload, + State = JobState.Running, + Attempts = attempt, + MaxAttempts = NewJob.DefaultMaxAttempts, + CreatedAt = DateTimeOffset.UtcNow, + ScheduledAt = DateTimeOffset.UtcNow, + }; + + return await scope.ServiceProvider.GetServices() + .Single(handler => handler.Type == RuleCompiler.JobType) + .RunAsync(new JobContext(job, scope.ServiceProvider, (_, _) => Task.FromResult(true)), CancellationToken.None); + } + + private sealed class ThrowingGateway : IModelGateway + { + public bool IsAvailable(string profile) => true; + + public Task> AskAsync(ModelRequest request, CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("The model client broke."); + } + } + + // The version is always this job's compile, and what is recorded is kept. + private sealed class RecordingRuleChecks : IRuleChecks + { + public List<(Guid RuleId, int Version, RuleCompileOutcome Outcome)> Recorded { get; } = []; + + public Task> CompilingAsync(Guid ruleId, int version, Guid jobId, CancellationToken cancellationToken = default) + { + return Task.FromResult(Result.Success(new RuleDefinition(RuleKind.VoiceAndStyle, "Never claim best in class."))); + } + + public Task RecordAsync(Guid ruleId, int version, Guid jobId, RuleCompileOutcome outcome, CancellationToken cancellationToken = default) + { + Recorded.Add((ruleId, version, outcome)); + return Task.FromResult(Result.Success()); + } + + public Task> GetAsync(string slug, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> CompileAsync(string slug, int version, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> ConfirmAsync( + string slug, + int number, + CheckDefinition? definition = null, + string? checkName = null, + CancellationToken cancellationToken = default) => throw new NotSupportedException(); + + public Task> RejectAsync(string slug, int number, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +}