diff --git a/CHANGELOG.md b/CHANGELOG.md index bd67dc8..943c335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,16 @@ version protects is the API surface `ApiSurfaceTest` calls exported. two ways to get it wrong in [`embedding.md`](docs/embedding.md#long-lived-sessions-and-eviction). A window in a rule and a window in the session are separate decisions that have to agree, and nothing checks that they do. +- **Documentation for host-owned lists and reference data**, the question this engine had no written + answer to: "can a rule check whether a value is in a list my application owns, when a rule's own + decision may add to it and every node in a cluster must see the addition". The answer is a fact, + looked up before the session with `member: true` *or* `false` so that an outage is an absence and + not a false, flipped by `setField` so the same session sees the change, and carried out by `emit` + for the host to persist. [`dsl-guide.md`](docs/dsl-guide.md#checking-a-list-your-application-owns) + has the compiled recipe, [`embedding.md`](docs/embedding.md#host-owned-lists-and-reference-data) + the host half and the cluster note, and the spec records in §1 why a lookup *during* matching is + structurally off the table rather than deferred. Nothing in the engine changed to support it, + which is the point. ### Changed diff --git a/docs/choosing-this-engine.md b/docs/choosing-this-engine.md index 55becc0..44a8eef 100644 --- a/docs/choosing-this-engine.md +++ b/docs/choosing-this-engine.md @@ -134,6 +134,7 @@ The specification's §9.1 has the full accounting; these are the ones people ask |---|---| | `collect` | answers with a collection, so it has no meaningful `having`, and binding a list needs a way to take one apart that the pattern language does not have | | a sliding-window *operator*, "nothing for 24h" | there is no window keyword and no engine-owned clock — nothing here notices time passing with *no fact arriving*, the one input an engine that acts on fact movement never receives. What you assemble instead is a bounded temporal join for what a rule matches plus an `EvictionPolicy.window` for what the session keeps, and a caller-advanced `Clock` fact where the window has to end at "now" — [the recipes](dsl-guide.md#counting-things-in-a-window). That is caller-driven session time, made of parts that already existed rather than a contract of its own | +| a lookup into host-owned data (a blocklist, a feature store) *during matching* | everything deciding which activation fires assumes a match's answer changes only because a fact moved, so a live lookup would blind refraction, the streaming conflict set and truth maintenance at once. Look it up before the session and insert the answer as a fact, `member: true` or `false`; a rule that adds to the list flips that field and emits the write. [The recipe](dsl-guide.md#checking-a-list-your-application-owns) and [the host half](embedding.md#host-owned-lists-and-reference-data) | | `or` inside a `where` | write two rules, use `in`, or reach for a `condition:` expression | | backward chaining | the forward-only decision stands; it was made before any code was written | | distributed evaluation | the immutability split makes it *feasible* and no more. The partitioning, the wire protocol and cross-node routing are an architecture, not a slice | diff --git a/docs/dsl-guide.md b/docs/dsl-guide.md index 27c591c..d51289b 100644 --- a/docs/dsl-guide.md +++ b/docs/dsl-guide.md @@ -14,6 +14,7 @@ and the engine disagree, this page is wrong. - [Matching two facts together](#matching-two-facts-together) - [Asking that a fact not exist](#asking-that-a-fact-not-exist) - [Doing something](#doing-something) +- [Checking a list your application owns](#checking-a-list-your-application-owns) - [Running it](#running-it) - [When it does not fire](#when-it-does-not-fire) - [Checking your rules in CI](#checking-your-rules-in-ci) @@ -607,6 +608,120 @@ event comes back as the return value of the fire call, so a rule is testable wit A `callFunction` runs real code at commit time, is not transactional, and if it throws, the changes that already landed stay landed. +## Checking a list your application owns + +Blocklists, allowlists, watchlists. A rule wants to ask "is this card on the blocklist", and the list +belongs to the application: it changes on its own cadence, a rule's own decision may add to it, and +it may live in a store shared by every process running this engine. There is no operator that reaches +out and asks. There is something better placed: **the answer is a fact.** + +Look the membership up *before* the session, once per entity the event names, and insert what you +found. Then the rules read it the way they read everything else: + +```yaml +apiVersion: rules.v1 +rules: + - id: decline-blocklisted-card + when: + - fact: Payment + as: p + - fact: ListMembership + as: m + where: + list: { eq: "card-blocklist" } + entityId: { eq: { $ref: p.cardId } } + member: { eq: true } + then: + - action: setField + target: p + field: decision + value: "DECLINE" + - action: emit + event: payment.declined + payload: + paymentId: { $ref: p.id } + reason: "card-blocklist" + + - id: blocklist-card-after-third-failure + when: + - fact: Payment + as: p + where: + failureCount: { gte: 3 } + - fact: ListMembership + as: m + where: + list: { eq: "card-blocklist" } + entityId: { eq: { $ref: p.cardId } } + member: { eq: false } + then: + - action: setField # the first rule sees this in the next cycle of THIS session + target: m + field: member + value: true + - action: emit # your application writes this to the shared store afterwards + event: list.entry.add + payload: + list: { $ref: m.list } + entityId: { $ref: m.entityId } + + - id: review-when-the-list-could-not-be-checked + when: + - fact: Payment + as: p + - fact: ListMembership # no answer at all: the lookup failed, so fail closed + as: m + quantifier: notExists + where: + list: { eq: "card-blocklist" } + entityId: { eq: { $ref: p.cardId } } + then: + - action: setField + target: p + field: decision + value: "REVIEW" +``` + +One `ListMembership` fact per (list, entity) the event names, with `member` true **or false**. That +second half matters: the fact saying "not on the list" is what the second rule matches, and it is also +what lets you tell an outage from a known non-membership. If the lookup fails, insert nothing. With no +fact to bind, neither of the first two rules can fire, so a store that is down declines nobody and +blocklists nobody; the third rule is the one that sees the gap, because `notExists` over the +membership is true exactly when nothing answered. Whether "could not check" means review, decline or +approve is a decision the rule file should state, and that third rule is where it says it. + +Three things are going on in that file, and each is a decision. + +**Why a fact rather than a callback.** Everything that decides which activation fires assumes that +during one session a match's answer can only change because a fact moved: refraction is cleared for +the rules testing a changed path, the streaming matcher drops a rejected match knowing an update will +bring it back, and truth maintenance re-asks a tuple expecting the same answer. A list consulted live +would change its answer with nothing moving, and every one of those mechanisms would be blind to it. +The same reasoning is why the engine owns no clock and time arrives as a +[`Clock` fact](#as-of-now-and-nothing-has-happened). A fact is constant until you update it, and +updating it is how the change is announced. + +**Reading your own write.** The second rule does two things. `setField` flips `member` on the fact, +which is a change to a path the first rule tests, so the first rule gets another look in the same +session and declines the payment in the next cycle. `emit` carries the addition to the outside +world, where your application writes it to the store after `fireAllRules()` returns. The next +evaluation, in this process or any other, looks the card up and finds it. The membership fact is a +snapshot that lives exactly as long as the session, which is why this shape wants one short session +per event: the engine keeps no longer-lived copy, so across a cluster there is nothing to invalidate +and the store is the only durable one. Two writes are involved, the decision and the list, and a +crash between them loses the addition after the decision has been acted on. Record the emitted event +durably before acting on the decision, or accept that loss knowingly. + +**What the engine cannot order for you.** Two evaluations for the same card running at the same +time each look the card up before either has written. Both see `member: false`, both decide, both +add. Adding to a set twice is harmless; a list write that is not idempotent is not, and the fix is +outside the engine: route events for one key to one lane, so they run in sequence. + +When the list *is* the stream, because entries arrive and expire continuously and the session runs +for days, model each entry as its own fact in a long-lived session and ask with `notExists` instead. +[`embedding.md`](embedding.md#host-owned-lists-and-reference-data) sets the two shapes side by side +and says when each is the right one. + ## When operator maps aren't enough There is an escape hatch, and it is deliberately a little inconvenient to reach: it needs an extra diff --git a/docs/dsl-reference.md b/docs/dsl-reference.md index a102b33..1886e68 100644 --- a/docs/dsl-reference.md +++ b/docs/dsl-reference.md @@ -1054,6 +1054,11 @@ than quietly corrected because of what the stale version cost: a reader who grep "temporal" before scrolling to the section that documents them concludes the feature does not exist and goes off to compute it at ingestion. That is exactly what happened to somebody. +**Neither is a lookup operator, and one is not coming.** "Is this value in a list my application +owns" is answered by inserting the membership as a fact before the session, `member: true` or +`false`, and letting a rule that adds to the list flip that field and `emit` the write. The guide has +the complete file: [Checking a list your application owns](dsl-guide.md#checking-a-list-your-application-owns). + The quantifiers are *not* on that list any more — [`notExists`](#negation-quantifier-notexists) and [`forAll`](#universals-quantifier-forall) are both implemented, with the boundaries those sections name: never over an evicted type, and — for `forAll` — vacuously true over an diff --git a/docs/embedding.md b/docs/embedding.md index 0edea5e..44579cc 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -17,6 +17,7 @@ For a complete application that does all of this, read - [`SessionOptions`](#sessionoptions) - [Limits, and the one the engine does not enforce](#limits-and-the-one-the-engine-does-not-enforce) - [Host functions](#host-functions) +- [Host-owned lists and reference data](#host-owned-lists-and-reference-data) - [Choosing a matcher](#choosing-a-matcher) - [Concurrency](#concurrency) - [Long-lived sessions and eviction](#long-lived-sessions-and-eviction) @@ -244,6 +245,96 @@ object serves many sessions. atomic, and cannot be withdrawn. Prefer `emit` and act on `FireResult.emitted()` after the call returns. +## Host-owned lists and reference data + +The question arrives as "can a rule check whether this value is in a list my application owns", and +the list is usually mutable, often written by the rules' own decisions, and in a cluster it lives in +a store every node reads. The engine has no lookup operator, no SPI that consults a host structure +during matching, and no CEL binding that reaches outside the tuple. That is deliberate, and the +reason is the one contract everything else here rests on: **during one session, a match's answer +changes only because a fact moved.** Refraction, the streaming matcher's conflict set, truth +maintenance and replay all assume it. A structure that changed underneath a running session would +break all four at once, whatever thread-safety it had. + +So the list enters as a fact, and there are two shapes for that. + +| Shape | Use it when | How | +|---|---|---| +| **Read-through per session** | one session per event; the list is large, shared, and lives in a store | before the session, look up each entity the event names and insert one membership fact per (list, entity), `member: true` or `false`. Writes leave as emitted events and the host applies them after the fire call | +| **Entries as facts in a long-lived session** | the list *is* the stream: entries arrive and expire continuously, and one session runs for days | insert each entry as its own fact, ask with `notExists`, let a rule's `insertFact` add one. The host retracts an entry when it expires, and that retract is the bound on the session's growth: eviction is not available here, because a `notExists` over an evicted type manufactures matches | + +The guide has the rule-file half of the first shape, including how a rule that adds to the list +makes the addition visible to the rest of the same session: +[Checking a list your application owns](dsl-guide.md#checking-a-list-your-application-owns). The +host half is a lookup before `newSession()` and a write after `fireAllRules()`: + +```java +List memberships = lookups.membershipsFor(event); // your store, your client +try (RuleSession session = rules.newSession(options)) { + session.insert("Payment", payment); + memberships.forEach(m -> session.insert(m.type(), m.payload())); + FireResult result; + try { + result = session.fireAllRules(); + } catch (RuleEngineLimitExceeded breach) { + result = breach.partialResult(); // completed work is never discarded; decide what it means + } + for (EmittedEvent e : result.emitted()) { // AFTER the decision, never during it + if (e.eventType().equals("list.entry.add")) { + // The one-argument form: path() gives a MissingNode for an absent key, and Jackson 3's + // stringValue() THROWS on it where the null-returning read is what a payload check wants. + String list = e.payload().path("list").stringValue(null); + String entityId = e.payload().path("entityId").stringValue(null); + if (list != null && entityId != null) { + outbox.record(list, entityId); // durable before the decision is acted on + } + } + } +} +``` + +That last line is the dual-write problem in one word. The session has decided and the store has not +yet heard; a crash between the two loses the addition while the decision stands. Write the emitted +event to something durable before acting on the decision, or accept the loss and say so where the +next reader will find it. + +A membership fact, as a fact document, so a fixture can say what the store would have said: + +```yaml +# memberships.yaml: one fact per (list, entity) the event names; member is true OR false +- type: ListMembership + payload: { list: "card-blocklist", entityId: "4111000000001111", member: false, asOfEpochMs: 1756800000000 } +``` + +Four things to hold onto: + +- **A failed lookup is an absent fact, never `member: false`.** With `member` true *or* false on + every fact the store answered for, a missing fact means one thing only: the store did not answer. + Insert `false` on an outage and every `member: { eq: false }` fires against a card nobody checked; + insert nothing and neither `eq: true` nor `eq: false` has a fact to bind, while a `notExists` over + the membership sees the gap and can fail closed. It is the absence-versus-value distinction §2.6.1 + draws for a field, applied one level up, to the fact. The entries-as-facts shape has the mirror + hazard: a load that fails part-way leaves an *empty list*, indistinguishable from a list with no + entries, so every `notExists` over it fires. There, a failed load must fail the session rather + than leave it half-filled. +- **Cluster propagation is the store's job, and the engine keeps no copy.** Every node reads through + at session start, so a write from any node is visible to the next session anywhere as soon as the + store has it. A per-node cache would reintroduce the problem this design removes. +- **Determinism holds per session, and ordering across sessions is yours.** Two sessions for the same + key running concurrently each read before either writes. Make list writes idempotent, or route + events for one key to one lane so they run in sequence. The engine cannot order sessions it did not + start. +- **Which lists a rule set reads is derivable from the compiled rules**, without parsing anything: + walk `CompiledRule.source().when()` for patterns on the membership type and read the `list` + literal. **Read it and never mutate it**: the constraint records deep-copy a literal on the way in + but hand back the live node, so an edit there changes what every session matches (§5.5's + invariant 1, `ImmutabilityTest`). An explicit declaration beside the rule file is the better audit + record; the walk is what checks that the declaration is complete. + +`callFunction` is not the door for either half. It is `void`, so it cannot bring a value back; it +runs at commit, inside the fire loop; and its own contract asks handlers to be deterministic and +non-blocking, which a store round trip is not. + ## Choosing a matcher Three matchers, held to producing **identical firing sequences**. Everything deciding *which* diff --git a/docs/rule-engine-spec.md b/docs/rule-engine-spec.md index 804bb8b..7b8b2da 100644 --- a/docs/rule-engine-spec.md +++ b/docs/rule-engine-spec.md @@ -150,6 +150,8 @@ Read this before §2. Two of these bullets — collection flattening and negatio - **Distributed evaluation across machines.** The immutability split in §5 makes this feasible later — a `CompiledRuleSet` is trivially shippable to other JVMs, sessions are cheap to spin up anywhere, and §2.1's `(sessionId, handle)` pair is already the identity you would need — but it is out of scope here. +- **Lookups into host-owned data during matching.** A blocklist, an allowlist, a feature store: "is this value in a list the application owns" is one of the first questions asked of any rule engine, and this one answers it with a fact rather than an operator. Not built, and structurally so rather than deferred: §4.4's refraction, §4.3's pushed-and-pulled conflict set, §4.4's truth maintenance and §7.3's replay all rest on a match's answer changing only because a fact moved through working memory, and a structure consulted live changes its answer with nothing moving. A `HostFunction` is the wrong door for the same reason plus two of its own: it is `void`, and it runs at commit inside the fire loop. **The supported answer:** look the membership up before the session and insert it as a fact, `member: true` *or* `false`, so that a failed lookup is an *absent* fact and not a false one, the fact-level analogue of the absent-versus-value line §2.6.1 draws for a field; a rule that adds to the list flips that field with `setField`, an update on a tested path that re-derives the match for the rules reading it in the same session, and `emit`s the write for the host to apply after the fire call. Where the list is itself a stream, entries are facts in a long-lived session and `NOT_EXISTS` asks the question. `docs/dsl-guide.md#checking-a-list-your-application-owns` and `docs/embedding.md#host-owned-lists-and-reference-data` carry the two halves. Note what this leaves with the host: two sessions for one key running concurrently each read before either writes, and the engine orders nothing it did not start. + > **Amendment (Phase 6, first slice, as built).** *(Superseded in part: this amendment was written when negation was the only slice built. `FOR_ALL`, `ACCUMULATE`, truth maintenance and the temporal operators have all since shipped — see the later amendments in §2.5, §4.4 and the `after`/`before` section. Only backward chaining is still unbuilt. The paragraph is left as written rather than edited, because the reasoning below is about negation and stands; but read the sentence that follows as historical.)* **`NOT_EXISTS` is implemented.** `FOR_ALL`, `ACCUMULATE`, backward chaining, truth maintenance and the temporal operators are not, and the bullets above stand for them unchanged — the marker-fact interim answer included. What follows is only about negation, and it is worth reading against the bullet above rather than instead of it, because the bullet priced a feature that was not the one built. > > **None of the machinery the bullet names was needed, and that is a property of negation rather than a shortcut.** A negated pattern binds no alias and contributes no tuple position, so the predicate it computes is a function of a *complete* tuple and working memory — the same shape a §6.4 `condition` has. A `NotNode` placed in a join chain computes that same predicate over a *prefix*, and a prefix's bindings survive into the complete tuple, so the two answers coincide: placement is a pruning optimisation, not a semantics choice. Negation is therefore answered in `RecomputingAgenda`, the shared base where §4.1's selection logic already lives, and the naive oracle and both networks cannot disagree about it. A `NotNode` would be faster and would have to be written twice, against exactly the semantics where divergence is most likely and hardest to detect. @@ -1700,6 +1702,7 @@ Why it matters: it is what makes rules *testable* (a golden-output test is meani | Parallel alpha evaluation (§5.3) | Merge results ordered by handle id before anything downstream sees them, or don't parallelize | | `Instant.now()` in a rule, via `callFunction` or CEL | Inject time as a fact or a bound CEL variable; never read the clock inside evaluation. Then a replay can supply the original timestamp and reproduce the original decision. Lint for it | | Iteration over `factsOfType` in an RHS | Snapshot semantics, ascending handle id (§2.4) | +| A host-owned list or feature store consulted *live* during matching, from a `HostFunction`, a CEL binding or a custom `ExpressionCompiler` | Not live. A snapshot bound for the session's lifetime is an input like any other and threatens nothing here; a structure that changes under a running session is the threat. Look it up before the session and insert the answer as a fact; a rule that changes the list writes the fact and emits the change (§1's lookups bullet). Then the session's inputs *are* the facts, and a replay that supplies the same facts reproduces the decision whatever the store says today | **Test it, don't assume it.** A shuffle test — same facts, N different *internal* iteration orders where order shouldn't matter, assert identical firing sequences — belongs in the Phase 0 suite alongside the naive-matcher oracle. It is the only way to catch a `HashSet` that crept in. @@ -1865,6 +1868,7 @@ That is the reusable lesson, and **this phase supplied its own counterexample ra - **`collect`** — answers with a collection rather than a scalar, so it has no meaningful `having`, and binding a list needs a way to take one apart that §2.5 does not have. Adding it is a new `AggregateFunction` constant plus a value-semantics decision, not a reshape. - **Sliding windows, and absence over time** — both need something to notice that time has passed *with no fact arriving*, which is exactly the input an engine that acts on fact movement never receives. Neither is possible without a clock or a caller-driven session time, and that is a §7.3 decision rather than an implementation detail: a clock read inside the engine makes the firing sequence depend on when it ran. §2.5's third amendment records what was chosen instead and what it costs. +- **Lookups into host-owned data during matching** — not built, and structurally so; §1's lookups bullet has the argument and the supported shape (a membership fact, `setField` plus `emit` for a rule that changes the list). - **Backward chaining** — untouched. §1's forward-only decision stands. - **Distributed evaluation** — §5's immutability split makes it *feasible* and that is all it makes it. A `CompiledRuleSet` is shippable to another JVM and §2.1's `(sessionId, handle)` is already the identity it would need, but the partitioning strategy, the wire protocol and cross-node fact routing are an architecture rather than a slice. Nothing here should be read as a start on it. @@ -1981,6 +1985,8 @@ Build **TREAT-style joins** (§3.1) as the v1 default, targeting one-shot/batch Note the boundary §4.6 draws: `callFunction` is the closed set's escape hatch and it is **not transactional**. Working-memory effects roll back; a sent notification does not. +> **Amendment (observed, not built).** A first shape that is awkward through `callFunction` has appeared, and this records it against the two-or-three threshold above. It surfaced while documenting host-owned lists rather than as a report from authoring, so it counts toward that threshold only once authoring confirms it recurs. It is a *write-through*: a rule that changes something the host also keeps, a blocklist membership being the case in hand, and has to do two things to do it once: `setField` on the fact so the rest of the session sees the change, and `emit` so the host persists it after the fire call (`docs/dsl-guide.md#checking-a-list-your-application-owns`). Two actions for one intent is the friction (B) exists to remove, and the pairing is easy to get half right: a `setField` alone is a change the store never learns of, an `emit` alone is a change this session never sees. It is one shape, so (A) stands; it is recorded so that the second one is recognised as the second rather than the first. `callFunction` remains the wrong door for it regardless, because the write must not happen before the decision is final and commit is not that. + ### 11.4 Fact identity — **Decided: session-scoped `long`, with the global id on the session** `FactHandle` is `record FactHandle(long id)` (§2.1); the session carries a UUIDv7 `sessionId()`, and external identity is the pair. diff --git a/rule-engine-testkit/src/test/java/com/codeheadsystems/rules/testkit/DocExamplesTest.java b/rule-engine-testkit/src/test/java/com/codeheadsystems/rules/testkit/DocExamplesTest.java index 55a2ab2..97ddfdc 100644 --- a/rule-engine-testkit/src/test/java/com/codeheadsystems/rules/testkit/DocExamplesTest.java +++ b/rule-engine-testkit/src/test/java/com/codeheadsystems/rules/testkit/DocExamplesTest.java @@ -7,12 +7,15 @@ import com.codeheadsystems.rules.dsl.FactSource; import com.codeheadsystems.rules.dsl.RuleFiles; import com.codeheadsystems.rules.dsl.RuleSource; +import com.codeheadsystems.rules.rule.RuleDefinition; +import com.codeheadsystems.rules.session.RuleSession; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -293,4 +296,110 @@ void flatteningExampleBehavesAsDocumented() throws IOException { .satisfies(step -> assertThat(step.emitted().getFirst()).contains("\"A\"")); } } + + /** + * "Checking a list your application owns", run rather than merely compiled. + * + *

The recipe claims more than that its file compiles: that a rule adding a card to the + * blocklist, by pairing {@code setField} on the membership fact with an {@code emit}, makes the + * decline rule fire in the same session -- the {@code setField} is an update on a tested + * path (§3.4.1), so the retract-and-reassert derives the decline rule's match afresh in the next + * cycle; and that an absent membership fact, the shape a failed lookup must take, is seen + * by the fail-closed rule and by nothing else. Both are the argument for answering "is this in a + * list" with a fact rather than a lookup, so both are held to here, through + * {@link MatcherEquivalence} so that all three matchers are held to them. + */ + @Nested + @DisplayName("the guide's list-membership recipe") + class ListMembershipRecipe { + + private static final String ADD = "blocklist-card-after-third-failure"; + private static final String DECLINE = "decline-blocklisted-card"; + private static final String UNCHECKED = "review-when-the-list-could-not-be-checked"; + + private List recipe() throws IOException { + final DocExamples.Example example = examplesIn("dsl-guide.md").stream() + .filter(candidate -> candidate.yaml().contains("id: " + DECLINE)) + .findFirst() + .orElseThrow(() -> new AssertionError( + "the guide no longer contains the list-membership recipe")); + return RuleFiles.parse(RuleSource.yaml(example.describe(), example.yaml())); + } + + private static Consumer payment(final String cardId, final int failures) { + return session -> session.insert("Payment", + Facts.obj("id", "p-" + cardId, "cardId", cardId, "failureCount", failures)); + } + + private static Consumer membership(final String cardId, final Object... rest) { + final Object[] fields = new Object[4 + rest.length]; + fields[0] = "list"; + fields[1] = "card-blocklist"; + fields[2] = "entityId"; + fields[3] = cardId; + System.arraycopy(rest, 0, fields, 4, rest.length); + return session -> session.insert("ListMembership", Facts.obj(fields)); + } + + @Test + @DisplayName("a rule that adds to the list makes the decline rule fire in the same session") + void additionIsVisibleToTheRestOfTheSession() throws IOException { + final FiringSequence fired = MatcherEquivalence.assertEquivalent(recipe(), + payment("c1", 3).andThen(membership("c1", "member", false))); + + // Order is the claim: the addition first, and the decline BECAUSE of it, in one fire call. + assertThat(fired.steps()).extracting(FiringSequence.Step::ruleId) + .containsExactly(ADD, DECLINE); + + // The write names the card that was added, not merely the event that something was. + final FiringSequence.Step added = fired.steps().get(0); + // effects() carries the field write AND the emit; pin the write by content. + assertThat(added.effects()).anySatisfy(effect -> + assertThat(effect).contains("path=/member").contains("value=true")); + assertThat(added.emitted()).singleElement().asString() + .startsWith("list.entry.add").contains("\"card-blocklist\"").contains("\"c1\""); + + final FiringSequence.Step declined = fired.steps().get(1); + assertThat(declined.effects()).anySatisfy(effect -> + assertThat(effect).contains("path=/decision").contains("\"DECLINE\"")); + assertThat(declined.emitted()).singleElement().asString() + .startsWith("payment.declined").contains("\"p-c1\""); + } + + @Test + @DisplayName("an already-listed card is declined once, and the add rule stays quiet") + void existingMembershipDeclines() throws IOException { + final FiringSequence fired = MatcherEquivalence.assertEquivalent(recipe(), + payment("c2", 5).andThen(membership("c2", "member", true))); + + assertThat(fired.steps()).extracting(FiringSequence.Step::ruleId).containsExactly(DECLINE); + } + + @Test + @DisplayName("no membership fact at all, the shape of a failed lookup, reaches only the fail-closed rule") + void absentFactFailsClosed() throws IOException { + final FiringSequence fired = MatcherEquivalence.assertEquivalent(recipe(), payment("c3", 3)); + + // Not the add rule, even at three failures: nothing says the card was NOT listed. + assertThat(fired.steps()).extracting(FiringSequence.Step::ruleId).containsExactly(UNCHECKED); + assertThat(fired.steps().getFirst().effects()).anySatisfy(effect -> + assertThat(effect).contains("path=/decision").contains("\"REVIEW\"")); + } + + @Test + @DisplayName("a membership fact with no member field decides nothing") + void absentFieldIsNeitherTrueNorFalse() throws IOException { + /* + * The reader's likeliest mistake: a fact that was inserted but never given the field. §2.6.1's + * table is what governs here -- an absent field satisfies neither `eq: true` nor `eq: false` + * -- and the fact's presence means the fail-closed rule has nothing to say either. The fixture + * shows the safe shape (member true OR false); this pins what the unsafe one costs, which is + * a silent nothing rather than an error. + */ + final FiringSequence fired = MatcherEquivalence.assertEquivalent(recipe(), + payment("c4", 3).andThen(membership("c4"))); + + assertThat(fired.steps()).isEmpty(); + } + } }