Repository navigation
Run the checks on every submitted change set - #2575
Merged
Merged
Conversation
This was referenced Oct 19, 2026
4 tasks done
pournasserian
added a commit
that referenced
this pull request
Oct 19, 2026
…2590) Closes #2587 ## What The fifth slice of F-DSK (#2422): in-app notification counts (DSK-05) and the security events view on the desk (SEC-08). - **Core** (`FluentCMS.Core.Audit`): - `SecurityEvents.Operations`: the operations SEC-08 names. - Agent keys issued and revoked. - The three cap refusals and an agent's refused move. - People added, disabled and enabled, password resets issued and used, and external sign-ins linked. - Routing rules created, revised, activated and deactivated. Those match nothing until routing rules (#2565) merge. - `IAuditEventLists`: a page of the log with any of the given operations, newest first, optionally since a time, with the total. `CountAsync` gives the total alone. A bad window fails with `offset_invalid` or `limit_invalid`, and no operations with `audit_operations_missing`. It is separate from `IAuditReader`, which streams the log oldest first for exports. - **Core** (`FluentCMS.Core.Desk`): `IDeskQueues.CountAsync(queue)` counts one queue, so the header does not count all six. - **Infrastructure**: `AuditEventLists` in its own file, SQL over `AuditEvents` as in `AuditReader`. - **Admin**: - `NotificationLinks` in the header replaces the plain Desk link. - Desk shows how many sets await the signed-in person (awaiting me). - A new Security link shows how many security events the last 24 hours hold. - Each count is a badge with a screen-reader suffix and appears only when it is above zero. Counts are read on each page load. - **`/security`**: the events newest first, in pages of 50. Each row has the time, a sentence for the operation, who acted (agent, person or system badge and name) and the subject, linked to its audit history. The rows rove like the desk's. - The desk links to `/security`. ## Why - **A list of operation names in Core.** Each lane appends its own events, and the desk should not depend on all of them. An operation nobody appends yet costs nothing, so routing rules can be named now. - **Counts per page load.** M1 has in-app counts only (DSK-05). The layout is static, so a count is current on every navigation without a circuit or polling. Each count is one indexed query. - **The last day for the security badge.** A count of everything ever recorded would never fall back to zero. One day is enough for a daily check, and `NotificationLinks.SecurityWithin` is the one place to change it. - **Not in this PR**: - "Injection flagged" (SEC-08): the instructions check (SEC-03) reports a finding in a check result, not an audit event. Once check results are stored (#2575), a flagged result can append its own event and join the list. - Marking events as seen, and email (DSK-06, M2). ## Tests Unit: - `DeskQueuesTests`: one queue is counted on its own, stale facts included. - bUnit `SecurityEventsViewTests`: - Each row has its sentence, initiator, subject link and time, and the query names the security operations. - The page comes from the address and links to its neighbours, and an empty log says so. - The header shows both counts only when they are above zero, and counts security events since one day ago. Integration: - On SQLite and PostgreSQL: a list holds only the named operations, newest first, with the total, a window and a time from any offset. - A bad window or no operation is refused, and a list appends nothing. - Host: after a key is issued and revoked, `/security` lists both newest first, links the credential's history and leaves out the change set events. The header counts one set awaiting the owner and two security events. An anonymous request goes to sign-in. - Browser: the axe WCAG 2.1 AA check now covers `/security` too, in both directions and themes. `dotnet build -warnaserror` and the full suite with the browser tests pass locally against a `postgres:17` container as well as SQLite. No new package and no new project reference. ## Checklist - [x] Linked to an issue a maintainer has confirmed - [x] Targets `v2` - [x] Tests or docs updated where behaviour changed - [x] No tool-generated co-author trailers or footers
This was referenced Oct 19, 2026
pournasserian
added a commit
that referenced
this pull request
Oct 19, 2026
Closes #2603 ## What A follow-up to the set view (#2574) in F-DSK (#2422). The set view shows the check results that #2575 now stores with each set (CHK-03, DSK-03), and a person can submit an open set. - **Checks card** (`IChangeSetChecks.GetAsync`): - The latest run's overall status, number, whether it ran on submit or in the background, when it finished and its model cost. - Each checked item with every check's status (passed, warning, failed, needs review) and whether it blocks or warns. Failures come first. Each finding has its message, field pointer and excerpt. - Problems that kept the engine from checking an item. - While the run is still going, the job's progress, such as "3 of 10 items done". - Before a submission, it says the checks run when the set is submitted. - **Submit for checks** (shortcut S) on an open set calls `IChangeSetChecks.SubmitAsync`. A small deterministic set is checked on the request and the page shows the result. Anything else is checked by the job. The action has the same moved-on guard and hidden placeholder form as the other actions. ## Why - **Submit belongs on the desk.** A person opens sets too: every revert, and soon every save from a generated form (F-FRM #2426). Agents submit by tool, and until now a person had no way to. - **Through `IChangeSetChecks`, not a plain transition.** #2575 makes it the one way into checks running, so the checks always run. - **Not in this PR**: the routing decision (#2565), and reopening a failed set, which whoever opened it does. ## Tests - bUnit: - A failed run lists the failing check first with its finding's message, pointer and excerpt, and shows the run's problems. - A run in progress shows the job's progress. - An open set offers Submit, all five action forms are on the page, and the shortcuts dialog lists S. - Integration, host: with an active terms check, submitting a person's open set with a failing entry moves it to checks failed. The page shows the failed run, the check's fail status, the `/headline` pointer and the excerpt. `dotnet build -warnaserror`, the unit and bUnit suite, and the full integration suite on SQLite with the browser tests pass locally. A full local PostgreSQL run on an overloaded machine (24 minutes) failed in 33 unrelated classes. The desk classes passed on a rerun against a cleaned container, and CI runs the full PostgreSQL suite. No new package and no new project reference. ## Checklist - [x] Linked to an issue a maintainer has confirmed - [x] Targets `v2` - [x] Tests or docs updated where behaviour changed - [x] No tool-generated co-author trailers or footers
pournasserian
added a commit
that referenced
this pull request
Oct 19, 2026
Closes #2513 ## What The SCH-05 slice of F-SCH (#2415), on top of change set publish (#2482) and revert (#2511). Every content type write is now a change set operation. A create or a revision stores its new version and records the operation in the change set it names, in one unit of work. Nothing a write does is live until that set publishes. - **Core** (`FluentCMS.Core.ChangeSets`): - `ChangeSetTarget`: the set a write joins, either an existing set by id or a session id with the rationale and sources of the set the session's first write opens (CHG-02). - `ChangeSet.Record(operation)` adds the operation, or extends the one the set already holds for the item. The write must be based on the version that operation proposes, and the operation then proposes the write's version. A create stays a create and an update keeps its base, so a set still has one operation per item (CHG-03) and its diff runs from the base to the newest version. - **Core** (`FluentCMS.Core.Schema`): - `IContentTypeDirectory.CreateAsync` and `ReviseAsync` take a `ChangeSetTarget`. A create records a create proposing version 1, and a revision records an update from its base to the new version. - A revision's base is the latest version or the published one. In a set that already holds the type it is the version that set proposes, so the set can keep revising its own draft after another set drafts the type. Basing it on the published version leaves an unpublished draft behind, such as a rejected one. The referenced-field rule (SCH-04) then also checks the fields of every draft an open set proposes, so such a revision cannot drop a field another draft references. - A revision of a type no set has published, or one a published delete took offline (a rejected or reverted create), records a create, since there is nothing live to update. `ChangeSet.Record` takes whether the item is published, and an agent needs the create scope for such a revision (MCP-05). - A draft is a base only while a set that can still publish proposes it. A revision of a rejected or reverted draft is refused at once with `version_conflict` and points to the published version, instead of failing later at publish. While an open set proposes a draft, the latest or an older one, a revision keeps its referenced fields and its entity flag (SCH-03). A draft whose set went stale still counts until a person rejects it; #2588 tracks that. - References between types are checked against what each type is heading to: every draft an open set proposes, or its published version while there is none. A rejected draft neither drops a live reference nor offers a field to reference, and a field a pending draft removes is no target. A field another type references stays while any of that type's live versions or open drafts references it. - `GetPublishedAsync(slug)` reads the live version, or fails with `content_type_not_published`. `ContentTypeSummary` carries `PublishedVersion`. - **Infrastructure**: - `ChangeSetDirectory` gains an internal join for writes owned by another directory. `PrepareAsync` finds the expected failures before the caller's unit of work begins: unknown set, set not open, blank rationale, agent ownership and the items cap. Inside it, `BeginWriteAsync` opens the set or moves its revision, and `RecordAsync` records the operation after the item's own rows and event. An agent's daily cap is counted in `BeginWriteAsync` with its credential held, as `OpenAsync` does since #2580. It is therefore the one refusal that rolls a caller's unit of work back, and #2513's acceptance now says so. - `content_type.created` and `content_type.revised` now carry the set's id, and come after `change_set.opened` when the write opens its session's set. An extended operation appends `change_set.operation_updated`, the name #2575 uses for the same move of an entry operation. - Both directories share one `ChangeSetDirectory` per scope. - **MCP write tools** (#2514): `create_content_type` and `revise_content_type` pass their target to the directory, which now records the operation itself. `ProposedWrite` no longer opens its own unit of work or checks the set ahead of the write; the directory's checks run before anything is written, so a refused cap still keeps its security event. The answer names the kind the set recorded, so a revision of a type with nothing live reads as a create. In `WriteToolsTests` the agent's revision of the person's unpublished product is now expected as `Create null→2` instead of `Update 1→2`, which is the behaviour change agents see. The entry tools (#2525) keep the tool-level pre-check and add, since entry writes do not join change sets in their directory yet. - No migration: both pointers already exist. ## Why The decision, as written in #2513: `Version` is the latest version, which authoring builds on, and `PublishedVersion` is what is live. The authoring reads stay on the latest version: `GetAsync`, `ListAsync`, the schema document and entry validation. A set can then build on its own drafts, and entries can be written against the schema change they need in the same set (CHG-09). Delivery reads the published version when F-API arrives. Choices worth a look: - **The operation is written by the change set directory, inside the content type directory's unit of work.** Version and operation commit together, and the existing "expected failures before the unit of work" rule still holds for both. - **Extending instead of refusing a second write.** An agent iterating on a type within a session would otherwise need a new set for every revision. - **A unique index at commit** means the slug or version race it always meant. When the write opened a session's set, it may also be a concurrent first write of that session, so that case answers with `change_set_conflict`. - **Two revisions racing into the same set**: the loser now fails with `change_set_conflict`, since the set's revision moved first, rather than `version_conflict`. The race tests give each writer its own set to keep checking the version race. - **Reference checks at write time are best effort.** Sets publish in any order, and a revert can take a field away again, so a reference can still dangle across sets. #2604 checks references at publish, which is where the order is known. It also covers dropping a field together with the last reference to it in one set, which the write-time check still refuses. - **Breaking signature**: every caller of `CreateAsync` and `ReviseAsync` passes a target. In this PR that is the MCP schema tools and tests: the schema, entry and MCP scenarios write their content model in a `content-model` session set, as a person, so agent caps and the change set reads in tests are not affected. - **Answering from the set the write joined**: the schema tools read the session's set again after the write, which can race with a submit; #2615 passes the set out of the write. - **Not in this PR**: entries as change set operations, and refusing at publish an entry whose content type version is not published (F-CNT). Also left out: deleting a content type, the MCP schema tools, the desk's schema review and delivery reading the published schema. ## Tests Unit (Core): - A later write extends the item's operation, keeping its kind and base, for a create and an update. - A later write not based on the proposed version, a delete over an update, and an update over a delete are refused and change nothing. - A write is recorded only while the set is open. Integration, each on SQLite and PostgreSQL: - A create is an operation of its set, with `content_type.created` naming the set. The type lists with no published version and `GetPublishedAsync` refuses it until the set publishes. - A revision in the same set extends the operation (create from nothing to version 2). Its events name the version it was based on, and the diff lists both fields as added. - A revision of a published type into a new set is an update from the published version. It is live only after that set publishes, and the previous version stays readable. - After a rejected draft, a revision based on the published version goes through, while one based on the rejected draft fails with `version_conflict`. - After a rejected create, and after a published create was reverted, the next revision is recorded as a create, and publishing it makes the type live again. - A revision from the published version cannot drop a field that another draft in an open set references. - Writes through a session share the set the first one opens. - An unknown set, a set that is not open and a blank session rationale are refused inside a caller's unit of work, which then commits. - Inside a caller's unit of work, an agent cannot write into another agent's set, and a write past its items cap is refused. Neither stores a content type or change set event, the cap refusal keeps its security event, and the caller's unit of work commits. - A failure injected at either audit append of a revision, after the pointer and the set's revision reached the database, leaves neither the version nor the operation. A caller's unit of work rolled back after the revision's rows were saved leaves neither either. - A rejected draft is refused as a base, while a draft an open set proposes can be built on and publishes after it. - An agent without the create scope cannot revise a type with nothing live. - A revision from the published version keeps an open draft's entity flag, and may drop it once that draft's set is rejected. - A rejected draft of a page does not let its brand drop a field the live page references, and a field only a rejected draft has cannot be referenced. - A field that a pending draft removes cannot be referenced, from the draft's own set or another one. - A field that an older open draft references cannot be removed, even when a later draft of that type is the latest. - A set keeps revising its own draft after another set drafted the type, and a revision of it from the latest version is refused with `version_conflict`. - A revision into a rejected set that holds the type is refused with `change_set_not_open`. - A revision keeps the entity flag of an older open draft when the latest draft was rejected. - A content type write that would open a set past an agent's daily cap is refused, stores no set or type, and keeps one security event. - A session's first write appends the set's opening before the content type's own event. `dotnet build -warnaserror` and the full suite pass locally on SQLite and against a `postgres:17` container. No new package and no new project reference. ## Checklist - [x] Linked to an issue a maintainer has confirmed - [x] Targets `v2` - [x] Tests or docs updated where behaviour changed - [x] No tool-generated co-author trailers or footers
4 tasks done
pournasserian
added a commit
that referenced
this pull request
Oct 19, 2026
Closes #2561 ## What The first slice of F-RTE (#2421): routing rules and their evaluation (RTE-01, D09). A rule maps conditions on a change set's items and on the set to a disposition. Evaluating the active rules gives a routing decision that names every rule that applied, with its version and reason. This slice decides; the next slice acts on the decision (approvals and auto-publish, RTE-05). - **Core** (`FluentCMS.Core.Routing`): - `RoutingRuleDefinition`, one JSON document with a `description`, `when` conditions and `then` a disposition. It is parsed, validated with every problem reported at once, and written back unchanged. - Item conditions are `itemKinds`, `contentTypes`, `fields` (changed field names) and `changeKinds`. Set conditions are `initiators`, `agents`, `checks` (the worst check outcome allowed) and `riskScore`. A condition left out matches anything. Checks and score do not match without facts. - `RoutingDisposition`: `auto_publish`, `review` with 1 to 10 approvals (1 when left out, RTE-05), or `block`. - `RoutingRule` mirrors `Check`: a name, versions and the active version. - `RoutingEvaluator` computes a `RoutingDecision`: the disposition (or none), the rules that applied with their items and reasons, and the items no rule matched. - **Directory**: `IRoutingRuleDirectory` lists, gets, creates, revises from a named base version, activates and deactivates. Only a person writes; agents and the product's engines get `routing_rule_people_only` (SEC-02). Each write appends `routing_rule.created`, `.revised`, `.activated` or `.deactivated` in the same unit of work. - **Routing a set**: `IChangeSetRouting.RouteAsync(changeSetId)` reads the set, its diff for each item's changed fields, each item's content type (taken from the diff's own operations, so both reads agree) (a schema item's own, or an entry's or entity's type) and the initiator, then evaluates the active rules. It is a read: no unit of work and no event. - **Infrastructure**: the `RoutingRules` and `RoutingRuleVersions` tables, with the definition as `jsonb` on PostgreSQL. Migrations for both providers. They are generated after v2's newest migration. ## Why D09 has explicit routing rules decide first and a risk score route what they leave. The S1 scenario needs a price rule and a routine spec rule side by side. Choices worth a look: - **Stored routing rules ahead of the rule book.** RUL-02 makes permission and routing a rule kind, but F-RUL (#2419) has not started. As checks did ahead of the rule book (#2524), routing rules are their own named, versioned records with an active version that only a person sets. Once the rule book exists, a published permission and routing rule compiles to one of these. - **The strictest disposition wins, and the order of rules never matters.** A rule applies when it matches at least one item. Block beats any review, and a review takes the highest approval count. A set auto-publishes only when auto-publish rules cover every item and every field each item changes, and nothing stricter applies. A rule with a `fields` condition covers only the fields an update changes, and never a change to the item as a whole (a create, a delete, a schema's `entity` flag or field order), so a spec update that changes `name` and `unit`, or that also makes `spec` an entity type, waits for a score under the S1 rules, while a review or block rule applies as soon as one named field changes. A routine rule therefore cannot carry a risky change through, whichever rule a person wrote first. - **Items no rule matches are listed, not defaulted.** The scorer slice (RTE-02) routes them by score, and to a person when no model is configured. Until then the next slice treats an undecided set as needing review. - **Activation validates again** and needs no examples, unlike checks: a routing rule only reads facts the product computes. - **Not in this PR**: acting on the decision (RTE-05), the risk scorer (RTE-02, RTE-03), sampling (RTE-04), the routing decision log (RTE-06), check results as facts (the `checks` condition is there, and the check run now exists since #2575, but `RouteAsync` does not pass its outcome yet, so a `checks` condition never matches; RTE-05 supplies it), sections, agent trust levels, and screens or tools for routing rules. ## Tests Unit (Core): - A definition with every condition reads back, round-trips through its JSON, and validates. Conditions may be left out, and a review defaults to one approval. - Malformed parts are all reported at once, by path. A count or a bound given as text or null is reported with the others. Every problem in a disposition is reported too, and a disposition given as a number is named as an unknown disposition. Approvals are only for a review, from 1 to 10. - Validation reports bad slugs, field names, duplicates, empty lists, unknown kinds, empty agent ids and inverted or out-of-range score bounds. - S1: a price change gets review with 1 approval, and a set of only spec name changes auto-publishes. A set with both gets review whichever rule comes first. - Block beats review, and review takes the highest count. - An unmatched item is listed and stops auto-publish, and a stricter rule still decides while other items wait for a score. - An auto-publish rule covers only the fields it names: an item that also changes another field waits, unless another auto-publish rule names that field or covers the whole item. A change to the item as a whole waits unless a rule without a field condition covers it. A create or a delete counts as one, whatever fields it lists. - Item conditions match by kind, type, changed field and change kind. Set conditions match by initiator kind and agent, and need facts for checks and scores. Integration, each on SQLite and PostgreSQL: - A person creates, activates, revises, reactivates and deactivates a rule. The active version stays until a new one is activated, and every write is audited with the version it replaced. - An invalid definition, a taken name, an unknown rule (on read, revise, activate and deactivate), a stale base, a missing version, re-activating and deactivating an inactive rule fail with their codes and write nothing. - An agent and the checks engine are refused for every write. - A schema update that makes a type an entity type is not covered by an auto-publish rule naming the field it also adds. - A stored set with a schema create, a schema update and an entry create routes by the active rules: the inactive rule is ignored, every item is matched, and no event is appended. The same routine set opened by an agent is undecided, because the rule names people. An unknown set fails with `change_set_not_found`. `dotnet build -warnaserror` and the full suite pass locally on SQLite and against a `postgres:17` container. One run had a timing failure in `JobQueueWorkerTests` (lease) under full-suite load, which passes on its own; this PR does not touch jobs. No new package and no new project reference. ## Checklist - [x] Linked to an issue a maintainer has confirmed - [x] Targets `v2` - [x] Tests or docs updated where behaviour changed - [x] No tool-generated co-author trailers or footers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2573
What
The check run slice of F-CHK (#2420), CHK-03 and PERF-02: every submitted change set is checked by the check engine. The results are stored with the set, and the engine, not the agent, moves the set to checks failed or awaiting review. It runs as
SystemInitiator.Checks(#2558, ADR-0012) with the runner from #2556 and the job queue from #2536.FluentCMS.Core.Checks):IChangeSetChecks.SubmitAsyncmoves a set to checks running and enqueues achecks.runjob, both in one unit of work, so a set in checks running always has a run coming.RunAsyncchecks each entry and entity the set creates or updates at its proposed version, against the content type version it was written to. An update's changed fields come from its base version. The active checks are read once, so every item is judged by the same checks.CheckInput.ChargedTo, SEC-04), not to the engine.change_set.checked, onecheck.judgedper LLM-judged result (model and prompt hash, CHK-05) and the move all commit together.checks.runjob. This is checked again after the move, inside the unit of work, so a run of an earlier submission, or one another run beat, rolls back and changes nothing. The job payload names only the set.change_set.operation_updatedrecords the move. Other duplicates, including content type operations, are still refused. The tools' size pre-check no longer counts such a fix as a new operation.CheckRuns(numbered per set, unique on set and number, with problems as JSON) andCheckResults(one row per item and check, with findings and the model call). Migrations for SQLite and PostgreSQL.EngineScopes.AddEngineScopes): a host wraps itsICurrentInitiatorso Core can open a scope acting as an engine for the inline run. The scope is opened before the set moves, so a host that cannot run the engine fails the submit rather than leaving every set to its job. Opening one is internal to Core and takes only aSystemInitiator, so no other code can act as someone it is not. The job worker keeps its own wrapper.submit_change_setreturns the run when it ran inline, or the job to follow otherwise.get_change_set_checksreturns the run of the latest submission (none while a resubmitted set is still running) and its job's state and progress, without the raw error a worker recorded.ICheckRunnergains an overload that takes the active checks.Why
CHK-03 requires checks on every change set before routing, with results, evidence and cost stored with the set. PERF-02 requires deterministic checks within ten seconds for 200 items, with LLM-judged checks running asynchronously with progress.
Choices worth a look:
submit_change_setfor the common case. The job enqueued with every submission makes the run durable even if the inline run never finishes. While any LLM-judged check is active, every set runs as a job, since a model call never holds up a request. Deciding by the checks whose scope applies would need the set's items read before the submit; that can come later if jobs prove slow.SystemInitiator.Retention.Tests
Unit:
CheckRunJob:CheckRunner: an LLM-judged check charges its model call to the agent named.ChangeSet: a later draft of an entry, as a create or an update from the same published version, moves its operation on. A draft that is not later, or a content type operation, is still a duplicate.Integration over MCP, on SQLite and PostgreSQL unless noted:
get_change_set_checksreads the run back, along with its waiting backup job. The agent reopens the set, updates the entry from the draft the set proposes with a source, and submits it again. The same set passes to awaiting review as run 2, holding one create operation at the new draft. The engine's four events name the check engine, with no credential, andchange_set.operation_updatedrecords the fix.SystemInitiator.Checksand is charged to the agent's credential. Itscheck.judgedevent names the model, the prompt hash and the set, as the engine, and the run's cost is the judgement's.CheckDirectoryTests) accepts either refusal the second activation can legitimately get.The full suite ran locally against a
postgres:17container as well as SQLite, and markdownlint passes. No new package. This PR adds noIContentTypeDirectory.CreateAsyncorReviseAsynccall.Checklist
v2