Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -157,6 +157,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<ISearchIndex, T>(ServiceLifetime.Scoped)` when it needs the request's scope, and passes `SearchIndexConformanceTests` on both databases. `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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
2 changes: 1 addition & 1 deletion src/FluentCMS.Core/Builtin/Checks/ModelJudgement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ private static IEnumerable<CheckFinding> 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))
Expand Down
60 changes: 60 additions & 0 deletions src/FluentCMS.Core/Builtin/Jobs/RuleCompileJob.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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
{
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;

public async Task<Result> 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.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.");
}

var rules = context.Services.GetRequiredService<IRuleChecks>();
var compiling = await rules.CompilingAsync(ruleId, version, context.Job.Id, cancellationToken);
if (!compiling.IsSuccess)
{
return Replaced(compiling.Errors);
}

try
{
var outcome = await context.Services.GetRequiredService<RuleCompiler>().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.
private static Result Replaced(IReadOnlyList<Error> errors)
{
return errors.Count == 0 || errors.All(error => error.Code == RuleErrorCodes.CompileReplaced) ? Result.Success() : Result.Failure(errors);
}
}
3 changes: 2 additions & 1 deletion src/FluentCMS.Core/Checks/ICheckDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ public interface ICheckDirectory

Task<Result<Check>> 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<IReadOnlyList<Check>> ListActiveAsync(CancellationToken cancellationToken = default);

// Creates version 1, not active.
Expand Down
5 changes: 5 additions & 0 deletions src/FluentCMS.Core/CoreServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -51,10 +52,14 @@ public static IServiceCollection AddFluentCmsCore(this IServiceCollection servic
// Writes through IEntryDirectory, IContentTypeDirectory and IChangeSetDirectory in one unit of work, which Infrastructure provides.
services.AddScoped<IEntryEdits, EntryEdits>();

// Asks the model through IModelGateway; the compile job records what it proposes through IRuleChecks, which Infrastructure provides.
services.AddScoped<RuleCompiler>();

// 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<Type, SystemInitiator>
{
[typeof(CheckRunJob)] = SystemInitiator.Checks,
[typeof(RuleCompileJob)] = SystemInitiator.Checks,
[typeof(RetentionPurgeJob)] = SystemInitiator.Retention,
}));

Expand Down
34 changes: 34 additions & 0 deletions src/FluentCMS.Core/Rules/IRuleChecks.cs
Original file line number Diff line number Diff line change
@@ -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<Result<RuleCompilation>> 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<Result<RuleCompilation>> 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<Result<RuleCheckProposal>> ConfirmAsync(
string slug,
int number,
CheckDefinition? definition = null,
string? checkName = null,
CancellationToken cancellationToken = default);

Task<Result<RuleCheckProposal>> 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<Result<RuleDefinition>> 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<Result> RecordAsync(Guid ruleId, int version, Guid jobId, RuleCompileOutcome outcome, CancellationToken cancellationToken = default);
}
4 changes: 4 additions & 0 deletions src/FluentCMS.Core/Rules/Rule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
52 changes: 52 additions & 0 deletions src/FluentCMS.Core/Rules/RuleCompilation.cs
Original file line number Diff line number Diff line change
@@ -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<RuleCheckProposal> Proposals);

// What a compile found, which the check engine records for the version it compiled.
public sealed record RuleCompileOutcome(RuleCompileStatus Status, string? Message, IReadOnlyList<CheckDefinition> 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, []);
}
}
Loading
Loading