Skip to content

Document how a rule checks a host-owned, mutable list - #9

Merged
wolpert merged 1 commit into
mainfrom
docs/host-owned-lists
Sep 3, 2026
Merged

Document how a rule checks a host-owned, mutable list#9
wolpert merged 1 commit into
mainfrom
docs/host-owned-lists

Conversation

@wolpert

@wolpert wolpert commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Documents how a rule checks membership in a list the application owns: a blocklist or allowlist that changes on its own cadence, that a rule's own decision may add to, and that every node in a cluster must see. Documentation and one test; no engine code changes.

  • Why no lookup operator, on the record. Refraction, the streaming conflict set, truth maintenance and replay all assume a match's answer changes only because a fact moved. A live lookup would blind all four. The spec now records this as a structural non-goal (§1 bullet, §7.3 threat-table row, §9.1 line) rather than leaving readers to guess at callFunction or a CEL binding.
  • The supported shape. The host looks up each (list, entity) before the session and inserts a ListMembership fact with member true or false, so a failed lookup is an absent fact and never a false one. A rule that adds to the list pairs setField (same-session visibility via the tested-path update) with emit (host persists after fireAllRules()). Across a cluster the store is the only durable copy; the engine caches nothing beyond the session's snapshot.
  • docs/dsl-guide.md gains "Checking a list your application owns" with a complete three-rule file (decline, add, and fail-closed when nothing answered).
  • docs/embedding.md gains the host half: read-through versus entries-as-facts, the failed-lookup rule, the dual-write gap, the cluster note, and the read-never-mutate warning for walking CompiledRule.source().
  • §11.3 amendment records setField + emit as a first observed candidate shape for an extensible action, counted only once authoring confirms it recurs.
  • choosing-this-engine.md, dsl-reference.md and CHANGELOG.md point at the recipe.

Test plan

  • DocExamplesTest compiles the new guide fixture and the new embedding.md fact document.
  • New DocExamplesTest.ListMembershipRecipe runs the guide's file through all three matchers via MatcherEquivalence and asserts what fired: the add rule first and the decline rule because of it in one fire call (effects and payloads pinned), an already-listed card declined once, an absent membership fact reaching only the fail-closed rule, and a membership fact with no member field deciding nothing.
  • Green under test and strictTest (28 cases).
  • Senior-reviewer pass run twice; one blocker (an unguarded stringValue() in a snippet) and eight should-fixes addressed.
  • CI (./gradlew build javadoc).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EcVJKNAXbPbyho6Gj9MdEv

The engine had no written answer to one of the first questions asked of any
rule engine: can a rule check whether a value is in a list the application
owns, when the list changes on its own cadence, a rule's own decision may add
to it, and every node in a cluster must see the addition. Readers were left
to guess between a callFunction, a CEL binding, or something the engine does
not have, and two of those guesses are wrong in ways that only show up later.

The reason there is no lookup operator is structural rather than deferred,
and nothing in the tree said so. Everything that decides which activation
fires assumes that during one session a match's answer changes only because
a fact moved through working memory: refraction is cleared for exactly the
rules testing a changed path, the streaming matcher drops a condition-
rejected match on the argument that an update will re-derive it, truth
maintenance re-asks a tuple expecting the same answer, and replay supplies
the same facts and expects the same decision. A structure consulted live
changes its answer with nothing moving, and all four are blind to it,
whatever thread-safety the read had.

The answer chosen is the one the Clock fact already takes for time: the
membership enters as a fact. The host looks each (list, entity) up before
the session and inserts a ListMembership fact with member true OR false, so
that a failed lookup is an absent fact rather than a false one; a rule that
adds to the list pairs setField on that fact, an update on a tested path that
makes the decline rule fire in the same session, with emit, which the host
persists after the fire call. Across a cluster nothing is cached in the
engine beyond the session's own snapshot, so the store is the only durable
copy and there is nothing to invalidate. Where the list is itself a stream,
entries are facts in a long-lived session and notExists asks the question.

The guide gains the recipe as a complete rule file, including a fail-closed
rule for the case where nothing answered; embedding.md gains the host half,
the dual-write gap and the cluster note; the spec records the non-goal in
section 1, a threat-table row in 7.3, a section 9.1 line, and a section 11.3
amendment noting setField-plus-emit as a first observed candidate shape for
an extensible action. DocExamplesTest runs the guide's file through all three
matchers and asserts what fired: the addition first and the decline because
of it, an absent fact reaching only the fail-closed rule, and a fact with no
member field deciding nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcVJKNAXbPbyho6Gj9MdEv
@wolpert
wolpert merged commit 87a0f9f into main Sep 3, 2026
2 checks passed
@wolpert
wolpert deleted the docs/host-owned-lists branch September 3, 2026 13:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant