Skip to content

Add routing rules and their evaluation - #2565

Merged
pournasserian merged 11 commits into
v2from
2561-routing-rules
Oct 19, 2026
Merged

pournasserian merged 11 commits into
v2from
2561-routing-rules

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

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 (F-RUL: Rule book as versioned content #2419) has not started. As checks did ahead of the rule book (Add check types, checks with test examples and the activation gate #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 Run the checks on every submitted change set #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

  • Linked to an issue a maintainer has confirmed
  • Targets v2
  • Tests or docs updated where behaviour changed
  • No tool-generated co-author trailers or footers

@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:routing Risk routing and sampling (RTE) labels Oct 19, 2026
pournasserian added a commit that referenced this pull request Oct 19, 2026
Closes #2574

## What

The second slice of F-DSK (#2422): the set view (DSK-03), with the
per-item diff rendered progressively (PERF-04). It is read only;
approve, reject and revert are the next slice.

- **`/sets/{id}`** (`ChangeSetView.razor`, static server rendering):
- The rationale as the heading, the state, the initiator, when the set
was opened and when it entered its state.
  - The set it reverts as a link, and a rejected set's reason.
- **Sources**: a source that is an `http` or `https` address is a link
with `target="_blank"` and `rel="noopener noreferrer"`. Anything else,
such as a file name, a person or a `javascript:` address, is text.
- **Checks**: says whether the checks failed, and that results per check
appear once checks run on every set (CHK-03, F-CHK #2420).
- **Routing**: says that routing has not decided. Routing rules (#2565)
are not on v2 yet.
- **History**: every event whose subject is the set, oldest first, with
who acted and when, read through `IAuditReader`. Refused moves are
listed too. The first 200 are shown, with a link to the full history.
- An unknown id calls `NavigationManager.NotFound()`, so the page
answers 404 with the not-found page.
- **`ChangeSetItems`** (`Components/Review`): the interactive island. It
reads `IChangeSetDirectory.DiffAsync` and renders the merged `DiffView`.
The first 25 items arrive with the prerendered page and the circuit adds
the rest in batches. A refused read is reported in place of the items.
- **The desk** links each set to its view instead of its audit history.

## Why

- **A static page with one interactive island.** PERF-04 needs the
circuit only for the diff batches. The rest of the page, including the
source links and history, then reads and links without a circuit. The
review actions in the next slice can post forms to this page, so a
decision does not depend on the circuit (ADR-0006).
- **Times from the history.** The view reads the set's history anyway,
so the opened and state times come from it, the same events
`IChangeSetLists` uses.
- **Only web addresses become links.** Sources are free text from
agents. Linking only `http` and `https` keeps `javascript:` and local
paths from becoming clickable.
- **Not in this PR**: review actions, the routing decision from #2565
once it merges, check results per check, item names in the diff, and
keyboard review.

## Tests

- bUnit `ChangeSetViewTests`:
  - The rationale, state, initiator and the two times from the history.
- Only the web address among four sources is a link, with its target and
`rel`. `Openable` accepts `http` and `https` and refuses `javascript:`,
`file:`, `mailto:` and a bare host.
  - The history in order, with a refused move listed.
  - A revert link and a rejection reason.
  - The check and routing notes.
  - An unknown set renders nothing.
- The island renders the diff with an assigned render mode and reports a
refused diff.
  - Logical utilities only.
- `DeskTests`: a set row links to `sets/{id}`.
- Integration, host:
- An anonymous request goes to sign-in, and an unknown set answers 404
with the not-found page.
- A set of 30 items opened by a user: the desk links to it, and the page
shows the rationale, the web source as a new-tab link, no `javascript:`
link and the text source. The history lists opened, submitted and
awaiting review. The prerendered island holds the first 25 items, is
`aria-busy` and carries the Blazor marker for the circuit.
- A rejected revert set shows its reason and links to the published set
it reverts.

`dotnet build -warnaserror` and the full suite pass locally against a
`postgres:17` container as well as SQLite. One loaded run had eight
PostgreSQL and job-lease timing failures in classes this PR does not
touch; all 102 tests of those classes passed on a rerun. The batches
over the circuit were not checked in a browser here: the prerendered
first batch is tested, and #2479 checked the batching on `/ui`. 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
…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 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
pournasserian merged commit 3ad3bc1 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2561-routing-rules branch October 19, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:routing Risk routing and sampling (RTE) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Routing rules: conditions on a change set to a disposition, the strictest winning

1 participant