Blog

Actions and Rules for Agents in ORM 2

A little-cited paper on dynamic rules in Object-Role Modeling turns out to be an action-schema language for agents. Here is what it got right, the one thing it left unfinished, and how to model agent actions today in a formalism a validator can actually check.

AI agentsORMconceptual modellingstate machinespromise theorycausal graphsFactumknowledge graphs

From state-transition constraints to multi-agent causal graphs — a close reading of Balsters, Carver, Halpin & Morgan's "Modeling Dynamic Rules in ORM," and what it offers people building agentic systems on causal temporal event graphs, promise theory, and verifiable trust infrastructure.


1. Why an obscure ORM paper matters to agent builders

Object-Role Modeling is usually filed under "conceptual data modeling" — a fact-oriented alternative to ER and UML class diagrams, valued for producing constraint diagrams a domain expert can actually read. Balsters, Carver, Halpin and Morgan's paper on dynamic rules is a narrower, less-cited contribution: it extends ORM's static constraint language — facts about a single state — into a declarative language for state transitions, with old state, new state, and a relationship between them.

At first glance this is database-schema housekeeping. Don't let a salary decrease. Don't let a marital status jump from single straight to divorced. Don't let a seating claim a table that isn't vacant.

Squint at the structure and it is something else entirely: an action schema. Context / needed before / after is STRIPS's precondition / effect wearing different clothes. The old/new pair is a state-transition edge. The case-statement transition tables in Figures 3 and 4 are finite-state-machine transition matrices, written in a syntax aimed at a non-technical reader instead of a planner.

That's the interesting part. The language was built for business analysts validating rules about payroll and restaurant seatings, and it independently arrived at a representation that overlaps heavily with the action-and-effect languages used in classical planning, with event-condition-action rules in active databases, and — closer to home if you work on Semantic Spacetime and causal temporal event graphs — with the LEADS_TO edge that links a state-node to its causal successor.

This piece works through the patterns one at a time, translates each into agent vocabulary, and then does the thing the paper could not: shows how to express each one in a model file a validator will check today, because the paper's own notation was never implemented anywhere.


2. The core pattern: context, precondition, and the old/new pair

The simplest example is the salary rule:

Context: Employee
For each Employee,
  new salary >= old salary

Generalized, any functional binary fact type A R's B with role p gets:

Context: A | For each A,
  new p <op> old p

where <op> is whatever comparison the domain needs. Three things in there map onto three different pieces of agent-action machinery.

(a) The context is a fact type, not a class. This is the paper's real methodological insight, and it makes it explicit in Section 4 by comparing against OCL. OCL forces every constraint's context to be an object or class, which produces artificial side effects the moment a transaction naturally spans two of them — their Seating occupies Table example, Figures 8 through 11. ORM lets the fact type itself — the relationship, not either participant — be the unit of transition.

That is exactly the difference between an object-oriented action model (a method call on one object that mutates a neighbour as a side effect) and a relational action model, where an action is a first-class edge connecting several participants. If you have been thinking of agent actions as verbs owned by a single agent object, the fact-type context is the nudge toward an action as a hyperedge that several parties co-author — which is how a causal temporal event graph, or a promise-theoretic promise/imposition pair, actually needs to be represented.

(b) old/new denotes a rule-scoped, single-step transaction. The paper is disciplined about scope: application of dynamic rules is restricted to "single-step transactions." No constraint spanning arbitrary numbers of states — one edge, old to new. That restriction is precisely what makes the rules checkable like SQL check-clauses rather than requiring general temporal logic. It is the same choice STRIPS makes with effects axioms, and the same choice a CTEG makes at the level of a single LEADS_TO edge: each edge is a local, checkable step, and global properties — safety, liveness, "paid within 30 days" — are built by composing edges rather than by writing one omniscient constraint over the whole timeline.

(c) An undefined old value doesn't violate the rule; it makes it vacuously true. If the employee had no prior salary, the inequality evaluates to unknown, and unknown is not a violation. Small decision, large consequence: first occurrences need a different rule from updates, and the paper says so explicitly. Any framework that collapses "create" and "update" into one mutation event will get this wrong, and it will get it wrong silently — the precondition for creating a fact is a different rule from the precondition for updating one, and pretending otherwise produces null-shaped bugs in the constraint layer rather than in the code, which is where nobody looks.


3. The unfinished part: the notation was never implemented

Here is the thing you find out when you try to use any of this.

The dynamic-rule extension was a proposal. NORMA never implemented it; neither has anything since. There is no validator that reads needed before, no verbalizer that emits it, no serialization that carries it. A rule expressed in a notation no tool can check is a comment — and a comment about a state transition is exactly the artifact that goes stale first, because it is the artifact that never fails a build.

So the practical question isn't how do I write old and new. It's:

What do I have to make into an object type so that old and new are both facts about it?

Three bands: a single population that an ORM constraint can see; an old and a new population with the rule drawn as the arrow between them; and a single population in which the transition itself is a row.
Three bands: a single population that an ORM constraint can see; an old and a new population with the rule drawn as the arrow between them; and a single population in which the transition itself is a row.

A static constraint is a predicate over one population. A dynamic rule is a predicate over a pair. Reify the transition — make the change itself a thing in the model — and old state and new state become two roles on one object type, in one population, where an ordinary static constraint can compare them.

This is not a workaround. It's the same move ORM already makes for facts about facts, and it is the move a CTEG makes when it turns an event into a node. If you cannot say it about a thing, make it a thing.


4. Enumerated transitions: the FSM hiding in the fact type

Section 2 of the paper generalizes the salary rule to non-ordered domains via a case statement:

Context: A
in case old p =
  'B1': new p in (B2, B3, ...)
  ...
end cases

That's a transition table. The marital-status matrix — single to married, married to widowed or divorced — is a finite-state-machine transition relation in role-based syntax rather than a state diagram.

Here's how the same thing looks when you reify it. Start with what most people model, which is the state and nothing else:

Task is in TaskState as one functional mandatory binary, with a value constraint listing six states.
Task is in TaskState as one functional mandatory binary, with a value constraint listing six states.
It is necessary that the possible values of TaskState are
  {'queued', 'running', 'paused', 'done', 'failed', 'abandoned'}.
Each Task is in exactly one TaskState.

Correct, and useless for the question an agent asks. It permits queued and it permits done, so it permits an agent to write done over queued and skip the work. The value constraint enumerates the states. Nothing enumerates the steps.

Now reify:

TaskState may be followed by TaskState, objectified as Step, with Action applies Step and Action changes Task as functional mandatory binaries.
TaskState may be followed by TaskState, objectified as Step, with Action applies Step and Action changes Task as functional mandatory binaries.
Each Step objectifies exactly one "TaskState may be followed by TaskState" fact.
In each population of "TaskState may be followed by TaskState",
  each TaskState, TaskState combination occurs at most once.
It is necessary that no TaskState is related to itself in
  "TaskState may be followed by TaskState".
Each Action applies exactly one Step.
Each Action changes exactly one Task.

TaskState may be followed by TaskState is a fact type whose population is the transition table. Step objectifies it, so a step is a referenceable thing. Action applies exactly one Step says an action must name one of the steps that exists.

An illegal transition is not rejected by a rule. It is unrepresentable, because there is no Step for it to apply.

The model, as code

This is an ORM 2 model file — the format Factum's editor, validator and CLI read. Populations live in the model beside the constraints, which is what makes the transition table part of the schema rather than a fixture:

{
  "objectTypes": [
    { "id": "state",  "name": "TaskState", "kind": "value", "dataType": "string" },
    { "id": "step",   "name": "Step",   "kind": "entity", "objectifiedFactTypeId": "mayFollow" },
    { "id": "action", "name": "Action", "kind": "entity", "refMode": "id" },
    { "id": "task",   "name": "Task",   "kind": "entity", "refMode": "id" }
  ],
  "factTypes": [
    {
      "id": "mayFollow",
      "roles": [
        { "id": "mayFollow.r0", "objectTypeId": "state" },
        { "id": "mayFollow.r1", "objectTypeId": "state" }
      ],
      "readings": [
        { "id": "mayFollow.rd", "roleOrder": ["mayFollow.r0", "mayFollow.r1"],
          "text": "{0} may be followed by {1}", "isPrimary": true }
      ],
      "meta": {
        "description": "The lifecycle rule, held as data. A step absent from this population is not a step an Action can apply, because there is no Step to apply."
      },
      "population": [
        { "values": ["queued",  "running"] },
        { "values": ["running", "paused"] },
        { "values": ["paused",  "running"] },
        { "values": ["running", "done"] },
        { "values": ["running", "failed"] },
        { "values": ["failed",  "queued"] },
        { "values": ["queued",  "abandoned"] },
        { "values": ["paused",  "abandoned"] }
      ]
    },
    {
      "id": "applies",
      "roles": [
        { "id": "applies.r0", "objectTypeId": "action" },
        { "id": "applies.r1", "objectTypeId": "step" }
      ],
      "readings": [
        { "id": "applies.rd", "roleOrder": ["applies.r0", "applies.r1"],
          "text": "{0} applies {1}", "isPrimary": true }
      ]
    }
  ],
  "constraints": [
    { "id": "u-mayfollow", "kind": "uniqueness",
      "roles": ["mayFollow.r0", "mayFollow.r1"] },
    { "id": "ring-step", "kind": "ring",
      "roles": ["mayFollow.r0", "mayFollow.r1"], "types": ["irreflexive"] },
    { "id": "u-applies", "kind": "uniqueness", "roles": ["applies.r0"] },
    { "id": "m-applies", "kind": "mandatory",  "roles": ["applies.r0"] },
    { "id": "vc-state", "kind": "value", "objectTypeId": "state",
      "ranges": [
        { "value": "queued" }, { "value": "running" }, { "value": "paused" },
        { "value": "done" }, { "value": "failed" }, { "value": "abandoned" }
      ] }
  ]
}

The irreflexive ring is worth a sentence. no TaskState is related to itself forbids a step from a state to the same state — the schema-level way of saying a no-op is not an action. If your domain has meaningful self-steps, a retry that stays running, drop the ring and say so. The point is that the decision is now written down rather than being whatever the code happened to do.

And the transition table comes back out as English:

$ factum verbalize task-lifecycle.orm.json --population
## Sample population

- queued may be followed by running
- running may be followed by paused
- paused may be followed by running
- running may be followed by done
- running may be followed by failed
- failed may be followed by queued
- queued may be followed by abandoned
- paused may be followed by abandoned

That is the finite-state machine, in eight lines a domain expert can approve and an agent can read, in the same file as the rest of the domain. Not a state chart in a wiki that stopped matching the code in March.

The trade you are making

Holding the lifecycle as a population rather than as a constraint means the validator checks that every referenced step exists, not that the set of steps is right. Nothing stops a migration from adding queued may be followed by done.

For an agentic system that's the correct trade, because lifecycles change — and a lifecycle held as data changes with a reviewable diff (factum diff before.orm.json after.orm.json) rather than with a schema migration and a deploy. If yours genuinely never changes, promote the table to a value constraint on a StepCode value type and take the stronger check. Most don't.


5. Historical facts, and who guarantees the ordering

The salary rule above assumes a snapshot: one current value, overwritten on update. Once you retain history — append rather than overwrite — the rule shape changes:

Context: Employee
For each salary added
if before:
  Employee was awarded some salary on some Date
then after:
  salary >= previous salary

Note the substitution. new/old become if before / then after, and a previous function replaces direct reference to old p, because in an append-only history there is no single old value — there is a most recent one, selected by an ordering. The paper generalizes this over (A, p, B, Tag), where Tag is "some value that consistently increases for a given A as new facts are added": dates, incident numbers, version numbers, anything totally ordered.

That is a description of an append-only event log with a monotonic sequence key, and the rule is a constraint on consecutive events in that log for a given subject. If you build agentic memory — event-sourced world models, CTEGs, audit trails for multi-agent decisions — this is the generalization to reach for. Don't force "did the value change legally" into a mutable-snapshot rule when the representation is a growing history. Ask instead whether the new event's p is a legal successor of the most recent prior event's p, ordered by the Tag.

That reframing is what lets a CTEG's LEADS_TO edges carry the same legality constraint the paper puts on a salary update without re-checking the whole graph for every new event. Each new edge is checked against its immediate predecessor along that subject's causal chain — the paper's single-step restriction, arrived at from the other direction.

Now the part that does not port for free. The paper assumes the Tag history is complete for each subject and that you never add a fact earlier than an existing one. A single centralized database enforces that trivially. A multi-agent system does not. Distributed agents proposing facts about one subject at overlapping wall-clock times need a consensus-ordered log, a logical clock, or a trust-anchored sequencing mechanism — which is the job KERI's key event logs and ACDC chains do for identity and credential state, where each event references its prior event's digest and previous becomes cryptographically, not merely logically, well-defined.

An audit chain: AuditEvent immediately follows AuditEvent as an irreflexive acyclic ring, belongs to a Run, has a canonical form, and is sealed with a derived Digest.
An audit chain: AuditEvent immediately follows AuditEvent as an irreflexive acyclic ring, belongs to a Run, has a canonical form, and is sealed with a derived Digest.

Porting the historical-fact pattern into a multi-agent setting without also porting some version of that guarantee is the single most common way these rules quietly stop meaning what they say.

In the model, the guarantee is one external uniqueness constraint:

It is necessary that each combination of Task and Seq refers to at most one Action.

That sentence is what makes the previous action on this task well-defined: a Seq is used at most once per task, so actions on a task are totally ordered and previous means Seq - 1. It says nothing about who assigns the numbers. What it does is make the requirement visible, so an implementation without one fails a check instead of failing a post-mortem.


6. Non-functional fact types: actions with two owners

Section 4's seating example is the paper's best argument against object-oriented action modelling, and the clearest bridge to promise theory.

Context: Seating occupies Table
For each fact added
needed before: the table is vacant
after: the table is not vacant

The authors then spend two pages (Figures 9–11) making one point: there is no single object whose method could carry this precondition without a side effect on the other participant. Seating.addTable(t) mutates t, which is not the seating's to mutate. Table.allocateSeating(s) mutates s's table property as a side effect of a Table method. Their resolution is to introduce an artificial class purely to satisfy OCL's class-centric context requirement — and they call it "roundabout and artificial," correctly.

That failure mode is what promise theory was built to name. An agent can make voluntary claims about its own state and never impose obligations on another autonomous agent's state as a side effect of its own action. The table's vacancy is not the seating's to promise; the seating's table-assignment is not the table's to promise.

Three mandatory functional binaries from Promise: is made by Agent, is made to Agent, and promises PromiseBody.
Three mandatory functional binaries from Promise: is made by Agent, is made to Agent, and promises PromiseBody.

What ORM's fact-type context gets right — treating Seating occupies Table as a first-class relationship distinct from either participant — is structurally the move promise theory makes when it insists a joint fact requires both parties to have made compatible promises: the seating promises to accept assignment to a vacant table, the table promises to accept a seating only while vacant, and the fact occupies exists only where those two are simultaneously honoured. The dynamic rule is the effect condition on that joint promise. It just doesn't name the promise structure underneath, because ORM's audience never needed the word "agent."

And then something better happens

Write the joint action down as a relationship and most of the precondition evaporates.

Session claims Sandbox as a one-to-one binary with a mandatory dot on Session, plus Session was opened by Agent.
Session claims Sandbox as a one-to-one binary with a mandatory dot on Session, plus Session was opened by Agent.
Each Session claims exactly one Sandbox.
Each Sandbox is claimed by at most one Session.
Each Session was opened by exactly one Agent.

The sandbox is free is not an extra rule. It is the reading of the uniqueness bar on the sandbox role, evaluated against the current population: a sandbox is free exactly when it plays no role in claims. The dynamic rule's needed before and after clauses were both restating one static invariant — and the invariant is the better thing to write down, because it holds at every instant rather than only at the moment of the write.

Which generalizes into a rule of thumb worth more than the rest of this section:

Before writing a precondition, look for the invariant it protects. Most preconditions on shared resources are an at-most-one bar nobody drew. The ones that survive the search are the genuinely temporal rules — monotonicity, ordering, budgets over a window — and those are what the reified action is for.


7. Two participants, one atomic effect

Section 5's banking example combines everything: a functional 1:1 update rule for account balances, a case statement branching on transaction type, and a genuinely two-account joint action.

Context: TransferTransaction
For each instance added
balance1 = (old account1.balance - amount) and
balance2 = (old account2.balance + amount) and
new account1.balance = balance1 and
new account2.balance = balance2

One dynamic rule, whose context is a subtype of a fact type, whose effect touches two independent accounts atomically. Read as an agent-action schema it is a two-phase-commit effect: meaningful only if both balances update together, with no legal intermediate state.

The declarative framing sidesteps locking and commit protocols — the paper notes that each transaction is "always considered to be isolated (serializable)" and, for the seating example, that "an appropriate locking mechanism is assumed." That is honest about where the scope ends. It specifies what must be true of the before/after pair, not how a distributed system guarantees that pair is observed atomically by everyone. The second question is where CTEGs, promise-based coordination and consensus-ordered logs pick up the work the paper deliberately leaves undone.


8. Deletion, and the rule that only needs a before

Section 6's payment-retention rule is the odd one out, and useful because it is minimal:

Context: MoneyAmount is paid to Company on Date
For each fact deleted
needed before: the date < today - 2 years

No after clause. Deletion rules only need a precondition, because there's no new state of a fact that no longer exists.

Three panels - add, update, delete - each showing the dynamic-rule template for that transaction and the ORM 2 constraint it becomes.
Three panels - add, update, delete - each showing the dynamic-rule template for that transaction and the ORM 2 constraint it becomes.

The three elementary transaction types have structurally different rule shapes, and a general agent-action language needs all three natively rather than pretending delete is "update to null" and add is "update from null."

TransactionRule shapeWhat it becomes when you reify
addneeded before guard, after effectA mandatory or subset constraint on the created fact. There is no prior value, so the rule may not mention one
updatenew p compared with old pA constraint on the Action, which carries both values as roles
deleteneeded before guard onlyA precondition with no effect clause

The one that bites is add, for the reason in section 2(c). A system that models creation as "update from null" inherits the hole silently: the guard finds no prior state, evaluates to something falsy or something vacuous depending on the language, and either refuses every first write or permits every first write. Both bugs are common. Neither produces an error message.


9. A template for agent action rules

Pulling it together, the patterns suggest a compact template — more declarative than STRIPS/PDDL, more checkable than free-text ECA rules:

Context:      <fact type / joint relation the action operates on>
Transaction:  added | updated | deleted
Precondition: needed before: <condition on current state, or on the most
              recent prior event via `previous`, for append-only history>
Effect:       after: <condition on new state, possibly relating it to the
              old state, or via a case/transition table>

Four design decisions, each easy to get wrong when translating into a multi-agent or LLM-agent setting:

  1. Scope each rule to a single-step transition. Resist writing one rule that reasons over an unbounded history; compose single-step rules the way a CTEG composes LEADS_TO edges, and let global properties emerge from composition.
  2. Let the context be a fact type, not necessarily a single agent or object, whenever the precondition or effect genuinely spans more than one party. If you cannot state the rule without an artificial coordinating class, you have found a joint action — model it as a hyperedge or a pair of compatible promises.
  3. Give create, update and delete distinct rule shapes. Collapsing them loses the "first occurrence is vacuously legal" behaviour that update rules need.
  4. When history is retained, replace old with a previous-of-the-ordering-key function, and name who guarantees the ordering. In one database the DBMS does it for free. In a multi-agent system something else has to — a consensus log, a logical clock, a KERI-style hash chain — and the rule is only as trustworthy as that guarantee.

10. Doing it: modelling agent actions with Factum

Everything above is representable in plain ORM 2 today, which means it is representable in a file a tool checks. This section is the concrete version.

Factum is an ORM 2 editor, validator, verbalizer and MCP server. The model is one .orm.json file; the toolchain reads it four ways.

The whole action ledger

The action ledger: Action applies an objectified Step over the TaskState transition table, and has functional binaries to Task, Agent, Instant, Seq and Promise, with an external uniqueness circle over Task and Seq.
The action ledger: Action applies an objectified Step over the TaskState transition table, and has functional binaries to Task, Agent, Instant, Seq and Promise, with an external uniqueness circle over Task and Seq.
$ factum validate action-ledger.orm.json
No problems found. 28 sample fact(s).
Each Action applies exactly one Step.
Each Action changes exactly one Task.
Each Action was taken by exactly one Agent.
Each Action occurred at exactly one Instant.
Each Action has exactly one Seq.
In each population of "Action is authorized by Promise",
  each Action, Promise combination occurs at most once.
It is obligatory that each Action is authorized by some Promise.
It is necessary that each combination of Task and Seq refers to at most one Action.

Six facts about one action, and each answers a question that gets asked in a post-mortem.

applies exactly one Step — what changed, and that the change was one the lifecycle permits.

was taken by exactly one Agent — mandatory, because an action with no actor is the modelling error that makes accountability impossible. Same decision the promise graph makes when it refuses a promise without a promiser.

is authorized by Promise is deonticIt is obligatory that, not It is necessary that. An unauthorized action is a thing that happens: an agent exceeds its remit, a token is stale, a human overrides. The whole value of a ledger is that it can record one. Making the constraint alethic wouldn't prevent the act, it would prevent the row, and the evidence would move to wherever the write went instead. The test: if this is violated, should the write fail, or should there be a record of the violation?

That distinction — necessity versus obligation, on the same constraint syntax — is native to ORM 2 and has no equivalent in SHACL's sh:severity, which changes how loudly a validator complains rather than what kind of rule it is.

Four ways the same file gets used

1. As the agent's prompt. factum verbalize turns the model into FORML sentences — controlled English, complete, and small enough for a context window:

$ factum verbalize action-ledger.orm.json > .agent/domain.md

The agent no longer infers the lifecycle from status strings in the codebase. It reads eight sentences that state it.

2. As CI. factum validate --strict --exit-code fails a build when the model is inconsistent. Because the transition table lives in the model's population, a pull request that adds queued may be followed by done shows up as a diff a reviewer reads in English, not as a constant buried in a service.

3. As the graph schema. factum graph maps the conceptual model to property graph DDL, and objectification maps the way you want it to:

// Objectified fact type "TaskState may be followed by TaskState" (Step)
CREATE NODE TABLE Step(
    id SERIAL PRIMARY KEY  // Generated key for the reified fact
);

// Links each reified fact to its TaskState role player
CREATE REL TABLE HAS_TASK_STATE(FROM Step TO TaskState, MANY_ONE);

// Fact type "Action applies Step"
CREATE REL TABLE APPLIES(FROM Action TO Step, MANY_ONE);

The reified transition becomes a node with edges to both states — which is exactly the CTEG shape, derived from the conceptual model rather than hand-drawn.

4. As the guard. This is the payoff.

An agent proposes an action; a guard reading domain.orm.json runs four checks - the Step table, the deontic authorization, the at-most-one claim, the external uniqueness on Seq - and either appends the action to the ledger or records a refusal.
An agent proposes an action; a guard reading domain.orm.json runs four checks - the Step table, the deontic authorization, the at-most-one claim, the external uniqueness on Seq - and either appends the action to the ledger or records a refusal.

Every check in that diagram is a constraint quoted from earlier in this article, and none of them is written twice. That's the property worth protecting: the sentence the agent reads when it plans and the predicate the guard evaluates when it writes come from one file, so they cannot drift apart. An agent told "tasks go queued, running, done" in a system prompt and guarded by a hand-written if has two copies of a rule, and every interesting failure lives in the gap between them.

Refusals are the evidence

A refused action is not a non-event. It is often the most informative row you have — the same reason a promise graph keeps rejected in its status list.

a-7  changes t-4  applies (running, done)   taken by agent-9   refused
a-8  changes t-4  applies (running, failed) taken by agent-9   applied

The agent tried to mark work complete, was refused by the lifecycle, and took the honest path. Drop the first row and you see a failure and have to guess.

Which is also why Outcome doesn't belong as a nullable field on Action. A refused action never changed a task and never applied a step. Model the attempt and the effect as different things — a Proposal that may become an Action — or keep refusals in the audit log, which is designed for observations rather than state changes. Merging them into one entity with a nullable outcome is the wide-table mistake in new clothes.

What still doesn't fit

Being explicit about the boundary is what makes the rest trustworthy.

  • Two-state comparisons over stored values. new salary >= old salary, where salary is a snapshot on Employee rather than a fact about a SalaryChange, isn't expressible. Reify the change; then both values are roles and the comparison is a derivation rule you write into the model's derivationRule field and check in your code.
  • Join constraints across a path. The previous action on this task must have been taken by the same agent needs a join between two fact types. ORM 2 states such things awkwardly and Factum doesn't check them; write the intent into meta.description so the agent reading the model still gets the rule.
  • Temporal windows. At most three actions per task per hour is a frequency constraint over a window, and neither ORM 2 nor Factum has windows. Enforce it in the guard.

None of that is a reason to skip the model. A model that states eight of ten rules and marks the other two unenforced beats a codebase that states none and implies all ten.


11. Where this connects to formal verification

The paper repeatedly emphasizes that its syntax is designed to be validated by non-technical domain experts, trading formal completeness for readability and explicitly declining to give a complete formal grammar. Reasonable for a business-rules audience. But if these patterns are going to gate real state transitions in a multi-agent system rather than be reviewed by a human analyst, each has a natural formalization worth pursuing:

  • The single-step old/new rule is a two-state Hoare triple: {P(old)} action {Q(old, new)}.
  • The enumerated case-statement pattern is a finite-state-machine transition relation — expressible as a SHACL shape over a property-graph encoding of allowed transitions, or as a small inductive type with one constructor per legal edge in a dependently-typed setting. Or, as above, as a populated fact type, which is the version a domain expert will actually read.
  • The two-party joint-action pattern is naturally a conjunction of two promise-compatibility predicates rather than a single-context postcondition — which also gives you a place to hang an authorization check ("who is permitted to assert this promise") that a business-rule syntax has no need to express and a multi-agent trust system cannot do without.

None of this is a criticism of the paper; it did the job it set out to do for the audience it had. But read now, with agentic memory, causal temporal event graphs and verifiable multi-agent coordination as the working context, "Modeling Dynamic Rules in ORM" is less a niche extension to a modelling notation and more an early, independently-derived sketch of the action-schema and joint-promise structures agent frameworks are still formalizing.

The part it left unfinished — a notation a tool can check — turns out not to need a new notation at all. Reify the transition, and the language you already have is enough.


Checklist

Before you ship an action model:

  1. Is the transition a thing? If old and new state are not both roles on one object type, no static constraint can relate them.
  2. Is the lifecycle a population or a constraint? Either is defensible. Not deciding is not.
  3. Does every action have an actor, mandatory? Attribution is why the ledger exists.
  4. Did you look for the invariant before writing the precondition? Most resource preconditions are an at-most-one bar nobody drew.
  5. Is previous well defined, and by what? Name the mechanism — external uniqueness on a sequence, a hash chain, a consensus log. "The database" is not an answer in a multi-agent system.
  6. Is authority deontic? If violating it should produce a record rather than a rejected write, it is obligatory, not necessary.

The models in this article ship with Fact-Based Agents, which covers ORM 2, FORML and Factum for agentic memory — including a chapter on actions and rules that works through the ledger above in full, and chapters on agentic memory, promise graphs and audit logs. Every diagram is rendered by Factum's own renderer from a model file you can open and take apart.

Source: H. Balsters, A. Carver, T. Halpin & T. Morgan, "Modeling Dynamic Rules in ORM."