Skip to content

Run the checks on every submitted change set - #2575

Merged
pournasserian merged 11 commits into
v2from
2573-check-run
Oct 19, 2026
Merged

pournasserian merged 11 commits into
v2from
2573-check-run

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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.

  • Core (FluentCMS.Core.Checks):
    • IChangeSetChecks.SubmitAsync moves a set to checks running and enqueues a checks.run job, both in one unit of work, so a set in checks running always has a run coming.
    • While every active check is deterministic, a set of up to 50 operations is checked on the submitting request, in a scope acting as the engine. Its job waits two minutes and does nothing if that run finished. Any other set is checked by the job, which reports progress at most once a second.
    • RunAsync checks 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.
    • An LLM-judged check's model call is charged to the agent that opened the set (CheckInput.ChargedTo, SEC-04), not to the engine.
    • The run, its results, change_set.checked, one check.judged per LLM-judged result (model and prompt hash, CHK-05) and the move all commit together.
  • Runs belong to submissions. A run stores its submission's job id. It does nothing unless the set is in checks running and that job is the set's latest checks.run job. 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.
  • Sets do not stay in checks running. An item that cannot be read, such as an entry deleted after it was proposed, is a problem that fails the run, and the other items are still checked. A conflict is retried. A job that still throws on its last attempt (6) fails the set with a problem. A set whose job ended without moving it, as when the host stops during the last attempt, is failed when the agent submits it again, so it can be reopened.
  • A failed set is fixed in place. A set holds one operation per item. Until now, an agent that reopened a failed set could not change an entry already in it, because the update was refused as a duplicate. Now a later draft of an entry or entity the set already proposes moves that operation on to the new draft. It must be the same change from the same published version. The item keeps one operation, one diff and one version to restore, and change_set.operation_updated records 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.
  • Reverts are not held. A revert takes no new operations, so it could never pass a check it fails, such as one asking for a source. Its run is recorded and it goes to review with the results (CHG-06).
  • Infrastructure: tables CheckRuns (numbered per set, unique on set and number, with problems as JSON) and CheckResults (one row per item and check, with findings and the model call). Migrations for SQLite and PostgreSQL.
  • Engine scopes (EngineScopes.AddEngineScopes): a host wraps its ICurrentInitiator so 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 a SystemInitiator, so no other code can act as someone it is not. The job worker keeps its own wrapper.
  • Tools:
    • submit_change_set returns the run when it ran inline, or the job to follow otherwise.
    • New read-only get_change_set_checks returns 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.
    • MCP-01 coverage row "Read the check results of a change set".
  • ICheckRunner gains an overload that takes the active checks.
  • CONTRIBUTING.md: the check run.

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:

  • Inline for small sets while every check is deterministic, a job otherwise. The agent gets its findings in the answer to submit_change_set for 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.
  • The inline run ignores the request's cancellation. The set is already submitted, so an agent that disconnects still gets its set checked, and an inline failure or fault is left to the job instead of being reported as a failed submit.
  • Problems as a run-level list, not fake check results: they say what kept the engine from checking an item and what to do, and they fail the run.
  • Fixing in place, in this PR. It is a change to the change set model (F-CHG), made here because this PR is the first to leave sets in checks failed. The F-CHG lane knows.
  • Core logs nothing, so an inline fault is not logged; the job checks the set two minutes later and records the error if the fault recurs.
  • Plugin check types run in the engine's scope. They are compiled-in extensions today. Per-plugin permissions come with the plugin SDK (EXT-03).
  • Passing sets wait for review. Routing (F-RTE F-RTE: Risk routing, approvals and sampling #2421) decides more once it lands.
  • Not in this PR: checks that read entities (F-ENT F-ENT: Entity registry with sources, validity and blast radius #2417), and a scheduled retention purge as SystemInitiator.Retention.

Tests

Unit:

  • CheckRunJob:
    • it runs as the engine for its own submission and reports progress
    • an invalid payload fails the job
    • a conflict is retried
    • a fault before the last attempt is retried, and on the last attempt it fails the set
    • a lost lease ends the attempt without failing the set
  • 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.
  • MCP-01 coverage row.

Integration over MCP, on SQLite and PostgreSQL unless noted:

  • S2: a set with "best in class", an image without alt text and unsourced copy is checked inline and lands in checks failed, with three failing results whose findings point at the field. get_change_set_checks reads 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, and change_set.operation_updated records the fix.
  • A set no check applies to passes to awaiting review.
  • The job of an earlier submission changes nothing, both while the set is reopened and after it is submitted again, even when a person runs it.
  • An item that cannot be read fails the set with a problem naming it, and the other item is still checked.
  • While an LLM-judged plugin check is active, a set is checked as a job with progress. The check runs as SystemInitiator.Checks and is charged to the agent's credential. Its check.judged event names the model, the prompt hash and the set, as the engine, and the run's cost is the judgement's.
  • A revert whose restored version fails the sources check goes to awaiting review with the failing result.
  • A set whose check job died while it was in checks running is failed when its agent submits it again, with a problem naming the job, and can be reopened. A person submitting a set whose job failed fails it the same way.
  • PERF-02: deterministic checks on a 200-item set run as a job and finish in under ten seconds.
  • The existing write tool test now expects a submitted schema-only set to pass to awaiting review.
  • A flaky activation race test (CheckDirectoryTests) accepts either refusal the second activation can legitimately get.

The full suite ran locally against a postgres:17 container as well as SQLite, and markdownlint passes. No new package. This PR adds no IContentTypeDirectory.CreateAsync or ReviseAsync call.

Checklist

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed

@pournasserian pournasserian added this to the M1 Governance core milestone Oct 19, 2026
@pournasserian pournasserian added type:feature New capability v2 FluentCMS v2 (v2 branch) area:checks Checks (CHK) labels Oct 19, 2026
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
@pournasserian
pournasserian merged commit 67a2e91 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2573-check-run branch October 19, 2026 07:17
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:checks Checks (CHK) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Check run on every change set, with results stored on the set

1 participant