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
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,17 +91,17 @@ 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.
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.

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.

`IAuditReader.Read(AuditQuery)` reads the log back: an optional window from `From` (inclusive) to `To` (exclusive) and an optional `AuditSubject` for one item's history, oldest first with ties broken by id, streamed from the database. With no window and no subject it is the full log. An inverted window or an incomplete subject is a failed `Result`. `IAuditExporter.ExportAsync` writes the same selection to a stream as JSON or CSV while it reads it, after committing an `audit.exported` event that names who exported and, for one item's history, the item; viewing history is a read and records nothing. Both formats carry the same fields under the same names, in this order: `id`, `timestamp`, `operation`, `initiatorKind` (`user` or `agent`), `initiatorId`, `initiatorName`, `agentCredentialId`, `subjectType`, `subjectId`, `model`, `promptHash`, `sources`, `changeSetId` and `previousVersionId`. The JSON is an array of objects with every key present and `null` for an empty field. The CSV follows RFC 4180 with a header row and CRLF line ends, plus three conventions so every value reads back exactly: `sources` is one cell holding a JSON array, an unquoted empty cell is null while a quoted one is an empty string, and a value starting with `=`, `+`, `-`, `@`, a tab, a carriage return or `'` gets one leading `'`, so a spreadsheet does not run it as a formula; a reader drops that one `'`.
`IAuditReader.Read(AuditQuery)` reads the log back: an optional window from `From` (inclusive) to `To` (exclusive) and an optional `AuditSubject` for one item's history, oldest first with ties broken by id, streamed from the database. With no window and no subject it is the full log. An inverted window or an incomplete subject is a failed `Result`. `IAuditExporter.ExportAsync` writes the same selection to a stream as JSON or CSV while it reads it, after committing an `audit.exported` event that names who exported and, for one item's history, the item; viewing history is a read and records nothing. Both formats carry the same fields under the same names, in this order: `id`, `timestamp`, `operation`, `initiatorKind` (`user`, `agent` or `system`), `initiatorId`, `initiatorName`, `agentCredentialId`, `subjectType`, `subjectId`, `model`, `promptHash`, `sources`, `changeSetId` and `previousVersionId`. The JSON is an array of objects with every key present and `null` for an empty field. The CSV follows RFC 4180 with a header row and CRLF line ends, plus three conventions so every value reads back exactly: `sources` is one cell holding a JSON array, an unquoted empty cell is null while a quoted one is an empty string, and a value starting with `=`, `+`, `-`, `@`, a tab, a carriage return or `'` gets one leading `'`, so a spreadsheet does not run it as a formula; a reader drops that one `'`.

The admin's `/audit` page offers the export and an item's history. A page cannot answer with a file, so its export form posts to `POST /audit/export` in the host, which maps the form, calls the exporter and streams the result; it needs a signed-in user and an anti-forgery token like every form, and sends a rejected form back to the page.

## Background jobs

Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error.
Work that outlives a request, such as a bulk operation or a check run, is a job in the `Jobs` table (ADR-0010). Enqueue it with `IJobQueue.EnqueueAsync(new NewJob(type, payload) { Subject = ..., RunAt = ..., MaxAttempts = ... })` inside the unit of work of the change that needs it, so the job commits with the change or not at all; outside a unit of work it throws, as the audit log does. The job records the current initiator, and its handler runs as them, so what it changes names whoever asked for it. A handler for work no person or agent does, such as the check run a submission starts, names a `SystemInitiator` in `RunAs`, and its jobs run as that engine whoever enqueued them, while the job still records who did (ADR-0012). `IJobQueue.GetAsync` and `ListForSubjectAsync` read a job's state (`queued`, `running`, `succeeded`, `failed` or `dead`), attempts, progress and last error.

A handler implements `IJobHandler` (an extension point) with the type name its jobs store, which never changes once released. It reads the payload from `JobContext.Job`, resolves services from `JobContext.Services`, a scope of its own, and reports progress with `ReportProgressAsync`, which also keeps its lease; when that returns false another worker has the job, and the handler stops. A returned failure ends the job as `failed`. An exception retries it after `JobQueue__RetryDelay`, doubled for each attempt up to `JobQueue__MaxRetryDelay`, and after its last attempt the job is `dead`. Work in a handler therefore has to be safe to run again.

Expand Down
41 changes: 41 additions & 0 deletions docs/adr/0012-system-initiator-for-engine-driven-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# ADR-0012: A system initiator for actions the product takes on its own

- Status: accepted
- Date: 2026-10-19
- Related: AUD-01, AUD-04, AUD-N2, CHG-01, CHG-04, CHK-03, RTE-01, MCP-06, SEC-02, ADR-0009, ADR-0010

## Context

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).
- 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).

These run inside a request or as a job (ADR-0010), and a job runs as whoever enqueued it. For the check run that is the agent that submitted the set, and agents may not make the checks' moves (MCP-06, SEC-02).

## Decision

- A third kind of initiator, `SystemInitiator`, names a part of the product acting on its own by its component: `checks` and `retention`, and `routing` when F-RTE lands. The audit log stores it with the kind `system`, the component as its name, a stable id derived from the component, and no agent credential.
- Its limits are enforced in Core:
- It is never a change set's initiator. `ChangeSet.Open` refuses it, and so do session grouping and revert, which open sets through it.
- `AgentPermissions` treats it as not an agent and lets it make only the moves its component owns. `checks` moves a set from checks running to checks failed or awaiting review; routing will add auto-publish.
- It reads content, and never creates, edits or deletes content, opens or adds to a change set, or calls a tool.
- What only a person may do refuses a system initiator as it refuses an agent: managing users, issuing credentials, and activating, writing or revising checks.
- Maintenance names what it may do: the prompt purge accepts `retention` as well as a person.
- It spends no model budget of its own. A model call it makes for an agent's change set is charged to that agent (SEC-04).
- A job type can run as a system initiator, whoever enqueued the job (`IJobHandler.RunAs`). The job still records who enqueued it. The check run is enqueued in the submitting agent's call and runs as `checks`. The job type decides this, not the code that enqueues it, so no caller can borrow an engine's permissions.

## Consequences

- The log tells what an agent did apart from what the product did: an agent submits its set, and the check engine, not the agent, moves it on.
- The audit log stores a system initiator by its component and its derived id, and refuses a row where the two disagree, as it refuses an unknown kind.
- Exports and the audit screen show a third initiator kind. Readers of the export handle `system`.
- A new engine moves nothing until its moves are written into `AgentPermissions`, and none writes content.
- AUD-01 and CHG-01 read "agent/user". The system kind covers only engine-driven moves and maintenance, and the maintainer updates that wording in the decision record.

## Alternatives considered

- Act as the submitting agent: no new kind, but false provenance. The log would say the agent moved its own set out of checks running, which it is not allowed to do.
- A service user, an Identity account the engine signs in as: a fake person in the user list and in the log, with a password or key to protect, and "human reviewed" claims would become ambiguous.
- No initiator for these actions: the audit log refuses a change without one (AUD-N1), and a gap would hide who did what.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ Technical decisions for FluentCMS v2. Product decisions live in `docs/requiremen
| [0009](0009-identity-aspnet-core-identity-agents-not-users.md) | Human sign-in with ASP.NET Core Identity; agents are not users | accepted |
| [0010](0010-in-database-search-and-job-queue.md) | In-database full-text search and a durable job queue | accepted |
| [0011](0011-admin-component-set-and-styling.md) | Admin component set and styling | accepted |
| [0012](0012-system-initiator-for-engine-driven-actions.md) | A system initiator for actions the product takes on its own | accepted |

Deferred to their own ADR when the work starts: CI and release pipeline, container image layout.
4 changes: 4 additions & 0 deletions src/FluentCMS.Admin/Components/Pages/AuditLog.razor
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@
{
<Badge>@L["Agent"]</Badge>
}
else if (auditEvent.Initiator is SystemInitiator)
{
<Badge Variant="outline">@L["System"]</Badge>
}
else
{
<Badge Variant="secondary">@L["User"]</Badge>
Expand Down
1 change: 1 addition & 0 deletions src/FluentCMS.Core/Agents/AgentPermissionErrorCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ public static class AgentPermissionErrorCodes
public const string SiteOutOfScope = "agent_site_out_of_scope";
public const string NotOwner = "agent_not_change_set_owner";
public const string ScopeDenied = "agent_scope_denied";
public const string SystemNotPermitted = "system_not_permitted";
}
58 changes: 58 additions & 0 deletions src/FluentCMS.Core/Agents/AgentPermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,28 @@ internal sealed class AgentPermissions(

private static readonly TimeSpan Day = TimeSpan.FromDays(1);

// The moves each part of the product owns (ADR-0012); routing adds auto-publish when it lands. Anything else an engine tries is
// refused, so a new engine moves nothing until someone writes down what it owns.
private static readonly Dictionary<string, (ChangeSetState From, ChangeSetState To)[]> SystemMoves = new(StringComparer.Ordinal)
{
[SystemInitiator.Checks.Component] =
[
(ChangeSetState.ChecksRunning, ChangeSetState.ChecksFailed),
(ChangeSetState.ChecksRunning, ChangeSetState.AwaitingReview),
],
};

public async Task<Result> CheckTransitionAsync(ChangeSet changeSet, ChangeSetState target, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(changeSet);

if (currentInitiator.Current is SystemInitiator system)
{
return SystemMoves.GetValueOrDefault(system.Component, []).Contains((changeSet.State, target))
? Result.Success()
: SystemRefused(system, $"move change set {changeSet.Id} from {Describe(changeSet.State)} to {Describe(target)}");
}

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
Expand Down Expand Up @@ -54,6 +72,11 @@ await RecordAsync(

public async Task<Result> CheckNewChangeSetAsync(CancellationToken cancellationToken = default)
{
if (currentInitiator.Current is SystemInitiator system)
{
return SystemRefused(system, "open a change set");
}

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
Expand Down Expand Up @@ -86,6 +109,11 @@ public async Task<Result> CheckChangeSetSizeAsync(ChangeSet changeSet, int addin
{
ArgumentNullException.ThrowIfNull(changeSet);

if (currentInitiator.Current is SystemInitiator system)
{
return SystemRefused(system, $"add to change set {changeSet.Id}");
}

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
Expand Down Expand Up @@ -128,6 +156,12 @@ public async Task<Result> CheckContentAsync(
{
ArgumentNullException.ThrowIfNull(contentType);

// An engine reads what it judges, and never writes content (ADR-0012).
if (currentInitiator.Current is SystemInitiator system)
{
return operation == AgentOperation.Read ? Result.Success() : SystemRefused(system, $"{Verb(operation)} entries of '{contentType}'");
}

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
Expand Down Expand Up @@ -166,6 +200,11 @@ public async Task<Result> CheckDefinitionAsync(string contentType, AgentOperatio
{
ArgumentNullException.ThrowIfNull(contentType);

if (currentInitiator.Current is SystemInitiator system)
{
return operation == AgentOperation.Read ? Result.Success() : SystemRefused(system, $"{Verb(operation)} the content type '{contentType}'");
}

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
Expand Down Expand Up @@ -194,6 +233,11 @@ public async Task<Result> CheckDefinitionAsync(string contentType, AgentOperatio

public async Task<Func<string, bool>> ReadableTypesAsync(CancellationToken cancellationToken = default)
{
if (currentInitiator.Current is SystemInitiator)
{
return _ => true;
}

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
Expand All @@ -211,6 +255,11 @@ public async Task<Func<string, bool>> ReadableTypesAsync(CancellationToken cance

public async Task<Result> CheckToolCallAsync(CancellationToken cancellationToken = default)
{
if (currentInitiator.Current is SystemInitiator system)
{
return SystemRefused(system, "call a tool");
}

var agent = await AgentAsync(cancellationToken);
if (!agent.IsSuccess)
{
Expand Down Expand Up @@ -239,6 +288,14 @@ await RecordAsync(
+ $"{caps.CallsPerPeriod} (calls per period). Wait until fewer than {caps.CallsPerPeriod} calls fall {Within(caps.CallPeriod)}, then call again.");
}

// A refusal here is a fault in the product, not in what a caller asked, so it is not recorded as a security event.
private static Result SystemRefused(SystemInitiator system, string action)
{
return Result.Failure(
AgentPermissionErrorCodes.SystemNotPermitted,
$"The '{system.Component}' engine cannot {action}: it makes only the moves it owns and never writes content (ADR-0012).");
}

// The operation first, then the type, so the message names the first part of the scope that refuses.
private static Result Allows(AgentCredential credential, AgentOperation operation, string contentType, string target)
{
Expand Down Expand Up @@ -345,6 +402,7 @@ private static string Describe(ChangeSetState state)
{
return state switch
{
ChangeSetState.ChecksRunning => "checks running",
ChangeSetState.ChecksFailed => "checks failed",
ChangeSetState.AwaitingReview => "awaiting review",
_ => state.ToString().ToLowerInvariant(),
Expand Down
3 changes: 2 additions & 1 deletion src/FluentCMS.Core/Agents/IAgentPermissions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ namespace FluentCMS.Core.Agents;

// What the current caller may do (MCP-06, SEC-02, SEC-04), decided in Core so tools and forms cannot diverge (ADR-0007).
// People pass every check. An agent is held to its stored credential, read again for the call, so a credential revoked or
// expired since its key was accepted, or one whose scope lacks the site, is refused. A refused cap or move is recorded as a
// expired since its key was accepted, or one whose scope lacks the site, is refused. A system initiator makes only the moves its
// component owns and reads content, and is refused everything else (ADR-0012). A refused cap or move is recorded as a
// security event (SEC-08); the refused write itself is left to the caller, which writes nothing.
public interface IAgentPermissions
{
Expand Down
2 changes: 1 addition & 1 deletion src/FluentCMS.Core/Audit/AuditExportShape.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ internal static class AuditExportShape
auditEvent.Operation,
kind,
Text(initiatorId),
auditEvent.Initiator.DisplayName,
InitiatorKinds.NameOf(auditEvent.Initiator),
Text(auditEvent.AgentCredentialId),
auditEvent.SubjectType,
Text(auditEvent.SubjectId),
Expand Down
Loading
Loading