Skip to content

Store rules as versioned content published through change sets - #2643

Merged
pournasserian merged 7 commits into
v2from
2634-rule-book
Oct 19, 2026
Merged

pournasserian merged 7 commits into
v2from
2634-rule-book

Conversation

@pournasserian

@pournasserian pournasserian commented Oct 19, 2026 •

Copy link
Copy Markdown
Member

Closes #2634

What

The first slice of F-RUL (#2419), RUL-01 to RUL-03: the rule book, with rules stored as versioned content and published through change sets. It builds on the change set writes of #2520 (SCH-05).

  • Core (FluentCMS.Core.Rules):
    • RuleDefinition holds four things, stored as one JSON document (ADR-0005), with Validate saying what to change:
      • a kind: voice and style, factual, structural, or permission and routing
      • the rule's text
      • a severity, block or warn
      • a scope of content types, fields and locales; the locale slot is kept and nothing reads it in M1
    • Rule: a slug, a latest version and an active version. Its state is draft, active or retired, and only publishing moves it.
    • IRuleDirectory:
      • reads: the list, the latest version, the active version, and any version
      • writes, each into a ChangeSetTarget: create, revise (from the latest, the active, or the version the set already proposes) and retire
    • ItemDiff.ForRule: the kind, text, severity and each scope list, before and after.
  • Infrastructure:
    • Rules and RuleVersions tables, with migrations for SQLite and PostgreSQL; the definition is jsonb on PostgreSQL.
    • RuleDirectory writes as ContentTypeDirectory does: every expected failure is found before the unit of work, and the version, its rule.created or rule.revised event and the change set operation commit together.
      • A second revision in the same set extends the rule's operation.
      • A retirement is a delete operation from the active version.
    • ChangeSetDirectory handles rule items:
      • resolving an operation, and refusing one from an agent or an engine
      • publishing: the active version and the state move together, and rule.published or rule.unpublished is logged
      • reverting, through the inverse operations
      • the diff
  • CONTRIBUTING.md: the rule book.

Why

RUL-01 makes rules content: written, versioned, reviewed and published through change sets like everything else. The next slices propose checks from a rule and read the rule book for agents, and both need a rule to have versions and an active one.

Choices worth a look:

  • State follows publishing. A rule is a draft until a published set makes a version active, and retired once a published set retires it, as content types and entries are. Reverting a retirement makes it active again, at the version it had, and reverting the set that created it makes it the draft it was.
  • Only a person writes rules. The epic says any administrator writes and activates rules in M1. An agent reads the rule book in a later slice. An agent or engine is refused in the directory, and also when it puts a rule operation in a set directly.
  • Severity reuses CheckSeverity, since the checks proposed from a rule take its severity.
  • No tools or screens yet. The rule book tool and resource come with RUL-06, and the desk shows rules with RUL-05.
  • Not in this PR: proposing checks (RUL-04), enforced and advisory labels and the agent read (RUL-05, RUL-06), flagging instruction-like rule text (RUL-07), and proposing a rule from the desk (RUL-08).

Tests

Unit:

  • A definition round-trips through its stored document.
  • Each kind has a stored name.
  • A new rule is a draft at version 1.
  • A rule that cannot be held lists each problem.
  • A revision builds on the latest or the active version.
  • A stored document that is not a rule says why.

Integration, each on SQLite and PostgreSQL:

  • A rule is a draft until its set publishes it, then active at version 1. Its operation and events are recorded, and publishing logs rule.published.
  • Two revisions in one set extend its operation to version 3, and version 1 stays active until the set publishes. The diff shows the text, severity and scope changes.
  • A retirement retires the rule once published; retiring a retired rule is refused; reverting the retirement makes it active again.
  • Reverting the set that created a rule makes it a draft again, not retired.
  • An invalid rule (kind, text and severity), a taken slug, an unknown rule and a stale base version are refused, and nothing is stored. A stored document with an unknown severity is refused when read.
  • An agent and the check engine are refused every rule write, and an agent's rule operation in its own set is refused.

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:rules Rule book (RUL) labels Oct 19, 2026
@pournasserian
pournasserian merged commit 7fb06d0 into v2 Oct 19, 2026
2 checks passed
@pournasserian
pournasserian deleted the 2634-rule-book branch October 19, 2026 22:34
pournasserian added a commit that referenced this pull request Oct 26, 2026
…its author (#2669)

Closes #2650

## What

The second slice of F-RUL (#2419), RUL-04 with MDL-03: saving a rule
proposes checks that enforce it, and the author confirms, edits or
rejects each one. It builds on the rule book of #2643 and the job
handler conformance suite now in v2.

- **Core** (`FluentCMS.Core.Rules`):
  - `RuleCompiler` asks the model on a new `rules` profile.
- The instructions describe the built-in types a rule can compile to:
terms, pattern, length, range, tone, claims and reading level.
- The rule's text and scope go in the prompt's data sections, never in
its instructions (SEC-03, RUL-07).
- The answer is read in a fixed shape: up to three checks, each with its
settings and at least one passing and one failing example. A proposal
takes the rule's severity and scope.
- A proposal of a type that was not offered, or that does not validate
against the installed check types, is left out.
- No model, a failed call, an unreadable answer, or no usable proposal
leaves the version advisory, with a message that says why.
- `RuleCompileJob` (`rules.compile`) runs the compile as the check
engine, on behalf of the person who saved the rule. `EngineJobs` lists
it for the check engine. The payload only names the version; whether the
job is still that version's compile comes from what is stored. If its
last attempt throws, it records the version advisory, so a version never
stays compiling.
  - `IRuleChecks`:
    - reads a version's compile and its proposals
    - proposes again, which marks the undecided proposals replaced
- confirms a proposal, as proposed or edited, under its proposed name or
another
    - rejects a proposal
- for the job only: reads the version it compiles, and records what it
found
- **Infrastructure**:
- Saving a rule version (create or revise) schedules its compile in the
same unit of work: a job when the `rules` profile is configured, and
otherwise an advisory compile at once.
- Tables `RuleCompilations` and `RuleCheckProposals`, with migrations
for SQLite and PostgreSQL; a proposal's definition is `jsonb` on
PostgreSQL.
- Confirming creates the check and activates it once its examples get
their verdicts. The check, its `check.created` and `check.activated`
events, the proposal's decision and `rule.check_confirmed` commit
together. The examples run before the unit of work, so no model call
happens inside it.
- `CheckDirectory.ListActiveAsync` leaves out a check confirmed from a
rule while the rule is not active at the version the check was proposed
for. So the check run, `check_draft` and the inline-or-job decision all
follow the rule's state.
- Docs: CONTRIBUTING.md on the compile, and ADR-0012 now lists proposing
checks among the check engine's actions.

## Why

RUL-04 turns a rule's prose into checks the author can trust: the system
proposes, a person decides (D17). The next slice labels rules enforced
or advisory and gives agents the rule book with its compiled checks,
which needs to know which checks serve which rule and version.

Choices worth a look:

- **Compiling is a job, as the check engine.** A model call can take
seconds, so saving a rule never waits for one. The job runs as the check
engine, since proposing checks is the engine's work, and is charged to
the rule's author. A person's calls are uncapped, as for the check run.
No new engine was added.
- **A check serves the rule version it was confirmed for.** It guards
content only while that version is active. A draft rule's checks wait
for its set to publish it, a retirement stops them, a revert brings them
back, and publishing a revision hands over to the checks confirmed for
it. The cost is that a revision's checks are confirmed again, even for a
small edit; that keeps "what runs" equal to "what the author confirmed
for this text".
- **Confirming also activates.** Activation is what runs a check's
examples, and a person confirms (D17), so one step does both. The
examples must get their verdicts, or nothing is stored.
- **Proposal numbers are never reused.** Proposing again marks undecided
proposals `replaced` rather than deleting them, so a number the author
read always names the same proposal. It first moves the compile to its
new job, which waits for a compile that is recording at the same time,
so that compile's proposals are replaced too.
- **Check names come from the rule**, as `<rule slug>-<number>`, not
from the model. The author can give another name when confirming.
- **Not in this PR**: enforced and advisory labels, the rule book tool
and resource and rule hints (RUL-05, RUL-06, MCP-02, MCP-03), flagging
instruction-like rule text (RUL-07), and proposing a rule from the desk
(RUL-08).

## Tests

Unit:

- A voice and style rule compiles to a terms check, with the rule's
severity and scope and its examples. The request goes to the `rules`
profile on the author's behalf, with the rule's text and scope only in
the data sections.
- No model is configured: the rule is advisory and no call is made.
- A failed call leaves the rule advisory, with the reason.
- An answer that cannot be read leaves the rule advisory. An answer in a
code fence is still read.
- A proposal of an unknown or unoffered type, without a failing example,
or with settings that are not an object is left out.
- A rule no check can enforce is advisory.
- At most three checks are proposed.
- The rule compile runs as the check engine, and `rules.compile` passes
the job handler conformance suite.
- A compile payload whose version is not a whole number fails as
`payload_invalid`.
- A compile that throws is retried, and its last attempt leaves the
version advisory with a message.

Integration, each on SQLite and PostgreSQL:

- Without a model, a saved rule is advisory with the message, and no
compile is queued.
- The compile job, run as the check engine for the author, proposes the
two checks. The model call's system message does not hold the rule's
text, which sits inside the user message's data delimiters.
`rule.compiled` names the model and the prompt hash.
- A confirmed check is created and active, with its events. It does not
run while the rule is a draft. Once the set publishes the rule, "best in
class" fails it; once the rule is retired, it does not run.
- A revision's confirmed check takes over from the active version's when
the revision is published.
- An edited confirmation creates the edited check under another name. A
confirmation whose examples fail, or whose name is taken, stores
nothing.
- A confirmed or rejected proposal cannot be decided again, and an
unknown number is refused.
- Proposing again marks the undecided proposals replaced and numbers new
ones after them. The compile it replaced records nothing.
- An unreadable answer leaves the version advisory.
- An agent and an engine are refused confirming, rejecting and proposing
again. A person and an agent are refused reading or recording a compile
as the job does.

The full suite ran locally against a `postgres:17` container as well as
SQLite, and markdownlint passes. No new package. Tests use a fake model
provider: no real model calls, no keys, no network.

## Checklist

- [x] Linked to an issue a maintainer has confirmed
- [x] Targets `v2`
- [x] Tests or docs updated where behaviour changed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:rules Rule book (RUL) type:feature New capability v2 FluentCMS v2 (v2 branch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rule book: rules as versioned content published through change sets

1 participant